mandrel-platform 0.17.2 → 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.
Files changed (43) hide show
  1. package/README.md +254 -34
  2. package/config/commitlint.base.mjs +36 -0
  3. package/config/edge-security/rate-limit.mjs +103 -20
  4. package/config/repo-settings.schema.json +78 -0
  5. package/default.json +4 -19
  6. package/package.json +2 -1
  7. package/scripts/apply-uptime-monitors.mjs +378 -0
  8. package/scripts/apply-uptime-monitors.test.mjs +372 -0
  9. package/scripts/audit-check.mjs +321 -180
  10. package/scripts/audit-check.test.mjs +263 -0
  11. package/scripts/check-action-pins.mjs +106 -173
  12. package/scripts/check-coverage-threshold.mjs +44 -6
  13. package/scripts/check-coverage-threshold.test.mjs +43 -0
  14. package/scripts/check-docs-staleness.mjs +130 -81
  15. package/scripts/check-docs-staleness.test.mjs +130 -0
  16. package/scripts/check-pin-drift.mjs +61 -110
  17. package/scripts/check-pin-drift.test.mjs +175 -3
  18. package/scripts/check-repo-settings.mjs +363 -0
  19. package/scripts/check-repo-settings.test.mjs +320 -0
  20. package/scripts/check-required-contexts.mjs +247 -129
  21. package/scripts/check-required-contexts.test.mjs +137 -0
  22. package/scripts/check-ruleset.mjs +435 -0
  23. package/scripts/check-ruleset.test.mjs +439 -0
  24. package/scripts/check-workflow-portability.mjs +163 -118
  25. package/scripts/check-workflow-portability.test.mjs +199 -0
  26. package/scripts/check-wrangler-baseline.mjs +514 -0
  27. package/scripts/check-wrangler-baseline.test.mjs +454 -0
  28. package/scripts/edge-security.test.mjs +81 -1
  29. package/scripts/lib/args.mjs +93 -0
  30. package/scripts/lib/args.test.mjs +152 -0
  31. package/scripts/lib/gh-json.mjs +119 -0
  32. package/scripts/lib/semver-duration.mjs +84 -0
  33. package/scripts/lib/uses-pins.mjs +220 -0
  34. package/scripts/lib/uses-pins.test.mjs +219 -0
  35. package/scripts/lib/walk.mjs +74 -0
  36. package/scripts/platform-repair.mjs +9 -3
  37. package/scripts/platform-sync.mjs +533 -5
  38. package/scripts/platform-sync.test.mjs +477 -0
  39. package/scripts/update-semgrep-rules.mjs +76 -5
  40. package/templates/runbooks/README.md +9 -5
  41. package/templates/runbooks/branch-protection-setup.md +9 -3
  42. package/templates/workflows/deploy-staging.yml +86 -0
  43. package/templates/workflows/uptime-apply.yml +54 -0
package/README.md CHANGED
@@ -1,7 +1,34 @@
1
1
  # mandrel-platform
2
2
 
3
- Shared CI/deploy workflows, composite toolchain action, npm config package,
4
- Renovate preset, and operator runbook templates for the Mandrel platform.
3
+ The **single source of truth (SSOT)** for CI/CD, security, and configuration
4
+ across the Mandrel fleet. A consumer repo adopts this platform once and inherits
5
+ a converged, hardened baseline — reusable GitHub Actions workflows, a composite
6
+ toolchain action, importable config bases, guardrail scripts, edge-security
7
+ middleware, a Renovate preset, and operator runbook templates — instead of
8
+ hand-maintaining its own copies and drifting apart over time.
9
+
10
+ ## What's in scope
11
+
12
+ | Pillar | What it provides |
13
+ | ------ | ---------------- |
14
+ | **Reusable workflows** | `workflow_call` CI, deploy, secret-scan, release, and CodeQL pipelines, consumed by tag/SHA pin. |
15
+ | **Composite action** | `setup-toolchain` — pnpm + Node + frozen install in one step. |
16
+ | **Config bases (npm)** | `extends`-able baselines: TypeScript, Biome, Knip, Stryker, commitlint, dependency-cruiser, size-limit, Lighthouse. |
17
+ | **Edge-security middleware (npm)** | Per-env closed-allowlist CORS, security headers, and app-layer rate limiting for Astro + Hono. |
18
+ | **Guardrail scripts (npm)** | Dependency-free policy checks: CVE gate, action-pin ratchet, coverage floor, destructive-migration guard, workflow-portability, required-contexts, docs-staleness. |
19
+ | **Renovate preset** | Shared dependency-update policy, including auto-bumping this repo's own `uses:` pins. |
20
+ | **Supply-chain config** | pnpm-native hardening fragment (`blockExoticSubdeps`, `trustPolicy`, 7-day `minimumReleaseAge`). |
21
+ | **Adoption & drift control** | `platform-sync` (adopt/repair) plus a scheduled cross-consumer pin-drift dashboard and auto-repair-PR loop. |
22
+ | **Runbook templates** | Copyable thin-stub operator runbooks that link back to the canonical process docs. |
23
+
24
+ **Out of scope.** mandrel-platform is not an application and ships no runtime
25
+ service — it deploys nothing of its own beyond its release train. Consumers keep
26
+ every project-specific knob (entrypoints, budgets, score floors, deploy targets)
27
+ local: the platform sets the floor, the consumer sets the ceiling. The `.agents/`
28
+ tree in this repo is the Mandrel agent framework used to *develop* the platform
29
+ (sourced from the separate `mandrel` CLI) — it is dev-time only and is **not**
30
+ part of the published npm package, whose `files` allowlist ships only `config/`,
31
+ `default.json`, `scripts/`, and `templates/`.
5
32
 
6
33
  **Docs:** [reusable-workflows.md](docs/reusable-workflows.md) (the `workflow_call`
7
34
  contract) · [decisions.md](docs/decisions.md) (decision log). Status, the
@@ -11,15 +38,24 @@ consumer convergence matrix, and the forward roadmap are tracked privately.
11
38
 
12
39
  ## Reusable workflows
13
40
 
14
- The shared `workflow_call` workflows `pr-quality.yml` and
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
18
- **[docs/reusable-workflows.md](docs/reusable-workflows.md)**. Consumers should
19
- configure their callers from that reference (input types, defaults,
20
- when-to-override, the frozen `{CLOUDFLARE_*, TURSO_*}` deploy secret allowlist,
21
- the single `ci-required` aggregator context, and the pin-by-tag/SHA versioning
22
- model).
41
+ Five workflows expose a stable `workflow_call` contract and are consumed by
42
+ tag/SHA pin. Configure your callers from
43
+ **[docs/reusable-workflows.md](docs/reusable-workflows.md)**the authoritative
44
+ reference for input types, defaults, when-to-override, the frozen
45
+ `{CLOUDFLARE_*, TURSO_*}` deploy-secret allowlist, the single `ci-required`
46
+ aggregator context, and the pin-by-tag/SHA versioning model.
47
+
48
+ | Workflow | Purpose |
49
+ | -------- | ------- |
50
+ | [`pr-quality.yml`](docs/reusable-workflows.md#pr-qualityyml) | Tiered PR gate — lint/format → typecheck → unit → contract → e2e/smoke → migration-guard → security → osv-scan, each tier independently toggled, behind one `ci-required` aggregator. |
51
+ | [`deploy-cloudflare.yml`](docs/reusable-workflows.md#deploy-cloudflareyml) | Defence-in-depth Cloudflare deploy with a frozen deploy-secret allowlist. |
52
+ | [`secret-scan-push.yml`](docs/reusable-workflows.md#secret-scan-pushyml) | Full-history gitleaks secret scan on push to the default branch. |
53
+ | [`release-automation.yml`](docs/reusable-workflows.md#release-automationyml) | Conventional-commit release lifecycle (version bump + `CHANGELOG.md` + tag) via release-please. |
54
+ | [`codeql.yml`](docs/reusable-workflows.md#codeqlyml) | CodeQL SAST analysis — dual-mode: runs on this repo's push/PR/schedule **and** is `workflow_call`-consumable. |
55
+
56
+ > `smoke-dispatch.yml` is a **platform-internal** cross-repo smoke trigger
57
+ > (`push` / `workflow_dispatch`, not `workflow_call`). It appears in the
58
+ > reference for completeness but is not part of the consumer caller surface.
23
59
 
24
60
  `release-automation.yml` extends the platform from CI/deploy into the **full
25
61
  release lifecycle**: a thin caller gets conventional-commit-driven version
@@ -119,34 +155,71 @@ mutate globs, bundle paths, score floors, and budgets — stays
119
155
  #### `knip.base.json`
120
156
 
121
157
  Shared Knip defaults (`ignoreExportsUsedInFile`, the `mandrel` binary +
122
- `mandrel-platform` dependency ignores). Knip supports a native `extends`,
123
- so consumers point at the base and add their own `entry` / `project`
124
- globs (`knip.json`):
158
+ `mandrel-platform` dependency ignores). Knip has **no native top-level
159
+ `extends`** to an npm-package config, so consumers import the base JSON in a
160
+ `knip.config.ts` (or `.js`) module and spread it, layering their own `entry`
161
+ / `project` globs on top:
125
162
 
126
- ```jsonc
127
- {
128
- "extends": ["mandrel-platform/knip.base.json"],
129
- "entry": ["src/index.ts", "scripts/*.ts"],
130
- "project": ["src/**", "scripts/**"]
131
- }
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;
132
175
  ```
133
176
 
134
177
  #### `stryker.base.json`
135
178
 
136
179
  Shared Stryker mutation-testing defaults (pnpm package manager,
137
180
  `perTest` coverage analysis, HTML + clear-text + progress reporters,
138
- `ignoreStatic`, a 60 s timeout, and high/low/break thresholds). Stryker
139
- supports a native `extends`; the consumer pins its test runner and
140
- mutate set (`stryker.config.json`):
181
+ `ignoreStatic`, a 60 s timeout, and high/low/break thresholds). Stryker's
182
+ `extends` resolves a **local JSON path**, not an npm-package specifier, so
183
+ the working mechanism is a `stryker.config.mjs` (or `.js` when
184
+ `"type": "module"`) module that imports the base JSON and spreads it, then
185
+ pins the test runner and mutate set:
141
186
 
142
- ```jsonc
143
- {
144
- "extends": ["mandrel-platform/stryker.base.json"],
145
- "testRunner": "vitest",
146
- "mutate": ["src/**/*.ts", "!src/**/*.test.ts"]
147
- }
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
+ };
197
+ ```
198
+
199
+ #### `commitlint.base.mjs`
200
+
201
+ Single-sources the conventional-commit **type-enum** — the eleven types
202
+ (`feat`, `fix`, `perf`, `refactor`, `revert`, `docs`, `style`, `chore`,
203
+ `test`, `build`, `ci`) documented in
204
+ [`.agents/rules/git-conventions.md`](.agents/rules/git-conventions.md) — so
205
+ consumers stop hand-copying the list into their own
206
+ `commitlint.config.js`. Extends `@commitlint/config-conventional` for
207
+ everything else (header casing/length, body/footer blank-line rules) and
208
+ narrows `type-enum` to the fleet list. commitlint supports a native
209
+ `extends`, so a consumer's local config reduces to the extend plus any
210
+ repo-specific scope enforcement (`commitlint.config.js`):
211
+
212
+ ```js
213
+ export default {
214
+ extends: ["mandrel-platform/commitlint.base.mjs"],
215
+ // repo-specific scope-enum, etc. — optional
216
+ };
148
217
  ```
149
218
 
219
+ Keep this base's `type-enum`, the git-conventions.md prose list, and
220
+ `release-please-config.json`'s `changelog-sections` in sync when adding a
221
+ type — all three must agree.
222
+
150
223
  #### `dependency-cruiser.base.json`
151
224
 
152
225
  Shared dependency-cruiser rule set (no-circular, no-orphans,
@@ -169,8 +242,9 @@ resolve the package export and add repo-specific rules
169
242
 
170
243
  size-limit's own config is a per-entry **array** whose paths and limits
171
244
  are inherently repo-specific, so the base ships the shared *check
172
- options* (gzip sizing, `running: false`). Spread it into each entry of
173
- your `.size-limit.json`:
245
+ options* (gzip sizing, `running: false`). A JSON config can't `import` the
246
+ base, so use a JS-module config (`.size-limit.js`) that imports the base
247
+ JSON and spreads it into each entry:
174
248
 
175
249
  ```jsonc
176
250
  // .size-limit.js — spread the base into each entry
@@ -362,6 +436,78 @@ is detected by Renovate).
362
436
 
363
437
  ---
364
438
 
439
+ ### Guardrail & policy-check scripts
440
+
441
+ Beyond the CVE gate, the package ships a set of **dependency-free guardrail
442
+ lints** (`node`-only, nothing to install) that enforce cross-repo CI and
443
+ security invariants. Several are already wired into the reusable workflows, so a
444
+ consumer that adopts those inherits the check for free; each is also runnable
445
+ standalone (`node node_modules/mandrel-platform/scripts/<name>.mjs`) or copyable
446
+ into a repo's own `scripts/`.
447
+
448
+ | Script | Enforces | Wired into |
449
+ | ------ | -------- | ---------- |
450
+ | `check-coverage-threshold.mjs` | Coverage floor (lines/statements/functions/branches) read from `coverage-summary.json`; threshold `0` disables the gate. | `pr-quality.yml` (unit tier) |
451
+ | `check-destructive-migration.mjs` | Blocks `DROP` / `TRUNCATE` / `ALTER … DROP` (and Drizzle `.dropTable()`) in migration files unless a reviewer applies the override label. | `pr-quality.yml` (migration-guard tier, opt-in) |
452
+ | `check-workflow-portability.mjs` | Catches cross-repo footguns in reusable workflows / composite actions: relative `uses:` paths, `${{ }}` in `workflow_call` input metadata, and lagging first-party pins. | `pr-quality.yml` + `ci.yml` |
453
+ | `check-action-pins.mjs` | Ratchet requiring every third-party Action to be pinned to a full 40-char commit SHA (tag-swap defence); local and first-party refs are exempt. | `ci.yml` |
454
+ | `check-required-contexts.mjs` | Validates that every branch-protection required check in `main-protection.json` maps to a real CI job — no phantom required checks. Also **warns** (never blocks) when the caller file / display `name:` / caller job id diverge from the canonical `ci.yml` / `CI` / `ci` triplet ([details](docs/reusable-workflows.md#canonical-caller-naming-the-ciyml--ci--ci-triplet)). | `ci.yml` |
455
+ | `check-docs-staleness.mjs` | Lints markdown/JSON docs for known staleness patterns (stale URLs, expired dates, dead runbook paths); suppressible per-rule. | standalone |
456
+ | `check-repo-settings.mjs` | Cross-consumer dashboard for the GitHub-side repo-settings baseline (merge methods, squash source, auto-merge, Actions default token permissions, PR-approval-by-Actions) — see below. | standalone / `platform-sync --check-settings` |
457
+
458
+ > **`config/main-protection.schema.json`** is the JSON Schema for the
459
+ > branch-protection contract (`docs/runbooks/main-protection.json`) — required
460
+ > status checks, the aggregator job, upstream jobs, and enforcement flags.
461
+ > `check-required-contexts.mjs` validates the contract against the actual
462
+ > workflow job graph; see the
463
+ > [branch-protection runbook](docs/runbooks/branch-protection-setup.md).
464
+
465
+ > **`config/repo-settings.schema.json`** is the JSON Schema for the
466
+ > repo-settings baseline contract (`docs/runbooks/repo-settings.json`),
467
+ > sibling to `main-protection.schema.json` — merge methods, squash-commit
468
+ > source, auto-merge/delete-branch-on-merge, Actions default workflow token
469
+ > permissions, and whether Actions can approve pull requests. Fleet baseline
470
+ > decided 2026-07-01 (see `docs/decisions.md`): squash-only merges, squash
471
+ > source `PR_TITLE`/`PR_BODY`, auto-merge + delete-branch-on-merge on, Actions
472
+ > default token permissions `read`, `can_approve_pull_request_reviews` off.
473
+ >
474
+ > **Because `squashMergeCommitMessage` is `PR_BODY`, PR templates must stay
475
+ > commit-body-safe.** The PR description becomes the literal squash-commit
476
+ > body on `main`, which `release-please` and `commitlint` then parse — a
477
+ > template that injects checklist boilerplate, HTML comments, or
478
+ > non-conventional-commit prose into that body will land in commit history and
479
+ > can break changelog generation or commitlint's body rules. Keep PR templates
480
+ > to short, commit-message-safe prose, or put checklists in sections authors
481
+ > delete before merge.
482
+ >
483
+ > **Check + apply.** `scripts/check-repo-settings.mjs` is the GitHub-side
484
+ > drift dashboard (mirrors `check-pin-drift.mjs`'s shape: data-driven consumer
485
+ > registry — reuses `scripts/pin-drift-consumers.json` — injectable `gh`
486
+ > runner, `--json`/`--strict`). `scripts/platform-sync.mjs` gained a
487
+ > settings mode for the per-consumer check/apply flow:
488
+ >
489
+ > ```bash
490
+ > # Report drift for one consumer against the baseline — never mutates, never
491
+ > # fails the exit code unless the read itself errors (standing decision #10).
492
+ > node scripts/platform-sync.mjs --check-settings --consumer-repo dsj1984/domio
493
+ >
494
+ > # Same read, then PATCH the drifted fields to match the baseline.
495
+ > node scripts/platform-sync.mjs --apply-settings --consumer-repo dsj1984/domio
496
+ >
497
+ > # Preview what --apply-settings would PATCH without mutating anything.
498
+ > node scripts/platform-sync.mjs --apply-settings --dry-run --consumer-repo dsj1984/domio
499
+ > ```
500
+ >
501
+ > Both commands accept `--baseline <path>` (default:
502
+ > `docs/runbooks/repo-settings.json`) and `--json` for a machine-readable
503
+ > envelope. **Non-blocking by design** (standing decision #10, same posture as
504
+ > the pin-drift dashboard and `check-ruleset.mjs`): drift is reported, not a
505
+ > hard gate — it never fails CI on a consumer's `main`. Branch-protection
506
+ > ruleset drift is out of scope here (see the companion `check-ruleset.mjs`
507
+ > story); this contract covers repo-settings only.
508
+
509
+ ---
510
+
365
511
  ## Renovate preset
366
512
 
367
513
  The shared Renovate preset (`default.json`, also exposed at
@@ -464,12 +610,27 @@ reports `already in sync`):
464
610
  actions (`actions/checkout`, …) are left untouched. The trailing
465
611
  `# <ref>` comment is refreshed so the pin stays human-auditable and the
466
612
  Renovate auto-bump rule above can track it.
467
- 2. **Materializes runbook reference stubs** (§2.2 *link, don't copy*). Copies
613
+ 2. **Checks CI-caller naming** (advisory only, Story #173). Reports whether
614
+ `.github/workflows/ci.yml` matches the canonical caller triplet — file
615
+ `ci.yml`, display name `CI`, caller job id `ci` (required context
616
+ `ci / ci-required`; see
617
+ [reusable-workflows.md § "Canonical caller naming"](docs/reusable-workflows.md#canonical-caller-naming-the-ciyml--ci--ci-triplet)).
618
+ Never renames or rewrites anything — a caller rename must land atomically
619
+ with its own branch-protection ruleset context update, which is a
620
+ deliberate per-consumer Story, not an automatic sync side-effect.
621
+ 3. **Materializes runbook reference stubs** (§2.2 *link, don't copy*). Copies
468
622
  the thin stubs from `templates/runbooks/` into the consumer's
469
623
  `docs/runbooks/` **only when absent** — an already-adopted stub is skipped,
470
624
  and a full local copy (no stub marker) is surfaced as a warning to
471
625
  reconcile by hand, never silently overwritten.
472
- 3. **Reconciles `extends`.** Prepends `github>dsj1984/mandrel-platform` to the
626
+ 4. **Materializes workflow caller templates.** Copies canonical callers from
627
+ `templates/workflows/` (e.g. `deploy-staging.yml`, the one-paved-road
628
+ `workflow_run` caller for `deploy-cloudflare.yml`'s CI-green guard — see
629
+ [`docs/reusable-workflows.md`](docs/reusable-workflows.md#deploy-cloudflareyml))
630
+ into the consumer's `.github/workflows/` — same link-don't-copy semantics:
631
+ only when absent, and a hand-authored file without the template marker is
632
+ surfaced as a warning rather than overwritten.
633
+ 5. **Reconciles `extends`.** Prepends `github>dsj1984/mandrel-platform` to the
473
634
  consumer's Renovate `extends` and `mandrel-platform/tsconfig.base.json` to
474
635
  its `tsconfig.json` `extends`. The SSOT goes first so the consumer's own
475
636
  later entries continue to override it.
@@ -481,16 +642,74 @@ result envelope on stdout).
481
642
 
482
643
  ---
483
644
 
645
+ ## Drift control & auto-repair
646
+
647
+ The platform actively keeps consumers converged rather than trusting them to
648
+ stay in sync by hand. Two scheduled workflows run this loop against the consumer
649
+ registry in
650
+ [`scripts/pin-drift-consumers.json`](scripts/pin-drift-consumers.json):
651
+
652
+ - **Detect** — [`pin-drift.yml`](.github/workflows/pin-drift.yml) (weekly +
653
+ `workflow_dispatch`) runs `check-pin-drift.mjs`, a cross-consumer dashboard
654
+ that flags split pins (multiple mandrel-platform SHAs in one repo), release
655
+ lag, npm lag, and npm-vs-workflow surface skew. Advisory by default; `--strict`
656
+ turns drift into a failure. See the
657
+ [pin-drift dashboard runbook](docs/runbooks/pin-drift-dashboard.md).
658
+ - **Repair** — [`platform-sync-repair.yml`](.github/workflows/platform-sync-repair.yml)
659
+ runs `platform-repair.mjs`, which clones each drifting consumer, runs
660
+ `platform-sync`, and opens (or updates) an **idempotent** repair PR on a stable
661
+ head branch. Requires a fine-grained `PIN_REPAIR_TOKEN` scoped to the consumer
662
+ repos' contents + pull-requests.
663
+
664
+ `update-semgrep-rules.mjs` is a related maintenance script that vendors Semgrep's
665
+ `p/default` ruleset — filtered to the languages actually in-repo — into
666
+ `.semgrep/rules.json` against a pinned Semgrep version, so the SAST step in
667
+ `pr-quality.yml` scans deterministically. Run it deliberately when bumping the
668
+ ruleset, not on every CI run.
669
+
670
+ ---
671
+
672
+ ## Runbook templates
673
+
674
+ `templates/runbooks/` ships **copyable thin-stub** operator runbooks — one per
675
+ canonical runbook in [`docs/runbooks/`](docs/runbooks). The adoption model is
676
+ *link, don't copy*: each stub links to the canonical process doc (the source of
677
+ truth) and carries only `<PLACEHOLDER>` slots for project-specific values, so an
678
+ upstream process change is picked up by re-reading the link rather than
679
+ re-authoring the stub. `platform-sync` materializes them for you — link-only, and
680
+ never clobbering a stub you have already filled in.
681
+
682
+ | Stub | Canonical runbook |
683
+ | ---- | ----------------- |
684
+ | `deploy-promotion.md` | staging → production promotion |
685
+ | `incident-response.md` | severity, escalation, postmortem |
686
+ | `database-backup-restore.md` | backup, PITR, restore/rollback |
687
+ | `observability.md` | logs, Sentry, uptime, metrics |
688
+ | `post-deploy-smoke.md` | boot-smoke gate + diagnosis |
689
+ | `environments-provisioning.md` | env model + provisioning steps |
690
+ | `dependency-update.md` | Renovate, CVE gate, catalog |
691
+ | `branch-protection-setup.md` | aggregator required-check model |
692
+
693
+ ---
694
+
484
695
  ## Development
485
696
 
486
697
  ```bash
487
- # Install dependencies
698
+ # Install dependencies (packageManager: pnpm@11.5.2)
488
699
  pnpm install
489
700
 
490
701
  # Bootstrap agent scaffolding
491
702
  pnpm run bootstrap
703
+
704
+ # Run the guardrail-script test suite (node:test)
705
+ pnpm test
492
706
  ```
493
707
 
708
+ Every script under `scripts/` (the guardrail lints, `platform-sync`,
709
+ `platform-repair`, `update-semgrep-rules`) carries a colocated `*.test.mjs`
710
+ suite run by `pnpm test`. The `.agents/` tree is the Mandrel agent framework this
711
+ repo is developed with — dev-time only, and not shipped in the npm package.
712
+
494
713
  ---
495
714
 
496
715
  ## Package exports
@@ -501,6 +720,7 @@ pnpm run bootstrap
501
720
  | `mandrel-platform/biome.base.json` | `config/biome.base.json` |
502
721
  | `mandrel-platform/knip.base.json` | `config/knip.base.json` |
503
722
  | `mandrel-platform/stryker.base.json` | `config/stryker.base.json` |
723
+ | `mandrel-platform/commitlint.base.mjs` | `config/commitlint.base.mjs` |
504
724
  | `mandrel-platform/dependency-cruiser.base.json` | `config/dependency-cruiser.base.json` |
505
725
  | `mandrel-platform/size-limit.base.json` | `config/size-limit.base.json` |
506
726
  | `mandrel-platform/lighthouse.base.json` | `config/lighthouse.base.json` |
@@ -0,0 +1,36 @@
1
+ /**
2
+ * commitlint.base.mjs — shared commitlint config for mandrel-platform
3
+ * consumers.
4
+ *
5
+ * Single-sources the conventional-commit type-enum from
6
+ * `.agents/rules/git-conventions.md` (§ Conventional Commits) so the eleven
7
+ * allowed types live in exactly one place instead of being hand-copied into
8
+ * each consumer's own `commitlint.config.js`. Keep this list, the
9
+ * git-conventions.md prose list, and `release-please-config.json`'s
10
+ * `changelog-sections` in sync when adding a type — all three must agree.
11
+ *
12
+ * Extends `@commitlint/config-conventional` for everything else (header
13
+ * casing/length, body/footer leading-blank-line, etc.) and narrows
14
+ * `type-enum` to the fleet's eleven types.
15
+ */
16
+
17
+ const TYPE_ENUM = [
18
+ "feat",
19
+ "fix",
20
+ "perf",
21
+ "refactor",
22
+ "revert",
23
+ "docs",
24
+ "style",
25
+ "chore",
26
+ "test",
27
+ "build",
28
+ "ci",
29
+ ];
30
+
31
+ export default {
32
+ extends: ["@commitlint/config-conventional"],
33
+ rules: {
34
+ "type-enum": [2, "always", TYPE_ENUM],
35
+ },
36
+ };
@@ -7,11 +7,22 @@
7
7
  * decision function — `createRateLimiter` — parameterized by limit, window, a
8
8
  * key extractor, and a pluggable store, so a consumer swaps the in-memory store
9
9
  * for a Cloudflare KV / Durable Object store without re-deriving the limiter
10
- * logic. The default store is a self-pruning in-memory `Map` suitable for a
10
+ * logic. The default store is a bounded in-memory `Map` suitable for a
11
11
  * single-isolate dev / small deployment; production multi-isolate consumers
12
12
  * pass a shared store.
13
13
  */
14
14
 
15
+ /**
16
+ * Max number of live buckets the default in-memory store retains before it
17
+ * evicts. A stream of distinct keys (e.g. a rotating-IP flood, or a spoofed
18
+ * forwarded header — see `defaultKeyExtractor`) would otherwise grow the
19
+ * backing `Map` without bound. Once the store holds this many buckets, the
20
+ * least-recently-touched entry is evicted (LRU) on the next `set`. The value
21
+ * is large enough to be a non-event for legitimate single-isolate traffic
22
+ * while capping worst-case memory.
23
+ */
24
+ const DEFAULT_MAX_BUCKETS = 10_000;
25
+
15
26
  /**
16
27
  * @typedef {Object} RateLimitStore
17
28
  * @property {(key: string) => Promise<{ count: number, resetAt: number } | null> | { count: number, resetAt: number } | null} get
@@ -19,14 +30,45 @@
19
30
  */
20
31
 
21
32
  /**
22
- * In-memory fixed-window store. Self-prunes expired buckets on access so it
23
- * does not leak unboundedly. NOT shared across isolates — fine for dev / single
24
- * instance; pass a KV-backed store in production.
33
+ * In-memory fixed-window store. Bounded two ways so a stream of distinct keys
34
+ * cannot grow the backing `Map` without bound:
35
+ *
36
+ * 1. **Expired-bucket sweep.** `get` evicts a bucket the moment its window has
37
+ * elapsed, and `set` amortizes a full sweep of expired buckets across
38
+ * writes. Keys that stop being seen do not linger past their window.
39
+ * 2. **Max-size LRU cap.** The `Map` retains at most `maxBuckets` live
40
+ * entries. When a `set` would exceed the cap after sweeping, the
41
+ * least-recently-touched entry is evicted first (a `Map` preserves
42
+ * insertion order, and every touch re-inserts, so the first key is the
43
+ * LRU one). This caps worst-case memory even under an active flood of
44
+ * keys that have not yet expired.
45
+ *
46
+ * NOT shared across isolates — fine for dev / single instance; pass a
47
+ * KV-backed store in production.
48
+ *
49
+ * @param {{ maxBuckets?: number }} [options]
50
+ * Optional cap override. Defaults to {@link DEFAULT_MAX_BUCKETS}. Callers
51
+ * that pass no argument get the default — the zero-arg signature is
52
+ * preserved for existing consumers.
25
53
  * @returns {RateLimitStore}
26
54
  */
27
- export function createMemoryStore() {
55
+ export function createMemoryStore(options) {
56
+ const maxBuckets =
57
+ options && typeof options.maxBuckets === "number" && options.maxBuckets >= 1
58
+ ? Math.floor(options.maxBuckets)
59
+ : DEFAULT_MAX_BUCKETS;
28
60
  /** @type {Map<string, { count: number, resetAt: number }>} */
29
61
  const buckets = new Map();
62
+
63
+ /** Evict every bucket whose window has already elapsed. */
64
+ function sweepExpired(now) {
65
+ for (const [key, bucket] of buckets) {
66
+ if (bucket.resetAt <= now) {
67
+ buckets.delete(key);
68
+ }
69
+ }
70
+ }
71
+
30
72
  return {
31
73
  get(key) {
32
74
  const bucket = buckets.get(key);
@@ -37,10 +79,31 @@ export function createMemoryStore() {
37
79
  buckets.delete(key);
38
80
  return null;
39
81
  }
82
+ // Re-insert so the touched key moves to the most-recently-used end,
83
+ // keeping the LRU eviction order in `set` honest.
84
+ buckets.delete(key);
85
+ buckets.set(key, bucket);
40
86
  return bucket;
41
87
  },
42
88
  set(key, value) {
89
+ const now = Date.now();
90
+ // A re-`set` of an existing key must not double-count toward the cap;
91
+ // drop it first so the size check and LRU ordering stay correct.
92
+ buckets.delete(key);
43
93
  buckets.set(key, value);
94
+ if (buckets.size > maxBuckets) {
95
+ // Cheap first: reclaim anything already expired.
96
+ sweepExpired(now);
97
+ }
98
+ // Still over cap (an active flood of un-expired keys) — evict LRU
99
+ // entries (insertion-order-oldest) until we are back within bound.
100
+ while (buckets.size > maxBuckets) {
101
+ const oldest = buckets.keys().next().value;
102
+ if (oldest === undefined) {
103
+ break;
104
+ }
105
+ buckets.delete(oldest);
106
+ }
44
107
  },
45
108
  };
46
109
  }
@@ -50,10 +113,12 @@ export function createMemoryStore() {
50
113
  * @property {number} limit Max requests allowed per window. Required.
51
114
  * @property {number} windowMs Window length in milliseconds. Required.
52
115
  * @property {(request: Request) => string} [keyExtractor]
53
- * Derives the rate-limit bucket key from the request. Defaults to the
54
- * client IP from `CF-Connecting-IP` / `X-Forwarded-For` (first hop), falling
55
- * back to a constant so a missing IP fails *closed* into one shared bucket
56
- * rather than bypassing the limit per-request.
116
+ * Derives the rate-limit bucket key from the request. Defaults to
117
+ * {@link defaultKeyExtractor}, which trusts only `CF-Connecting-IP` and
118
+ * falls back to a constant shared bucket it does NOT read
119
+ * `X-Forwarded-For`, which is client-spoofable. See that function's doc for
120
+ * the trust boundary and how to opt back into `X-Forwarded-For` when your
121
+ * own edge is known to overwrite it.
57
122
  * @property {RateLimitStore} [store] Defaults to `createMemoryStore()`.
58
123
  */
59
124
 
@@ -67,24 +132,42 @@ export function createMemoryStore() {
67
132
  */
68
133
 
69
134
  /**
70
- * Default key extractor: client IP, failing closed to a shared bucket.
135
+ * Default key extractor: the Cloudflare-supplied client IP, failing closed to
136
+ * a shared bucket.
137
+ *
138
+ * **Trust boundary.** Identity is derived only from `CF-Connecting-IP`, a
139
+ * header Cloudflare's edge sets (and overwrites) from the terminating TCP
140
+ * connection — a client cannot forge it. `X-Forwarded-For` is deliberately
141
+ * NOT consulted: any client can send an arbitrary `X-Forwarded-For`, so
142
+ * keying off it lets an attacker mint a fresh bucket per request (defeating
143
+ * the limit) or impersonate another client's bucket. When no trusted client
144
+ * IP is present, we fail *closed* into one shared `"anonymous"` bucket rather
145
+ * than handing every request its own unlimited allowance.
146
+ *
147
+ * If your own reverse proxy is known to strip inbound `X-Forwarded-For` and
148
+ * append the real client, opt back in explicitly with a custom
149
+ * `keyExtractor`, e.g.:
150
+ *
151
+ * ```js
152
+ * createRateLimiter({
153
+ * limit, windowMs,
154
+ * keyExtractor: (req) =>
155
+ * req.headers.get("CF-Connecting-IP") ??
156
+ * req.headers.get("X-Forwarded-For")?.split(",").pop()?.trim() ??
157
+ * "anonymous",
158
+ * });
159
+ * ```
160
+ *
71
161
  * @param {Request} request
72
162
  * @returns {string}
73
163
  */
74
164
  function defaultKeyExtractor(request) {
75
165
  const cf = request.headers.get("CF-Connecting-IP");
76
166
  if (cf) {
77
- return cf;
78
- }
79
- const xff = request.headers.get("X-Forwarded-For");
80
- if (xff) {
81
- const first = xff.split(",")[0];
82
- if (first) {
83
- return first.trim();
84
- }
167
+ return cf.trim();
85
168
  }
86
- // No identifiable client fail closed into one shared bucket rather than
87
- // handing every anonymous request its own unlimited allowance.
169
+ // No trusted client identity. `X-Forwarded-For` is intentionally ignored
170
+ // here because it is client-spoofable; fail closed into one shared bucket.
88
171
  return "anonymous";
89
172
  }
90
173
 
@@ -0,0 +1,78 @@
1
+ {
2
+ "$schema": "http://json-schema.org/draft-07/schema#",
3
+ "$id": "https://github.com/dsj1984/mandrel-platform/blob/main/config/repo-settings.schema.json",
4
+ "title": "Repository Settings Baseline Contract",
5
+ "description": "Defines the GitHub-side repository settings baseline (merge methods, squash-commit source, auto-merge, Actions default token permissions, PR-approval-by-Actions) the fleet must converge on. Validated by scripts/check-repo-settings.mjs against each consumer's live settings (gh api repos/{owner}/{repo}); applied where safe by scripts/platform-sync.mjs --apply-settings. Non-blocking by design (standing decision #10) — drift is reported, never a hard gate on a consumer's main.",
6
+ "type": "object",
7
+ "required": [
8
+ "allowSquashMerge",
9
+ "allowMergeCommit",
10
+ "allowRebaseMerge",
11
+ "squashMergeCommitTitle",
12
+ "squashMergeCommitMessage",
13
+ "deleteBranchOnMerge",
14
+ "allowAutoMerge",
15
+ "actionsDefaultWorkflowPermissions",
16
+ "actionsCanApprovePullRequestReviews"
17
+ ],
18
+ "additionalProperties": false,
19
+ "properties": {
20
+ "$schema": {
21
+ "type": "string",
22
+ "description": "JSON Schema reference."
23
+ },
24
+ "allowSquashMerge": {
25
+ "type": "boolean",
26
+ "description": "Whether squash merging is allowed. The fleet baseline requires this true (squash-only).",
27
+ "default": true
28
+ },
29
+ "allowMergeCommit": {
30
+ "type": "boolean",
31
+ "description": "Whether merge commits are allowed. The fleet baseline requires this false (squash-only).",
32
+ "default": false
33
+ },
34
+ "allowRebaseMerge": {
35
+ "type": "boolean",
36
+ "description": "Whether rebase merging is allowed. The fleet baseline requires this false (squash-only).",
37
+ "default": false
38
+ },
39
+ "squashMergeCommitTitle": {
40
+ "type": "string",
41
+ "description": "Source of the squash-commit title.",
42
+ "enum": ["PR_TITLE", "COMMIT_OR_PR_TITLE"],
43
+ "default": "PR_TITLE"
44
+ },
45
+ "squashMergeCommitMessage": {
46
+ "type": "string",
47
+ "description": "Source of the squash-commit message body. The fleet baseline requires PR_BODY — commits landing on main feed release-please + commitlint, so the PR body becomes the literal commit body and must stay commit-body-safe (see the _note below).",
48
+ "enum": ["PR_BODY", "COMMIT_MESSAGES", "BLANK"],
49
+ "default": "PR_BODY"
50
+ },
51
+ "deleteBranchOnMerge": {
52
+ "type": "boolean",
53
+ "description": "Whether head branches are auto-deleted after a merge.",
54
+ "default": true
55
+ },
56
+ "allowAutoMerge": {
57
+ "type": "boolean",
58
+ "description": "Whether GitHub's native auto-merge feature is enabled for the repo.",
59
+ "default": true
60
+ },
61
+ "actionsDefaultWorkflowPermissions": {
62
+ "type": "string",
63
+ "description": "Default GITHUB_TOKEN permissions granted to Actions workflows. The fleet baseline requires 'read' (least-privilege) — a workflow that needs write scopes requests it explicitly via a `permissions:` block.",
64
+ "enum": ["read", "write"],
65
+ "default": "read"
66
+ },
67
+ "actionsCanApprovePullRequestReviews": {
68
+ "type": "boolean",
69
+ "description": "Whether GitHub Actions workflows are permitted to approve pull requests. The fleet baseline requires this false — an automated actor approving its own (or any) PR is a bypass of human review, not a feature.",
70
+ "default": false
71
+ },
72
+ "_note": {
73
+ "type": "string",
74
+ "description": "Human-readable note for maintainers."
75
+ }
76
+ },
77
+ "_note": "PR templates must stay commit-body-safe: with squashMergeCommitMessage=PR_BODY, the full PR description becomes the literal squash-commit body on main, which release-please and commitlint then parse. A PR template that injects checklist boilerplate, HTML comments, or non-conventional-commit prose into that body will land in the commit history and can break release-please's changelog generation or commitlint's body-line rules. Keep PR templates short, commit-message-safe prose, or move checklists into template sections that authors delete before merge."
78
+ }