From e2bb535d3986b9c903ba06c8e3a1fd6d824c7c64 Mon Sep 17 00:00:00 2001 From: Chris Kehayias Date: Wed, 30 Sep 2026 06:41:04 -0400 Subject: [PATCH 01/24] =?UTF-8?q?chore(deps):=20dependency=20audit=202026-?= =?UTF-8?q?09-30=20=E2=80=94=201=20advisory=20to=200,=20adopt=20dotenv=201?= =?UTF-8?q?8=20(#33)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - **Security:** 1 high → **0**. `brace-expansion` DoS (GHSA-q2hr-2g5m-vwhr, GHSA-qhr7-859c-m2p7, GHSA-6j4f-fj2g-mc7p), reached via `eslint` and `mjml` → `minimatch`. Cleared by in-range updates alone. Low real exposure: every path matches config-file globs, never request input. - **In-range:** `next` 16.3.7, `better-auth` 1.7.6, `zod` 4.6.5, `vitest` 5.0.2, `jsdom` 30.1.1, plus minor bumps. - **Major adopted:** `dotenv` 17 → 18. Used only by the MP codegen scripts; both verified with `--help` (loads `.env.local`, exit 0). - **Holds re-checked, all still blocked:** TypeScript 7, ESLint 10, GrapesJS 0.23, `@types/node` 25+. - **Removed:** none. Every package without a direct import is reached implicitly. OSV.dev sweep: 472 packages, 1 hit (the known `grapesjs` false positive). Full record: `.claude/packages/2026-09-30.md` ## Verification | | Baseline | After | |---|---|---| | Tests | 1563 / 116 files | 1563 / 116 files | | Lint | clean | clean | | Type errors | 0 | 0 | | `next build` | — | passes | | `coverage/coverage-final.json` | — | present | Commits are separable: in-range / dotenv 18 / docs. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 (1M context) --- .claude/packages/2026-09-30.md | 145 ++++ .claude/packages/README.md | 1 + CLAUDE.md | 1 + package-lock.json | 1382 +++++++++++++++----------------- package.json | 28 +- 5 files changed, 829 insertions(+), 728 deletions(-) create mode 100644 .claude/packages/2026-09-30.md diff --git a/.claude/packages/2026-09-30.md b/.claude/packages/2026-09-30.md new file mode 100644 index 0000000..cba1588 --- /dev/null +++ b/.claude/packages/2026-09-30.md @@ -0,0 +1,145 @@ +# Dependency Audit — 2026-09-30 + +**Branch:** `chore/dependency-audit-2026-09` · **Node:** 24.18.0 · **npm:** 11.16.0 + +## Result + +| | Before | After | +|---|---|---| +| `npm audit` | 1 (1 high) | **0** | +| Direct dependencies | 55 | 55 | +| Tests | 1563 passing / 116 files | **1563 passing / 116 files** | +| `eslint .` | clean | clean | +| `tsc --noEmit` type errors | 0 | 0 | +| `next build` | passes | passes | +| OSV.dev sweep | — | 472 packages, 1 hit (known false positive) | + +--- + +## 1. Security + +`npm update` alone cleared the one finding. + +**`brace-expansion`** (high) — three DoS advisories, all quadratic-time or +unbounded recursion on crafted brace patterns: +[GHSA-q2hr-2g5m-vwhr](https://github.com/advisories/GHSA-q2hr-2g5m-vwhr), +[GHSA-qhr7-859c-m2p7](https://github.com/advisories/GHSA-qhr7-859c-m2p7), +[GHSA-6j4f-fj2g-mc7p](https://github.com/advisories/GHSA-6j4f-fj2g-mc7p). + +| Resolved | Reached us via | +|---|---| +| 1.1.18 → 1.1.21 | `eslint` → `minimatch@3` | +| 5.0.9 → 5.0.12 | `typescript-eslint` → `minimatch@10`; `mjml-cli` → `glob`/`minimatch`; `mjml-core` → `js-beautify` → `editorconfig` → `minimatch` | + +**Real exposure was low.** Every path feeds `minimatch` glob patterns that come +from config files or the repo's own code, never from a request. The only runtime +path is `mjml` (server action `compileMjml`), where `editorconfig` matches +`.editorconfig` section headers, not user template content. + +### Independent sweep beyond `npm audit` + +All **472** resolved packages queried against OSV.dev. **1 hit:** +`grapesjs@0.22.16` → GHSA-589f-c66p-hxr4 — the standing false positive recorded +on 2026-09-13. Re-verified: `affected[].ranges` is still +`[{"introduced":"0"}]` with no `fixed` event. Not re-triaged. + +### Supply-chain checks + +- No deprecated packages in the resolved tree. +- Fresh releases (< 2 days) accepted after checking the previous version used the + same publish path: `lucide-react@1.49.0` (0.5d), `next@16.3.7` (1.1d), + `eslint-config-next@16.3.7` (1.1d), `docx@9.8.1` (1.7d). All four were + published by GitHub Actions **with provenance**, exactly as the versions they + replace were, and none adds an install script. +- `dotenv@18.0.4` has **no provenance** — neither did 17.4.2; the maintainer + publishes manually. Same publisher (`motdotla`), zero dependencies, no install + scripts, 5 days of soak. Accepted. +- **npm 11 `allowScripts` notice** — `npm install` now warns that `esbuild` and + `unrs-resolver` have `postinstall` scripts "not yet covered by allowScripts". + **Not new**: both are at the same versions as before this audit and the repo + has never had an `allowScripts` config. See §5. + +--- + +## 2. In-range updates + +`next` 16.3.5 → 16.3.7, `eslint-config-next` 16.3.5 → 16.3.7, `better-auth` +1.7.4 → 1.7.6, `zod` 4.6.4 → 4.6.5, `vitest` + `@vitest/coverage-v8` 5.0.0 → +5.0.2, `jsdom` 30.0.1 → 30.1.1, `docx` 9.7.1 → 9.8.1, `docxtemplater` 3.69.3 → +3.71.0, `pizzip` 3.2.0 → 3.3.0, `lucide-react` 1.45.0 → 1.49.0, +`react-hook-form` 7.88.0 → 7.89.0, `tsx` 4.23.13 → 4.23.15, `chalk` 6.0.0 → +6.0.1, `@types/node` 24.13.4 → 24.19.0 (stays on the 24 line). + +Verified: tests, lint and types identical to baseline; `next build` passes; +`npm run setup:check` exits 0. + +--- + +## 3. Removed — none + +Every direct dependency without a direct import is reached another way: + +| Package | How it is used | +|---|---| +| `react-dom` | Required peer of `next` | +| `tsx` | Runs 7 `package.json` scripts | +| `@types/mjml` | Types the dynamic `import('mjml')` in `template-editor/actions.ts`; `mjml@5.4.1` ships no `.d.ts` | +| `@types/node`, `@types/react`, `@types/react-dom` | Ambient types | +| `@vitest/coverage-v8` | `provider: 'v8'` in `vitest.config.mts` | +| `typescript` | Toolchain | +| `postcss` | Kept deliberately as the root version pin (see 2026-09-13 §2) | + +--- + +## 4. Majors + +### Adopted: `dotenv` 17.4.2 → 18.0.4 + +Used only by the two MP codegen scripts (`generate-types.ts`, +`generate-storedprocs.ts`), never by the app. Breaking changes are cosmetic: the +"injected env" notice moved from stdout to **stderr**, and the tips were +removed. 18 also adds a `dotenv` CLI bin, an opt-in fast parser, and moves the +entry point to `dist/index.cjs`. + +Verified by running both scripts with `--help`: each loaded `.env.local` +(11 keys) and exited 0. Test/lint/types unchanged; `coverage/coverage-final.json` +is still written at the path CI uploads. + +### Held — all four re-checked, all still blocked + +| Package | Re-check result (2026-09-30) | Clears when | +|---|---|---| +| `typescript` 6.0.3 → 7.0.2 | `typescript-eslint@8.71.0` still peers `typescript: >=4.8.4 <6.1.0` | TS 7.1 ships a stable API **and** `typescript-eslint` widens its peer | +| `eslint` 9.39.5 → 10.11.0 | `eslint-plugin-react@7.37.5` is still latest, still peers `eslint ^9.7` at most | `eslint-plugin-react` supports `^10` and `eslint-config-next` picks it up | +| `grapesjs` 0.22.16 → 0.23.6 | `@grapesjs/react@2.0.0` is still latest, still peers `grapesjs ^0.22.5` | `@grapesjs/react` peers `^0.23` | +| `@types/node` 24 → 26.6.3 | Vercel still lists **24.x (default)**, 22.x, 20.x — no newer runtime offered | Vercel offers a newer default Node major | + +Re-check commands are unchanged from the 2026-09-13 record and CLAUDE.md. + +Note: `typescript-eslint` now peers `eslint: ^8.57 || ^9 || ^10`, so it is +no longer part of the ESLint 10 blocker — `eslint-plugin-react` alone is. + +--- + +## 5. Pre-existing issues — status + +### Resolved since the last record + +Both items in 2026-09-13 §5 have since been fixed on `dev`, outside a dependency +audit: +- The **12 `next build` type errors** in test files are gone (0 today), and CI + now runs `npm run typecheck` (#29). +- `vitest.config.ts` was renamed to **`vitest.config.mts`**; the Vite + `configLoader: 'native'` warning no longer appears. + +### Found, NOT fixed here + +- **npm 11 `allowScripts`.** `esbuild` and `unrs-resolver` run `postinstall` + scripts that the repo has never explicitly approved. Today npm only warns; if + a future npm makes unapproved scripts fail closed, `npm ci` in CI would skip + them and `esbuild` (via Vite/`tsx`) could break. Fix is a reviewed + `npm approve-scripts esbuild unrs-resolver`, which writes the allow-list into + `package.json`. Out of scope for a version audit — it is a policy decision. +- **Test performance hint.** Vitest reports jsdom was created 116 times + (~73% of tracked time) and suggests `pool: 'vmThreads'`. Not a dependency + change; not attempted. diff --git a/.claude/packages/README.md b/.claude/packages/README.md index b2b2027..6cbc418 100644 --- a/.claude/packages/README.md +++ b/.claude/packages/README.md @@ -18,6 +18,7 @@ later audit can re-check in one command instead of re-deriving the analysis. | Date | Summary | |---|---| +| [2026-09-30](2026-09-30.md) | Cleared 1 high (`brace-expansion` DoS) via in-range updates. Adopted dotenv 18. Re-checked all 4 holds: still blocked. | | [2026-09-13](2026-09-13.md) | Cleared 14 advisories (1 critical). Vitest 5 / jsdom 30 / jest-dom 7 / chalk 6. Dropped 5 unused deps. Held TS 7, ESLint 10, GrapesJS 0.23. | ## Conventions diff --git a/CLAUDE.md b/CLAUDE.md index 5eb1a22..c2598b3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -281,6 +281,7 @@ condition that clears it. Do not re-derive that analysis. | Date | Advisories | Highlights | Record | |---|---|---|---| +| 2026-09-30 | 1 → **0** | `brace-expansion` DoS (high) cleared by in-range updates; `next` → 16.3.7. Adopted dotenv 18. All 4 holds re-checked, still blocked. | [2026-09-30](.claude/packages/2026-09-30.md) | | 2026-09-13 | 14 → **0** | `next` 16.2.10 → 16.3.5 (**critical** RCE on Windows hosts, proxy bypass, SSRF). Adopted Vitest 5, jsdom 30, jest-dom 7, chalk 6. Dropped 5 unreferenced deps. Held TS 7, ESLint 10, GrapesJS 0.23. | [2026-09-13](.claude/packages/2026-09-13.md) | ### Current holds diff --git a/package-lock.json b/package-lock.json index fde644f..385ee03 100644 --- a/package-lock.json +++ b/package-lock.json @@ -27,28 +27,28 @@ "@radix-ui/react-switch": "^1.3.7", "@radix-ui/react-tooltip": "^1.2.16", "@react-pdf/renderer": "^4.9.0", - "better-auth": "^1.7.4", + "better-auth": "^1.7.6", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", - "docx": "^9.7.1", - "docxtemplater": "^3.69.3", + "docx": "^9.8.1", + "docxtemplater": "^3.71.0", "docxtemplater-image": "^0.1.2", - "dotenv": "^17.4.2", + "dotenv": "^18.0.4", "grapesjs": "^0.22.16", "grapesjs-mjml": "^1.0.8", - "lucide-react": "^1.45.0", + "lucide-react": "^1.49.0", "mjml": "^5.4.1", - "next": "^16.3.5", - "pizzip": "^3.2.0", + "next": "^16.3.7", + "pizzip": "^3.3.0", "react": "^19.3.0", "react-dom": "^19.3.0", - "react-hook-form": "^7.88.0", + "react-hook-form": "^7.89.0", "sonner": "^2.0.8", "tailwind-merge": "^3.7.0", - "tsx": "^4.23.13", + "tsx": "^4.23.15", "vaul": "^1.1.2", - "zod": "^4.6.4" + "zod": "^4.6.5" }, "devDependencies": { "@inquirer/prompts": "^8.7.2", @@ -57,15 +57,15 @@ "@testing-library/react": "^16.3.3", "@testing-library/user-event": "^14.6.7", "@types/mjml": "^5.0.0", - "@types/node": "^24.13.4", + "@types/node": "^24.19.0", "@types/react": "^19.3.0", "@types/react-dom": "^19.3.0", "@vitejs/plugin-react": "^6.1.1", "@vitest/coverage-v8": "^5.0.0", - "chalk": "^6.0.0", + "chalk": "^6.0.1", "eslint": "~9.39.4", - "eslint-config-next": "^16.3.5", - "jsdom": "^30.0.1", + "eslint-config-next": "^16.3.7", + "jsdom": "^30.1.1", "postcss": "^8.5.28", "tailwindcss": "^4.2.0", "tw-animate-css": "^1.4.0", @@ -97,26 +97,26 @@ } }, "node_modules/@asamuzakjp/css-color": { - "version": "6.0.7", - "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-6.0.7.tgz", - "integrity": "sha512-vC/bk1Lz7Tn/EfU9/apOTBk80/8dyGyWMowPoV1tJ52muDGsDqt2HPT2klrFUiY60MQmQv9q8yIht15JnBgDGw==", + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-7.1.2.tgz", + "integrity": "sha512-99DHAnXDB5z6EEK+9GMpVI7Mw4oxj97dY5bpOzMnjADQWxI8rN6TvTduuFLUhUMlS7/CfVZ06tcZsus6cltnNw==", "devOptional": true, "license": "MIT", "dependencies": { - "@csstools/css-calc": "^3.3.0", - "@csstools/css-color-parser": "^4.1.10", - "@csstools/css-parser-algorithms": "^4.0.0", - "@csstools/css-tokenizer": "^4.0.0", - "lru-cache": "^11.5.2" + "@csstools/css-calc": "^3.4.1", + "@csstools/css-color-parser": "^4.2.4", + "@csstools/css-parser-algorithms": "^4.0.1", + "@csstools/css-tokenizer": "^4.0.2", + "lru-cache": "^11.5.3" }, "engines": { - "node": "^22.13.0 || >=24.0.0" + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" } }, "node_modules/@asamuzakjp/css-color/node_modules/lru-cache": { - "version": "11.5.2", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", - "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", + "version": "11.5.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.3.tgz", + "integrity": "sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==", "devOptional": true, "license": "BlueOak-1.0.0", "engines": { @@ -124,25 +124,25 @@ } }, "node_modules/@asamuzakjp/dom-selector": { - "version": "8.3.2", - "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-8.3.2.tgz", - "integrity": "sha512-93Z1N+BQNXysodoicpOIyNh2drHfz/CTf9nnT0FEx72GJcIiwgydD7tGAr78j41LsYn3hlRn+LdGPuBLn1Bl8Q==", + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-9.2.2.tgz", + "integrity": "sha512-lSWTBMjAcmu2xn5yEDU7jh6QDV+C8GEKtdJ4pIQhXh26RkKQ7S3FuQlt+zZkUbTdJ4d3XyV1kPI/G0zWXxqaqw==", "devOptional": true, "license": "MIT", "dependencies": { - "bidi-js": "^1.0.3", + "bidi-js": "^1.1.0", "css-tree": "^3.2.1", "is-potential-custom-element-name": "^1.0.1", - "lru-cache": "^11.5.2" + "lru-cache": "^11.5.3" }, "engines": { - "node": "^22.13.0 || >=24.0.0" + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" } }, "node_modules/@asamuzakjp/dom-selector/node_modules/lru-cache": { - "version": "11.5.2", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", - "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", + "version": "11.5.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.3.tgz", + "integrity": "sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==", "devOptional": true, "license": "BlueOak-1.0.0", "engines": { @@ -324,9 +324,9 @@ } }, "node_modules/@babel/parser": { - "version": "7.29.8", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz", - "integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==", + "version": "7.29.9", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.9.tgz", + "integrity": "sha512-CjXrNHTnvqBVqHgdBysY3vk2T8tpJHb5/RMeHJBTyVa9xgugCB0CJTx/3oO8RV2QRQP391RWpB7D6hLjm8V9uA==", "devOptional": true, "license": "MIT", "dependencies": { @@ -407,9 +407,9 @@ } }, "node_modules/@better-auth/core": { - "version": "1.7.4", - "resolved": "https://registry.npmjs.org/@better-auth/core/-/core-1.7.4.tgz", - "integrity": "sha512-g66dKOMB5PLxoujkxkZeYb6dh5wLhfAx/D+w9XtcO669Dslnyen2naBYqnD1r5ZwnxFKcr6aSrUPaH952V89Lg==", + "version": "1.7.6", + "resolved": "https://registry.npmjs.org/@better-auth/core/-/core-1.7.6.tgz", + "integrity": "sha512-yBDDO0J4VCHT1qEZtzj6aMexX4etyLesitoxehcWHzPrzYqrFHwe/hgskye+imlGhQVOysuljMg8M8BA6GrtIg==", "license": "MIT", "dependencies": { "@opentelemetry/semantic-conventions": "^1.41.1", @@ -418,8 +418,7 @@ }, "peerDependencies": { "@better-auth/utils": "0.4.2", - "@better-fetch/fetch": "1.3.1", - "@cloudflare/workers-types": ">=4", + "@better-fetch/fetch": "1.3.2", "@opentelemetry/api": "^1.9.0", "better-call": "1.4.0", "jose": "^6.1.0", @@ -427,21 +426,18 @@ "nanostores": "^1.0.1" }, "peerDependenciesMeta": { - "@cloudflare/workers-types": { - "optional": true - }, "@opentelemetry/api": { "optional": true } } }, "node_modules/@better-auth/drizzle-adapter": { - "version": "1.7.4", - "resolved": "https://registry.npmjs.org/@better-auth/drizzle-adapter/-/drizzle-adapter-1.7.4.tgz", - "integrity": "sha512-J9JMl0DGYNYwtDj5nq/fzycU9dzPUDEtTNn89KmToUrwPDGfByDgfmiQ9jGK+LN3EnfsG1Pt/dDmRocbnfihlA==", + "version": "1.7.6", + "resolved": "https://registry.npmjs.org/@better-auth/drizzle-adapter/-/drizzle-adapter-1.7.6.tgz", + "integrity": "sha512-ENlhwC7kkTjquuiel3Efggik0hC4vtD7ppJ8FhsiHhRJA8K6RT4hbhzkfm6keNvjdnAeWwCejm7FmMO4GoSGpA==", "license": "MIT", "peerDependencies": { - "@better-auth/core": "^1.7.4", + "@better-auth/core": "^1.7.6", "@better-auth/utils": "0.4.2", "drizzle-orm": "^0.45.2 || >=1.0.0-rc.1 <2.0.0" }, @@ -452,12 +448,12 @@ } }, "node_modules/@better-auth/kysely-adapter": { - "version": "1.7.4", - "resolved": "https://registry.npmjs.org/@better-auth/kysely-adapter/-/kysely-adapter-1.7.4.tgz", - "integrity": "sha512-WHLsTs4ZwTKorifdK6Whol6Zy+0gIzOJEXzWsvzmLtlT1uIZFpd7IPBlDZTrXvIZnxPWJHPIv8/uHqS9QFPZOA==", + "version": "1.7.6", + "resolved": "https://registry.npmjs.org/@better-auth/kysely-adapter/-/kysely-adapter-1.7.6.tgz", + "integrity": "sha512-BV+DX2/z/6ozNC9DYSge78wjLerq6Bxd/lSPjzzuq9eEutkQDAJTuiaETzgGYqNV5LlEWi8TZFAQTEq/wavOEQ==", "license": "MIT", "peerDependencies": { - "@better-auth/core": "^1.7.4", + "@better-auth/core": "^1.7.6", "@better-auth/utils": "0.4.2", "kysely": "^0.28.17 || ^0.29.0" }, @@ -468,22 +464,22 @@ } }, "node_modules/@better-auth/memory-adapter": { - "version": "1.7.4", - "resolved": "https://registry.npmjs.org/@better-auth/memory-adapter/-/memory-adapter-1.7.4.tgz", - "integrity": "sha512-xAihOPh4mFjvKLzmu2XNzj7RchDmytA/d/xjrGyps1oHVGCTGDGghlSt/rIT5fagtTn2R9+TBLh5QrZnTvYu9g==", + "version": "1.7.6", + "resolved": "https://registry.npmjs.org/@better-auth/memory-adapter/-/memory-adapter-1.7.6.tgz", + "integrity": "sha512-ng1xv8k2JsEIjnqzJzdoVOQrnoCLRuk4EiQL9yYAThyg6zYzV00LynlhH+O9OMUZ+yGVpGqGZmr5xcTiUSV2uA==", "license": "MIT", "peerDependencies": { - "@better-auth/core": "^1.7.4", + "@better-auth/core": "^1.7.6", "@better-auth/utils": "0.4.2" } }, "node_modules/@better-auth/mongo-adapter": { - "version": "1.7.4", - "resolved": "https://registry.npmjs.org/@better-auth/mongo-adapter/-/mongo-adapter-1.7.4.tgz", - "integrity": "sha512-sIdIc8vONXgQxKPfzL4bbk6gO3gdFEDzC5VG8rD18xSFB6j7blBKcuzOofbswbwwbyuspnshoViGRqSoqp2PnA==", + "version": "1.7.6", + "resolved": "https://registry.npmjs.org/@better-auth/mongo-adapter/-/mongo-adapter-1.7.6.tgz", + "integrity": "sha512-GvBwZli8i4XINMAj9tlW7eV9E8Dr+JWqavXjUs2o81GZt/Vh8wiX+31KK1jQ4iOuejfjpLoXcoFtlIPgf/pVog==", "license": "MIT", "peerDependencies": { - "@better-auth/core": "^1.7.4", + "@better-auth/core": "^1.7.6", "@better-auth/utils": "0.4.2", "mongodb": "^6.0.0 || ^7.0.0" }, @@ -494,12 +490,12 @@ } }, "node_modules/@better-auth/prisma-adapter": { - "version": "1.7.4", - "resolved": "https://registry.npmjs.org/@better-auth/prisma-adapter/-/prisma-adapter-1.7.4.tgz", - "integrity": "sha512-cvsMNaXLcwQiAIxQCx5Gq2VA1reW4RjkC5oLNXNJR4IhgeiqcvYF6qKF6UEC0vV2x3euKz5V/SDY/hblHWwx+w==", + "version": "1.7.6", + "resolved": "https://registry.npmjs.org/@better-auth/prisma-adapter/-/prisma-adapter-1.7.6.tgz", + "integrity": "sha512-31rz2OXIx2Q1L4Jhd5F71dafIWqder/Bo6vJ+eCgO5iZ0gvnv7fUmM95DvbVUoPC69XYpcIM/6PJmQ3M6RydWg==", "license": "MIT", "peerDependencies": { - "@better-auth/core": "^1.7.4", + "@better-auth/core": "^1.7.6", "@better-auth/utils": "0.4.2", "@prisma/client": "^5.0.0 || ^6.0.0 || ^7.0.0", "prisma": "^5.0.0 || ^6.0.0 || ^7.0.0" @@ -514,14 +510,14 @@ } }, "node_modules/@better-auth/telemetry": { - "version": "1.7.4", - "resolved": "https://registry.npmjs.org/@better-auth/telemetry/-/telemetry-1.7.4.tgz", - "integrity": "sha512-FycnvWXP1gZ8BlSN1lW9L/YYFVqxi7+n6A9i66gJKdoJqREDdmhxBKOaVCbNhNC2UNDAo9PbBm/6J3gv6WmAEA==", + "version": "1.7.6", + "resolved": "https://registry.npmjs.org/@better-auth/telemetry/-/telemetry-1.7.6.tgz", + "integrity": "sha512-xgRwPja2wTJtcu+OQZR2IiNpAmviddnW3n5Do5umXRugJFW3v9KlSmYjYm580mVsPnU4upvZkvSRl9v8DgSDtA==", "license": "MIT", "peerDependencies": { - "@better-auth/core": "^1.7.4", + "@better-auth/core": "^1.7.6", "@better-auth/utils": "0.4.2", - "@better-fetch/fetch": "1.3.1" + "@better-fetch/fetch": "1.3.2" } }, "node_modules/@better-auth/utils": { @@ -534,9 +530,9 @@ } }, "node_modules/@better-fetch/fetch": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/@better-fetch/fetch/-/fetch-1.3.1.tgz", - "integrity": "sha512-ABkD1WhyfPZprKRQI3bhATjeiFuNWC9PXhfGWqL+sg/gKrM977oFrYkdb4msM3hgUGonr7KlOsOFT5TU2rht9g==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@better-fetch/fetch/-/fetch-1.3.2.tgz", + "integrity": "sha512-Gs7n99b5tqUC6cQAPbV0uED3IraHB6xQbHLQ/C3l7ZFafHScOx9pQ+DYmP5blbLFShVWLqxNUlI9wi4xU/X+ow==", "license": "MIT" }, "node_modules/@bramus/specificity": { @@ -559,9 +555,9 @@ "license": "MIT" }, "node_modules/@csstools/color-helpers": { - "version": "6.1.1", - "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.1.tgz", - "integrity": "sha512-gLNsunvwf3mCi5u5o46/Z/JcJMnhbHSaZ69rkgPzNM3J4s8hWwpPUQB6/tt0EDFyCiWzxANlx+2LJwpYj4zS1w==", + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.2.tgz", + "integrity": "sha512-grhRy3OKmniaAEKXMjua5z/EODX0MSqBGjunw8+j/3HQjOnahs2AGhvEOIYVUWcU6ScApbhLhVrQTX8XqrMrow==", "devOptional": true, "funding": [ { @@ -579,9 +575,9 @@ } }, "node_modules/@csstools/css-calc": { - "version": "3.4.0", - "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.4.0.tgz", - "integrity": "sha512-XQKj5B7QiZcHiegCOCAzcAOJdhGgWOHbbu62h5e5mkHnn8lWcfiJhllkqWmxu5zWR9jucPHuo1iTB56P033hcg==", + "version": "3.4.1", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.4.1.tgz", + "integrity": "sha512-EtC7SoN1j6J4E4DCwg5QgbO5TGxgxIA1RXqe+W+qUM+BUcezx9wT+/tiQ/WO2yCX4i5X+Cuf9ciJ22aP4UEwWw==", "devOptional": true, "funding": [ { @@ -598,14 +594,14 @@ "node": ">=20.19.0" }, "peerDependencies": { - "@csstools/css-parser-algorithms": "^4.0.0", - "@csstools/css-tokenizer": "^4.0.0" + "@csstools/css-parser-algorithms": "^4.0.1", + "@csstools/css-tokenizer": "^4.0.2" } }, "node_modules/@csstools/css-color-parser": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.2.3.tgz", - "integrity": "sha512-y4LpL+lmpuyKDiEFq2PnZUVFdAjsoB/qQJod79yLNokXyW7jewi+/WJ69EfItj8A2unWtxXnGjw6LYXgXu5ZjA==", + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.2.4.tgz", + "integrity": "sha512-DyefytAZ735mX4Dq/WcDAXFtXhaEFvme0ZS9tVEBAc2whxUthXr0R0L2rmEPm59SrMBkGrFzQviBXMs/UtnABQ==", "devOptional": true, "funding": [ { @@ -619,21 +615,21 @@ ], "license": "MIT", "dependencies": { - "@csstools/color-helpers": "^6.1.1", - "@csstools/css-calc": "^3.4.0" + "@csstools/color-helpers": "^6.1.2", + "@csstools/css-calc": "^3.4.1" }, "engines": { "node": ">=20.19.0" }, "peerDependencies": { - "@csstools/css-parser-algorithms": "^4.0.0", - "@csstools/css-tokenizer": "^4.0.0" + "@csstools/css-parser-algorithms": "^4.0.1", + "@csstools/css-tokenizer": "^4.0.2" } }, "node_modules/@csstools/css-parser-algorithms": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", - "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.1.tgz", + "integrity": "sha512-ShL8BqPfbKJrJiKFH0xBbN0i7Nrh9HXYRuF+pzyj93R/BL2YAsUxJeqANErzqM+0JGl7vjHp+3OIgd/DL69YIA==", "devOptional": true, "funding": [ { @@ -650,13 +646,13 @@ "node": ">=20.19.0" }, "peerDependencies": { - "@csstools/css-tokenizer": "^4.0.0" + "@csstools/css-tokenizer": "^4.0.2" } }, "node_modules/@csstools/css-syntax-patches-for-csstree": { - "version": "1.1.13", - "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.13.tgz", - "integrity": "sha512-i9ZylF5QNhmNfPA9l0vHAWK4kPrbIp6g9lKgaiIFsIBz2F/WNB7OLrzlNNcCOm+h42bkaSD2v1PG+IBPHhc3ZA==", + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.14.tgz", + "integrity": "sha512-HpbVXyrofRXpHpgkNIjU/3EWR4WJvOkO3emNK/L6X/mTJU7bGUI3AkkpoTNXznQLp0KRjLHELTGeKI5dIkI9JQ==", "devOptional": true, "funding": [ { @@ -679,9 +675,9 @@ } }, "node_modules/@csstools/css-tokenizer": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", - "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.2.tgz", + "integrity": "sha512-OoKoR0f76dCY666JlcbhmVTs2drYj1GUXZTYTcbUgJjh9Nv41aFfZ21bPQTERm5+L5cBDo466NltB2lplS5GBw==", "devOptional": true, "funding": [ { @@ -1397,9 +1393,9 @@ } }, "node_modules/@exodus/bytes": { - "version": "1.15.1", - "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.1.tgz", - "integrity": "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==", + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.16.0.tgz", + "integrity": "sha512-IcpW84uEn3N7ETtNZMlxKhfl6Pec8rUNGOTBtWbK1FKhJxIFAptZyVrvVRVBimAJxJCgc3PxepxkdWWG4DVzfA==", "devOptional": true, "license": "MIT", "engines": { @@ -1665,9 +1661,9 @@ } }, "node_modules/@img/sharp-darwin-arm64": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.4.tgz", - "integrity": "sha512-Uhfl4V4lhP2nbUVF9+hyH1+luj86f1gUFeo8ALYxFoULoU+G87D43BfeMP8XHsk9boxAnCY/bf2EHwhA7MuGsA==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.5.tgz", + "integrity": "sha512-QRUlFQ0WxvdWyqqG/WtI3iupfD5rBzmCHXSdPsY91sAtVtTo7Q4cb6zOccZ3gqEqkr0f1As1ehLqmEpDsRf+lg==", "cpu": [ "arm64" ], @@ -1683,13 +1679,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-darwin-arm64": "1.3.3" + "@img/sharp-libvips-darwin-arm64": "1.3.4" } }, "node_modules/@img/sharp-darwin-x64": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.4.tgz", - "integrity": "sha512-hWniXY3bG5qKpkKrAwPe4y+VTPmf086YQAnkxWh7uA1YrlRouWGa0M0Mxj3ZjnXFkv7/TD1bTy9lGUK26vRvWw==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.5.tgz", + "integrity": "sha512-+BR255RhDlpygUpOc/Jdt1nT6DQ3XG/ERo5wbcdOf5Q320dKtPCKPLR1LJs9VGXRaMa8l1uUa0tkCNOXiAxZUw==", "cpu": [ "x64" ], @@ -1705,20 +1701,20 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-darwin-x64": "1.3.3" + "@img/sharp-libvips-darwin-x64": "1.3.4" } }, "node_modules/@img/sharp-freebsd-wasm32": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.4.tgz", - "integrity": "sha512-lIsKw/BU+kjB4eZjxrYrZmwOJYi3Ajrv66iAlBmUPyKc3HpnloevB1g3wxGD9P/5BbQ1brBGl65VRRrCvQDEqA==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.5.tgz", + "integrity": "sha512-Y/z91nEZ4uIBX5X3nfTovjU9lHNKFYbL2lpHCLVNmXQK03VIZvXBBt0KxbPGp2SdGSF+2mQU4e+hQaWOt86iAw==", "license": "Apache-2.0", "optional": true, "os": [ "freebsd" ], "dependencies": { - "@img/sharp-wasm32": "0.35.4" + "@img/sharp-wasm32": "0.35.5" }, "engines": { "node": ">=20.9.0" @@ -1728,9 +1724,9 @@ } }, "node_modules/@img/sharp-libvips-darwin-arm64": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.3.tgz", - "integrity": "sha512-suTBPTDGrI9WodccaDdwZItTSaBYASlBk1NSfElSHrUfzu3szG6lvIF58+WiFvnfzuK8ZBFS5zE00PxqxnRiPg==", + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.4.tgz", + "integrity": "sha512-5R89nBYiRdUlSWJxPhO+GVtaXzXSxKnRu/xqMn3KTA3L9EB9Oy/P+Nn2f2vlhPuUdy/Zusb2DarbyTpGCfEDuw==", "cpu": [ "arm64" ], @@ -1744,9 +1740,9 @@ } }, "node_modules/@img/sharp-libvips-darwin-x64": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.3.tgz", - "integrity": "sha512-FVJZ5mITMobmXIz/hPDTw0EintTW5H3WfrxwLqEqjiIihlu+hVRyGrFQ60xl0Lxn7Bt3zdpevPaQi0HEzqz9fw==", + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.4.tgz", + "integrity": "sha512-iR2OKH80yi0U+dUplyh3/xdpFvps6YkCwsXenIJxqxR1v9o+xtKTGbS9H7cps+2Vxjc8B1j96p75NmTGjIhtpQ==", "cpu": [ "x64" ], @@ -1760,9 +1756,9 @@ } }, "node_modules/@img/sharp-libvips-linux-arm": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.3.tgz", - "integrity": "sha512-3rbU4vqXXc3hY/OiXdl52xZvT0F1yEngWfvqudtPJg/KkyiaQw2DRsFrNzpmLvfavbwOq3qXn36GP8obHRULQA==", + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.4.tgz", + "integrity": "sha512-LmRtTsOHuvM2+wlO2Db37dx5MiZhB0FvSunciw48YjdOkZz9KAiRbm8ujeMOA1INqmei5NapFxYEK1D1ZSidmw==", "cpu": [ "arm" ], @@ -1779,9 +1775,9 @@ } }, "node_modules/@img/sharp-libvips-linux-arm64": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.3.tgz", - "integrity": "sha512-0DaL0A6Xu6sQSQFwe4iVCrKWU2cCTItnRsYsCdxAMm9NF6twAA9BKnoqy4hqz4+azQ0JHuA26qiUKsf1XJ/v5A==", + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.4.tgz", + "integrity": "sha512-Y3dgX/6lE2QhQb+Gxy0WZxfg9MEm/JBjamZpS2IklP7xIQoKN4hzAm7KcMVGtaVDt3neE9OKBC7vAfonA/Lr1A==", "cpu": [ "arm64" ], @@ -1798,9 +1794,9 @@ } }, "node_modules/@img/sharp-libvips-linux-ppc64": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.3.tgz", - "integrity": "sha512-cdn1OvUBwsXhbC0zSzJnNzf5MZ/mTrobawDvNXBTxe8VtqKAm0sRuEY2Evzovb/w9JMk4TvRxqt1mekSuJz64w==", + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.4.tgz", + "integrity": "sha512-Le6boB8Tai0Nis+gIxIpKx68UDVVIqdR8Tin5Yf1z2LJJQLDJvCDRqRu+jC2qCoD+eIomonmOwB4smBRxfVpYQ==", "cpu": [ "ppc64" ], @@ -1817,9 +1813,9 @@ } }, "node_modules/@img/sharp-libvips-linux-riscv64": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.3.tgz", - "integrity": "sha512-HjPVx7yKz+0lqdhDlTw1tt90wamBoxhiXpvl1XZpJLiHH4RCJ5yDTqH+VlYPv2fwFs89JFw4c1IexYOcQUi4IQ==", + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.4.tgz", + "integrity": "sha512-aHkkIEHPRdQEegJN20MLmGtxYD9R2wQr3Cwpddnu5+YKMt6Uzax7S9h5gpZTo8wyrGuZSlfQ63OevL5mTyOC7Q==", "cpu": [ "riscv64" ], @@ -1836,9 +1832,9 @@ } }, "node_modules/@img/sharp-libvips-linux-s390x": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.3.tgz", - "integrity": "sha512-neWLh+3yCNThxnfy3c4BbVBeGgt9aftno+XbT56iK28RgeDs3UOFWviLWlUu0bArYVYJaFDK+RRohbicUNCm8Q==", + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.4.tgz", + "integrity": "sha512-ra/mB6MikESDUO7Yg+Mi95bFBb9GsObURuhnOv3OqknjGe9sZrG8tCe9q0xSIGrtLgvgw0gKnFWcK4blSgQOuQ==", "cpu": [ "s390x" ], @@ -1855,9 +1851,9 @@ } }, "node_modules/@img/sharp-libvips-linux-x64": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.3.tgz", - "integrity": "sha512-4vKmvAst9nrowcqquKFAyZJUDolUaIp8uRiN0mWFguJ1IplC9/pitXtlnnlU4aa/eJw3J7i67V+pwUL+wZGdsA==", + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.4.tgz", + "integrity": "sha512-GJ//SSXbnwSDes02umB3nDJLFcQzw8a18V8fyhqr6tV515tOEMdImjjxj1AoafMRz56F3PHgftnj1QEKSU1zkw==", "cpu": [ "x64" ], @@ -1874,9 +1870,9 @@ } }, "node_modules/@img/sharp-libvips-linuxmusl-arm64": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.3.tgz", - "integrity": "sha512-Y9kQaLMuNoB0bPYOOdcZMaseNrFpPodIWWMrx+CZyydf2xn68j9WYc6sWWRrDwNkzCQjKYfc68L7jKjGlHMibw==", + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.4.tgz", + "integrity": "sha512-hvulFwtjUcagsis6BBxHwGFwWoNZjgYmULGVrZcyfNbjA8hKILbRxGg15/7w5HDyXHXUos/j6baAWqnCyQ2DWA==", "cpu": [ "arm64" ], @@ -1893,9 +1889,9 @@ } }, "node_modules/@img/sharp-libvips-linuxmusl-x64": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.3.tgz", - "integrity": "sha512-fj8Mv0HHfD1Rr+4I68+3agJynxDWtBFgicTbSOb9Bke6pIwzGcJ+RX/yHjmiEGFMCavY/dxvem7MyNaJF+wDiw==", + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.4.tgz", + "integrity": "sha512-6zXKeE/p39I1AmA3cJG35eyBGNqNddLnUXjhwBnsGjFPWqf5VKkDBEqaEkPDoTEtkxwi2vv8Tcr2mDyP4So7Fg==", "cpu": [ "x64" ], @@ -1912,9 +1908,9 @@ } }, "node_modules/@img/sharp-linux-arm": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.4.tgz", - "integrity": "sha512-7OAS8gI0EReKGVN2HssHlM6umJgxF5VI3xN0p9FA91p/YO+ou5hiNghLdZ5BEHztwaaK5+bLKRf8x/o2L2nk9A==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.5.tgz", + "integrity": "sha512-LEaXK2WdXVK5ykcw0buWyPMsmLLL2vpHLD6yrNSW+JGEL3BZPA4tpKN6iaMc4AxTTAoaX/sU1rOL51lcIz48ZQ==", "cpu": [ "arm" ], @@ -1933,13 +1929,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-arm": "1.3.3" + "@img/sharp-libvips-linux-arm": "1.3.4" } }, "node_modules/@img/sharp-linux-arm64": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.4.tgz", - "integrity": "sha512-De4jpEnAU8Hd5oT0j1G3uL4ZvTuipVMn7YC6vPaJhy6/7EwEae0SVAoBrUMYQbkLGDm85taVWwuPc1a44LTzCQ==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.5.tgz", + "integrity": "sha512-LYVx5JTsOM2CBzmxreh+nl64/3H6Xb09iSLknqH47z2T2DFFxDeFLP5y4dJwe6H7uGQlHPyEEtIqyo3DYsRwdQ==", "cpu": [ "arm64" ], @@ -1958,13 +1954,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-arm64": "1.3.3" + "@img/sharp-libvips-linux-arm64": "1.3.4" } }, "node_modules/@img/sharp-linux-ppc64": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.4.tgz", - "integrity": "sha512-2oYZJeIl4kCcMGk4ouZVjnkCtFrpQFlNEtJ6GbxzhHQchwH0NH/qEb9ykmOl29dqwMq+JhFdZn+1ak2FKhI9fQ==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.5.tgz", + "integrity": "sha512-QVxAAq8evVRI9ia2vqgwrmWucn5Dfv+JdWzj75pD8omHLPSP7f8p20O8jxzjCcuCEQEOtYOZUmX1hkiZ0kdevA==", "cpu": [ "ppc64" ], @@ -1983,13 +1979,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-ppc64": "1.3.3" + "@img/sharp-libvips-linux-ppc64": "1.3.4" } }, "node_modules/@img/sharp-linux-riscv64": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.4.tgz", - "integrity": "sha512-cPbNChoRURAWdebDIHSenxRpgEdy7JkPydSnUxRm9VvKD7m0/xVaR/8Fzlu81pk5nHEvHH87UZUA7cTtwnbJSA==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.5.tgz", + "integrity": "sha512-LtdreXguaavKODPIfzJ4kffx7UNt1omwtK0rch4EBbbSTXPnxWmYSayXdLJw0fJzQ97kHt1gL/yh4tvU+nCyRQ==", "cpu": [ "riscv64" ], @@ -2008,13 +2004,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-riscv64": "1.3.3" + "@img/sharp-libvips-linux-riscv64": "1.3.4" } }, "node_modules/@img/sharp-linux-s390x": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.4.tgz", - "integrity": "sha512-RY0JFY8Fd6RonCBtHz+DvadaPkXDSI1AUn6yWL9TipqkZ1vY8w8evqdgyDFnkm4/K1ve1TvZiaePP5oSd4+WVQ==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.5.tgz", + "integrity": "sha512-UZasTOFiYzotTsGOCu42BfUzP6Tu6Do/947iRm1RsLKvlllxwGcn4RN27LibGWceix4Y+Pmw3jsnTcCQIgWjqA==", "cpu": [ "s390x" ], @@ -2033,13 +2029,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-s390x": "1.3.3" + "@img/sharp-libvips-linux-s390x": "1.3.4" } }, "node_modules/@img/sharp-linux-x64": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.4.tgz", - "integrity": "sha512-9qvvEAuk8k89TfWUoX2htWjbAMX8p+NxCppjpcg5k6xMsjhBQPTsoIh36h9Qde4WRuGpJeYnOjdosDn/cnv+OA==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.5.tgz", + "integrity": "sha512-SxFtLTeJInhAA9Q836kux2vZNeOBQEx658qvbboZScr0wIARym3IcGmW7KpVD5sbVg0Ojy+udFQdayYIZyoNog==", "cpu": [ "x64" ], @@ -2058,13 +2054,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-x64": "1.3.3" + "@img/sharp-libvips-linux-x64": "1.3.4" } }, "node_modules/@img/sharp-linuxmusl-arm64": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.4.tgz", - "integrity": "sha512-KB5jxpfWQTr0nc3xdHtWChdbifHrBGsd2SM62Eyxrl8afikm+f5qGBU75SJIZBT/S1MC8XyacdlXBMSWq6OURA==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.5.tgz", + "integrity": "sha512-9HbMclmI1zlNkFRs3z9/eBtDjfD0sGlrX1z6b1qwmiFY5ElDLh4BC0LPBdVp7z1DXFiKlIcznf+ZlsuZzLxQqg==", "cpu": [ "arm64" ], @@ -2083,13 +2079,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-arm64": "1.3.3" + "@img/sharp-libvips-linuxmusl-arm64": "1.3.4" } }, "node_modules/@img/sharp-linuxmusl-x64": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.4.tgz", - "integrity": "sha512-f+eZJZIQNEEd26RPSW+76chwOf1XtA2Y/O+5ocVyLliHkeih3e+jhLVBdNTd2rS3IbNXK8+ug93Vf5ZXtF5Lxg==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.5.tgz", + "integrity": "sha512-4KOphqB035HrVdqLZfCgMzzERrQkkzOwRhl4OAkRO1YCldbaFjySXMaK534Mo0V+LndnlJk+sbUyLeU0ULyD1A==", "cpu": [ "x64" ], @@ -2108,13 +2104,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-x64": "1.3.3" + "@img/sharp-libvips-linuxmusl-x64": "1.3.4" } }, "node_modules/@img/sharp-wasm32": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.4.tgz", - "integrity": "sha512-zQnl4Kwp7Q6NHsENtU2T/00Zi+w3AQNwz3+UaTyVBy2FpXrzXzGjndpK61onhZjRtRpQXxCTeqw19bVyXOh7jA==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.5.tgz", + "integrity": "sha512-Ptsga1su4tQx+LLF1ECS9U6nz5kmrXKo6XVbtR48Ke3ZRxxgaWBu7IDtEe1quo8hiupwm6WFqxVlXaSf7IINGQ==", "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", "optional": true, "dependencies": { @@ -2128,16 +2124,16 @@ } }, "node_modules/@img/sharp-webcontainers-wasm32": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.4.tgz", - "integrity": "sha512-ESfNkywmCfPNyaZjxooddJQiQ+l/nTpGEOGthxiLnIHXC/CmcBixnfwUleX9mCz9ovrUUvKMap/pm8RYbzfwaA==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.5.tgz", + "integrity": "sha512-hfhF/FmoQyTUkA0bIKFOtw536BQSeBMe6BF6QyWlrPxT754+TFLaZ7sKKTfvvM0yJgKgaYTwnFCIZ/GuDw5SUA==", "cpu": [ "wasm32" ], "license": "Apache-2.0", "optional": true, "dependencies": { - "@img/sharp-wasm32": "0.35.4" + "@img/sharp-wasm32": "0.35.5" }, "engines": { "node": ">=20.9.0" @@ -2147,9 +2143,9 @@ } }, "node_modules/@img/sharp-win32-arm64": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.4.tgz", - "integrity": "sha512-iNdlBX9gLVvqe2I3uIJSIKTq6wckP/DYxZtcqxm09x5Gi24DnFBmPAWZmr60ZyYMG0xlzo6goG3670ar+RXvRw==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.5.tgz", + "integrity": "sha512-X4t7g+7ZA5DKblCBEXGjUqqemj4vczING/5viFwAL8h4N3qYeyjwdCvRLHi4EdOUI+2Z7UFlp1VM+p/AuEtm6Q==", "cpu": [ "arm64" ], @@ -2166,9 +2162,9 @@ } }, "node_modules/@img/sharp-win32-ia32": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.4.tgz", - "integrity": "sha512-kqRsbaa5CS6KHlpxnN7WhE6vAAugXyZButpRdvDWetlv6Qv4N9WTcrWzF7tXfB9T7MsoadqdI8hmwLq6UlLvtw==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.5.tgz", + "integrity": "sha512-5Zm82LoBc43nhwNybZlG7Y1KO//Zhsn306fQl29ZOuStHLGTo3BWL83q3cznX0poxSAMuYL1On/BHBxkBeKr6A==", "cpu": [ "ia32" ], @@ -2185,9 +2181,9 @@ } }, "node_modules/@img/sharp-win32-x64": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.4.tgz", - "integrity": "sha512-XtmnYhBcrORsJ4XJngyzr/EWP0hRZLAZRFaApdKuviyqF78+ylxh2y06ZmtULAMOnObJ3ucpN0AcwSWnMowTRg==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.5.tgz", + "integrity": "sha512-x76eH0vEiHlcMQu8Y8IenntaACtddpT6W0wmXtWrnKcnKI7ME5DdgqhAD6SEWOEl1v2zDvkZDhFA9KnURwpfqg==", "cpu": [ "x64" ], @@ -2620,15 +2616,15 @@ } }, "node_modules/@next/env": { - "version": "16.3.5", - "resolved": "https://registry.npmjs.org/@next/env/-/env-16.3.5.tgz", - "integrity": "sha512-NWEXVDMqoEo0ktmU6u0sE2Vg0LOcsD7NnOTJNo3/fEaTfsg+F1bMIxuDmQbda4e3yTIQwVdUREF2yIuMOusKtg==", + "version": "16.3.7", + "resolved": "https://registry.npmjs.org/@next/env/-/env-16.3.7.tgz", + "integrity": "sha512-/HuhBN1CorqNTmewTIh81yXOQri6B8Ye/c9D7+830XqwVaf8qMnJNrgNxDn6dLtihq8YzBbI6NSAuMKvpr+DvQ==", "license": "MIT" }, "node_modules/@next/eslint-plugin-next": { - "version": "16.3.5", - "resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-16.3.5.tgz", - "integrity": "sha512-PGfSeItHJ12DH8t+6sEbuMe59NE5rAhCfgk06QKTH2ne9VUL1JlaXdYXY3B8RaiF2SO50rDYvd39TulP6A6xZQ==", + "version": "16.3.7", + "resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-16.3.7.tgz", + "integrity": "sha512-ZQwUKBxqKAasarxLHJkkIv0cuG80pJ7TKvCUcFvlKewkuzYkL+Lsxb++DQlcBnY4uT0kOazDye4j1pyDPlDcDQ==", "dev": true, "license": "MIT", "dependencies": { @@ -2669,9 +2665,9 @@ } }, "node_modules/@next/swc-darwin-arm64": { - "version": "16.3.5", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.3.5.tgz", - "integrity": "sha512-pMmGgETfKvElucLHtVaeiMRbp2zUbvKx7b1yGko0liBz3cw1mKSggWN/Rp/wPz8z+E1O82u3r4L1Co+ZS5hokQ==", + "version": "16.3.7", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.3.7.tgz", + "integrity": "sha512-MDAd3woxfJOVFtfG2VAuaz5zyyVZTMVUPJAknotCNgeEPrPlRBlcV9YSd8S620P6TiSc2MWpfwO/UAHl9EWRug==", "cpu": [ "arm64" ], @@ -2685,9 +2681,9 @@ } }, "node_modules/@next/swc-darwin-x64": { - "version": "16.3.5", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.3.5.tgz", - "integrity": "sha512-76VaGYvf6HPa5/w12yLkE3dXTn9AfdEviI79oEL3aZoAmRLc9rWitjWqyjViVysK/ht/y9YKzFkBrUdi/wGkow==", + "version": "16.3.7", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.3.7.tgz", + "integrity": "sha512-Usd86QilBWl2G7JxfWskV9NLAxacVRrDDQ3WQIoPKRB0MXiVuVSdyu3wt5EA8SyjQpaVk/7VOzSkRB4LgJltyA==", "cpu": [ "x64" ], @@ -2701,9 +2697,9 @@ } }, "node_modules/@next/swc-linux-arm64-gnu": { - "version": "16.3.5", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.3.5.tgz", - "integrity": "sha512-zKDELJ5jSQMHeO/hmXUQsAzagX4bQD4OiMi3pQ5FbUj+yK506oLVHnKA2YXMlbg1EHHqJYtyePOgByIDXD1lqw==", + "version": "16.3.7", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.3.7.tgz", + "integrity": "sha512-pVauSs1WomgtBgfJj/Q+846nBix4CdohLUIDafyJGdAvNw5kOusLawoX+rpc2P4U3Kd7tLwqFXsnHeSkLZrqyw==", "cpu": [ "arm64" ], @@ -2720,9 +2716,9 @@ } }, "node_modules/@next/swc-linux-arm64-musl": { - "version": "16.3.5", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.3.5.tgz", - "integrity": "sha512-7Vql0pgzCoHagv6+FNOZoqmJqA52c6zeVbhtS/47qFozO1MSx4ms7x7GHiciY8R5CDsSMKMQjJEryoJLcsBIbA==", + "version": "16.3.7", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.3.7.tgz", + "integrity": "sha512-vY+iamd6cOfk29bGTgxeS/OXlrlfOfyFgyd34cibC7e25bXRMxuqNUfs2Z9Fxb/VHpV5dopffMSWnCeR2tc8rA==", "cpu": [ "arm64" ], @@ -2739,9 +2735,9 @@ } }, "node_modules/@next/swc-linux-x64-gnu": { - "version": "16.3.5", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.3.5.tgz", - "integrity": "sha512-NH/xzehyHEFWE2nlcZon7TB/0+H4shfWCi7S1zka815XCOhJDYZhoeJtOYy0dh0WVRWACVXSyGNFFytoMxUhRg==", + "version": "16.3.7", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.3.7.tgz", + "integrity": "sha512-NWx0LRZ9IO9rTDXLc+Hi/bNcTKblT86du4OLMRm5Z62C2T5/+mNEhK2XeQxwoSmVFvU2rjMSjJMK4ttZAk88+w==", "cpu": [ "x64" ], @@ -2758,9 +2754,9 @@ } }, "node_modules/@next/swc-linux-x64-musl": { - "version": "16.3.5", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.3.5.tgz", - "integrity": "sha512-lV4+EhWMfS8jcC+EH2nn/Cm5cn6XsgbE07bU9tMH8fCo0tNAqhyzi1b5wQ/Tn6NGFTvKDY65w3ZH95EjwBRAnQ==", + "version": "16.3.7", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.3.7.tgz", + "integrity": "sha512-v5Dk/iMB4JyQZwQ+UWOZGGAqA4HjkBewKwaCkXi5DqL1Dy1TjXpRmAh8N7VOuID0hLmouICSXozJrMBBG1Xeyw==", "cpu": [ "x64" ], @@ -2777,9 +2773,9 @@ } }, "node_modules/@next/swc-win32-arm64-msvc": { - "version": "16.3.5", - "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.3.5.tgz", - "integrity": "sha512-/wKzAREX2RF++MhicjDbg8tGn2AiBIM0+EFeTFKoUEUbW5D6amCJehd5Z5G1H5/gxNdgnwoXMcHz24H/c2tGkQ==", + "version": "16.3.7", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.3.7.tgz", + "integrity": "sha512-G4BkB7AhfKJnaoIpIkoA66l5pXPGJXD3EF1EsuYj/sgM4rHA41dsa3CIIqjR4FQFZEjU+zGQRbIEPYZIOi5EoA==", "cpu": [ "arm64" ], @@ -2793,9 +2789,9 @@ } }, "node_modules/@next/swc-win32-x64-msvc": { - "version": "16.3.5", - "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.3.5.tgz", - "integrity": "sha512-LNdCHzgLFc+UeqMS84LzXPaeBRKyqDN9OMyFAr1OrB0XrNw78IRrEVtZvvA7245W/HsaoeVOQX9jPjPk8jojwA==", + "version": "16.3.7", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.3.7.tgz", + "integrity": "sha512-DuvRhf50tGU7leT9Ow0cs/9lo81X5lHdNoxSRk4ckSk2Ihn2dsM+BEe5j7ANpEFhD25p//SmV8ajdRtuaBIg3g==", "cpu": [ "x64" ], @@ -2896,9 +2892,9 @@ } }, "node_modules/@oxc-project/types": { - "version": "0.149.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.149.0.tgz", - "integrity": "sha512-Efcc+iF0j3Bf67YjEqIqWXbX5XddXoK/Mw4K1/JuXwRCZ8N16VR7iT23nlCc9XrveFVh/E5Rqs2StT0V8v9LdA==", + "version": "0.151.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.151.0.tgz", + "integrity": "sha512-J1yXrIlNDZVzE3ada310xeAw7nH8yCAyLPuUIsjKatFPmfn5bS1oW+cM+QsGOtVWd5nhSpbwZWx/rue+r5Z+PA==", "devOptional": true, "license": "MIT", "peer": true, @@ -3981,9 +3977,9 @@ } }, "node_modules/@rolldown/binding-android-arm-eabi": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.8.tgz", - "integrity": "sha512-tN5aztYkKCte4i5SIrrz5yK/HMjEuCqCSCJa418jOV8tZ1cBY3YF2otxB1ktPxzsLA1BeTqwapK0bfjxNvHJVw==", + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.11.tgz", + "integrity": "sha512-A5kXfGKvKWWZE0TtPrfsvT+q4Y5d1QG8gGUzpYjGydM+fARM9MuX90PrXYXe0XbsDVgyxxNzHo6giCj90bsFNw==", "cpu": [ "arm" ], @@ -3998,9 +3994,9 @@ } }, "node_modules/@rolldown/binding-android-arm64": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.8.tgz", - "integrity": "sha512-dIYTWl9XprMUiQFoc55KUyk/oS8SKYH3zFl0LTR7RT0Xj4hgSVyuJcroH8JUu8RcpF8fTB6E0aOwCkZoYPcDSQ==", + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.11.tgz", + "integrity": "sha512-z6cTycz+iJ4PVkuL4HHW4DfTfoeU/2nqYYuSOrTmH7yHK5Y0LCOnA03V4ZNxavyVaU1oOqUgIg2klN/s+USGOA==", "cpu": [ "arm64" ], @@ -4015,9 +4011,9 @@ } }, "node_modules/@rolldown/binding-darwin-arm64": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.8.tgz", - "integrity": "sha512-PCSDQGXD2IyTEFrcgPyBM8jJuGmrbCMuoIOXdbEGVemruKACXoLQJrb+A45Z0L5t1RQkdfJprAYPkikbh7dzdA==", + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.11.tgz", + "integrity": "sha512-jShvqNtP6vDC6/A5JOAzbVV+DkgHqhl/ScVCJEbt+TUY6QYz7YnXcrg3sLtFBniro0f/Ld50ZwCWA6f7KYD1nQ==", "cpu": [ "arm64" ], @@ -4032,9 +4028,9 @@ } }, "node_modules/@rolldown/binding-darwin-x64": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.8.tgz", - "integrity": "sha512-Uk7lRsGhPFHVX/sAUC6D5H9Ol30dFHd6iquokll2th3LpdJ3F5CzQB+7DHn0Ri2mG+U7k2zXiPHDrwZenXhwSA==", + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.11.tgz", + "integrity": "sha512-f2i2xiNWq1Z1l2++q2fuhZRdLAT3aqxD6vRNm1RAxpUoBcdqNB3C0s1Bt+K+PbEx2F5F4gQp6hqKkphCY/xF9w==", "cpu": [ "x64" ], @@ -4049,9 +4045,9 @@ } }, "node_modules/@rolldown/binding-freebsd-x64": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.8.tgz", - "integrity": "sha512-DjszaTEVogPqA5bYzsEeqDCQxbcp2fexQwKcRspYji2yzR68fCf+e4fx6kBSRDwX5/brZaHw/hWS9+A/+/w9sQ==", + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.11.tgz", + "integrity": "sha512-4Ir5FSOKIAMr4r0kExpt1s3bMgzJU3rA45AYOHtQpls0oNeqcYBKrWMlckrYH4KCfGLfkfn1tN1dmZPMVsdXow==", "cpu": [ "x64" ], @@ -4066,9 +4062,9 @@ } }, "node_modules/@rolldown/binding-linux-arm-gnueabihf": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.8.tgz", - "integrity": "sha512-zmwa7FTmdzB6aaEEuuls18H6Ap5JmJPSoPTuXixeJZV6tG40SyLkApQtz1g8ptZtiEKqj9OM0oNLPh1AgvE31Q==", + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.11.tgz", + "integrity": "sha512-/gnRDM+39BROzAN/k1OZjDPnDMcZxB/0EUxKjONO5yVkNEvlsoMDrxGNKgZi/ttFriS2gwlDNzB65pvNbFOXIQ==", "cpu": [ "arm" ], @@ -4083,9 +4079,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-gnu": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.8.tgz", - "integrity": "sha512-KdYQDPHwJVnbFwdTGMgxsI9SqblBlz6STGM+w1We/d5B8OWWidYH0MwkU/uA1wM5fIpO2MkOVxXrNzzuZhw9ew==", + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.11.tgz", + "integrity": "sha512-PFaK8HwvAHbaKbBcDNQihjMKYvFnA5hiENx/l5tphTDz1E0WFp32l0A7aq7lyUwGsRw/xSrNIy/gIK4thrSCrw==", "cpu": [ "arm64" ], @@ -4103,9 +4099,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-musl": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.8.tgz", - "integrity": "sha512-jFJTifHnNPY+yzOoNZQfSIysrVyXzEQPhPnOUjmD1bcQGHH6s7c8cViKWar8YplQImE5N9JRqMCLrM2CdxOrZA==", + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.11.tgz", + "integrity": "sha512-AskzJUIKRLPxkruR1wLKewGbOw+EYfU/9lOrBFj4AFrEA8hPpKFnODWNu2WLaNs0QNkEb9QIJufmVZZIL/bJlg==", "cpu": [ "arm64" ], @@ -4123,9 +4119,9 @@ } }, "node_modules/@rolldown/binding-linux-ppc64-gnu": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.8.tgz", - "integrity": "sha512-FhiOziBDWPBjbcmRzfLyIJnaP7AVMFXT7YCXPjXxj7wKU3vx24RjrCNN/zjvVa+N2vVoHJwCoUBvsrN/DG3zIA==", + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.11.tgz", + "integrity": "sha512-qlUGAheh2yh8afH7QBgx0PrRHN85hKnNd78x8MeMhXivuevgd8vgf6/CstOzmNKY/lLTHvNTrPy98cLnAugzJw==", "cpu": [ "ppc64" ], @@ -4143,9 +4139,9 @@ } }, "node_modules/@rolldown/binding-linux-s390x-gnu": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.8.tgz", - "integrity": "sha512-WnHfADMzOV2Y55wlx1hzzQnar/wDt/VdvWSD99r18Mz9ylNieIGOkRx3UV21h7m/eJvjySYJkO26VvGNFkwsIQ==", + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.11.tgz", + "integrity": "sha512-secpEad+0vCbSfn8upFySkDskv+bGPk3THSDS9Y89yc4rb4kzqHp8Dmyd9BkQW4SnhNXBZCl/6CrO//hZahNJQ==", "cpu": [ "s390x" ], @@ -4163,9 +4159,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-gnu": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.8.tgz", - "integrity": "sha512-H9tRr5ibfXFVLxbPOseVewewFpl28zcEdjRDt2FTUZU7odxP0gEv1ki4/kGmcGOh78oRwZuuQllGLZ9zTJp84g==", + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.11.tgz", + "integrity": "sha512-mOVBT3dPpkWm8XBWPmU4bf+U6dYDLeMo/9ojUmis4N0L5uu10qra5vOyngZ7/PSdoE4G9KvRt4bloRxNjLas7A==", "cpu": [ "x64" ], @@ -4183,9 +4179,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-musl": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.8.tgz", - "integrity": "sha512-UefiqfM3D6IVNlZ8tSGs9+Ejjud2T+oxO0IHADU45Y+lyEjD2dVFyZHbkfX0LUb5Zugo/oIv1eCO/KVYhgYJYA==", + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.11.tgz", + "integrity": "sha512-Is78i9A8Ui4SqcxUwFJ9uMmjDn58IbVTjFWYdQestFEgeuEmHMLGNriXnVJKkwG2YiZjw8cP0zCTyDMdDGtOOg==", "cpu": [ "x64" ], @@ -4203,9 +4199,9 @@ } }, "node_modules/@rolldown/binding-openharmony-arm64": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.8.tgz", - "integrity": "sha512-637Ke4kWSy6rp9cxQ9gMOXlxPgIw/c1beASV4M//3+9I4uwBVOOl74G+e3zyU3u19U7RkRl/HuewixZ/Z6+Rjg==", + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.11.tgz", + "integrity": "sha512-dUCXneZ87INUMyQ0D+C0HrEBNUPNXHaPmU5GTjyKTJEiussw9Kaj5Ln8UztPe4epV/ffvgNBEadksdYhmW6xJA==", "cpu": [ "arm64" ], @@ -4220,9 +4216,9 @@ } }, "node_modules/@rolldown/binding-win32-arm64-msvc": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.8.tgz", - "integrity": "sha512-xWBkPOF1Q9k/Gv1nQXnVdLxKu74jXppuOM4Z3mnypVUJJJwLsMl7hNJGRAUJoG8A5MgOI1ACKM+wBFxSJzKy4A==", + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.11.tgz", + "integrity": "sha512-jByxb6qfd+bH1xUd0qnfFnb17i9sWBPY2tOavJ0l3tdr3OTu+Kvtm8cd/JV5nFt657b1VqGltxg9olOEfofXWw==", "cpu": [ "arm64" ], @@ -4237,9 +4233,9 @@ } }, "node_modules/@rolldown/binding-win32-x64-msvc": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.8.tgz", - "integrity": "sha512-uz2ZvfgXbxqNwijjjbxrnvALwpyODDcgc1T1N8N3rf/DXKQmaFwmB4LX4yyjggpwN2obdQLb2rgirX5ffCWYng==", + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.11.tgz", + "integrity": "sha512-/PzKqzAJ03i19oy2ItPvyvaVjOjBCNnfaJs8yvUdGBKmiESgnrJSQ2awd81QzFbbnAmu7YO9ZnJrDCb9VSJPRA==", "cpu": [ "x64" ], @@ -4572,9 +4568,9 @@ } }, "node_modules/@testing-library/dom": { - "version": "10.4.1", - "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", - "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", + "version": "10.4.2", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.2.tgz", + "integrity": "sha512-yzr2S9HyAIdhz2/6qHgbs665Q7PKVcDF05vsOlHPxG1mo36gKVesdYVeDLnXgfjJ03CrKRk08knc6+E/9m8v2Q==", "dev": true, "license": "MIT", "peer": true, @@ -4761,13 +4757,13 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "24.13.4", - "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.4.tgz", - "integrity": "sha512-YJ7EqCstVTzIr0fMr7qul/977en+pQHrfmuKIo6Zr9i75Be21dr3MovcfvGtyvi2HAUrRerWps5sMO9I7WaxDw==", + "version": "24.19.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.19.0.tgz", + "integrity": "sha512-zY+5tKxXdhGh1PYI0ac+7juvEu4OI6vWtVVoj5i2m42jxAY1U+zHGt6QCyOFwykdP62sM3MJ9stoYYUw5aCWew==", "devOptional": true, "license": "MIT", "dependencies": { - "undici-types": "~7.18.0" + "undici-types": ">=7.24.0 <7.24.7" } }, "node_modules/@types/react": { @@ -4801,17 +4797,17 @@ "license": "MIT" }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.70.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.70.0.tgz", - "integrity": "sha512-/v8HZt6RlyIZxB3ntehELOcUcfxKPVGWXnQdJuHRmzrqgF8nQypcC/oxGW+Ot4VGKDq81XugPKxx0n5PBtf9PA==", + "version": "8.71.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.71.0.tgz", + "integrity": "sha512-pqcS9c1HxZTHt7End4nXqd0s5lJrrFzrgCkKFJrsbUnaL6M3+6oBFZaslg6Gjsl3argl2DDRFROnXARaZ2e4Nw==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/regexpp": "^4.12.2", - "@typescript-eslint/scope-manager": "8.70.0", - "@typescript-eslint/type-utils": "8.70.0", - "@typescript-eslint/utils": "8.70.0", - "@typescript-eslint/visitor-keys": "8.70.0", + "@typescript-eslint/scope-manager": "8.71.0", + "@typescript-eslint/type-utils": "8.71.0", + "@typescript-eslint/utils": "8.71.0", + "@typescript-eslint/visitor-keys": "8.71.0", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" @@ -4824,15 +4820,15 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^8.70.0", + "@typescript-eslint/parser": "^8.71.0", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { - "version": "7.0.9", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.9.tgz", - "integrity": "sha512-brTTsvFRt5C1gGHtPst/281UjPD5t9fBqbgoMPlVWy11ZLTPfu7HxK4ZYqO9H7o/yC9rSTCI85EaQ4OoY12qYw==", + "version": "7.0.10", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.10.tgz", + "integrity": "sha512-HpbUakT7xp5miBUywCHf36ZEuAJNklBJDDsGpUIjMzOSmM8ELSfA9Sa/QDPeNeqeoN31u+UTCkL4klCOVvRm4Q==", "dev": true, "license": "MIT", "engines": { @@ -4840,16 +4836,16 @@ } }, "node_modules/@typescript-eslint/parser": { - "version": "8.70.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.70.0.tgz", - "integrity": "sha512-zYvrmj9Yxd63UGaXw+kdt6A0F0s0qveJyuatIM77bYC2DE4pgmg7a50u8LR7PRtXd0x+h+Tl3eXabGm06SWd3Q==", + "version": "8.71.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.71.0.tgz", + "integrity": "sha512-CG4nPk1f2zc8yw4pALqHsFYH2hdo+h1T9daSp21+Hnxi9LOE3GT9hAfTKJCBXVNM2GmYs1eMEP615wPoeOgk3A==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/scope-manager": "8.70.0", - "@typescript-eslint/types": "8.70.0", - "@typescript-eslint/typescript-estree": "8.70.0", - "@typescript-eslint/visitor-keys": "8.70.0", + "@typescript-eslint/scope-manager": "8.71.0", + "@typescript-eslint/types": "8.71.0", + "@typescript-eslint/typescript-estree": "8.71.0", + "@typescript-eslint/visitor-keys": "8.71.0", "debug": "^4.4.3" }, "engines": { @@ -4865,14 +4861,14 @@ } }, "node_modules/@typescript-eslint/project-service": { - "version": "8.70.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.70.0.tgz", - "integrity": "sha512-hFHbTNqhU9G+2eKFXCBVb1tjFT/LceiJ4+HfLO4pTpDI0KHi6iajpcFFkaSQ9gXmCh7n82A0PthaayEdN6mspQ==", + "version": "8.71.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.71.0.tgz", + "integrity": "sha512-aABjw5rjBacYONVPaPiWOCjJu0vEF4a25iQuodlmQYL1trtLZ0X/y+2Vzl3BKI1odM4LnwLE1oUDXYp1wzx1TQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.70.0", - "@typescript-eslint/types": "^8.70.0", + "@typescript-eslint/tsconfig-utils": "^8.71.0", + "@typescript-eslint/types": "^8.71.0", "debug": "^4.4.3" }, "engines": { @@ -4887,14 +4883,14 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.70.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.70.0.tgz", - "integrity": "sha512-8nP3Kwh5hlgZ4FicGvmznAmJe8UL4sdU8tLukrPaMuQmDuk4Y8xYfzu/aYZW4xT2JCgc7H/TpDI5cGlxcWJSqQ==", + "version": "8.71.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.71.0.tgz", + "integrity": "sha512-gWF0BhUcnjZxSpLE8ngS/59n2SB0J3YqRxvX1+2aoRJk9hNtHSLOV+TcarFiOr5ipXm3yc1QrI4c9YZc8zyCxw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.70.0", - "@typescript-eslint/visitor-keys": "8.70.0" + "@typescript-eslint/types": "8.71.0", + "@typescript-eslint/visitor-keys": "8.71.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -4905,9 +4901,9 @@ } }, "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.70.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.70.0.tgz", - "integrity": "sha512-adnkeeNq9Sq1sUf4+FRVc0KdgYghzsgFpZSQVZVvY0LCuUuN0FnQgyGzCJeC4fW1cdXseBAjU2EOqUIjbNcZUw==", + "version": "8.71.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.71.0.tgz", + "integrity": "sha512-Z1UlWHADEK2Mlb9NpWfDeSjqoZ5EyrOv4R3eQpbkzqn/EwaIdOpXXupEA1+0ZIOSJSZZDBHG0BrQyN8zUG6Pwg==", "dev": true, "license": "MIT", "engines": { @@ -4922,15 +4918,15 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.70.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.70.0.tgz", - "integrity": "sha512-NUMKIhYVaVIVLnRL9CRt+VVcuLgSHUCpXn4/+K8wql+vdInUzvx8BjUO1oJ7cG9shjFJKtF8F8Hh2kCh3/KBVw==", + "version": "8.71.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.71.0.tgz", + "integrity": "sha512-i8uO1qbdxeKgRnS5sCRt6On3/nfo2d2DwQe3Yvjx543zLy7r8ySqRuPPiIIXAhS03U0v5NfAFx+rUgxFzKKwNw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.70.0", - "@typescript-eslint/typescript-estree": "8.70.0", - "@typescript-eslint/utils": "8.70.0", + "@typescript-eslint/types": "8.71.0", + "@typescript-eslint/typescript-estree": "8.71.0", + "@typescript-eslint/utils": "8.71.0", "debug": "^4.4.3", "ts-api-utils": "^2.5.0" }, @@ -4947,9 +4943,9 @@ } }, "node_modules/@typescript-eslint/types": { - "version": "8.70.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.70.0.tgz", - "integrity": "sha512-asTOIYhDg4zdzOScCyaytrsV3cR6B4ecPQlXw/dJIm7J/MZTtCtfVII9JD8Geh4jTCrK/Xe6cg5UevoleMcoJQ==", + "version": "8.71.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.71.0.tgz", + "integrity": "sha512-cJ4OoxPGWvFnBTnSZyaU+qJzGTqPTGJY+gDchj6cRyLRdmIdt4rcsE4twj+zPfrNiWuVi38wijHzShL++Z9atQ==", "dev": true, "license": "MIT", "engines": { @@ -4961,16 +4957,16 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.70.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.70.0.tgz", - "integrity": "sha512-d9NmHMPEKQ7QCLLm1jI3zmoQBwT5KwFYjXBJ9ymZfKCUU+5rmTRykKAFvH5Qn/ZCds3CEAFS9OC9M/jkl0X2bA==", + "version": "8.71.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.71.0.tgz", + "integrity": "sha512-PEEF4G5sLLWAS5BpPrUvms4ySZkiBQQZM4z+3ReI46axK5Vqr/vXBQatJQIZZOYdGyPUAKTtsrWzpqKuU+3DEw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/project-service": "8.70.0", - "@typescript-eslint/tsconfig-utils": "8.70.0", - "@typescript-eslint/types": "8.70.0", - "@typescript-eslint/visitor-keys": "8.70.0", + "@typescript-eslint/project-service": "8.71.0", + "@typescript-eslint/tsconfig-utils": "8.71.0", + "@typescript-eslint/types": "8.71.0", + "@typescript-eslint/visitor-keys": "8.71.0", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", @@ -4999,9 +4995,9 @@ } }, "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { - "version": "5.0.9", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", - "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", + "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", "dev": true, "license": "MIT", "dependencies": { @@ -5041,16 +5037,16 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "8.70.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.70.0.tgz", - "integrity": "sha512-oZmtKJz/4fufZ2p3+Cn3ijEojcdfR+1zYDH2xKYrEly0dR/Q/1xUPRCOlKGxod78nWlU2UnDe09GZ3TaknBFGA==", + "version": "8.71.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.71.0.tgz", + "integrity": "sha512-pKR/tEMVrXZG23UFKUn5BQf3zfmfk7KQceI2cGzywZ5nxM5Eu3hEJU1utjWzydtzBbcJAQhHN8iPCxobHpPcZQ==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", - "@typescript-eslint/scope-manager": "8.70.0", - "@typescript-eslint/types": "8.70.0", - "@typescript-eslint/typescript-estree": "8.70.0" + "@typescript-eslint/scope-manager": "8.71.0", + "@typescript-eslint/types": "8.71.0", + "@typescript-eslint/typescript-estree": "8.71.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -5065,13 +5061,13 @@ } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.70.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.70.0.tgz", - "integrity": "sha512-BoC8PiO4Hkdo0TVJh9Ntxr5MxPDI7/oFsrygN5ADelFSeXG/qgNuucIGA+L5Z6JpPTE/uRfcTWtscjbUaufepQ==", + "version": "8.71.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.71.0.tgz", + "integrity": "sha512-8eQ9R218XORK+KLosnf4bu/QsUXvUyVwTbArg7/0NMB1Pu87OJKvj4nhFblkYE8gQV73mW1dx1ptlPCkwRGa7A==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.70.0", + "@typescript-eslint/types": "8.71.0", "eslint-visitor-keys": "^5.0.0" }, "engines": { @@ -5480,9 +5476,9 @@ } }, "node_modules/@vitest/coverage-v8": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-5.0.0.tgz", - "integrity": "sha512-toMg6PZGCIa/lQNCDoASrfb1ly4hsUKXFtFYC9kD4t78o5Y6LyNJU7AENt8eHPr3quYdxaxK7hj2mnbFfUk9NA==", + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-5.0.2.tgz", + "integrity": "sha512-3ffHBEi8DOOBLwIGBhOBZbRfYFYWjMUuxZicONdhuFEFEG5AVsMOSrVeuROskqnqpbOmEJwxM2eTVZ9N3a1t+A==", "devOptional": true, "license": "MIT", "dependencies": { @@ -5499,8 +5495,8 @@ "url": "https://opencollective.com/vitest" }, "peerDependencies": { - "@vitest/browser": "5.0.0", - "vitest": "5.0.0" + "@vitest/browser": "5.0.2", + "vitest": "5.0.2" }, "peerDependenciesMeta": { "@vitest/browser": { @@ -5509,9 +5505,9 @@ } }, "node_modules/@vitest/istanbul-lib-coverage": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@vitest/istanbul-lib-coverage/-/istanbul-lib-coverage-1.0.1.tgz", - "integrity": "sha512-k3DJZ8LhMBK9NS4SclF1ASD3OgXEWDorbIcPTRDK0/Zae6fRvu+fJRxtFdLfHsa9Y24beCdPnoNZ4LviTNstfA==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@vitest/istanbul-lib-coverage/-/istanbul-lib-coverage-1.0.2.tgz", + "integrity": "sha512-9J/JMwOf9AoJhAywhrn7ScKTL38hsWQP/qPG60OtaAFcQ5OXPwKsxZFlbnuCKmZ61m8/lGgHYnFpdyQZUvG/iA==", "devOptional": true, "license": "MIT", "engines": { @@ -5519,27 +5515,27 @@ } }, "node_modules/@vitest/istanbul-lib-report": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@vitest/istanbul-lib-report/-/istanbul-lib-report-1.0.1.tgz", - "integrity": "sha512-1EOLRfsTMnyAr3+kEAsP4o9dhaDlGPpD7H5iLBBeq//YpNB1VIahkPhB+eRp9N2Dkfw8oySROjE3yf9XDeaIkQ==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@vitest/istanbul-lib-report/-/istanbul-lib-report-1.0.2.tgz", + "integrity": "sha512-gUsfXZJbzPamoIY5TvHFiMMoXESBrUMo+xqaj+rYrWI69+EnvRlYBlP96ZnHPY4vX8kyUpgAnFUCg5wZG/HkDQ==", "devOptional": true, "license": "MIT", "dependencies": { - "@vitest/istanbul-lib-coverage": "1.0.1" + "@vitest/istanbul-lib-coverage": "1.0.2" }, "engines": { "node": ">=22" } }, "node_modules/@vitest/mocker": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-5.0.0.tgz", - "integrity": "sha512-66PGTMIiVJP3t4a5yxU9qPtf7MdTBs8jmToMvy+HVflB3Yy13WJZTtPePdvU+wjRV02SKK5doLbSA6o9pwOmiA==", + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-5.0.2.tgz", + "integrity": "sha512-Z5FS00Q1SJHkB35xATsmWGdQ5WA1/0MV3CDjqyv7GavHv1OfOj145MNfHOlHk7QLes21dKFDHr8EO2zvL+9WGA==", "devOptional": true, "license": "MIT", "dependencies": { "@jridgewell/trace-mapping": "0.3.31", - "@vitest/spy": "5.0.0", + "@vitest/spy": "5.0.2", "estree-walker": "^3.0.3", "magic-string": "^1.2.3" }, @@ -5560,9 +5556,9 @@ } }, "node_modules/@vitest/mocker/node_modules/magic-string": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.3.1.tgz", - "integrity": "sha512-rm91zr2Ou+XueDTohjQQjdQEcYM6zVi8KVUCG8Ec3vHwUEKrhSdCNyfuIywkA6hcCAteIn0ZOtAHA6eGpiX+Pg==", + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.4.2.tgz", + "integrity": "sha512-vG+rjFRj1PqdIBozIxAGMjPlOhaVe+GXpbttY/iSK7rGcJRMlwNJO7dcUwmUqkymsFLJiNGI06t4D7Fr7yRC9g==", "devOptional": true, "license": "MIT", "dependencies": { @@ -5570,9 +5566,9 @@ } }, "node_modules/@vitest/spy": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-5.0.0.tgz", - "integrity": "sha512-uy+luWBAPw9XfthoHi5AkfHUnuPYEESjl0p/r+meoBnU8bxg5GDQ3Ey8MjcJ6sqahkL4PFyrvfMJJBw7LbU06g==", + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-5.0.2.tgz", + "integrity": "sha512-Ijc7T1nT9efNb5LxvjaBrEqw3f/QwUv5EE0nKqZxgqsaV/FxAAZ8baGylA8X/Z2oS4Lp+K74Jr6dTJsDKxJDeg==", "devOptional": true, "license": "MIT", "funding": { @@ -5864,9 +5860,9 @@ "license": "MIT" }, "node_modules/ast-v8-to-istanbul": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-1.0.6.tgz", - "integrity": "sha512-fvpl29helSO2w/z7utIbrkNXILdrLwDwAMH2I/zPKlGf5244+gf+B4cyS1sANcrPY2h+hWCGSgC8N61s/+AF9A==", + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-1.0.7.tgz", + "integrity": "sha512-kFL68AG6ajd8fg248zwM9GQrUWEp79gsmjum34OEXjs4yHuUMZfYKwOLW9GMmB4oNvVrj+EAGxsP7ye2UR9UlA==", "devOptional": true, "license": "MIT", "dependencies": { @@ -5976,9 +5972,9 @@ "license": "MIT" }, "node_modules/baseline-browser-mapping": { - "version": "2.11.23", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.23.tgz", - "integrity": "sha512-le521dGVfxM7yRX0EikCoSz+rOK+hHzdDt/E7mG1jOJB/6WAAUuwVroLwaB7ApaUsz5Q0kFlDXLSA9MheUIfRQ==", + "version": "2.11.26", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.26.tgz", + "integrity": "sha512-GLQdD3y6UF8iVuMJl5fHgE4jdn/ua7n+toKfLgNlg3BqQtOZjpy68T8Tup8/wGWZCDlm7KMg7tPb4MPn7oN0TQ==", "license": "Apache-2.0", "bin": { "baseline-browser-mapping": "dist/cli.cjs" @@ -5988,20 +5984,20 @@ } }, "node_modules/better-auth": { - "version": "1.7.4", - "resolved": "https://registry.npmjs.org/better-auth/-/better-auth-1.7.4.tgz", - "integrity": "sha512-rjL9g8b8UvdwK/ZSdawhqYzOm8YegurB6Fuzqtu/zFJamlFrPa+eEhP8Qj2vFWsMzHOk2mb/L4FRYh9pgaU7OA==", - "license": "MIT", - "dependencies": { - "@better-auth/core": "1.7.4", - "@better-auth/drizzle-adapter": "1.7.4", - "@better-auth/kysely-adapter": "1.7.4", - "@better-auth/memory-adapter": "1.7.4", - "@better-auth/mongo-adapter": "1.7.4", - "@better-auth/prisma-adapter": "1.7.4", - "@better-auth/telemetry": "1.7.4", + "version": "1.7.6", + "resolved": "https://registry.npmjs.org/better-auth/-/better-auth-1.7.6.tgz", + "integrity": "sha512-WTMqOpmTTj+oBoX7zzPOzL85342Upyf+/v0IkH1kvGRA85lV4JGRFIYMIRKqvjZ6ShsuL5VX/c9C13jtoZXcKA==", + "license": "MIT", + "dependencies": { + "@better-auth/core": "1.7.6", + "@better-auth/drizzle-adapter": "1.7.6", + "@better-auth/kysely-adapter": "1.7.6", + "@better-auth/memory-adapter": "1.7.6", + "@better-auth/mongo-adapter": "1.7.6", + "@better-auth/prisma-adapter": "1.7.6", + "@better-auth/telemetry": "1.7.6", "@better-auth/utils": "0.4.2", - "@better-fetch/fetch": "1.3.1", + "@better-fetch/fetch": "1.3.2", "@noble/ciphers": "^2.2.0", "@noble/hashes": "^2.2.0", "better-call": "1.4.0", @@ -6017,7 +6013,6 @@ "@sveltejs/kit": "^2.0.0", "@tanstack/react-start": "^1.0.0", "@tanstack/solid-start": "^1.0.0", - "better-sqlite3": "^12.0.0", "drizzle-kit": ">=0.31.4 || >=1.0.0-beta.1", "drizzle-orm": "^0.45.2 || >=1.0.0-rc.1 <2.0.0", "mongodb": "^6.0.0 || ^7.0.0", @@ -6048,9 +6043,6 @@ "@tanstack/solid-start": { "optional": true }, - "better-sqlite3": { - "optional": true - }, "drizzle-kit": { "optional": true }, @@ -6137,9 +6129,9 @@ "license": "ISC" }, "node_modules/brace-expansion": { - "version": "1.1.18", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", - "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz", + "integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==", "dev": true, "license": "MIT", "dependencies": { @@ -6170,9 +6162,9 @@ } }, "node_modules/browserslist": { - "version": "4.28.9", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.9.tgz", - "integrity": "sha512-EWazOblFYUvlGZcfGhPUPmYh3nikUxBVb+y9MJun5f3hBi812X+8MSQTujLBtgK3cf51fJWbWfOjyeO954d+Eg==", + "version": "4.29.3", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.29.3.tgz", + "integrity": "sha512-1R4kiYKXGViqEN0CnoDrXc1StD9niAwu+j2dukWzrD4bJgsD4lDmEp0CRbc6E/vYJIfTHwPmwyaKtVSudICdPA==", "funding": [ { "type": "opencollective", @@ -6189,11 +6181,11 @@ ], "license": "MIT", "dependencies": { - "baseline-browser-mapping": "^2.11.20", - "caniuse-lite": "^1.0.30001810", - "electron-to-chromium": "^1.5.420", - "node-releases": "^2.0.54", - "update-browserslist-db": "^1.3.2" + "baseline-browser-mapping": "^2.11.26", + "caniuse-lite": "^1.0.30001813", + "electron-to-chromium": "^1.5.439", + "node-releases": "^2.0.57", + "update-browserslist-db": "^1.3.3" }, "bin": { "browserslist": "cli.js" @@ -6262,9 +6254,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001810", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001810.tgz", - "integrity": "sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg==", + "version": "1.0.30001814", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001814.tgz", + "integrity": "sha512-/Uaf1lAzr59XcMpW0o96WoEfr+VXK2OX4U9AgFoiSHsVJ4HppnIFUjtYzsyDH2+tgANaQb2/oxYGwCPapN1FpA==", "funding": [ { "type": "opencollective", @@ -6292,9 +6284,9 @@ } }, "node_modules/chalk": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-6.0.0.tgz", - "integrity": "sha512-2uNTXIuTTxk7ciZgAU1BQcgnchcG0xXnrs6jzkQfj9SsRa9M2s5zE8WT96hS6KmG4MzWHSrvH43DF1m4XRkrFg==", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-6.0.1.tgz", + "integrity": "sha512-/Ce6KNm3vIbWdMlNna6RVIZ/ICQxnJxCicet5LBKK9ZffBkqzDw0xh9EiKSljdRtiIQ1S1z4YgcscUUGzNCWrA==", "dev": true, "license": "MIT", "engines": { @@ -6377,6 +6369,15 @@ "url": "https://github.com/inikulin/parse5?sponsor=1" } }, + "node_modules/cheerio/node_modules/undici": { + "version": "7.30.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.30.0.tgz", + "integrity": "sha512-dkrQXeHSaoamnItlYbmzG0wFYrM0ZwDxCIg0A7aKjTyyhh9svRzCNFEzV+Vm05/yehjCzjDZ31KXfGEjYSztDQ==", + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, "node_modules/cheerio/node_modules/whatwg-mimetype": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", @@ -6770,6 +6771,21 @@ "node": "^20.19.0 || ^22.12.0 || >=24.0.0" } }, + "node_modules/data-urls/node_modules/whatwg-url": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", + "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.11.0", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/data-view-buffer": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", @@ -6950,15 +6966,15 @@ } }, "node_modules/docx": { - "version": "9.7.1", - "resolved": "https://registry.npmjs.org/docx/-/docx-9.7.1.tgz", - "integrity": "sha512-ilXFf9Moz47ABjFpDiA5s1w9lpb4EFSp7+5iiJSbfyYDM+bpZdAgLlSr7fW4aXhVe/E+F6QCv0EvRVFEd5CsWg==", + "version": "9.8.1", + "resolved": "https://registry.npmjs.org/docx/-/docx-9.8.1.tgz", + "integrity": "sha512-bv4yDxgiV9aRyh327HG+V36wG0f23wk32SDhXGQpUhnykVtIPaR1K4ISi8E+DYJ7OcRQRkR8Y70QYlSS08V1cA==", "license": "MIT", "dependencies": { - "@types/node": "^25.2.3", + "@types/node": "^26.6.2", "hash.js": "^1.1.7", "jszip": "^3.10.1", - "nanoid": "^5.1.3", + "nanoid": "^6.0.1", "xml": "^1.0.1", "xml-js": "^1.6.8" }, @@ -6967,27 +6983,27 @@ } }, "node_modules/docx/node_modules/@types/node": { - "version": "25.9.6", - "resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.6.tgz", - "integrity": "sha512-JR6Q/PV5DKFvjrGFVqQJdeG0qvsqQQLDa3TzFrqVwhqRXqwNaxPo2KYCtCQXpOdIulCouKwT7a6in9nFthBAzw==", + "version": "26.6.3", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.6.3.tgz", + "integrity": "sha512-dsqMQQoeTLqu9wynDD00q573mNzso3IdQOAfHRJqLCcmCFPoGo9A1bDpUcv/9tnKpErQWv9uKeGfl37EIS02Yg==", "license": "MIT", "dependencies": { - "undici-types": ">=7.24.0 <7.24.7" + "undici-types": "~8.9.0" } }, "node_modules/docx/node_modules/undici-types": { - "version": "7.24.6", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz", - "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==", + "version": "8.9.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.9.0.tgz", + "integrity": "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==", "license": "MIT" }, "node_modules/docxtemplater": { - "version": "3.69.3", - "resolved": "https://registry.npmjs.org/docxtemplater/-/docxtemplater-3.69.3.tgz", - "integrity": "sha512-z6IIXImBvOFudR1VHZIjIoewPd/xft/OVF1JT9eKgJLGWQaFZ8gPrjPuAnho0bbQEfKd5Ge7RPq+owqKLmC3PA==", + "version": "3.71.0", + "resolved": "https://registry.npmjs.org/docxtemplater/-/docxtemplater-3.71.0.tgz", + "integrity": "sha512-4SyQzPVDrhsPb9vHNstxJpJWMLJvrbKEcXDRNBlb5Bnbjqm1T51PVt5r+iLVe1yAkmIvakk8M3KywO5m5LsUSA==", "license": "MIT", "dependencies": { - "@xmldom/xmldom": "^0.9.10" + "@xmldom/xmldom": "^0.9.12" }, "engines": { "node": ">=0.10" @@ -7067,10 +7083,13 @@ } }, "node_modules/dotenv": { - "version": "17.4.2", - "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz", - "integrity": "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==", + "version": "18.0.4", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-18.0.4.tgz", + "integrity": "sha512-jGt9uI5BIxpVO15kvy+iEFqNAwxYClKWnkptLjSNPnFLC003Imd0ofr3aEVsbpHdSBdSsbcApNIlz4nbyGMwdA==", "license": "BSD-2-Clause", + "bin": { + "dotenv": "dist/index.cjs" + }, "engines": { "node": ">=12" }, @@ -7121,9 +7140,9 @@ } }, "node_modules/editorconfig/node_modules/brace-expansion": { - "version": "5.0.9", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", - "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", + "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", "license": "MIT", "dependencies": { "balanced-match": "^4.0.2" @@ -7160,9 +7179,9 @@ } }, "node_modules/electron-to-chromium": { - "version": "1.5.427", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.427.tgz", - "integrity": "sha512-n14zb3FdsChZ2BNobqNHAJMcP3ifFv4paox2LvCrfVAQcqGiSURgbJl+PfMpHVCNFkStnNc+RRVtPBTVW5PDgw==", + "version": "1.5.442", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.442.tgz", + "integrity": "sha512-najZYZ3+ZpjN1z3VOsrBiv5ej18vQgfV2lvEmxWzfmKrk4bdm3Owseyud4yGU6DfFn9pyunoAHDDyM4KmP78Ew==", "license": "ISC" }, "node_modules/emoji-regex": { @@ -7204,9 +7223,9 @@ } }, "node_modules/enhanced-resolve": { - "version": "5.25.0", - "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.25.0.tgz", - "integrity": "sha512-ghq3mhs649mvbarTCAlZn2wRhbfHmzAFiKxoWA14B3VtqnxtZt+wz8BroKXU0tF3GiJsnUldjVncQGZ8qk4rdA==", + "version": "5.26.0", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.26.0.tgz", + "integrity": "sha512-9vhedylFonb2YGogzUKX6+Ja72gOJbN1QHAqdrvqLwhdl/QWbKopzoUC9EbQNVsAns/bx/4uyqalrQAoy1IByw==", "dev": true, "license": "MIT", "dependencies": { @@ -7590,13 +7609,13 @@ } }, "node_modules/eslint-config-next": { - "version": "16.3.5", - "resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-16.3.5.tgz", - "integrity": "sha512-wPjq9MLQuWykHs8tsm2gH6OJThk6N27L8Te2JatlaGZ7jT1gtgGmJKukygL28xOlWsg5J1a1bGy/PvLyQC8OYA==", + "version": "16.3.7", + "resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-16.3.7.tgz", + "integrity": "sha512-/a7OkjM1dBK3CY159QlqXI2d3DS6F9CjTF8iwAACas2keulTTpPG0ap7ClRstBhPCr0OH1yBlRX9tpvlLZzqzg==", "dev": true, "license": "MIT", "dependencies": { - "@next/eslint-plugin-next": "16.3.5", + "@next/eslint-plugin-next": "16.3.7", "eslint-import-resolver-node": "^0.3.6", "eslint-import-resolver-typescript": "^3.5.2", "eslint-plugin-import": "^2.32.0", @@ -8427,9 +8446,9 @@ } }, "node_modules/glob/node_modules/brace-expansion": { - "version": "5.0.9", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", - "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", + "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", "license": "MIT", "dependencies": { "balanced-match": "^4.0.2" @@ -8675,16 +8694,16 @@ "license": "ISC" }, "node_modules/html-encoding-sniffer": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", - "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-7.0.0.tgz", + "integrity": "sha512-UikN5yr7xsCDAq87Or5or0PAlD3HJJOKVzM05az588WnpDJ4Ux7a2A53Qi6gofGg2/EtvF/H4hCi/TXfCW4Y6w==", "devOptional": true, "license": "MIT", "dependencies": { - "@exodus/bytes": "^1.6.0" + "@exodus/bytes": "^1.15.1" }, "engines": { - "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + "node": "^22.13.0 || >=24.0.0" } }, "node_modules/html-entities": { @@ -8991,13 +9010,13 @@ } }, "node_modules/is-core-module": { - "version": "2.16.2", - "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.2.tgz", - "integrity": "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==", + "version": "2.17.0", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.17.0.tgz", + "integrity": "sha512-J/vG0zBCbIKOQFfufSwyXdMrsohyJIUNkrnmo6WZGzoM7tr/lsbfW5b2BvisL6zsyMzK9UxV9L6c7AoFbyXHOA==", "dev": true, "license": "MIT", "dependencies": { - "hasown": "^2.0.3" + "hasown": "^2.0.4" }, "engines": { "node": ">= 0.4" @@ -9457,32 +9476,31 @@ } }, "node_modules/jsdom": { - "version": "30.0.1", - "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-30.0.1.tgz", - "integrity": "sha512-52v7mUVUfNQVYYqE1lcdaymWL0njO7lTLUog6ZvW2U5KsbiLk/GnZlVJ+qx0xfNJZ6Gn+KSpPNE52vurbxZwrA==", + "version": "30.1.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-30.1.1.tgz", + "integrity": "sha512-FahmoPK5vbPc+jxV1iErMHmAZypCZ942NHF4+qqaWAuvaKKTBZxawnmAtrbGWLU7MtlxfqIP0qw6aSI+aWGtLg==", "devOptional": true, "license": "MIT", "dependencies": { - "@asamuzakjp/css-color": "^6.0.5", - "@asamuzakjp/dom-selector": "^8.3.0", + "@asamuzakjp/css-color": "^7.0.0", + "@asamuzakjp/dom-selector": "^9.2.1", "@bramus/specificity": "^2.4.2", - "@csstools/css-syntax-patches-for-csstree": "^1.1.7", + "@csstools/css-syntax-patches-for-csstree": "^1.1.13", "@exodus/bytes": "^1.15.1", "css-tree": "^3.2.1", "data-urls": "^7.0.0", "decimal.js": "^10.6.0", - "html-encoding-sniffer": "^6.0.0", + "html-encoding-sniffer": "^7.0.0", "is-potential-custom-element-name": "^1.0.1", "lru-cache": "^11.5.2", "parse5": "^8.0.1", "saxes": "^6.0.0", - "symbol-tree": "^3.2.4", "tough-cookie": "^6.0.2", - "undici": "^8.9.0", - "w3c-xmlserializer": "^5.0.0", + "undici": "^8.10.2", + "w3c-xmlserializer": "^6.0.0", "webidl-conversions": "^8.0.1", "whatwg-mimetype": "^5.0.0", - "whatwg-url": "^17.1.0", + "whatwg-url": "^17.1.1", "xml-name-validator": "^5.0.0" }, "engines": { @@ -9498,40 +9516,15 @@ } }, "node_modules/jsdom/node_modules/lru-cache": { - "version": "11.5.2", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", - "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", + "version": "11.5.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.3.tgz", + "integrity": "sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==", "devOptional": true, "license": "BlueOak-1.0.0", "engines": { "node": "20 || >=22" } }, - "node_modules/jsdom/node_modules/undici": { - "version": "8.10.2", - "resolved": "https://registry.npmjs.org/undici/-/undici-8.10.2.tgz", - "integrity": "sha512-/y4/bH9YNU5hi9NIrpOuvGXFcxrj3CMrV+/AYpowAYTpHn8gX/XPFjNy766FPoYY0miQhdW977JFWKGNhBdwyQ==", - "devOptional": true, - "license": "MIT", - "engines": { - "node": ">=22.19.0" - } - }, - "node_modules/jsdom/node_modules/whatwg-url": { - "version": "17.1.1", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-17.1.1.tgz", - "integrity": "sha512-ohjk1mdUebJVadRt3bAhQhx8lSnISq+GDttK79LFl8EHQkAPvzwctoasC4hs8tBt6kLAncBWWyq1N52qEfKvDw==", - "devOptional": true, - "license": "MIT", - "dependencies": { - "@exodus/bytes": "^1.15.1", - "tr46": "^6.0.0", - "webidl-conversions": "^8.0.1" - }, - "engines": { - "node": "^22.14.0 || >=24.0.0" - } - }, "node_modules/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", @@ -9728,9 +9721,9 @@ } }, "node_modules/juice/node_modules/undici": { - "version": "6.28.1", - "resolved": "https://registry.npmjs.org/undici/-/undici-6.28.1.tgz", - "integrity": "sha512-zWpdTVD54H48CIybL0rWQ3ukpb9d23wM7eH5RtfdmeP70cWHNjtfo7P4vZX+5CoDcO53J4Pu5uXp7lNfjc6DRA==", + "version": "6.29.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.29.0.tgz", + "integrity": "sha512-R+RODBqp6i2pPflGdq+xIOUkl+RNfGgHwoinecKu/JCuf2uO06cOKoDbI2P7Dn6KcswdKwrczbU6IYJ6K8X+wg==", "license": "MIT", "engines": { "node": ">=18.17" @@ -9756,9 +9749,9 @@ } }, "node_modules/kysely": { - "version": "0.29.5", - "resolved": "https://registry.npmjs.org/kysely/-/kysely-0.29.5.tgz", - "integrity": "sha512-ooa+eSbBNPTo3MycPEuW5jdrxQdQwdtB3LC3h43FiXQbIry5tR0C5lDG7eealK0E4D7XjrnOP5DIUg/LyjRMYQ==", + "version": "0.29.6", + "resolved": "https://registry.npmjs.org/kysely/-/kysely-0.29.6.tgz", + "integrity": "sha512-hHaB8C/rfzDDtr/t8YZwxAuPJTT0zHyaPoVzcXwDYhYNAgH/4sIfVhi/XLLIY+bL/FqaIJnjATDbi8ObSELmxg==", "license": "MIT", "engines": { "node": ">=22.0.0" @@ -10181,12 +10174,18 @@ } }, "node_modules/lucide-react": { - "version": "1.45.0", - "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.45.0.tgz", - "integrity": "sha512-yH1ubCAduho9UR7oJhRXIQXogksRILBiTuZC4/bQIGeB9JOkxMlSuEHyyZpo1Z3S0yWJO2KTSUZbjiNvVxeOUw==", + "version": "1.49.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.49.0.tgz", + "integrity": "sha512-ApfaNM4/7jBrVopqLJ84Jz8TMQ1Q1JeZ1PmKh25aeS3E1FTVcMkSh1bEQB7dfzdIrwXRP8p3z4AJjTIHHb5IIA==", "license": "ISC", "peerDependencies": { + "@types/react": "*", "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } } }, "node_modules/lz-string": { @@ -10431,9 +10430,9 @@ } }, "node_modules/mjml-cli/node_modules/brace-expansion": { - "version": "5.0.9", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", - "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", + "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", "license": "MIT", "dependencies": { "balanced-match": "^4.0.2" @@ -10888,19 +10887,6 @@ "postcss": "^8.5.13" } }, - "node_modules/mjml-core/node_modules/postcss-selector-parser": { - "version": "7.1.6", - "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.6.tgz", - "integrity": "sha512-7qASPzhKF2l2KLboRZux8CCTRMdGiV08vWmyKzPz22qZ7ZjQBOeY7rNzNoCLSUiftJ7HUq0GERHmxw/t0dCdMw==", - "license": "MIT", - "dependencies": { - "cssesc": "^3.0.0", - "util-deprecate": "^1.0.2" - }, - "engines": { - "node": ">=4" - } - }, "node_modules/mjml-core/node_modules/postcss-svgo": { "version": "7.1.3", "resolved": "https://registry.npmjs.org/postcss-svgo/-/postcss-svgo-7.1.3.tgz", @@ -11261,9 +11247,9 @@ } }, "node_modules/nanoid": { - "version": "5.1.16", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.1.16.tgz", - "integrity": "sha512-kVrnsrJqMR8+oLJnGEmSWw9BivK5mt7H3FZatVRjrc5wGqFYuBxX1yG7+A7Gi5AefkX6t/oCkizcQgpu0cY1dQ==", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-6.0.1.tgz", + "integrity": "sha512-3wVS3i51pE2pi1k5FFL/95BGfVS0kSsvDVuGXHOtxox/TywUmtgq+3qiTOTbs9J7KfHaXPiN171k/A6dBnaXFw==", "funding": [ { "type": "github", @@ -11275,13 +11261,13 @@ "nanoid": "bin/nanoid.js" }, "engines": { - "node": "^18 || >=20" + "node": "^22 || ^24 || >=26" } }, "node_modules/nanostores": { - "version": "1.5.3", - "resolved": "https://registry.npmjs.org/nanostores/-/nanostores-1.5.3.tgz", - "integrity": "sha512-rQLB6eV4f2AW/n3L0JmwCROpaisYy9EDEADvEFSd1C/qG8hB6O5TPlh9A791JRbJr4CnMQBzptDcvD9OR1+6WA==", + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/nanostores/-/nanostores-1.5.4.tgz", + "integrity": "sha512-4UXY7OAJEK0Y6V3SudBU7cXjAzTDc189R78dDOh0Xbm19gpKYto6WizSg58LJf12lTsRxKSLGyE117OiLpM4DQ==", "funding": [ { "type": "github", @@ -11317,12 +11303,12 @@ "license": "MIT" }, "node_modules/next": { - "version": "16.3.5", - "resolved": "https://registry.npmjs.org/next/-/next-16.3.5.tgz", - "integrity": "sha512-MdtsTgzyfCPRLC6uJ1mN8ao7lyJ4BB0U6Inhnx3gta1UcCIdHK3yxLG0E8OWQteWD8/Q0qb8A5o7wJaL8M9y2w==", + "version": "16.3.7", + "resolved": "https://registry.npmjs.org/next/-/next-16.3.7.tgz", + "integrity": "sha512-S4AlB0KMYcvVyEVjfD2Ze/3JsX9PWjpD3hJPcEJTVvkAQsMTYVs9DB1NtaBbs41ZZ18NxYwkmM8ljqtrFrmLsQ==", "license": "MIT", "dependencies": { - "@next/env": "16.3.5", + "@next/env": "16.3.7", "@swc/helpers": "0.5.23", "baseline-browser-mapping": "^2.9.19", "caniuse-lite": "^1.0.30001579", @@ -11336,14 +11322,14 @@ "node": ">=20.9.0" }, "optionalDependencies": { - "@next/swc-darwin-arm64": "16.3.5", - "@next/swc-darwin-x64": "16.3.5", - "@next/swc-linux-arm64-gnu": "16.3.5", - "@next/swc-linux-arm64-musl": "16.3.5", - "@next/swc-linux-x64-gnu": "16.3.5", - "@next/swc-linux-x64-musl": "16.3.5", - "@next/swc-win32-arm64-msvc": "16.3.5", - "@next/swc-win32-x64-msvc": "16.3.5", + "@next/swc-darwin-arm64": "16.3.7", + "@next/swc-darwin-x64": "16.3.7", + "@next/swc-linux-arm64-gnu": "16.3.7", + "@next/swc-linux-arm64-musl": "16.3.7", + "@next/swc-linux-x64-gnu": "16.3.7", + "@next/swc-linux-x64-musl": "16.3.7", + "@next/swc-win32-arm64-msvc": "16.3.7", + "@next/swc-win32-x64-msvc": "16.3.7", "sharp": "^0.35.4" }, "peerDependencies": { @@ -11435,9 +11421,9 @@ } }, "node_modules/node-releases": { - "version": "2.0.55", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.55.tgz", - "integrity": "sha512-mIrE/Cw9y+9Au6dS5vDKDhQza9YvG6w+ZrS6X+ZzA7yFW/soAeaups4Qzn1bL6g5FVy8WtP79+0j82oPIbqRjQ==", + "version": "2.0.57", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.57.tgz", + "integrity": "sha512-kQK9LGGFiHtrWiNhZtA7Qbw17AQz+dmsEKODRIVTXA9+e5MS/2gZEBhYJt13GrAz5/IOZKddH/0Z3TP/Zgo+yw==", "license": "MIT", "engines": { "node": ">=18" @@ -11869,9 +11855,9 @@ } }, "node_modules/path-scurry/node_modules/lru-cache": { - "version": "11.5.2", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", - "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", + "version": "11.5.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.3.tgz", + "integrity": "sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==", "license": "BlueOak-1.0.0", "engines": { "node": "20 || >=22" @@ -11935,9 +11921,9 @@ } }, "node_modules/pizzip": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/pizzip/-/pizzip-3.2.0.tgz", - "integrity": "sha512-X4NPNICxCfIK8VYhF6wbksn81vTiziyLbvKuORVAmolvnUzl1A1xmz9DAWKxPRq9lZg84pJOOAMq3OE61bD8IQ==", + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/pizzip/-/pizzip-3.3.0.tgz", + "integrity": "sha512-seR2ALUYS4Z0GCs0sVwXWImhBsoCImXpHr+ExNfIZ9G0czvHqpUxlJgwHCl1BBHFyU3nUFmZJPFEEWbpY8cKRg==", "license": "(MIT OR GPL-3.0)", "dependencies": { "pako": "^2.1.0" @@ -12021,19 +12007,6 @@ "postcss": "^8.4.38" } }, - "node_modules/postcss-calc/node_modules/postcss-selector-parser": { - "version": "7.1.6", - "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.6.tgz", - "integrity": "sha512-7qASPzhKF2l2KLboRZux8CCTRMdGiV08vWmyKzPz22qZ7ZjQBOeY7rNzNoCLSUiftJ7HUq0GERHmxw/t0dCdMw==", - "license": "MIT", - "dependencies": { - "cssesc": "^3.0.0", - "util-deprecate": "^1.0.2" - }, - "engines": { - "node": ">=4" - } - }, "node_modules/postcss-discard-comments": { "version": "7.0.8", "resolved": "https://registry.npmjs.org/postcss-discard-comments/-/postcss-discard-comments-7.0.8.tgz", @@ -12049,19 +12022,6 @@ "postcss": "^8.5.13" } }, - "node_modules/postcss-discard-comments/node_modules/postcss-selector-parser": { - "version": "7.1.6", - "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.6.tgz", - "integrity": "sha512-7qASPzhKF2l2KLboRZux8CCTRMdGiV08vWmyKzPz22qZ7ZjQBOeY7rNzNoCLSUiftJ7HUq0GERHmxw/t0dCdMw==", - "license": "MIT", - "dependencies": { - "cssesc": "^3.0.0", - "util-deprecate": "^1.0.2" - }, - "engines": { - "node": ">=4" - } - }, "node_modules/postcss-discard-empty": { "version": "7.0.3", "resolved": "https://registry.npmjs.org/postcss-discard-empty/-/postcss-discard-empty-7.0.3.tgz", @@ -12089,6 +12049,19 @@ "postcss": "^8.5.13" } }, + "node_modules/postcss-selector-parser": { + "version": "7.1.6", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.6.tgz", + "integrity": "sha512-7qASPzhKF2l2KLboRZux8CCTRMdGiV08vWmyKzPz22qZ7ZjQBOeY7rNzNoCLSUiftJ7HUq0GERHmxw/t0dCdMw==", + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/postcss-value-parser": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", @@ -12362,9 +12335,9 @@ "license": "MIT" }, "node_modules/react-hook-form": { - "version": "7.88.0", - "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.88.0.tgz", - "integrity": "sha512-QRaLOWhX93YCnMiRfnOFRSwWXZNt8qhm2JTZwypoDvKpSffTJHmpzMXt8U6PV5UThvL3IiiLDUWe2nHMtz6Mmw==", + "version": "7.89.0", + "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.89.0.tgz", + "integrity": "sha512-vKcoCfy8RKZDrhSdqsRFp0uEOdS1AhLjOL6hR7Wmfzg1akNxEhaPf5WORx8SgEQPVXakdDyDkqQp+KNxVnnPHg==", "license": "MIT", "engines": { "node": ">=18.0.0" @@ -12374,7 +12347,13 @@ "url": "https://opencollective.com/react-hook-form" }, "peerDependencies": { + "@types/react": "*", "react": "^16.8.0 || ^17 || ^18 || ^19" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } } }, "node_modules/react-is": { @@ -12619,14 +12598,14 @@ } }, "node_modules/rolldown": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.8.tgz", - "integrity": "sha512-Z67nTmhZe7anqnM/EjI392w5i/ANUinjip7QYsOyN37oayduxt3ksdX0hf5OOamkAd53BiIHfbfSzfUmzKFQqQ==", + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.11.tgz", + "integrity": "sha512-qpSwIyz0jHQq5qXBTNxFmE6664rJ7O+4TvPFOiOaBSrz8IOHc1koKKSqTM2H6u1UG1+TveuC6vaDHKXFOvb1Kw==", "devOptional": true, "license": "MIT", "peer": true, "dependencies": { - "@oxc-project/types": "=0.149.0", + "@oxc-project/types": "=0.151.0", "@rolldown/pluginutils": "^1.0.0" }, "bin": { @@ -12636,21 +12615,21 @@ "node": "^20.19.0 || >=22.12.0" }, "optionalDependencies": { - "@rolldown/binding-android-arm-eabi": "1.2.8", - "@rolldown/binding-android-arm64": "1.2.8", - "@rolldown/binding-darwin-arm64": "1.2.8", - "@rolldown/binding-darwin-x64": "1.2.8", - "@rolldown/binding-freebsd-x64": "1.2.8", - "@rolldown/binding-linux-arm-gnueabihf": "1.2.8", - "@rolldown/binding-linux-arm64-gnu": "1.2.8", - "@rolldown/binding-linux-arm64-musl": "1.2.8", - "@rolldown/binding-linux-ppc64-gnu": "1.2.8", - "@rolldown/binding-linux-s390x-gnu": "1.2.8", - "@rolldown/binding-linux-x64-gnu": "1.2.8", - "@rolldown/binding-linux-x64-musl": "1.2.8", - "@rolldown/binding-openharmony-arm64": "1.2.8", - "@rolldown/binding-win32-arm64-msvc": "1.2.8", - "@rolldown/binding-win32-x64-msvc": "1.2.8" + "@rolldown/binding-android-arm-eabi": "1.2.11", + "@rolldown/binding-android-arm64": "1.2.11", + "@rolldown/binding-darwin-arm64": "1.2.11", + "@rolldown/binding-darwin-x64": "1.2.11", + "@rolldown/binding-freebsd-x64": "1.2.11", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.11", + "@rolldown/binding-linux-arm64-gnu": "1.2.11", + "@rolldown/binding-linux-arm64-musl": "1.2.11", + "@rolldown/binding-linux-ppc64-gnu": "1.2.11", + "@rolldown/binding-linux-s390x-gnu": "1.2.11", + "@rolldown/binding-linux-x64-gnu": "1.2.11", + "@rolldown/binding-linux-x64-musl": "1.2.11", + "@rolldown/binding-openharmony-arm64": "1.2.11", + "@rolldown/binding-win32-arm64-msvc": "1.2.11", + "@rolldown/binding-win32-x64-msvc": "1.2.11" } }, "node_modules/rou3": { @@ -12864,9 +12843,9 @@ "license": "MIT" }, "node_modules/sharp": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.4.tgz", - "integrity": "sha512-n++8XWcj+jCOr2IOl7h8LbKnGBDY4aPbmprMONBNFdn0ImXqpGVv5zliDs0V9HbmbCQLpbuo2ej9rAoOQTvMDA==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.5.tgz", + "integrity": "sha512-Ywn4OnzGukp7CDMrp08RQ50YKmuwG47brZgIVPTvBaaAfQlRlygrRqSrxdCiL9M+LlzLBiJ68IR1QqvzHyjC7g==", "license": "Apache-2.0", "optional": true, "dependencies": { @@ -12881,31 +12860,31 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-darwin-arm64": "0.35.4", - "@img/sharp-darwin-x64": "0.35.4", - "@img/sharp-freebsd-wasm32": "0.35.4", - "@img/sharp-libvips-darwin-arm64": "1.3.3", - "@img/sharp-libvips-darwin-x64": "1.3.3", - "@img/sharp-libvips-linux-arm": "1.3.3", - "@img/sharp-libvips-linux-arm64": "1.3.3", - "@img/sharp-libvips-linux-ppc64": "1.3.3", - "@img/sharp-libvips-linux-riscv64": "1.3.3", - "@img/sharp-libvips-linux-s390x": "1.3.3", - "@img/sharp-libvips-linux-x64": "1.3.3", - "@img/sharp-libvips-linuxmusl-arm64": "1.3.3", - "@img/sharp-libvips-linuxmusl-x64": "1.3.3", - "@img/sharp-linux-arm": "0.35.4", - "@img/sharp-linux-arm64": "0.35.4", - "@img/sharp-linux-ppc64": "0.35.4", - "@img/sharp-linux-riscv64": "0.35.4", - "@img/sharp-linux-s390x": "0.35.4", - "@img/sharp-linux-x64": "0.35.4", - "@img/sharp-linuxmusl-arm64": "0.35.4", - "@img/sharp-linuxmusl-x64": "0.35.4", - "@img/sharp-webcontainers-wasm32": "0.35.4", - "@img/sharp-win32-arm64": "0.35.4", - "@img/sharp-win32-ia32": "0.35.4", - "@img/sharp-win32-x64": "0.35.4" + "@img/sharp-darwin-arm64": "0.35.5", + "@img/sharp-darwin-x64": "0.35.5", + "@img/sharp-freebsd-wasm32": "0.35.5", + "@img/sharp-libvips-darwin-arm64": "1.3.4", + "@img/sharp-libvips-darwin-x64": "1.3.4", + "@img/sharp-libvips-linux-arm": "1.3.4", + "@img/sharp-libvips-linux-arm64": "1.3.4", + "@img/sharp-libvips-linux-ppc64": "1.3.4", + "@img/sharp-libvips-linux-riscv64": "1.3.4", + "@img/sharp-libvips-linux-s390x": "1.3.4", + "@img/sharp-libvips-linux-x64": "1.3.4", + "@img/sharp-libvips-linuxmusl-arm64": "1.3.4", + "@img/sharp-libvips-linuxmusl-x64": "1.3.4", + "@img/sharp-linux-arm": "0.35.5", + "@img/sharp-linux-arm64": "0.35.5", + "@img/sharp-linux-ppc64": "0.35.5", + "@img/sharp-linux-riscv64": "0.35.5", + "@img/sharp-linux-s390x": "0.35.5", + "@img/sharp-linux-x64": "0.35.5", + "@img/sharp-linuxmusl-arm64": "0.35.5", + "@img/sharp-linuxmusl-x64": "0.35.5", + "@img/sharp-webcontainers-wasm32": "0.35.5", + "@img/sharp-win32-arm64": "0.35.5", + "@img/sharp-win32-ia32": "0.35.5", + "@img/sharp-win32-x64": "0.35.5" }, "peerDependenciesMeta": { "@types/node": { @@ -13025,13 +13004,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/siginfo": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", - "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", - "devOptional": true, - "license": "ISC" - }, "node_modules/signal-exit": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", @@ -13086,17 +13058,10 @@ "dev": true, "license": "MIT" }, - "node_modules/stackback": { - "version": "0.0.2", - "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", - "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", - "devOptional": true, - "license": "MIT" - }, "node_modules/std-env": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz", - "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.3.0.tgz", + "integrity": "sha512-OtU/EgQ1kIm5KwqQpBC6ZEMXrZRui11w8zgfTWp8cdO9B8OaPsbA8bTHO2P+HNo1VlUTGMVBwPhydu6poeXiag==", "devOptional": true, "license": "MIT" }, @@ -13422,13 +13387,6 @@ "url": "https://github.com/sponsors/fb55" } }, - "node_modules/symbol-tree": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", - "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", - "devOptional": true, - "license": "MIT" - }, "node_modules/tailwind-merge": { "version": "3.7.0", "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.7.0.tgz", @@ -13467,9 +13425,9 @@ "license": "MIT" }, "node_modules/tinybench": { - "version": "6.1.4", - "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-6.1.4.tgz", - "integrity": "sha512-9APumHG7r4yOk4X4WlkmE71aZcv1gvin1czO3OQ1U9iJcFA5Ja/ygyb0vPOVHTthFozUYs8CLoLUlM8grb2lTQ==", + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-6.2.0.tgz", + "integrity": "sha512-78U2TlB2CnVenajOFzf3BKSm0J6oz5L0NV7g32LCPccvYc0lbWvys4d3uUUCS2B1N8PAf2+aekR8i1KbC3HO7Q==", "devOptional": true, "license": "MIT", "engines": { @@ -13477,9 +13435,9 @@ } }, "node_modules/tinyexec": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.0.tgz", - "integrity": "sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==", + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.1.tgz", + "integrity": "sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA==", "devOptional": true, "license": "MIT", "engines": { @@ -13545,22 +13503,22 @@ } }, "node_modules/tldts": { - "version": "7.4.12", - "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.12.tgz", - "integrity": "sha512-WylhSDKVeYnWXL3a+vKTaOxjnOeEGw938hImY8zoRWJjRRK/Jp1K+IihBzIONpUmW4e3WmXT6q5FW6vlESVZCA==", + "version": "7.4.16", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.16.tgz", + "integrity": "sha512-QwBER5KMR86IIjpIiO7H/Z3IMJPsZ1A6RKPAqzTTgOyUQUSt9FdnKcqhTaJmkY6HVrgouZHZR0ncK5QxvmnQeg==", "devOptional": true, "license": "MIT", "dependencies": { - "tldts-core": "^7.4.12" + "tldts-core": "^7.4.16" }, "bin": { "tldts": "bin/cli.js" } }, "node_modules/tldts-core": { - "version": "7.4.12", - "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.12.tgz", - "integrity": "sha512-nYNzS2WRf4QJmjzFFgAxLOBjyBxAGRbCy9PVBPaglcYyYajh40VBn+v5Ngr96ZMc7oM0+aCJdtQnNejvdBnXMQ==", + "version": "7.4.16", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.16.tgz", + "integrity": "sha512-MDolfaSJtlSK5Y0A1xl3277ekubZwobpBjugknDizI9O5Rm60a1m8k4ICK+MRsCDzPygT81mp3BBf5RKDlFRfA==", "devOptional": true, "license": "MIT" }, @@ -13649,9 +13607,9 @@ "license": "0BSD" }, "node_modules/tsx": { - "version": "4.23.13", - "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.13.tgz", - "integrity": "sha512-BL5MGkRln6aDYhb0xbQlEAGw743BaZYWdbWtdJOBriYJboKgUUYCadFp2/FpBBZquBC/ezNBn7wMMPx7FDZUDw==", + "version": "4.23.15", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.15.tgz", + "integrity": "sha512-Yiex1Ovn8z2xPpOWckIiysV1SSyRMY9BkLF++q0yKiDxCqRhosKfMg3janKkiLBwZ5c/YryloKwGZcrEmtwxKw==", "license": "MIT", "dependencies": { "esbuild": "~0.28.0" @@ -13725,19 +13683,18 @@ } }, "node_modules/typed-array-byte-offset": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/typed-array-byte-offset/-/typed-array-byte-offset-1.0.4.tgz", - "integrity": "sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ==", + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/typed-array-byte-offset/-/typed-array-byte-offset-1.0.5.tgz", + "integrity": "sha512-0FHJvLPqZ7KJzp17O13jfsAjsqazgrxBu2zEK95PmUz8lv2+GjRuxUInCr2Rk9Dms3ihN21zJ929ZO43yJ95QQ==", "dev": true, "license": "MIT", "dependencies": { "available-typed-arrays": "^1.0.7", - "call-bind": "^1.0.8", - "for-each": "^0.3.3", + "call-bind": "^1.0.9", + "for-each": "^0.3.5", "gopd": "^1.2.0", - "has-proto": "^1.2.0", "is-typed-array": "^1.1.15", - "reflect.getprototypeof": "^1.0.9" + "reflect.getprototypeof": "^1.0.10" }, "engines": { "node": ">= 0.4" @@ -13782,16 +13739,16 @@ } }, "node_modules/typescript-eslint": { - "version": "8.70.0", - "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.70.0.tgz", - "integrity": "sha512-P/W5cz70/cQAuKfY3xwQMWWTV7BvJ0mAQmi+9mBcsVPaBUpd6Ohpa+fECv9rBFrQcig86jAiNBFNWUqnTjr4pw==", + "version": "8.71.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.71.0.tgz", + "integrity": "sha512-fBdHYiqQ14RW6mOMXD14Svn82ZsCYAoQSzGRzyEjR59S5A2Krh/l7fGTOQ7iCr8gGy/mHVXtEF7s5fgjEdV0Pw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/eslint-plugin": "8.70.0", - "@typescript-eslint/parser": "8.70.0", - "@typescript-eslint/typescript-estree": "8.70.0", - "@typescript-eslint/utils": "8.70.0" + "@typescript-eslint/eslint-plugin": "8.71.0", + "@typescript-eslint/parser": "8.71.0", + "@typescript-eslint/typescript-estree": "8.71.0", + "@typescript-eslint/utils": "8.71.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -13831,18 +13788,19 @@ "license": "MIT" }, "node_modules/undici": { - "version": "7.29.1", - "resolved": "https://registry.npmjs.org/undici/-/undici-7.29.1.tgz", - "integrity": "sha512-RYONW2MeafgYlkVOKYKkA/Ag7BmXqgIWCa8t1m0JcxrQg9pI9lEqRhAOruOBCbAohOa/gkCF+iPi9hrgvTzu6Q==", + "version": "8.11.2", + "resolved": "https://registry.npmjs.org/undici/-/undici-8.11.2.tgz", + "integrity": "sha512-u4UB2/IrKdU6lFxumHmmo1a3fCQO5tzQllRorfoRS63txhrB7xTpSn1PftwC4qEHkOaqP95fCWW4lJzwErwzhQ==", + "devOptional": true, "license": "MIT", "engines": { - "node": ">=20.18.1" + "node": ">=22.19.0" } }, "node_modules/undici-types": { - "version": "7.18.2", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", - "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "version": "7.24.6", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz", + "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==", "devOptional": true, "license": "MIT" }, @@ -14022,9 +13980,9 @@ } }, "node_modules/vite": { - "version": "8.3.0", - "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.0.tgz", - "integrity": "sha512-lhZBVvEHefgE+HQZC9O7EBJgCU/nVzFNl7vkS4RE0APtWLP02/8QVIkQtzBxPquh7lq5/78NHipTj7ODQ6XuyQ==", + "version": "8.3.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.1.tgz", + "integrity": "sha512-/bvH9E9tmCXRGp2uXY3WbOldqpTwFkbha/8ANaEQ6VkxhH60KyqLwgZq6lG2y+4uT55x9+9eUHMpQ7uGnOCKjA==", "devOptional": true, "license": "MIT", "peer": true, @@ -14032,7 +13990,7 @@ "lightningcss": "^1.33.0", "picomatch": "^4.0.7", "postcss": "^8.5.28", - "rolldown": "~1.2.6", + "rolldown": "~1.2.9", "tinyglobby": "^0.2.17" }, "bin": { @@ -14389,14 +14347,14 @@ } }, "node_modules/vitest": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-5.0.0.tgz", - "integrity": "sha512-gpsMNoRhMjMktVxPtstOH4/PJuPyovVaMDr4oDilXaGH1EcqM2OE96SoHT2VIQ6fTGtTjqmHDrEu2X9RQiXf8Q==", + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-5.0.2.tgz", + "integrity": "sha512-7MQrx9pDv5aHiUcovIb/70Ys3tgtkUVgCtledvKdCmEO+/1Dicq5ZqoSxOW034m03oqC+oHOKui2dM6qtMLoJg==", "devOptional": true, "license": "MIT", "dependencies": { "@types/chai": "^5.2.2", - "@vitest/mocker": "5.0.0", + "@vitest/mocker": "5.0.2", "chai": "^6.2.2", "es-module-lexer": "^2.3.2", "expect-type": "^1.4.0", @@ -14404,10 +14362,10 @@ "obug": "^2.1.4", "picomatch": "^4.0.7", "std-env": "^4.2.0", - "tinybench": "6.1.4", - "tinyexec": "1.3.0", + "tinybench": "^6.1.4", + "tinyexec": "^1.3.0", "tinyglobby": "^0.2.17", - "why-is-node-running": "^2.3.0" + "why-is-node-running": "^3.2.1" }, "bin": { "vitest": "vitest.mjs" @@ -14422,12 +14380,12 @@ "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^22.0.0 || >=24.0.0", - "@vitest/browser-playwright": "5.0.0", - "@vitest/browser-preview": "5.0.0", + "@vitest/browser-playwright": "5.0.2", + "@vitest/browser-preview": "5.0.2", "@vitest/browser-webdriverio": "^5.0.0-beta.5 || >=5.0.0", - "@vitest/coverage-istanbul": "5.0.0", - "@vitest/coverage-v8": "5.0.0", - "@vitest/ui": "5.0.0", + "@vitest/coverage-istanbul": "5.0.2", + "@vitest/coverage-v8": "5.0.2", + "@vitest/ui": "5.0.2", "happy-dom": "*", "jsdom": "*", "vite": "^6.4.0 || ^7.0.0 || ^8.0.0" @@ -14472,9 +14430,9 @@ } }, "node_modules/vitest/node_modules/magic-string": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.3.1.tgz", - "integrity": "sha512-rm91zr2Ou+XueDTohjQQjdQEcYM6zVi8KVUCG8Ec3vHwUEKrhSdCNyfuIywkA6hcCAteIn0ZOtAHA6eGpiX+Pg==", + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.4.2.tgz", + "integrity": "sha512-vG+rjFRj1PqdIBozIxAGMjPlOhaVe+GXpbttY/iSK7rGcJRMlwNJO7dcUwmUqkymsFLJiNGI06t4D7Fr7yRC9g==", "devOptional": true, "license": "MIT", "dependencies": { @@ -14495,16 +14453,16 @@ } }, "node_modules/w3c-xmlserializer": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", - "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-6.0.0.tgz", + "integrity": "sha512-4Nsy8K5Tr6SPDH9jhKJOHf7ChDrc1zufZTVSF7x72hwuEXBqxqk9G6cK+K2NRUtB3iELRJqjXb4JPDMBjMTl2Q==", "devOptional": true, "license": "MIT", "dependencies": { "xml-name-validator": "^5.0.0" }, "engines": { - "node": ">=18" + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" } }, "node_modules/web-resource-inliner": { @@ -14588,18 +14546,18 @@ } }, "node_modules/whatwg-url": { - "version": "16.0.1", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", - "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", + "version": "17.1.2", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-17.1.2.tgz", + "integrity": "sha512-TEZA+Zqxin7Jjsm2cjRohCmen5awh+hT6Zi3VZdqZlNRk7zvOI/9WpBFg/DWlA56bWnzwm6DuB8NS0EsxQH9uQ==", "devOptional": true, "license": "MIT", "dependencies": { - "@exodus/bytes": "^1.11.0", + "@exodus/bytes": "^1.15.1", "tr46": "^6.0.0", "webidl-conversions": "^8.0.1" }, "engines": { - "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + "node": "^22.14.0 || >=24.0.0" } }, "node_modules/which": { @@ -14693,9 +14651,9 @@ } }, "node_modules/which-typed-array": { - "version": "1.1.22", - "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.22.tgz", - "integrity": "sha512-fvO4ExWMFsqyhG3AiPAObMuY1lxaqgYcxbc49CNdWDDECOJNgQyvsOWVwbZc+qf3rzRtxojBK+CMEv0Ld5CYpw==", + "version": "1.1.24", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.24.tgz", + "integrity": "sha512-wk4Mf4pR5mRP7eYuuTBCIQ9d0ud2Fv2jRLQpfgnRjbOxAFHmjKFValgTpitVKzJJS8ajnYQV2Du1SZ8j6b/EUQ==", "dev": true, "license": "MIT", "dependencies": { @@ -14715,20 +14673,16 @@ } }, "node_modules/why-is-node-running": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", - "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-3.2.2.tgz", + "integrity": "sha512-NKUzAelcoCXhXL4dJzKIwXeR8iEVqsA0Lq6Vnd0UXvgaKbzVo4ZTHROF2Jidrv+SgxOQ03fMinnNhzZATxOD3A==", "devOptional": true, "license": "MIT", - "dependencies": { - "siginfo": "^2.0.0", - "stackback": "0.0.2" - }, "bin": { "why-is-node-running": "cli.js" }, "engines": { - "node": ">=8" + "node": ">=20.11" } }, "node_modules/word-wrap": { @@ -14871,9 +14825,9 @@ "license": "MIT" }, "node_modules/zod": { - "version": "4.6.4", - "resolved": "https://registry.npmjs.org/zod/-/zod-4.6.4.tgz", - "integrity": "sha512-AXSD6hvGdvRjajG/l1cC+d6IrhH+sjmPKtYeQdJIK8MFJl3LyClzS+o/YsVC+zQZPupAaeH5skwwm8YqYH7BqA==", + "version": "4.6.5", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.6.5.tgz", + "integrity": "sha512-v5l/aFXZQeai4awLbOpSoHecE9UiMrnfx75tEXLjNonXVARxQ5mOeipTjROUchszUNCqnE+hqAMujRsRHsut2Q==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/colinhacks" diff --git a/package.json b/package.json index f404496..380ac57 100644 --- a/package.json +++ b/package.json @@ -41,28 +41,28 @@ "@radix-ui/react-switch": "^1.3.7", "@radix-ui/react-tooltip": "^1.2.16", "@react-pdf/renderer": "^4.9.0", - "better-auth": "^1.7.4", + "better-auth": "^1.7.6", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", - "docx": "^9.7.1", - "docxtemplater": "^3.69.3", + "docx": "^9.8.1", + "docxtemplater": "^3.71.0", "docxtemplater-image": "^0.1.2", - "dotenv": "^17.4.2", + "dotenv": "^18.0.4", "grapesjs": "^0.22.16", "grapesjs-mjml": "^1.0.8", - "lucide-react": "^1.45.0", + "lucide-react": "^1.49.0", "mjml": "^5.4.1", - "next": "^16.3.5", - "pizzip": "^3.2.0", + "next": "^16.3.7", + "pizzip": "^3.3.0", "react": "^19.3.0", "react-dom": "^19.3.0", - "react-hook-form": "^7.88.0", + "react-hook-form": "^7.89.0", "sonner": "^2.0.8", "tailwind-merge": "^3.7.0", - "tsx": "^4.23.13", + "tsx": "^4.23.15", "vaul": "^1.1.2", - "zod": "^4.6.4" + "zod": "^4.6.5" }, "devDependencies": { "@inquirer/prompts": "^8.7.2", @@ -71,15 +71,15 @@ "@testing-library/react": "^16.3.3", "@testing-library/user-event": "^14.6.7", "@types/mjml": "^5.0.0", - "@types/node": "^24.13.4", + "@types/node": "^24.19.0", "@types/react": "^19.3.0", "@types/react-dom": "^19.3.0", "@vitejs/plugin-react": "^6.1.1", "@vitest/coverage-v8": "^5.0.0", - "chalk": "^6.0.0", + "chalk": "^6.0.1", "eslint": "~9.39.4", - "eslint-config-next": "^16.3.5", - "jsdom": "^30.0.1", + "eslint-config-next": "^16.3.7", + "jsdom": "^30.1.1", "postcss": "^8.5.28", "tailwindcss": "^4.2.0", "tw-animate-css": "^1.4.0", From 843f862439f4951bf88033d46cfb64045d25ba15 Mon Sep 17 00:00:00 2001 From: Chris Kehayias Date: Wed, 30 Sep 2026 06:46:21 -0400 Subject: [PATCH 02/24] docs(todo): record MPNext playbook audit (2026-09-30) (#34) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds 14 TODOs from auditing the five MPNext port playbooks: 9 ordered remediation steps (one PR each), 2 ops actions (rotate \`BETTER_AUTH_SECRET\`, set \`MP_SECURITY_ROLES=*\`), 3 held items (CSP for customer clones, stored-proc allowlist, GitHub security settings). Docs only. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 5.5 (1M context) --- ...026-09-30-held-csp-customer-deployments.md | 32 ++++++++++++ ...9-30-held-github-repo-security-settings.md | 20 ++++++++ ...6-09-30-held-stored-procedure-allowlist.md | 24 +++++++++ ...026-09-30-ops-rotate-better-auth-secret.md | 30 +++++++++++ ...026-09-30-ops-set-mp-security-roles-env.md | 25 ++++++++++ ...-30-step1-close-live-auth-holes-f12-f3b.md | 50 +++++++++++++++++++ ...tep2-authorization-and-input-validation.md | 50 +++++++++++++++++++ ...p3-session-lifetime-and-secrets-hygiene.md | 44 ++++++++++++++++ ...ep4-oidc-lazy-discovery-and-sub-binding.md | 46 +++++++++++++++++ ...5-mp-http-client-and-provider-hardening.md | 30 +++++++++++ ...26-09-30-step6-headers-csp-build-guards.md | 27 ++++++++++ ...-step7-write-audit-and-datetime-cleanup.md | 31 ++++++++++++ ...9-30-step8-fork-specific-low-risk-items.md | 28 +++++++++++ .../2026-09-30-step9-ci-setup-and-docs.md | 24 +++++++++ .claude/TODO/INDEX.md | 42 ++++++++++++---- 15 files changed, 493 insertions(+), 10 deletions(-) create mode 100644 .claude/TODO/2026-09-30-held-csp-customer-deployments.md create mode 100644 .claude/TODO/2026-09-30-held-github-repo-security-settings.md create mode 100644 .claude/TODO/2026-09-30-held-stored-procedure-allowlist.md create mode 100644 .claude/TODO/2026-09-30-ops-rotate-better-auth-secret.md create mode 100644 .claude/TODO/2026-09-30-ops-set-mp-security-roles-env.md create mode 100644 .claude/TODO/2026-09-30-step1-close-live-auth-holes-f12-f3b.md create mode 100644 .claude/TODO/2026-09-30-step2-authorization-and-input-validation.md create mode 100644 .claude/TODO/2026-09-30-step3-session-lifetime-and-secrets-hygiene.md create mode 100644 .claude/TODO/2026-09-30-step4-oidc-lazy-discovery-and-sub-binding.md create mode 100644 .claude/TODO/2026-09-30-step5-mp-http-client-and-provider-hardening.md create mode 100644 .claude/TODO/2026-09-30-step6-headers-csp-build-guards.md create mode 100644 .claude/TODO/2026-09-30-step7-write-audit-and-datetime-cleanup.md create mode 100644 .claude/TODO/2026-09-30-step8-fork-specific-low-risk-items.md create mode 100644 .claude/TODO/2026-09-30-step9-ci-setup-and-docs.md diff --git a/.claude/TODO/2026-09-30-held-csp-customer-deployments.md b/.claude/TODO/2026-09-30-held-csp-customer-deployments.md new file mode 100644 index 0000000..36be5f9 --- /dev/null +++ b/.claude/TODO/2026-09-30-held-csp-customer-deployments.md @@ -0,0 +1,32 @@ +--- +title: "Held — CSP enforcement notes for customer-deployed clones (report endpoint, GrapesJS fonts, browser walk)" +severity: medium +tags: [security, doc] +area: routing +files: [src/lib/security-headers.ts, src/proxy.ts, src/components/template-editor/grapes-config.ts] +discovered: 2026-09-30 +discovered_by: playbook-audit (port-downstream-hardening.md F9) +status: open +--- + +## Problem +Owner decision (2026-09-30): **skip for now; document for customer clones.** The nonce CSP (`security-headers.ts`, +`proxy.ts:33-59`) is enforced by default via `CSP_ENFORCE`, but: +- The GrapesJS canvas loads `fonts.googleapis.com` (`grapes-config.ts:64`), which `style-src`/`font-src` block. +- No CSP report endpoint is configured. +- The enforced-CSP browser walk (sign-in, sign-out, images, every Radix surface, template editor) hasn't been done + against a production build. + +## Notes for customer-deployed clones +- **Report endpoint:** set `report-to`/`report-uri` to a collector the customer owns (Sentry, report-uri.com, or a + small route that logs shape only). Run `CSP_ENFORCE=false` (report-only) for at least one release cycle first. +- **Fonts:** either allow `https://fonts.googleapis.com` in `style-src` and `https://fonts.gstatic.com` in + `font-src`, or self-host the fonts and drop the Google URL from `grapes-config.ts` (preferred — no third-party + request, no CSP widening). +- **Customer additions:** any analytics, chat widget or embedded MP widget a customer adds needs its own origin + added — do it per deployment, never with `'unsafe-inline'` or a wildcard. +- **Walk:** after any CSP change, `npm run build && npm start`, open DevTools console, walk every tool; zero CSP + violations is the bar. + +## Impact if not fixed +Template editor fonts silently fall back; violations go unseen without a report endpoint. diff --git a/.claude/TODO/2026-09-30-held-github-repo-security-settings.md b/.claude/TODO/2026-09-30-held-github-repo-security-settings.md new file mode 100644 index 0000000..584bc6f --- /dev/null +++ b/.claude/TODO/2026-09-30-held-github-repo-security-settings.md @@ -0,0 +1,20 @@ +--- +title: "Held — Enable GitHub repo security settings (private vuln reporting, secret scanning, push protection, Dependabot security updates)" +severity: low +tags: [security, doc] +area: commands +files: [SECURITY.md] +discovered: 2026-09-30 +discovered_by: playbook-audit (port-security-review-2026-09-28.md P15) +status: open +--- + +## Problem +Owner decision (2026-09-30): **hold.** These are repo-admin toggles only the owner can set. + +## Proposed fix +GitHub → Settings → Code security: enable private vulnerability reporting, secret scanning, push protection, +Dependabot alerts and security updates. Point `SECURITY.md` at private vulnerability reporting. + +## Impact if not fixed +Leaked secrets aren't blocked at push; reporters have no private channel. diff --git a/.claude/TODO/2026-09-30-held-stored-procedure-allowlist.md b/.claude/TODO/2026-09-30-held-stored-procedure-allowlist.md new file mode 100644 index 0000000..5ef1a59 --- /dev/null +++ b/.claude/TODO/2026-09-30-held-stored-procedure-allowlist.md @@ -0,0 +1,24 @@ +--- +title: "Held — Deny-all stored-procedure allowlist in the MP provider" +severity: medium +tags: [security] +area: mp-provider +files: [src/lib/providers/ministry-platform/services/procedure.service.ts] +discovered: 2026-09-30 +discovered_by: playbook-audit (port-security-review-2026-09-28.md P9) +status: open +--- + +## Problem +Owner decision (2026-09-30): **skip for now.** Upstream restricts `executeProcedure*` to an explicit allowlist so +a future call path can't reach arbitrary `api_*` procs via the service account. Procs this fork calls: +`api_MPNextTools_GetPages`, `api_MPNextTools_GetPageFields`, `api_MPNextTools_UpdatePageFieldOrder`, +`api_Tools_GetPageData`, `api_Tools_GetUserTools`, `api_Common_GetSelection`, `api_dev_DeployTool` +(plus `api_dev_GetProcedureDefinition` from the dev panel). + +## Proposed fix +Confirm each proc with the owner, then add a frozen allowlist checked in `procedure.service.ts`, with a test that +an unlisted name is refused before any HTTP call. + +## Impact if not fixed +Defence in depth only — every current caller is gated and passes a literal proc name. diff --git a/.claude/TODO/2026-09-30-ops-rotate-better-auth-secret.md b/.claude/TODO/2026-09-30-ops-rotate-better-auth-secret.md new file mode 100644 index 0000000..fd49046 --- /dev/null +++ b/.claude/TODO/2026-09-30-ops-rotate-better-auth-secret.md @@ -0,0 +1,30 @@ +--- +title: "Ops — Rotate BETTER_AUTH_SECRET (exposure window 2026-07-09 → 2026-09-13) and tell downstream clones to do the same" +severity: high +tags: [security, doc] +area: auth +files: [.env.example, .claude/references/security/README.md] +discovered: 2026-09-30 +discovered_by: playbook-audit (port-downstream-hardening.md F-UPDATE-USER, port-security-review-2026-09-28.md P3) +status: open +--- + +## Problem +`e599075` (2026-07-09) made `userGuid` client-writable through `/api/auth/update-user`; the fix `e94e549` +(2026-09-13) added `disabledPaths`. Any session cookie minted or re-signed during that window may carry an +attacker-chosen `userGuid`, and with `refreshCache` on (fixed in Step 3) such a cookie can keep refreshing itself. +Only rotating the signing secret invalidates them. Owner decision (2026-09-30): **rotate**. + +## Proposed fix +1. Generate a new secret: `openssl rand -base64 32` (≥ 32 chars). +2. Set `BETTER_AUTH_SECRET` in every Vercel environment (Production, Preview/staging, Development) and every + `.env.local`; redeploy. All users are signed out once — schedule it with the Step 3 deploy. +3. Review `dp_Audit_Log` for writes attributed via this app between 2026-07-09 and the date `e94e549` reached each + environment, looking for `User_ID`s that don't match the signed-in person. +4. **Downstream clones:** any repo forked/copied from MPNext-Tools (or MPNext) before 2026-09-13 inherited the same + hole and gets no Dependabot alert. Add a line to the security README / advisory recommending that every downstream + clone port `disabledPaths` **and** rotate its own `BETTER_AUTH_SECRET`, then review its own audit log for the + window it was exposed. + +## Impact if not fixed +A forged-identity cookie from the exposure window stays valid. diff --git a/.claude/TODO/2026-09-30-ops-set-mp-security-roles-env.md b/.claude/TODO/2026-09-30-ops-set-mp-security-roles-env.md new file mode 100644 index 0000000..b6e859c --- /dev/null +++ b/.claude/TODO/2026-09-30-ops-set-mp-security-roles-env.md @@ -0,0 +1,25 @@ +--- +title: "Ops — Set MP_SECURITY_ROLES=* in every environment before/with the Step 2 deploy" +severity: high +tags: [security, doc] +area: auth +files: [.env.example, src/services/authorizationService.ts] +discovered: 2026-09-30 +discovered_by: playbook-audit +status: open +--- + +## Problem +Step 2 makes `MP_SECURITY_ROLES` fail closed: unset / blank / `","` → nobody; `*` → any MP security role; a +comma list → only those roles. Any environment without a value locks every user out after the deploy. +Owner decision (2026-09-30): use `*` for now, keep the control. + +## Proposed fix +1. Set `MP_SECURITY_ROLES=*` in Vercel Production, Preview and Development, and in local `.env.local` files. +2. Later: replace `*` with the specific role names that should use the app. +3. Ask MP admins to restrict who can create/rename Security Roles — roles are matched by **name**, so anyone who can + create a role with a listed name gains access. +4. Downstream clones: same change, same env step. + +## Impact if not fixed +Environment-wide lockout (safe failure, but an outage). diff --git a/.claude/TODO/2026-09-30-step1-close-live-auth-holes-f12-f3b.md b/.claude/TODO/2026-09-30-step1-close-live-auth-holes-f12-f3b.md new file mode 100644 index 0000000..ea157b7 --- /dev/null +++ b/.claude/TODO/2026-09-30-step1-close-live-auth-holes-f12-f3b.md @@ -0,0 +1,50 @@ +--- +title: "Step 1 — Close live auth holes: F12 ID-token sign-in, F3b open-redirect bypass, /sign-in/social boundary" +severity: critical +tags: [security, missing-test] +area: auth +files: [src/lib/auth.ts, src/app/api/auth/[...all]/route.ts, src/app/signin/sign-in-content.tsx] +discovered: 2026-09-30 +discovered_by: playbook-audit (port-downstream-hardening.md, port-security-review-2026-09-28.md) +status: open +--- + +## Problem +Two findings from the upstream MPNext advisory `docs/security/2026-09-25-signin-hardening.md` were never ported: + +- **F12** — `discoveryUrl` is set (`auth.ts:180`), so better-auth's `idToken` branch of `POST /sign-in/social` + is reachable. `getUserInfo` trusts the caller-supplied `accessToken` with no `id_token.sub` ↔ userinfo `sub` + binding. An attacker holding a victim's MP access token can sign in **as the victim**. +- **F3b** — `sanitizeCallbackUrl` (`sign-in-content.tsx:35-39`) only rejects `//` and `/\`. Browsers strip + tab/CR/LF, so `/signin?callbackUrl=/%09/example.com` redirects a signed-in user off-site. + +Also missing from the same boundary: no body-size cap / exact Content-Type / `callbackURL` length limit on +`/sign-in/social`, no `Cache-Control: no-store` on `/api/auth`, `/link-social` not in `disabledPaths` +(allowlist already 404s it — defence in depth only), `requireIdTokenVerification: true` not set, and `/signin` +echoes free-text `?error=` (`sign-in-content.tsx:56`). + +## Evidence +- `src/lib/auth.ts:180` `discoveryUrl`; no `hooks` in options (`:344-392`); `getUserInfo` `:250-316` never reads `tokens.idToken` +- `src/app/api/auth/[...all]/route.ts` has a path allowlist but no body-key filter +- `src/app/signin/sign-in-content.tsx:35-39,56` + +## Proposed fix +Follow `S:\MP\MPNext\.claude\playbooks\port-downstream-hardening.md` Phase 6b + Phase 7 (F3b) and +`port-security-review-2026-09-28.md` Phase 6. Reference upstream code in `S:\MP\MPNext` (`65a3225..cf5a824`). + +1. `auth.ts`: `hooks.before` refusing any `idToken` in `/sign-in/social` with `ID_TOKEN_SIGN_IN_DISABLED`; + `requireIdTokenVerification: true`; add `/link-social` to `disabledAuthPaths`. + *(The `sub` binding inside `getUserInfo` lands in Step 4's rewrite; the hook + route filter close the path now.)* +2. `route.ts`: `allowedSignInSocialKeys = ["provider","callbackURL"]` checked on `req.clone()`; exact + `application/json` (no comma/params); byte cap; `provider === "ministryplatform"`; `callbackURL` length cap; + `Cache-Control: no-store` on all `/api/auth` responses. +3. `sign-in-content.tsx`: port the F3b sanitizer (control chars, any backslash, `%2f`/`%5c` in path, URL backstop, + return the raw value). Map `?error=` through a fixed lookup, never render free text. +4. Tests: `src/auth.id-token-sign-in.test.ts` (each layer tested with the other two removed); F3b cases through a + real query string (`%09`/`%0A`/`%0D`, `\u0085`, `/.//evil` unchanged, deep links round-trip). +5. Mutation checks (must go red, then revert): delete the hook; drop the comma check; remove `CONTROL_CHARS`; + return the normalised form. +6. Non-production curl check against `https://mpi.ministryplatform.com`-backed local `next start`. + +## Impact if not fixed +Account takeover for anyone whose MP access token leaks; open redirect usable for phishing from the app's origin. diff --git a/.claude/TODO/2026-09-30-step2-authorization-and-input-validation.md b/.claude/TODO/2026-09-30-step2-authorization-and-input-validation.md new file mode 100644 index 0000000..9fd0824 --- /dev/null +++ b/.claude/TODO/2026-09-30-step2-authorization-and-input-validation.md @@ -0,0 +1,50 @@ +--- +title: "Step 2 — Authorization and input validation: fail-closed roles, group-wizard mass assignment, page-metadata injection, F11" +severity: high +tags: [security, missing-test] +area: services +files: [src/services/authorizationService.ts, src/services/groupService.ts, src/components/group-wizard/actions.ts, src/services/familyService.ts, src/components/shared-actions/domain.ts, src/app/(web)/tools/layout.tsx, src/lib/validation.ts, src/services/domainTimezoneService.ts, .env.example] +discovered: 2026-09-30 +discovered_by: playbook-audit (port-downstream-hardening.md, port-mp-write-attribution.md, port-security-review-2026-09-28.md P7/P10, port-mp-datetime-handling.md) +status: open +--- + +## Problem +1. **`MP_SECURITY_ROLES` fails open** — blank means "any role" (`authorizationService.ts:141-145`); `.env.example:80` + ships it blank. Upstream (2026-09-28): blank / `","` → nobody; `*` → any role. +2. **Group-wizard mass assignment (fork-only)** — `groupService.ts:31-36` spreads the unparsed client + `GroupWizardFormData` into the Groups POST/PUT; `updateGroup` builds `{Group_ID: groupId, ...data}` so a + payload `Group_ID` overrides the target record; `groupId` is never `validatePositiveInt`'d; actions + (`group-wizard/actions.ts:77-101`) never parse on the server. Any Groups column is writable + (e.g. `_Last_Attendance_Posted`). +3. **`FamilyService.resolveContactIdFromPage` query injection** (`familyService.ts:140-165`) — caller controls + `tableName`; any `contactIdField` containing `_TABLE` goes into `$select` unvalidated. A role holder can read a + numeric value from any table/column via `addeditfamily/actions.ts:74`. +4. **F11** — `shared-actions/domain.ts:13` (`getMpTimezone`) has no session check and no in-file justification, + though CLAUDE.md rule 12 lists it as a carve-out. Callable unauthenticated. +5. **Pages don't gate themselves** — `tools/layout.tsx:7-9` comment claims the layout redirect stops the page from + running (upstream corrected this 2026-09-29). Data can't leak today (services gate) but structure is wrong. +6. **Filter / error hygiene** — `escapeFilterString` (`validation.ts:38`) doesn't escape `[`, refuse non-strings + or control chars; search has no length cap. `validateGuid`/`validatePositiveInt`/`validateColumnName` and + `domainTimezoneService.ts:294,310` put caller input in error messages (CLAUDE.md rule 14). + +## Proposed fix +1. Fail-closed roles; `*` = any role. Tests for blank, `","`, `*`, list. `.env.example` → `MP_SECURITY_ROLES=*` + with a comment explaining the control. CLAUDE.md + security README updated. + **Deploy note:** owner sets `MP_SECURITY_ROLES=*` in every Vercel environment (see ops TODO). +2. Derive the allowlist by probing the wizard (`group-wizard/schema.ts`, step components, `STEP_FIELDS`, + `prepareForApi`): the writable set = fields the wizard UI actually edits. Parse with `groupWizardSchema` + server-side in the action; `pick` allowlist in the service; `Group_ID` last; `validatePositiveInt(groupId)`. + Adversarial tests (smuggled `Group_ID`, unknown column, `$userId` key, `groupId <= 0`) that fail when the + strip is reverted; "gate throws → no write" tests for create/update. +3. Strict regex per `_TABLE` path segment + column; restrict `tableName` (prefer resolving page metadata + server-side from `pageID`). Hostile-input test. +4. Session check in `getMpTimezone` + written justification in the file; missing-session test. +5. `requireSecurityRole()` in each `tools/*/page.tsx` (`UnauthorizedError` → `redirect("/no-access")` or the + repo's equivalent); fix the layout comment. +6. Port upstream `escapeFilterString` hardening + length cap; validators report field names only; port upstream's + domainTimezoneService messages + its two injection tests (update the test pinning old wording, use `mockReset`). + +## Impact if not fixed +Any MP user with any security role can use the app's service account to write arbitrary Groups columns, read +arbitrary numeric columns, and edit domain-wide page-field config. diff --git a/.claude/TODO/2026-09-30-step3-session-lifetime-and-secrets-hygiene.md b/.claude/TODO/2026-09-30-step3-session-lifetime-and-secrets-hygiene.md new file mode 100644 index 0000000..5223f97 --- /dev/null +++ b/.claude/TODO/2026-09-30-step3-session-lifetime-and-secrets-hygiene.md @@ -0,0 +1,44 @@ +--- +title: "Step 3 — Session lifetime, token-in-cookie removal, boot guards, secrets hygiene, client-IP header" +severity: high +tags: [security, missing-test, doc] +area: auth +files: [src/lib/auth.ts, src/lib/env.ts, .gitignore, .claude/settings.local.json, src/lib/providers/ministry-platform/client.ts, src/lib/providers/ministry-platform/auth/client-credentials.ts] +discovered: 2026-09-30 +discovered_by: playbook-audit (port-security-review-2026-09-28.md P2/P3/P6) +status: open +--- + +## Problem +- **Session (P3):** `auth.ts:362-372` — `strategy:"jwt"`, `refreshCache` defaults on (cookie lives up to 7 days), + no fixed `expiresIn`/`disableSessionRefresh`, `storeAccountCookie:true`. The user's MP refresh token with the full + `dataplatform/scopes/all` scope sits in a cookie; `offline_access` is requested (`:185`). `customSession` + (`:403-410`) passes token/IP/user-agent through. +- **Boot guards (P2):** raw `process.env` (`auth.ts:11,345-346`, `client.ts:41`, `client-credentials.ts:4`) — no + secret-length/default-secret check, no URL validation, no `src/lib/env.ts`. +- **Secrets hygiene:** `.gitignore:415` ignores only `.env.local`, not `.env*`. `.claude/settings.local.json` is + tracked and grants `Bash(sed:*)`, `rm -rf .claude/*`. No pre-commit secrets check, no `SECURITY.md`. +- **Client-IP header (P6):** no `AUTH_IP_ADDRESS_HEADERS` / `advanced.ipAddress.ipAddressHeaders`, so rate limiting + keys on whatever header better-auth picks. + +## Proposed fix +Follow `port-security-review-2026-09-28.md` Phases 2, 3 and the IP part of 6. +1. `refreshCache:false`, `jwe`, 12 h fixed lifetime, no sliding refresh, `storeAccountCookie:false`; strip user + OAuth tokens from the session; drop `offline_access`. Verify by decoding `session_data` and confirming no + `account_data` cookie. Port the session-lifetime/config/user-OAuth-token tests. +2. `src/lib/env.ts` with validated `MINISTRY_PLATFORM_BASE_URL`, `BETTER_AUTH_URL`, `BETTER_AUTH_SECRET` + (length ≥ 32, not a known default); start-up refusal tests. Keep the `NEXTAUTH_*` fallbacks CLAUDE.md documents. +3. `.gitignore` → `.env*` with `!.env.example`; `git rm --cached .claude/settings.local.json`; add a lightweight + pre-commit secrets check if the repo has a hook mechanism (don't add a heavy dependency without noting it); `SECURITY.md`. +4. **Client-IP header recommendation (owner decision 6):** on Vercel, trust `x-vercel-forwarded-for` first, then + `x-real-ip` — Vercel sets/overwrites both at its edge, so they can't be spoofed by the client. Never trust a raw + leftmost `x-forwarded-for` value. Make it configurable via `AUTH_IP_ADDRESS_HEADERS` (comma list, default + `x-vercel-forwarded-for,x-real-ip`) and document in `.env.example` that customer clones behind another proxy / + CDN (Cloudflare → `cf-connecting-ip`, Azure Front Door → `x-azure-clientip`, nginx → the header it sets) must + set it to the header **their** edge overwrites. Verify the Vercel claim against current Vercel docs before committing. +5. Record the `BETTER_AUTH_SECRET` rotation in the PR body (the session-config change logs everyone out anyway, + so rotate at the same deploy) — see `2026-09-30-ops-rotate-better-auth-secret.md`. + +## Impact if not fixed +A stolen cookie grants up to 7 days of access and carries a full-scope MP refresh token; mis-set env vars fail +silently at runtime instead of at boot. diff --git a/.claude/TODO/2026-09-30-step4-oidc-lazy-discovery-and-sub-binding.md b/.claude/TODO/2026-09-30-step4-oidc-lazy-discovery-and-sub-binding.md new file mode 100644 index 0000000..56ec43d --- /dev/null +++ b/.claude/TODO/2026-09-30-step4-oidc-lazy-discovery-and-sub-binding.md @@ -0,0 +1,46 @@ +--- +title: "Step 4 — OIDC lazy discovery, id_token sub binding, dedicated OIDC client, sign-out id_token_hint" +severity: high +tags: [security, missing-test, doc] +area: auth +files: [src/lib/auth.ts, src/components/user-menu/actions.ts, src/components/user-menu/user-menu.tsx, src/app/auth-error/page.tsx, src/auth.test.ts, package.json, .claude/references/auth/oauth-flow.md, .claude/references/security/README.md] +discovered: 2026-09-30 +discovered_by: playbook-audit (port-oidc-lazy-discovery.md, port-security-review-2026-09-28.md P4/P5) +status: open +--- + +## Problem +State **B** per `port-oidc-lazy-discovery.md`: `discoveryUrl` fetched at boot (`auth.ts:180`), +`disableIdTokenNonceBinding:true` (`:232`), no `jose`, no verifier. `getUserInfo` (`:250-316`) never verifies the +id_token, has no `sub` binding (F12's third layer), no `exp`/`azp` checks, no timeout / `redirect:"error"`, and +`fetch()`/`response.json()` can throw. The OIDC client uses `MINISTRY_PLATFORM_CLIENT_ID/SECRET` — the **same** +client as the service account (`client-credentials.ts:44`). Sign-out (`user-menu/actions.ts:23-28`) hand-builds the +endsession URL with no `client_id` / `id_token_hint`, and `user-menu.tsx:34-36` swallows failures. + +## Proposed fix +Follow `S:\MP\MPNext\.claude\playbooks\port-oidc-lazy-discovery.md` Phases 2–7. +1. Phase 2: read-only curl of `https://mpi.ministryplatform.com/ministryplatformapi/oauth/.well-known/openid-configuration` + (confirm exact path from the code) — record `jwks_uri`, algs, `connect/*` paths, RFC 9207 flag in the PR. +2. Add `jose` as a direct dependency (`npm install jose@^6`), lockfile root must show it. +3. Lazy id_token verifier + explicit `authorizationUrl`/`tokenUrl`/`userInfoUrl`/`endSessionEndpoint`; remove + `discoveryUrl` and `disableIdTokenNonceBinding` (keep the F8/pkce comment, fix its "mitigated by PKCE" wording); + `accountSubject` typeof form. **Keep `providerId: "ministryplatform"`** (registered redirect URI). +4. Rewrite `getUserInfo`: refuse missing id_token → verify → `sub`/`exp`/`azp` → userinfo fetch (10 s timeout, + `redirect:"error"`, never throws) → case-insensitive `sub` match, log `auth.userinfo.sub_mismatch`. Keep the + synthetic email, `mpEmail`, claim-derived `emailVerified`, `accountLinking:false`, `disabledPaths`. +5. **Dedicated OIDC client (owner decision 3: the OIDC client must be for this app only):** new env vars + `MP_OIDC_CLIENT_ID` / `MP_OIDC_CLIENT_SECRET` used as the OIDC client and id_token audience. Until every + environment has them, fall back to `MINISTRY_PLATFORM_CLIENT_ID/SECRET` with a one-time structured warning + `auth.oidc.shared_client`. Document in `.env.example`, setup wizard, auth README. Follow-up TODO: make them + required once environments are split. +6. Sign-out via `endSessionEndpoint` with `client_id` + `id_token_hint` if obtainable after Step 3's cookie changes; + otherwise `client_id` + `post_logout_redirect_uri` and document. try/catch with `unstable_rethrow` first. +7. Tests: port `src/test-utils/mock-oidc.ts`; `src/auth.oidc-discovery.test.ts`; move `auth.test.ts` userinfo + tests to signed tokens; replace pins at `auth.test.ts:461,486`; alg-confusion (HS256, `none`); F12 sub-binding. + Mutation: restore `discoveryUrl`, run with `--testTimeout=3000`, suite must go red. +8. Phase 6 build check with `MINISTRY_PLATFORM_BASE_URL=https://mp.invalid` logs no discovery error. +9. Phase 7 docs; `/auth-error` copy for `oauth_provider_not_found`, remove `nonce_binding_missing`. + +## Impact if not fixed +Any MP outage at cold start breaks sign-in until redeploy; F12's defence-in-depth layer stays missing; a single +client compromise exposes both the service account and user sign-in. diff --git a/.claude/TODO/2026-09-30-step5-mp-http-client-and-provider-hardening.md b/.claude/TODO/2026-09-30-step5-mp-http-client-and-provider-hardening.md new file mode 100644 index 0000000..338ce00 --- /dev/null +++ b/.claude/TODO/2026-09-30-step5-mp-http-client-and-provider-hardening.md @@ -0,0 +1,30 @@ +--- +title: "Step 5 — MP HTTP client and provider-service hardening" +severity: medium +tags: [security, missing-test] +area: mp-provider +files: [src/lib/providers/ministry-platform/utils/http-client.ts, src/lib/providers/ministry-platform/client.ts, src/lib/providers/ministry-platform/services/table.service.ts, src/lib/providers/ministry-platform/types/provider.types.ts] +discovered: 2026-09-30 +discovered_by: playbook-audit (port-security-review-2026-09-28.md P8/P9) +status: open +--- + +## Problem +- `http-client.ts`: no timeout, no `redirect:"error"`, no path guard in `buildUrl` (`:164`), query keys not encoded, + `response.json()` unguarded. Token response not validated (`client.ts:63`). Single-flight refresh exists but no + negative cache and no retry on 401. +- Provider services: paths `encodeURIComponent`'d but not validated (`..` survives); `recordId` unvalidated + (`table.service.ts:84,113`); no `guards.ts`; `$ignorePermissions` still in `provider.types.ts:101`; ~25 + `logger.error(msg, error)` calls log raw error objects (rule 14). + +## Proposed fix +Follow `port-security-review-2026-09-28.md` Phases 8–9 (upstream code in `S:\MP\MPNext`). +Timeouts + `redirect:"error"`; path guard; encoded keys; guarded JSON parse; token-response validation; one 401 +retry after forced refresh; negative cache on token failure; `guards.ts` for table / identifier / recordId; +remove `$ignorePermissions`; log identifiers and shape only. +**Out of scope (owner decision 5 — skipped):** the deny-all stored-procedure allowlist — see +`2026-09-30-held-stored-procedure-allowlist.md`. + +## Impact if not fixed +A hung MP request pins a function for its full timeout; a crafted identifier could reach unexpected API paths; +raw error objects can leak response bodies into logs. diff --git a/.claude/TODO/2026-09-30-step6-headers-csp-build-guards.md b/.claude/TODO/2026-09-30-step6-headers-csp-build-guards.md new file mode 100644 index 0000000..8ea0c18 --- /dev/null +++ b/.claude/TODO/2026-09-30-step6-headers-csp-build-guards.md @@ -0,0 +1,27 @@ +--- +title: "Step 6 — Security headers, next.config hardening, server-only guards, prerender + build in CI" +severity: medium +tags: [security, drift] +area: routing +files: [next.config.ts, src/proxy.ts, src/lib/security-headers.ts, .github/workflows/test.yml, src/services/domainTimezoneService.ts] +discovered: 2026-09-30 +discovered_by: playbook-audit (port-downstream-hardening.md F9, port-security-review-2026-09-28.md P11/P14) +status: open +--- + +## Problem +- `next.config.ts:17-51` lacks COOP/CORP `same-origin`, `poweredByHeader:false`, `images.unoptimized`, + `logging.serverFunctions:false` — the last matters most: `next dev` prints server-action arguments (household + lists) while pointed at production MP. +- `proxy.ts:22` `startsWith('/api/auth')` has no trailing slash; matcher (`:95`) unanchored; `originOf` doesn't + validate scheme/host; CSP `base-uri 'self'` (upstream `'none'`). +- No `server-only` anywhere (e.g. `domainTimezoneService`, services); no `scripts/check-prerender.mjs`; CI never + runs `npm run build`. + +## Proposed fix +Port per the playbooks. Verify `server-only` by planting a client import that must break `next build`. +**CSP enforcement walk and the GrapesJS Google Fonts decision are held** — see +`2026-09-30-held-csp-customer-deployments.md`. + +## Impact if not fixed +Server-action payloads in dev logs; weaker cross-origin isolation; server code can silently enter the client bundle. diff --git a/.claude/TODO/2026-09-30-step7-write-audit-and-datetime-cleanup.md b/.claude/TODO/2026-09-30-step7-write-audit-and-datetime-cleanup.md new file mode 100644 index 0000000..aab567b --- /dev/null +++ b/.claude/TODO/2026-09-30-step7-write-audit-and-datetime-cleanup.md @@ -0,0 +1,31 @@ +--- +title: "Step 7 — Write-audit gaps and datetime drift cleanup" +severity: medium +tags: [drift, missing-test] +area: services +files: [src/services/familyService.ts, src/services/groupService.ts, src/components/add-edit-family/add-edit-family.tsx, src/services/authorizationService.ts, src/lib/providers/ministry-platform/db/] +discovered: 2026-09-30 +discovered_by: playbook-audit (port-mp-write-attribution.md, port-mp-datetime-handling.md) +status: open +--- + +## Problem +- Stored-proc writes (`api_MPNextTools_UpdatePageFieldOrder`, `api_dev_DeployTool`) pass `$userId` in the query + string but neither proc declares `@UserID` or writes `dp_Audit_Log` — MP almost certainly doesn't audit DML inside + a proc, so page-field reorders leave no audit trail. +- `familyService.ts:83-87` `toDatetime()` builds `` `${value}T00:00:00` `` itself for `Date_of_Birth`, + `Season_Start/End` instead of `DomainTimezoneService`; `includes("T")` passes `Z`-tagged values through. +- Edit prefill (`groupService.getGroup` ~`:227,261`, `add-edit-family.tsx:1239`) uses `.split('T')[0]` — breaks on + MP's space-separated `YYYY-MM-DD HH:MM:SS`. +- `mp.write.non_user` is `console.warn(name, obj)` not JSON with an `event` key, and fires on refused reads. +- `familyService.test.ts` asserts `$userId` on only some update calls. Default tz mock is `"UTC"` (hides drift). +- Datetime reference doc is thinner than upstream (132 vs 230 lines); not linked from INDEX / services README. + Note: Git Bash on Windows drops `TZ` values containing `/` — use PowerShell `$env:TZ`. + +## Proposed fix +Explicit `@UserID` + `dp_Audit_Log`/`dp_Audit_Detail` inserts in the proc SQL (then `npm run mp:build:install`), or +document the gap. Route family dates through `tz.toMpSqlDatetime`; `replace(' ', 'T').slice(0,10)` prefill; +JSON log for writes only; loop `$userId` assertion over every update call; non-UTC tz in tests; port the fuller doc. + +## Impact if not fixed +No audit trail for domain-wide page-field changes; latent date drift if a caller ever sends a zoned value. diff --git a/.claude/TODO/2026-09-30-step8-fork-specific-low-risk-items.md b/.claude/TODO/2026-09-30-step8-fork-specific-low-risk-items.md new file mode 100644 index 0000000..433c63e --- /dev/null +++ b/.claude/TODO/2026-09-30-step8-fork-specific-low-risk-items.md @@ -0,0 +1,28 @@ +--- +title: "Step 8 — Fork-specific low-risk items: address-label caps, error boundaries, tool-params decode, codegen escaping" +severity: low +tags: [security, bug] +area: components +files: [src/components/address-labels/actions.ts, src/services/addressLabelService.ts, src/app/global-error.tsx, src/lib/tool-params.server.ts, src/lib/providers/ministry-platform/scripts/generate-types.ts, src/lib/providers/ministry-platform/db/] +discovered: 2026-09-30 +discovered_by: playbook-audit (port-security-review-2026-09-28.md, fork sweep) +status: open +--- + +## Problem +- Address labels: `mergeTemplate`/`generateLabel*` merge client-sent labels (not re-fetched) with no count cap; + the 5 MB `.docx` template is PizZip-decompressed with no size limit (zip bomb); the 5 MB check is dead because + Next's default server-action body limit is 1 MB. +- `global-error.tsx` is retry-button only — no plain ``, button not held until hydration. +- `tool-params.server.ts` `decodeURIComponent(recordDescription)` throws on a stray `%` and crashes the page. +- `generate-types.ts:361,414-421` interpolates MP metadata raw into generated source. +- `api_dev_GetProcedureDefinition` lacks `@DomainID INT` as first parameter (repo rule for `api_*` procs). +- `UserService.getUserProfile` returns `User_ID`, `Mobile_Phone`, roles and groups to the client — trim to what the UI uses. + +## Proposed fix +Count cap + re-fetch or validate labels server-side; cap uncompressed size / entry count before PizZip; align +`serverActions.bodySizeLimit` with the documented limit; `global-error` fallback link; safe decode; escape codegen +strings; add `@DomainID`; minimise profile DTO. + +## Impact if not fixed +Memory exhaustion from a crafted template; a malformed URL crashes a tool page; minor data over-exposure. diff --git a/.claude/TODO/2026-09-30-step9-ci-setup-and-docs.md b/.claude/TODO/2026-09-30-step9-ci-setup-and-docs.md new file mode 100644 index 0000000..8d97f7c --- /dev/null +++ b/.claude/TODO/2026-09-30-step9-ci-setup-and-docs.md @@ -0,0 +1,24 @@ +--- +title: "Step 9 — CI supply-chain hardening, setup wizard env writer, docs and CLAUDE.md rules" +severity: low +tags: [security, doc] +area: commands +files: [.github/workflows/test.yml, .github/dependabot.yml, scripts/setup.ts, CLAUDE.md, .env.example, .claude/references/security/README.md] +discovered: 2026-09-30 +discovered_by: playbook-audit (port-security-review-2026-09-28.md P15/P16, port-downstream-hardening.md P13) +status: open +--- + +## Problem +- Actions pinned by tag not SHA; no `persist-credentials: false`; no `dependabot.yml`. +- `scripts/setup.ts:273-288` writes env via string `replace` (`$&` expansion), unquoted values, no mode 0600, runs + `npm install`/`npm update`, doesn't check a hand-entered secret's length. +- CLAUDE.md lacks: "sanitize every `$filter` value, number-typed included" and "better-auth body options / idToken — + re-check on upgrade". Security README lacks F12. `mp.write.non_user` undocumented. + +## Proposed fix +SHA-pin actions, `persist-credentials: false`, add Dependabot config; replacer-function env writes, quoted values, +0600; add the two CLAUDE.md rules; F12 + F3b rows in the security README. + +## Impact if not fixed +Tag-hijack risk in CI; setup can corrupt a secret containing `$&`. diff --git a/.claude/TODO/INDEX.md b/.claude/TODO/INDEX.md index e6b6dc6..87981f9 100644 --- a/.claude/TODO/INDEX.md +++ b/.claude/TODO/INDEX.md @@ -1,7 +1,7 @@ --- title: TODO Index type: index -last_updated: 2026-09-13 +last_updated: 2026-09-30 --- @@ -9,6 +9,7 @@ last_updated: 2026-09-13 + @@ -22,7 +23,12 @@ Severity tiers: - **medium**: doc drift, missing test, refactor with real cost - **low**: nits, minor doc fixes, stylistic improvements -Total: **4 open TODOs**. +Total: **18 open TODOs**. + +> **2026-09-30 — MPNext playbook audit.** The five `S:\MP\MPNext\.claude\playbooks` +> port playbooks were audited against this fork. Remediation is split into nine +> ordered steps (`step1`…`step9`, one PR each), two ops actions the owner performs +> in Vercel, and three held items awaiting owner decisions. > **2026-09-13 — unit-test coverage push.** Statement coverage over authored > code went from 49.67% to 98.84% (3,610/3,652), lines to 99.70%, across 1,535 @@ -40,21 +46,37 @@ Total: **4 open TODOs**. ## By severity -### Critical (0) -_none open_ +### Critical (1) +| Area | Tags | Title | File | +|---|---|---|---| +| auth | security, missing-test | Step 1 — F12 ID-token sign-in, F3b open-redirect bypass, /sign-in/social boundary | [→](2026-09-30-step1-close-live-auth-holes-f12-f3b.md) | -### High (0) -_none open_ +### High (5) +| Area | Tags | Title | File | +|---|---|---|---| +| services | security, missing-test | Step 2 — Fail-closed roles, group-wizard mass assignment, page-metadata injection, F11 | [→](2026-09-30-step2-authorization-and-input-validation.md) | +| auth | security, missing-test, doc | Step 3 — Session lifetime, token-in-cookie, boot guards, secrets hygiene, client-IP header | [→](2026-09-30-step3-session-lifetime-and-secrets-hygiene.md) | +| auth | security, missing-test, doc | Step 4 — OIDC lazy discovery, sub binding, dedicated OIDC client, sign-out | [→](2026-09-30-step4-oidc-lazy-discovery-and-sub-binding.md) | +| auth | security, doc | Ops — Rotate BETTER_AUTH_SECRET; advise downstream clones | [→](2026-09-30-ops-rotate-better-auth-secret.md) | +| auth | security, doc | Ops — Set MP_SECURITY_ROLES=* in every environment | [→](2026-09-30-ops-set-mp-security-roles-env.md) | -### Medium (2) +### Medium (7) | Area | Tags | Title | File | |---|---|---|---| +| mp-provider | security, missing-test | Step 5 — MP HTTP client and provider hardening | [→](2026-09-30-step5-mp-http-client-and-provider-hardening.md) | +| routing | security, drift | Step 6 — Headers, next.config, server-only, prerender + build in CI | [→](2026-09-30-step6-headers-csp-build-guards.md) | +| services | drift, missing-test | Step 7 — Write-audit gaps and datetime drift cleanup | [→](2026-09-30-step7-write-audit-and-datetime-cleanup.md) | +| routing | security, doc | Held — CSP notes for customer-deployed clones | [→](2026-09-30-held-csp-customer-deployments.md) | +| mp-provider | security | Held — Deny-all stored-procedure allowlist | [→](2026-09-30-held-stored-procedure-allowlist.md) | | components | bug, drift | Template editor ignores pageID/recordID (no MP persistence) | [→](2026-04-17-components-template-editor-no-mp-persistence.md) | | components | bug, refactor | Merge tokens `{{Field_Name}}` have no resolver anywhere | [→](2026-04-17-components-template-editor-merge-token-resolver.md) | -### Low (2) +### Low (5) | Area | Tags | Title | File | |---|---|---|---| +| components | security, bug | Step 8 — Address-label caps, error boundaries, tool-params decode, codegen escaping | [→](2026-09-30-step8-fork-specific-low-risk-items.md) | +| commands | security, doc | Step 9 — CI supply chain, setup env writer, docs | [→](2026-09-30-step9-ci-setup-and-docs.md) | +| commands | security, doc | Held — GitHub repo security settings | [→](2026-09-30-held-github-repo-security-settings.md) | | components | bug | Add/Edit Family search: "No contacts found" empty state never renders | [→](2026-09-13-search-empty-state-never-renders.md) | | components | refactor, missing-test | Unreachable "empty STEP_FIELDS" branch in `GroupWizard.handleNext` | [→](2026-09-13-dead-empty-fields-branch-handlenext.md) | @@ -62,8 +84,8 @@ _none open_ ## By tag -### security (0) -_none open_ +### security (13) +- all 2026-09-30 step/ops/held items except step7 ### bug (3) - components-template-editor-no-mp-persistence — medium From d2ca3b38564692458f14e4bb3be191936c83f15f Mon Sep 17 00:00:00 2001 From: Chris Kehayias Date: Wed, 30 Sep 2026 07:01:13 -0400 Subject: [PATCH 03/24] =?UTF-8?q?fix(auth):=20Step=201=20=E2=80=94=20refus?= =?UTF-8?q?e=20id=5Ftoken=20sign-in=20(F12),=20close=20callbackUrl=20bypas?= =?UTF-8?q?s=20(F3b),=20harden=20/sign-in/social=20(#35)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What / why Ports two unported findings from upstream MPNext's 2026-09-25 sign-in advisory, plus the `/sign-in/social` boundary from the 2026-09-28 security review. Closes the critical TODO `.claude/TODO/2026-09-30-step1-close-live-auth-holes-f12-f3b.md`. **F12 — account takeover via id_token sign-in.** `discoveryUrl` is set, so better-auth 1.7's `idToken` branch of `POST /sign-in/social` was live. It calls `getUserInfo` with a caller-supplied access token, and nothing binds `id_token.sub` to userinfo `sub`. An attacker's own id_token plus a victim's MP access token gave a session as the victim. (MP's discovery doc advertises implicit/hybrid flows, so this is Low–Medium.) - `hooks.before: refuseIdTokenSignIn` → 404 `ID_TOKEN_SIGN_IN_DISABLED` whenever the body has an `idToken` key. It checks that the key is present, not that it is truthy. It covers HTTP and in-process `auth.api` calls. - `/link-social` added to `disabledAuthPaths`. - `requireIdTokenVerification: true`: a discovery doc missing `issuer`/`jwks_uri` now skips the provider instead of leaving the id_token unverified. **Route boundary** (`api/auth/[...all]/route.ts`). `/sign-in/social` returns a plain 404, and Better Auth is never called, unless all of these hold: - the raw Content-Type is `application/json` with no comma - the body is ≤ 4096 bytes, both as declared and as actually streamed - the keys are a subset of `provider`/`callbackURL` - `provider === "ministryplatform"` - `callbackURL` is ≤ 2048 chars Every `/api/auth` response, 404s included, now gets `Cache-Control: no-store`. **F3b — open redirect bypass.** `/signin?callbackUrl=/%09/example.com` sent a signed-in user off-site. The sanitizer now mirrors better-auth's `isSafeRelativeURL`: it refuses control chars, any backslash, `%2F`/`%5C` in the path, and anything that resolves off-origin, and it returns the raw value. `?error=` is now only a lookup key into a fixed message map. Free text is never rendered or logged. ## Tests - **New `src/auth.id-token-sign-in.test.ts`.** Runs the real `auth` instance against a mock MP OIDC provider with really-signed id_tokens. Any other `fetch` throws. - **`route.test.ts`.** Runs the body filter with Better Auth mocked out, and asserts the handler is never called on any refusal. Also covers no-store on every path. - **`page.test.tsx`.** F3b cases both as strings and through a real query string, on both sinks (signed-in redirect and signed-out `callbackURL`). - All 17 mutation checks turned tests red and were reverted. The full list is in the TODO's Resolution section. It includes deleting the hook, dropping the comma check, removing `CONTROL_CHARS`, and returning the normalised URL. - Local: `lint`, `typecheck`, `test:coverage` (1673 tests; 98.79% statements / 99.68% lines) and `build` all pass. - **Live check.** Ran `next start` against non-prod `mpi.ministryplatform.com`: - attack body, comma Content-Type and a 3000-char `callbackURL` all → 404 no-store - a normal sign-in → 200 with the MP authorize URL, so `requireIdTokenVerification` is compatible with MP's discovery doc ## Deliberately deferred - **Step 4:** the `sub` binding in `getUserInfo`, and removing `discoveryUrl`. A test named "KNOWN GAP until Step 4" shows that with the hook removed, the attack still succeeds. That test must be flipped when the binding lands. - **Step 3:** the client-IP / trusted-proxy rate-limit config. ## Deploy notes - No env or config changes. `providerId` stays `ministryplatform`, so the redirect URI is unchanged. - If MP's discovery doc ever loses `issuer`/`jwks_uri`, sign-in now fails closed (`PROVIDER_NOT_FOUND`) instead of skipping id_token verification. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 (1M context) --- ...-30-step1-close-live-auth-holes-f12-f3b.md | 66 +++- .claude/TODO/INDEX.md | 14 +- .claude/references/security/README.md | 11 + src/app/api/auth/[...all]/route.test.ts | 279 +++++++++++++++- src/app/api/auth/[...all]/route.ts | 211 +++++++++++- src/app/signin/page.test.tsx | 150 ++++++++- src/app/signin/sign-in-content.tsx | 109 ++++-- src/auth.id-token-sign-in.test.ts | 316 ++++++++++++++++++ src/auth.test.ts | 13 + src/lib/auth.ts | 82 +++++ 10 files changed, 1207 insertions(+), 44 deletions(-) create mode 100644 src/auth.id-token-sign-in.test.ts diff --git a/.claude/TODO/2026-09-30-step1-close-live-auth-holes-f12-f3b.md b/.claude/TODO/2026-09-30-step1-close-live-auth-holes-f12-f3b.md index ea157b7..1bbd09d 100644 --- a/.claude/TODO/2026-09-30-step1-close-live-auth-holes-f12-f3b.md +++ b/.claude/TODO/2026-09-30-step1-close-live-auth-holes-f12-f3b.md @@ -6,7 +6,7 @@ area: auth files: [src/lib/auth.ts, src/app/api/auth/[...all]/route.ts, src/app/signin/sign-in-content.tsx] discovered: 2026-09-30 discovered_by: playbook-audit (port-downstream-hardening.md, port-security-review-2026-09-28.md) -status: open +status: resolved --- ## Problem @@ -48,3 +48,67 @@ Follow `S:\MP\MPNext\.claude\playbooks\port-downstream-hardening.md` Phase 6b + ## Impact if not fixed Account takeover for anyone whose MP access token leaks; open redirect usable for phishing from the app's origin. + +## Resolution (2026-09-30) + +Fixed on branch `fix/step1-f12-f3b-signin-hardening`, three code commits plus this one. + +### F12 — id_token sign-in (`src/lib/auth.ts`) +- `hooks.before: refuseIdTokenSignIn` refuses any `/sign-in/social` body with an + `idToken` key (presence, not truthiness) with **404 `ID_TOKEN_SIGN_IN_DISABLED`**. + Covers HTTP and in-process `auth.api.signInSocial` alike. +- `/link-social` added to `disabledAuthPaths` (its own id_token branch). +- `requireIdTokenVerification: true` — a discovery document without + `issuer`/`jwks_uri` now skips the provider (sign-in 404s `PROVIDER_NOT_FOUND`) + instead of silently leaving the id_token unverified. Checked: MP's + `mpi.ministryplatform.com` document publishes both, and a local `next start` + against it still returns the authorize URL. +- `providerId: "ministryplatform"` (no hyphen) is unchanged. + +### `/sign-in/social` boundary (`src/app/api/auth/[...all]/route.ts`) +Plain 404 (Better Auth never called) unless: raw Content-Type matches +`/^application\/json[\t ]*(;|$)/i` with no comma; declared Content-Length and +the streamed `clone()` are both ≤ 4096 bytes; body is a JSON object with keys ⊆ +`["provider","callbackURL"]`; `provider === "ministryplatform"`; `callbackURL` +is a string ≤ 2048 chars. `Cache-Control: no-store` on every `/api/auth` +response, 404s included (`withNoStore` copies immutable-header responses). + +### F3b + `?error=` (`src/app/signin/sign-in-content.tsx`) +Sanitizer ported from upstream `b7dc8e6`: refuses C0/DEL/C1, any backslash, +`%2F`/`%5C` in the path, and off-origin resolution; returns the raw value. +`?error=` is only a lookup key into a fixed `Map`; unknown codes get a generic +message; the raw value is neither rendered nor logged. + +### Tests +- New `src/auth.id-token-sign-in.test.ts` (node env): real `auth` instance vs a + mock MP OIDC provider with really RS256-signed id_tokens; `fetch` throws for + any other URL. Hook over HTTP and in-process, `idToken: null/{}/false`, + control (normal flow → authorize URL), `/link-social` 404 + reopened control, + `requireIdTokenVerification` with a no-`jwks_uri` document + flag-off control. +- `route.test.ts`: body filter with Better Auth mocked out (so the hook is out of + the way) — every refusal asserts the handler was never called; chunked + oversize body, declared length, NBSP/comma/`+json` Content-Types, duplicate + keys, `__proto__`, BOM; no-store on every path. +- `page.test.tsx`: F3b cases as strings and through a real query string + (`%09`/`%0A`/`%0D`/`%C2%85`/`%252F`…) on both sinks; `/.//evil.example` + unchanged; deep links round-trip; `?error=` free text never rendered/logged. + +### Mutation checks (each went red, then reverted) +Delete hook wiring (6 red); presence→truthiness (2); drop `/link-social` (2); +drop `requireIdTokenVerification` (2); drop comma check (1); key allowlist (≥6); +provider check (4); streamed byte cap (1 — after fixing the test to pad with JSON +whitespace, it initially survived); declared-length cap (1); `callbackURL` cap +(1); `withNoStore` (2); `trim()` the Content-Type (1); remove `CONTROL_CHARS` +(7, incl. new on-origin `/tools\t/x` cases — the original `/%09/` cases are also +caught by the `new URL` backstop, and go red when both are removed); return the +normalised form (2); drop backslash rule (2); drop encoded-separator rule (6); +echo unknown `?error=` (5). + +### Deliberately deferred +- **Step 4:** `id_token.sub` ↔ userinfo `sub` binding in `getUserInfo`, and + removing `discoveryUrl`. The test "KNOWN GAP until Step 4" in + `src/auth.id-token-sign-in.test.ts` shows the substituted token still signs in + as the victim **when the hook is removed**, and must be flipped to a refusal + when the binding lands. +- **Step 3:** `advanced.ipAddress` / `AUTH_IP_ADDRESS_HEADERS` / + `AUTH_TRUSTED_PROXIES` (rate-limit bucket) from the same security-review phase. diff --git a/.claude/TODO/INDEX.md b/.claude/TODO/INDEX.md index 87981f9..c9c82e8 100644 --- a/.claude/TODO/INDEX.md +++ b/.claude/TODO/INDEX.md @@ -10,6 +10,7 @@ last_updated: 2026-09-30 + @@ -23,7 +24,7 @@ Severity tiers: - **medium**: doc drift, missing test, refactor with real cost - **low**: nits, minor doc fixes, stylistic improvements -Total: **18 open TODOs**. +Total: **17 open TODOs**. > **2026-09-30 — MPNext playbook audit.** The five `S:\MP\MPNext\.claude\playbooks` > port playbooks were audited against this fork. Remediation is split into nine @@ -46,10 +47,8 @@ Total: **18 open TODOs**. ## By severity -### Critical (1) -| Area | Tags | Title | File | -|---|---|---|---| -| auth | security, missing-test | Step 1 — F12 ID-token sign-in, F3b open-redirect bypass, /sign-in/social boundary | [→](2026-09-30-step1-close-live-auth-holes-f12-f3b.md) | +### Critical (0) +_none open_ ### High (5) | Area | Tags | Title | File | @@ -84,8 +83,8 @@ Total: **18 open TODOs**. ## By tag -### security (13) -- all 2026-09-30 step/ops/held items except step7 +### security (12) +- all open 2026-09-30 step/ops/held items except step7 (step1 resolved) ### bug (3) - components-template-editor-no-mp-persistence — medium @@ -135,6 +134,7 @@ _none open_ | Date | Title | File | |---|---|---| +| 2026-09-30 | Step 1 — F12 id_token sign-in refused, F3b callbackUrl bypass closed, /sign-in/social boundary + no-store | [→](2026-09-30-step1-close-live-auth-holes-f12-f3b.md) | | 2026-09-13 | Coverage config omitted `coverage.include`, hiding every untested file | [→](2026-09-13-testing-coverage-report-masked-untested-files.md) | | 2026-09-13 | No tests for `src/components/template-editor/` | [→](2026-04-17-components-template-editor-missing-tests.md) | | 2026-09-13 | `vitest.config.ts` loaded as CommonJS (renamed to `.mts`) | [→](2026-09-13-testing-vitest-config-loaded-as-cjs.md) | diff --git a/.claude/references/security/README.md b/.claude/references/security/README.md index 0b4e1bf..0d52c41 100644 --- a/.claude/references/security/README.md +++ b/.claude/references/security/README.md @@ -128,6 +128,15 @@ deny-by-default, and it is why this is the *primary* control, with - `/sign-out` is deliberately **absent**: sign-out runs server-side through `auth.api.signOut`, which never crosses this HTTP boundary. Moving it to `authClient.signOut()` would 404 — loudly, which is the point. +- `POST /sign-in/social` is additionally **body-filtered** (F12): raw + Content-Type `application/json` (params allowed, no comma, untrimmed), body + ≤ 4096 bytes (declared and streamed), keys ⊆ `provider`/`callbackURL`, + `provider === "ministryplatform"`, `callbackURL` ≤ 2048 chars. Anything else + gets the same plain 404. The primary F12 control is `refuseIdTokenSignIn` + (`hooks.before` in `src/lib/auth.ts`), which also covers in-process + `auth.api` calls. If the client ever sends another key, add it deliberately; + never add `idToken`. +- Every `/api/auth` response, 404s included, carries `Cache-Control: no-store`. - `/oauth2/link` no longer exists in 1.7 (it was genericOAuth's account-linking endpoint); core `/link-social` and `/unlink-account` are deliberately absent. - **`onAPIError.errorURL` is set to `/auth-error`.** Better Auth's default is @@ -376,6 +385,8 @@ Closed in this repo, from the upstream MPNext hardening playbook | F5 | Medium | Member PII, `$filter` strings and response bodies in logs and thrown messages | | F9 | Medium | No CSP, no HSTS, no anti-framing, no Referrer-Policy | | F3 | Medium | Open redirect via `?callbackUrl=` on `/signin` | +| F3b | Low–Medium | F3 bypass: `?callbackUrl=/%09/evil` — the URL parser strips tab/LF/CR after string checks. Closed 2026-09-30 (sanitizer mirrors `isSafeRelativeURL`, returns the raw value) | +| F12 | Low–Medium | `/sign-in/social` id_token branch + caller-supplied access token = sign in as another user. Closed 2026-09-30 by `hooks.before` (`ID_TOKEN_SIGN_IN_DISABLED`) + route body filter + `/link-social` disabled. MP's client allows implicit/hybrid, hence Low–Medium. The `sub` binding in `getUserInfo` is **still open** (Step 4) | | F7 | Low | ~30 Better Auth endpoints publicly mounted; OAuth errors on a third-party page | | F8 | Low | **Resolved as WONTFIX.** PKCE stays `false`: MP advertises `S256` in discovery but rejects the token exchange with `invalid_grant`. See below. | diff --git a/src/app/api/auth/[...all]/route.test.ts b/src/app/api/auth/[...all]/route.test.ts index bab4fbc..ee0d657 100644 --- a/src/app/api/auth/[...all]/route.test.ts +++ b/src/app/api/auth/[...all]/route.test.ts @@ -13,7 +13,19 @@ vi.mock('better-auth/next-js', () => ({ toNextJsHandler: () => ({ GET: mockHandlerGet, POST: mockHandlerPost }), })); -import { GET, POST, allowedAuthRoutes, authRoutePath, isAllowedAuthRoute } from './route'; +import { + GET, + POST, + allowedAuthRoutes, + allowedSignInSocialKeys, + authRoutePath, + isAllowedAuthRoute, + isAllowedSignInSocialBody, + withNoStore, + MAX_CALLBACK_URL_LENGTH, + MAX_SIGN_IN_SOCIAL_BODY_BYTES, + SIGN_IN_SOCIAL_PROVIDER, +} from './route'; const url = (path: string) => `https://tools.example.org${path}`; @@ -76,7 +88,13 @@ describe('catch-all handler', () => { it('routes the three endpoints the client actually uses', async () => { await GET(new Request(url('/api/auth/get-session'))); await GET(new Request(url('/api/auth/callback/ministryplatform'))); - await POST(new Request(url('/api/auth/sign-in/social'), { method: 'POST' })); + await POST( + new Request(url('/api/auth/sign-in/social'), { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ provider: 'ministryplatform', callbackURL: '/' }), + }), + ); expect(mockHandlerGet).toHaveBeenCalledTimes(2); expect(mockHandlerPost).toHaveBeenCalledTimes(1); @@ -126,3 +144,260 @@ describe('catch-all handler', () => { expect(res.status).toBe(404); }); }); + +/** + * F12, route layer — the `POST /sign-in/social` body filter. + * + * Tested here with Better Auth MOCKED OUT entirely (`toNextJsHandler` is a + * stub), i.e. with the `refuseIdTokenSignIn` hook out of the way: every + * refusal below comes from this filter alone. The assertion that matters is + * that the handler is NEVER CALLED for a refused body — a 404 from Better Auth + * itself would prove nothing about this layer. + */ +describe('POST /sign-in/social body filter', () => { + const SIGN_IN = url('/api/auth/sign-in/social'); + + function signIn( + body: BodyInit | null, + headers: Record = { 'Content-Type': 'application/json' }, + ) { + return new Request(SIGN_IN, { method: 'POST', headers, body }); + } + + function chunked(bytes: Uint8Array, chunkSize: number) { + const stream = new ReadableStream({ + start(controller) { + for (let i = 0; i < bytes.length; i += chunkSize) { + controller.enqueue(bytes.slice(i, i + chunkSize)); + } + controller.close(); + }, + }); + return new Request(SIGN_IN, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: stream, + duplex: 'half', + } as RequestInit & { duplex: 'half' }); + } + + const good = () => + JSON.stringify({ provider: 'ministryplatform', callbackURL: '/tools/addresslabels?s=1' }); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('pins the key allowlist to exactly what the client sends', () => { + expect([...allowedSignInSocialKeys]).toEqual(['provider', 'callbackURL']); + expect(allowedSignInSocialKeys).not.toContain('idToken'); + // No hyphen: it is part of the redirect URI registered with MP. + expect(SIGN_IN_SOCIAL_PROVIDER).toBe('ministryplatform'); + }); + + it.each([ + ['application/json', good()], + ['application/json; charset=utf-8', good()], + ['Application/JSON;charset=UTF-8', good()], + ['application/json', JSON.stringify({ provider: 'ministryplatform' })], + // A BOM, stripped the way request.json() strips it. + ['application/json', '' + good()], + [ + 'application/json', + JSON.stringify({ + provider: 'ministryplatform', + callbackURL: '/' + 'a'.repeat(MAX_CALLBACK_URL_LENGTH - 1), + }), + ], + ])('reaches Better Auth for Content-Type %j with an allowed body', async (contentType, body) => { + const res = await POST(signIn(body, { 'Content-Type': contentType })); + + expect(res.status).toBe(200); + expect(mockHandlerPost).toHaveBeenCalledTimes(1); + }); + + it('hands Better Auth an unconsumed body (the filter reads a clone)', async () => { + mockHandlerPost.mockImplementationOnce( + async (...args: unknown[]) => new Response(await (args[0] as Request).text()), + ); + + const res = await POST(signIn(good())); + + expect(await res.text()).toBe(good()); + }); + + it.each([ + [ + 'an idToken (the F12 takeover body)', + JSON.stringify({ provider: 'ministryplatform', idToken: { token: 'x', accessToken: 'y' } }), + ], + ['idToken: null', JSON.stringify({ provider: 'ministryplatform', idToken: null })], + ['idToken: {}', JSON.stringify({ provider: 'ministryplatform', idToken: {} })], + ['scopes', JSON.stringify({ provider: 'ministryplatform', scopes: ['openid'] })], + ['errorCallbackURL', JSON.stringify({ provider: 'ministryplatform', errorCallbackURL: '/x' })], + ['newUserCallbackURL', JSON.stringify({ provider: 'ministryplatform', newUserCallbackURL: '/x' })], + ['additionalParams', JSON.stringify({ provider: 'ministryplatform', additionalParams: { a: 'b' } })], + ['additionalData', JSON.stringify({ provider: 'ministryplatform', additionalData: { a: 'b' } })], + ['loginHint', JSON.stringify({ provider: 'ministryplatform', loginHint: 'x' })], + ['requestSignUp', JSON.stringify({ provider: 'ministryplatform', requestSignUp: true })], + ['disableRedirect', JSON.stringify({ provider: 'ministryplatform', disableRedirect: true })], + ['__proto__ as an own key', '{"provider":"ministryplatform","__proto__":{"idToken":1}}'], + ['a different provider', JSON.stringify({ provider: 'github', callbackURL: '/' })], + [ + 'the hyphenated upstream provider id', + JSON.stringify({ provider: 'ministry-platform', callbackURL: '/' }), + ], + [ + 'a duplicate provider whose LAST value is foreign (JSON.parse: last wins)', + '{"provider":"ministryplatform","provider":"github"}', + ], + ['no provider', JSON.stringify({ callbackURL: '/' })], + ['a non-string callbackURL', JSON.stringify({ provider: 'ministryplatform', callbackURL: 42 })], + ['a null callbackURL', JSON.stringify({ provider: 'ministryplatform', callbackURL: null })], + [ + 'a 2049-character callbackURL', + JSON.stringify({ + provider: 'ministryplatform', + callbackURL: '/' + 'a'.repeat(MAX_CALLBACK_URL_LENGTH), + }), + ], + ['malformed JSON', '{"provider":'], + ['a JSON array', JSON.stringify(['provider', 'ministryplatform'])], + ['JSON null', 'null'], + ['a JSON string', '"ministryplatform"'], + ['an empty body', ''], + ])('refuses a body with %s: plain 404, Better Auth never called', async (_label, body) => { + const res = await POST(signIn(body)); + + expect(res.status).toBe(404); + expect(res.headers.get('Cache-Control')).toBe('no-store'); + expect(mockHandlerPost).not.toHaveBeenCalled(); + }); + + it.each([ + ['text/plain', 'text/plain'], + ['form-encoded', 'application/x-www-form-urlencoded'], + ['multipart', 'multipart/form-data; boundary=x'], + ['a +json suffix type', 'application/ld+json'], + ['a comma-joined multi-value header', 'application/json, application/x-www-form-urlencoded'], + ['a comma after the parameters', 'application/json; charset=utf-8, text/plain'], + ['a longer media type sharing the prefix', 'application/jsonx'], + ['a leading NBSP (JS trim() strips it; HTTP does not)', ' application/json'], + ])('refuses Content-Type %s even with an allowed body', async (_label, contentType) => { + const res = await POST(signIn(good(), { 'Content-Type': contentType })); + + expect(res.status).toBe(404); + expect(mockHandlerPost).not.toHaveBeenCalled(); + }); + + it('refuses a request with no Content-Type', async () => { + const bare = new Request(SIGN_IN, { method: 'POST' }); + expect(bare.headers.get('content-type')).toBeNull(); + + expect((await POST(bare)).status).toBe(404); + expect(mockHandlerPost).not.toHaveBeenCalled(); + }); + + it('refuses a declared Content-Length over the cap before cloning anything', async () => { + const req = signIn(good(), { + 'Content-Type': 'application/json', + 'Content-Length': String(MAX_SIGN_IN_SOCIAL_BODY_BYTES + 1), + }); + const cloneSpy = vi.spyOn(req, 'clone'); + + expect(await isAllowedSignInSocialBody(req)).toBe(false); + expect(cloneSpy).not.toHaveBeenCalled(); + }); + + it('refuses a non-numeric Content-Length', async () => { + const req = signIn(good(), { 'Content-Type': 'application/json', 'Content-Length': '12abc' }); + expect(await isAllowedSignInSocialBody(req)).toBe(false); + }); + + it('refuses an oversized body that declares no length (chunked), without hanging', async () => { + // Insignificant JSON whitespace as padding: the body is otherwise valid + // and allowed, so ONLY the byte cap can refuse it. + const big = `{"provider":"ministryplatform"${' '.repeat(MAX_SIGN_IN_SOCIAL_BODY_BYTES)}}`; + expect(JSON.parse(big)).toEqual({ provider: 'ministryplatform' }); + const req = chunked(new TextEncoder().encode(big), 1000); + expect(req.headers.get('content-length')).toBeNull(); + + const res = await POST(req); + + expect(res.status).toBe(404); + expect(mockHandlerPost).not.toHaveBeenCalled(); + }); + + it('accepts a chunked body under the cap', async () => { + const req = chunked(new TextEncoder().encode(good()), 10); + expect(await isAllowedSignInSocialBody(req)).toBe(true); + }); + + it('does not body-filter GET routes', async () => { + const res = await GET(new Request(url('/api/auth/get-session'))); + expect(res.status).toBe(200); + expect(mockHandlerGet).toHaveBeenCalledTimes(1); + }); +}); + +/** + * Every response from the auth route is session-bearing or about the session, + * so none may be cached — 404s included. + */ +describe('Cache-Control: no-store on every /api/auth response', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('sets no-store on proxied GET and POST responses', async () => { + const get = await GET(new Request(url('/api/auth/get-session'))); + const post = await POST( + new Request(url('/api/auth/sign-in/social'), { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ provider: 'ministryplatform' }), + }), + ); + + expect(get.headers.get('Cache-Control')).toBe('no-store'); + expect(post.headers.get('Cache-Control')).toBe('no-store'); + }); + + it('overrides a cacheable header Better Auth might set', async () => { + mockHandlerGet.mockResolvedValueOnce( + new Response('ok', { headers: { 'Cache-Control': 'public, max-age=600' } }), + ); + + const res = await GET(new Request(url('/api/auth/get-session'))); + + expect(res.headers.get('Cache-Control')).toBe('no-store'); + }); + + it('sets no-store on non-allowlisted 404s', async () => { + const get = await GET(new Request(url('/api/auth/list-accounts'))); + const post = await POST(new Request(url('/api/auth/update-user'), { method: 'POST' })); + + expect(get.headers.get('Cache-Control')).toBe('no-store'); + expect(post.headers.get('Cache-Control')).toBe('no-store'); + }); + + it('copies a response with immutable headers (e.g. Response.redirect)', () => { + const redirect = Response.redirect('https://tools.example.org/', 302); + expect(() => redirect.headers.set('X-Probe', '1')).toThrow(); + + const out = withNoStore(redirect); + + expect(out).not.toBe(redirect); + expect(out.status).toBe(302); + expect(out.headers.get('Location')).toBe('https://tools.example.org/'); + expect(out.headers.get('Cache-Control')).toBe('no-store'); + }); + + it('sets the header in place when it can, keeping Set-Cookie', () => { + const res = new Response('x', { headers: { 'Set-Cookie': 'a=1' } }); + const out = withNoStore(res); + expect(out).toBe(res); + expect(out.headers.get('Cache-Control')).toBe('no-store'); + expect(out.headers.get('Set-Cookie')).toBe('a=1'); + }); +}); diff --git a/src/app/api/auth/[...all]/route.ts b/src/app/api/auth/[...all]/route.ts index a4546c3..9152683 100644 --- a/src/app/api/auth/[...all]/route.ts +++ b/src/app/api/auth/[...all]/route.ts @@ -61,6 +61,204 @@ export function isAllowedAuthRoute( return (allowedAuthRoutes[method] as readonly string[]).includes(path); } +/** + * The only body keys `POST /sign-in/social` may carry (F12, route layer). + * + * The browser client sends exactly these two — + * `authClient.signIn.social({ provider, callbackURL })` in + * `src/app/signin/sign-in-content.tsx`, which better-auth's client proxy + * forwards as the JSON body verbatim. + * + * better-auth's body schema accepts far more: `idToken` (the direct sign-in + * mode behind the token-substitution account takeover — see + * `refuseIdTokenSignIn` in `src/lib/auth.ts`), plus `scopes`, `loginHint`, + * `additionalParams`, `errorCallbackURL`, `newUserCallbackURL`, + * `additionalData`, `requestSignUp` and `disableRedirect`, each of which lets + * a caller reshape the authorize request or the post-login redirects. None is + * used here, so all are closed. Allowlisted, not denylisted: a key a future + * better-auth version adds is refused until deliberately opened here. Never + * add `idToken`. + */ +export const allowedSignInSocialKeys = ["provider", "callbackURL"] as const; + +/** + * The one provider `/sign-in/social` may name. Must equal `providerId` in + * `src/lib/auth.ts` — no hyphen; it is part of the redirect URI registered + * with Ministry Platform. + */ +export const SIGN_IN_SOCIAL_PROVIDER = "ministryplatform"; + +/** + * Largest `POST /sign-in/social` body this route will read, in bytes. The real + * client sends a few hundred bytes at most, and under ~2.1 KB even at the + * `callbackURL` cap. Without a cap, one anonymous request with a + * multi-megabyte RELATIVE `callbackURL` passes better-auth's + * `isSafeRelativeURL`, is copied into the OAuth state, and comes back as a + * Set-Cookie roughly twice its size — and this filter runs BEFORE + * better-auth's rate limiter. + */ +export const MAX_SIGN_IN_SOCIAL_BODY_BYTES = 4096; + +/** Longest `callbackURL` accepted (UTF-16 code units, i.e. `String.length`). */ +export const MAX_CALLBACK_URL_LENGTH = 2048; + +/** + * The only Content-Type accepted, tested against the RAW header value. + * + * better-call (`getBody`) uses its JSON parser only when the untrimmed header + * matches the anchored `/^application\/([a-z0-9.+-]*\+)?json/i`; anything else + * falls through to SUBSTRING matches for form, multipart, text and + * octet-stream. A filter that JSON-parsed a body better-call then parses some + * other way would inspect different keys than better-auth acts on. Anything + * this regex accepts starts with `application/json`, so better-call's JSON + * parser is the one that runs, on the same bytes this filter reads. + * + * Deliberately NOT `trim()`med first: JS `trim()` strips U+00A0 (NBSP), which + * HTTP does not treat as whitespace, so a trimmed check would accept + * `application/json` — which better-call does not parse as JSON. + */ +const JSON_CONTENT_TYPE = /^application\/json[\t ]*(;|$)/i; + +/** + * Reads a body stream with a hard byte cap. Returns `null` (and stops reading) + * as soon as more than `limit` bytes arrive, so a chunked body with no + * Content-Length — or one that lies about it — is never buffered past the cap. + */ +async function readBodyWithLimit( + body: ReadableStream, + limit: number, +): Promise { + const reader = body.getReader(); + const chunks: Uint8Array[] = []; + let total = 0; + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + total += value.byteLength; + if (total > limit) { + // Do NOT `await reader.cancel()`. This is a `clone()` (a tee branch), + // and a tee branch's cancel promise settles only once BOTH branches are + // cancelled — the original never is on this path, so awaiting it hangs + // the request. Releasing the lock is enough. + reader.releaseLock(); + return null; + } + chunks.push(value); + } + const bytes = new Uint8Array(total); + let offset = 0; + for (const chunk of chunks) { + bytes.set(chunk, offset); + offset += chunk.byteLength; + } + return bytes; +} + +/** + * Body filter for `POST /sign-in/social` — defence in depth behind + * `refuseIdTokenSignIn` (the primary control, which also covers in-process + * `auth.api.signInSocial` calls this route never sees). + * + * Exported for testing. All of these must hold, or the request gets the same + * plain 404 as a non-allowlisted path, so a caller learns nothing about which + * check tripped: + * + * - Content-Type matches `JSON_CONTENT_TYPE` and contains no `,`. The comma + * rule is belt and braces: a repeated header (which `Headers.get` joins with + * ", ") is refused because the real client never sends one. + * - A declared Content-Length, if present, is all digits and within + * `MAX_SIGN_IN_SOCIAL_BODY_BYTES`; the clone is then read with the same hard + * cap, because a chunked body carries no length. + * - The bytes decode (`new TextDecoder()` — BOM stripped, bad sequences + * replaced, exactly as `request.json()` does) and `JSON.parse` to a plain + * object whose keys are a subset of `allowedSignInSocialKeys`. + * - `provider === SIGN_IN_SOCIAL_PROVIDER`. + * - `callbackURL`, if present, is a string of at most `MAX_CALLBACK_URL_LENGTH`. + * + * Reads a `clone()` so the original body stream is still intact for + * better-auth. + */ +export async function isAllowedSignInSocialBody(request: Request): Promise { + const contentType = request.headers.get("content-type"); + if (contentType === null || contentType.includes(",")) return false; + if (!JSON_CONTENT_TYPE.test(contentType)) return false; + const contentLength = request.headers.get("content-length"); + if ( + contentLength !== null && + (!/^\d+$/.test(contentLength) || + Number(contentLength) > MAX_SIGN_IN_SOCIAL_BODY_BYTES) + ) { + return false; + } + const stream = request.clone().body; + if (stream === null) return false; + let body: unknown; + try { + const bytes = await readBodyWithLimit(stream, MAX_SIGN_IN_SOCIAL_BODY_BYTES); + if (bytes === null) return false; + body = JSON.parse(new TextDecoder().decode(bytes)); + } catch { + return false; + } + if (typeof body !== "object" || body === null || Array.isArray(body)) { + return false; + } + const allowedKeys: readonly string[] = allowedSignInSocialKeys; + if (!Object.keys(body).every((key) => allowedKeys.includes(key))) { + return false; + } + const { provider, callbackURL } = body as { + provider?: unknown; + callbackURL?: unknown; + }; + if ( + "callbackURL" in body && + (typeof callbackURL !== "string" || + callbackURL.length > MAX_CALLBACK_URL_LENGTH) + ) { + return false; + } + return provider === SIGN_IN_SOCIAL_PROVIDER; +} + +/** + * Marks a response from this route as uncacheable. + * + * Every response here is session-bearing or about the session: the OAuth + * callback's 302 sets the session cookies, `/get-session` returns the user, + * and `/sign-in/social` sets the state cookie. better-auth sets `no-store` on + * some of these but not all, so it is set here, on EVERY return path, 404s + * included. + * + * Set in place where possible. A response whose headers are immutable (e.g. + * one built with `Response.redirect()`) throws on `set`, so it is copied — + * status, headers (Set-Cookie included) and body stream — first. + * + * Exported for testing. + */ +export function withNoStore(response: Response): Response { + try { + response.headers.set("Cache-Control", "no-store"); + return response; + } catch { + const copy = new Response(response.body, response); + copy.headers.set("Cache-Control", "no-store"); + return copy; + } +} + +/** + * Plain 404 — never reaches Better Auth, so a disallowed endpoint (or a + * refused `/sign-in/social` body) is indistinguishable from one that does not + * exist. + */ +function notFound(): Response { + return new Response(null, { + status: 404, + headers: { "Cache-Control": "no-store" }, + }); +} + const handlers = toNextJsHandler(auth); function guard( @@ -69,11 +267,16 @@ function guard( ) { return async (request: Request): Promise => { if (!isAllowedAuthRoute(method, request.url)) { - // Plain 404 — never reaches Better Auth, so a disallowed endpoint is - // indistinguishable from one that does not exist. - return new Response(null, { status: 404 }); + return notFound(); + } + if ( + method === "POST" && + authRoutePath(request.url) === "/sign-in/social" && + !(await isAllowedSignInSocialBody(request)) + ) { + return notFound(); } - return handler(request); + return withNoStore(await handler(request)); }; } diff --git a/src/app/signin/page.test.tsx b/src/app/signin/page.test.tsx index 44e978f..32f93f2 100644 --- a/src/app/signin/page.test.tsx +++ b/src/app/signin/page.test.tsx @@ -242,7 +242,28 @@ describe('sanitizeCallbackUrl', () => { ['', '/'], [null, '/'], [undefined, '/'], - ])('rejects %s', (input, expected) => { + // F3b — the WHATWG parser strips tab/LF/CR AFTER string checks run, so + // each of these navigated as `//evil.example`. + ['/\t/evil.example', '/'], + ['/\n/evil.example', '/'], + ['/\r/evil.example', '/'], + ['/\u0000/evil.example', '/'], + ['/\u007f/evil.example', '/'], + ['/\u0085/evil.example', '/'], // C1 (NEL) + // A control character the URL parser would strip to an ON-origin path. + // The `new URL` backstop alone accepts these, so only CONTROL_CHARS + // refuses them — keeping client and server (`isSafeRelativeURL`) agreed. + ['/tools\t/addresslabels', '/'], + ['/tools/addresslabels\n', '/'], + // Any backslash, not only a leading one: special schemes treat `\` as `/`. + ['/a\\b', '/'], + ['/tools\\..\\\\evil.example', '/'], + // Percent-encoded separators in the PATH can be decoded downstream. + ['/%2F/evil.example', '/'], + ['/%2f/evil.example', '/'], + ['/%5C/evil.example', '/'], + ['/%5c/evil.example', '/'], + ])('rejects %j', (input, expected) => { expect(sanitizeCallbackUrl(input as string | null | undefined)).toBe(expected); }); @@ -251,10 +272,111 @@ describe('sanitizeCallbackUrl', () => { '/tools/addresslabels', '/tools/addresslabels?s=123&pageID=292', '/tools/groupwizard/abc?tab=members', + // `%2F` in the query string or fragment is ordinary data, not a separator. + '/tools/addresslabels?next=%2Fhome', + '/tools/template#%2Fsection', ])('preserves the legitimate deep link %s', (input) => { // A sanitizer that breaks deep links gets reverted, so pin these too. expect(sanitizeCallbackUrl(input)).toBe(input); }); + + it('returns /.//evil.example UNCHANGED, and it resolves to this origin', () => { + // Returning the URL-normalized form would turn this into `//evil.example` + // (dot-segment removal) — a protocol-relative redirect manufactured by the + // sanitizer itself. The raw value is a harmless same-origin path. + const out = sanitizeCallbackUrl('/.//evil.example'); + expect(out).toBe('/.//evil.example'); + expect(new URL(out, 'https://tools.example.org').origin).toBe('https://tools.example.org'); + }); + + it('every accepted value resolves on-origin under the real URL parser', () => { + const base = 'https://tools.example.org'; + for (const input of ['/', '/.//evil.example', '/tools/x?y=%2F', '/a/./b/../c']) { + const out = sanitizeCallbackUrl(input); + expect(new URL(out, base).origin).toBe(base); + } + }); +}); + +/** + * F3b through a REAL query string. The bug lives in the decoding step — + * `%09` only becomes a tab once `URLSearchParams` decodes it — so the hostile + * cases are driven from the raw `?callbackUrl=` text, through both sinks: the + * signed-in `location.href` assignment (no server in the loop) and the + * signed-out `callbackURL` handed to `signIn.social`. + */ +describe('SignIn callbackUrl from a raw query string (F3b)', () => { + let originalLocation: Location; + let locationHref: string; + + beforeEach(() => { + vi.clearAllMocks(); + originalLocation = window.location; + locationHref = ''; + Object.defineProperty(window, 'location', { + configurable: true, + writable: true, + value: { + get href() { + return locationHref; + }, + set href(value: string) { + locationHref = value; + }, + }, + }); + }); + + afterEach(() => { + Object.defineProperty(window, 'location', { + configurable: true, + writable: true, + value: originalLocation, + }); + }); + + const hostile = [ + 'callbackUrl=/%09/evil.example', + 'callbackUrl=/%0A/evil.example', + 'callbackUrl=/%0D/evil.example', + 'callbackUrl=/%C2%85/evil.example', + 'callbackUrl=/%5Cevil.example', + 'callbackUrl=/%252F/evil.example', + 'callbackUrl=%2F%2Fevil.example', + 'callbackUrl=https%3A%2F%2Fevil.example', + ]; + + it.each(hostile)('signed in: ?%s lands on "/"', async (query) => { + mockUseSearchParams.mockReturnValue(new URLSearchParams(query)); + mockGetSession.mockResolvedValue({ data: { user: { id: 'u1' }, session: { token: 't' } } }); + + render(); + + await waitFor(() => expect(locationHref).toBe('/')); + }); + + it.each(hostile)('signed out: ?%s sends callbackURL "/"', async (query) => { + mockUseSearchParams.mockReturnValue(new URLSearchParams(query)); + mockGetSession.mockResolvedValue({ data: null }); + + render(); + + await waitFor(() => + expect(mockSignInSocial).toHaveBeenCalledWith({ provider: 'ministryplatform', callbackURL: '/' }), + ); + }); + + it.each([ + ['callbackUrl=%2Ftools%2Faddresslabels%3Fs%3D123%26pageID%3D292', '/tools/addresslabels?s=123&pageID=292'], + ['callbackUrl=/tools/groupwizard/abc%3Ftab%3Dmembers', '/tools/groupwizard/abc?tab=members'], + ])('deep link ?%s round-trips intact', async (query, expected) => { + mockUseSearchParams.mockReturnValue(new URLSearchParams(query)); + mockGetSession.mockResolvedValue({ data: { user: { id: 'u1' }, session: { token: 't' } } }); + + render(); + + await waitFor(() => expect(locationHref).toBe(expected)); + }); }); /** @@ -338,12 +460,34 @@ describe('SignIn OAuth error classification', () => { }, ); - it('falls back to a generic message that names an unrecognised code', async () => { + it('falls back to a fixed generic message for an unrecognised code, without echoing it', async () => { setSearchParams({ error: 'some_unmapped_code' }); render(); - expect(await screen.findByText(/sign-in failed \(some_unmapped_code\)/i)).toBeInTheDocument(); + expect( + await screen.findByText(/^sign-in failed\. please retry; if the problem persists, contact support\.$/i), + ).toBeInTheDocument(); + expect(screen.queryByText(/some_unmapped_code/)).not.toBeInTheDocument(); + }); + + it.each([ + ['attacker-chosen text', 'Your account is locked. Call 555-0100 to restore access'], + ['a prototype key', '__proto__'], + ['a prototype key', 'constructor'], + ['markup', 'urgent'], + ])('never renders free text from ?error= (%s)', async (_label, value) => { + setSearchParams({ error: value }); + + const { container } = render(); + + expect(await screen.findByRole('alert')).toBeInTheDocument(); + expect(container.textContent).not.toContain(value); + expect(screen.getByText(/^sign-in failed\. please retry/i)).toBeInTheDocument(); + // Nor is the raw value logged. + for (const call of vi.mocked(console.error).mock.calls) { + expect(call.join(' ')).not.toContain(value); + } }); it('describes access_denied as a cancellation the user can retry', async () => { diff --git a/src/app/signin/sign-in-content.tsx b/src/app/signin/sign-in-content.tsx index 581f4ef..b5b7f1b 100644 --- a/src/app/signin/sign-in-content.tsx +++ b/src/app/signin/sign-in-content.tsx @@ -17,44 +17,95 @@ import { // an error state so the user sees a retry path instead of an infinite spinner. const REDIRECT_TIMEOUT_MS = 10_000; +// C0 controls, DEL and C1 controls. The WHATWG URL parser silently STRIPS tab, +// LF and CR from anywhere in the input before it parses — i.e. AFTER every +// string check below has run. So `/\t/evil.example` (from +// `?callbackUrl=/%09/evil.example`) passes a `startsWith("//")` test and then +// navigates as `//evil.example`: off-site. Refusing all control characters +// closes that and any other parser-stripped variant in one rule. +const CONTROL_CHARS = /[\u0000-\u001f\u007f-\u009f]/; +// A percent-encoded `/` or `\` in the PATH can be decoded by a router or proxy +// downstream into a real separator (`/%2F/evil` -> `//evil`). Only the path is +// checked: `%2F` in a query string or fragment is ordinary data. +const ENCODED_SEPARATOR = /%2f|%5c/i; +// A throwaway base for the final resolution check. `.invalid` is reserved +// (RFC 2606), so it can never collide with a real origin. +const SENTINEL = "https://sentinel.invalid"; + /** * Clamps `?callbackUrl=` to a path on this origin. * - * Without this, `/signin?callbackUrl=https://evil.example` bounces the user - * off-site from a URL that looks exactly like this app's own login page — a - * credible phishing hop. + * F3: without this, `/signin?callbackUrl=https://evil.example` bounces the + * user off-site from a URL that looks exactly like this app's own login page — + * a credible phishing hop. + * + * F3b: the first version only refused a leading `//` or `/\`, which + * `?callbackUrl=/%09/example.com` bypassed (see `CONTROL_CHARS`). The rules + * now mirror better-auth's server-side `isSafeRelativeURL`, which already + * refuses these values as a `callbackURL` on the signed-out path. The + * signed-in path is a bare `location.href` assignment with no server in the + * loop, so this function is the ONLY check there — and client and server + * should agree on what "safe" means, or a URL one accepts and the other + * rejects strands the user. * * Sanitizing happens once, HERE AT THE SOURCE, rather than at each sink, * because the value feeds two different consumers: the `window.location.href` - * assignment (where no server is involved at all) and the `callbackURL` handed - * to `signIn.oauth2`. Cleaning it once means a future third use cannot miss it. - * - * `//` is protocol-relative (`//evil.example` → `https://evil.example`), and - * browsers normalize `/\` to `//`, so both are rejected. + * assignment and the `callbackURL` handed to `signIn.social`. Cleaning it once + * means a future third use cannot miss it. */ export function sanitizeCallbackUrl(raw: string | null | undefined): string { - if (!raw || !raw.startsWith("/")) return "/"; - if (raw.startsWith("//") || raw.startsWith("/\\")) return "/"; + if (typeof raw !== "string" || !raw.startsWith("/")) return "/"; + // `//evil` is protocol-relative (another origin). ANY backslash is refused, + // not just a leading `/\`: special-scheme URLs treat `\` as `/`, and a + // backslash has no legitimate use in this app's paths. + if (raw.startsWith("//") || raw.includes("\\") || CONTROL_CHARS.test(raw)) return "/"; + const pathEnd = raw.search(/[?#]/); + if (ENCODED_SEPARATOR.test(pathEnd === -1 ? raw : raw.slice(0, pathEnd))) return "/"; + // Backstop: let the real URL parser resolve it and insist it stays on this + // origin. After the checks above no input is known to fail this; it guards + // against a browser parser diverging from the string rules. + try { + if (new URL(raw, SENTINEL).origin !== SENTINEL) return "/"; + } catch { + return "/"; + } + // Return the RAW value, never the `new URL()`-normalized form: dot-segment + // removal turns `/.//evil.com` into the pathname `//evil.com`, which would + // itself be a protocol-relative redirect. Raw `/.//evil.com` resolves safely + // to this origin's `//evil.com` path. return raw; } +const PROVIDER_REJECTED = + "The sign-in request was rejected by the provider. Please retry; if the problem persists, contact support."; +const PROVIDER_UNAVAILABLE = + "The sign-in provider is temporarily unavailable. Please retry in a moment."; +const GENERIC_FAILURE = + "Sign-in failed. Please retry; if the problem persists, contact support."; + +/** + * The fixed set of messages `?error=` can select. The query value is only + * ever used as a LOOKUP KEY — never rendered — so a crafted link cannot put + * attacker-chosen text on this app's own sign-in page ("Your account is + * locked, call 555-…"). An unknown code gets the generic message. + * + * A `Map`, not an object literal, so keys like `__proto__` or `constructor` + * cannot resolve to inherited properties. + */ +const OAUTH_ERROR_MESSAGES: ReadonlyMap = new Map([ + ["access_denied", "Sign-in was cancelled. Click retry to try again."], + ["invalid_request", PROVIDER_REJECTED], + ["invalid_client", PROVIDER_REJECTED], + ["invalid_grant", PROVIDER_REJECTED], + ["unauthorized_client", PROVIDER_REJECTED], + ["unsupported_response_type", PROVIDER_REJECTED], + ["invalid_scope", PROVIDER_REJECTED], + ["server_error", PROVIDER_UNAVAILABLE], + ["temporarily_unavailable", PROVIDER_UNAVAILABLE], +]); + function describeOAuthError(code: string): string { - switch (code) { - case "access_denied": - return "Sign-in was cancelled. Click retry to try again."; - case "invalid_request": - case "invalid_client": - case "invalid_grant": - case "unauthorized_client": - case "unsupported_response_type": - case "invalid_scope": - return "The sign-in request was rejected by the provider. Please retry; if the problem persists, contact support."; - case "server_error": - case "temporarily_unavailable": - return "The sign-in provider is temporarily unavailable. Please retry in a moment."; - default: - return `Sign-in failed (${code}). Please retry; if the problem persists, contact support.`; - } + return OAUTH_ERROR_MESSAGES.get(code) ?? GENERIC_FAILURE; } export function SignInContent() { @@ -164,7 +215,11 @@ export function SignInContent() { // If the URL arrived with ?error=..., don't auto-start OAuth — the user // just came back from a failed attempt and should see the retry UI. if (errorParam) { - console.error("SignIn: OAuth provider returned error=%s", errorParam); + // Log whether the code is one we recognise, not the free-text value. + console.error( + "SignIn: OAuth provider returned an error (known code: %s)", + OAUTH_ERROR_MESSAGES.has(errorParam) + ); return; } diff --git a/src/auth.id-token-sign-in.test.ts b/src/auth.id-token-sign-in.test.ts new file mode 100644 index 0000000..58e451a --- /dev/null +++ b/src/auth.id-token-sign-in.test.ts @@ -0,0 +1,316 @@ +// @vitest-environment node +// (node, not jsdom: better-auth verifies the id_token with `jose` over WebCrypto, +// which rejects jsdom-realm typed arrays.) +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { betterAuth } from 'better-auth'; +import { genericOAuth, type GenericOAuthConfig, type GenericOAuthOptions } from 'better-auth/plugins'; + +/** + * F12 — end-to-end guard for the `/sign-in/social` id_token token-substitution + * account takeover (upstream advisory 2026-09-25). + * + * better-auth 1.7's `/sign-in/social` has an id_token mode: given + * `idToken: { token, accessToken }` it verifies the id_token (signature, iss, + * aud) and then calls our `getUserInfo` with the CALLER'S access token. This + * fork sets `discoveryUrl`, so the mode is live for the "ministryplatform" + * provider, and nothing binds the verified id_token's `sub` to the access + * token's userinfo `sub`: an attacker's own valid id_token plus a victim's + * access token mints a session as the victim. + * + * These tests drive the REAL `auth` instance from src/lib/auth.ts against a + * mock MP OIDC provider (discovery, JWKS and userinfo), with id_tokens really + * RS256-signed by a key the mock JWKS publishes — so better-auth's own + * verification genuinely passes and the only thing standing between the + * attacker and the victim's session is this app's code. `fetch` is replaced + * with a stub that THROWS for any URL the mock doesn't serve: nothing here can + * reach a real Ministry Platform. + * + * Layers, each proven with the others out of the way: + * - `refuseIdTokenSignIn` (`hooks.before`) — here, driving `auth.handler` + * directly, i.e. with the route filter bypassed. + * - The route's body filter — src/app/api/auth/[...all]/route.test.ts, with + * Better Auth (and so the hook) mocked out. + * - `/link-social` in `disabledAuthPaths` — here, bypassing the route. + * - `requireIdTokenVerification` — here, on an instance whose discovery + * document lacks `jwks_uri`. + * + * NOT YET: the `id_token.sub` <-> userinfo `sub` binding inside `getUserInfo` + * lands with the OIDC lazy-discovery rewrite (security Step 4). The "KNOWN GAP" + * test below pins today's behaviour with the hook removed, and must be flipped + * to a refusal when that binding lands. + */ + +const mockOidc = await vi.hoisted(async () => { + const { generateKeyPairSync, sign } = await import('node:crypto'); + const base = 'https://test-mp.example.com'; + const issuer = `${base}/oauth`; + const { publicKey, privateKey } = generateKeyPairSync('rsa', { modulusLength: 2048 }); + const jwk = { ...publicKey.export({ format: 'jwk' }), kid: 'test-key-1', alg: 'RS256', use: 'sig' }; + + const subs = { + attacker: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', + victim: 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb', + }; + const accessTokens: Record = { + 'access-token-attacker': subs.attacker, + 'access-token-victim': subs.victim, + }; + const userinfoCalls: string[] = []; + + const b64 = (v: unknown) => Buffer.from(JSON.stringify(v)).toString('base64url'); + function signIdToken(sub: string): string { + const now = Math.floor(Date.now() / 1000); + const input = `${b64({ alg: 'RS256', kid: 'test-key-1', typ: 'JWT' })}.${b64({ + iss: issuer, + aud: 'test-client-id', + sub, + iat: now, + exp: now + 300, + })}`; + return `${input}.${sign('RSA-SHA256', Buffer.from(input), privateKey).toString('base64url')}`; + } + + const json = (body: unknown, status = 200) => + new Response(JSON.stringify(body), { + status, + headers: { 'Content-Type': 'application/json' }, + }); + + const discovery = { + issuer, + authorization_endpoint: `${issuer}/connect/authorize`, + token_endpoint: `${issuer}/connect/token`, + userinfo_endpoint: `${issuer}/connect/userinfo`, + jwks_uri: `${issuer}/.well-known/jwks`, + id_token_signing_alg_values_supported: ['RS256'], + }; + + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const request = new Request(input, init); + const url = request.url; + if (url === `${issuer}/.well-known/openid-configuration`) { + return json(discovery); + } + // A discovery document with no jwks_uri, for requireIdTokenVerification. + if (url === `${base}/no-jwks/.well-known/openid-configuration`) { + const { jwks_uri: _omit, ...rest } = discovery; + void _omit; + return json(rest); + } + if (url === `${issuer}/.well-known/jwks`) { + return json({ keys: [jwk] }); + } + if (url === `${issuer}/connect/userinfo`) { + const token = (request.headers.get('authorization') ?? '').replace(/^Bearer /, ''); + userinfoCalls.push(token); + const sub = accessTokens[token]; + return sub + ? json({ sub, given_name: 'Test', family_name: 'User', email: `${sub}@example.test` }) + : json({ error: 'invalid_token' }, 401); + } + throw new Error(`Blocked unexpected fetch in test: ${url}`); + }) as typeof fetch; + + return { base, subs, signIdToken, userinfoCalls }; +}); + +import { auth, ID_TOKEN_SIGN_IN_DISABLED } from '@/lib/auth'; + +const ORIGIN = 'http://localhost:3000'; +const PROVIDER = 'ministryplatform'; + +function post( + instance: { handler: (r: Request) => Promise }, + path: string, + body: unknown, +) { + return instance.handler( + new Request(`${ORIGIN}/api/auth${path}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Origin: ORIGIN }, + body: JSON.stringify(body), + }), + ); +} + +const signInSocial = (instance: { handler: (r: Request) => Promise }, body: unknown) => + post(instance, '/sign-in/social', body); + +function sessionCookies(response: Response): string[] { + return response.headers.getSetCookie().filter((c) => /session_token=/.test(c)); +} + +/** The attack body: attacker's own valid id_token, victim's access token. */ +function attackBody() { + return { + provider: PROVIDER, + idToken: { + token: mockOidc.signIdToken(mockOidc.subs.attacker), + accessToken: 'access-token-victim', + }, + }; +} + +/** Rebuilds the genericOAuth plugin with a modified provider config. */ +function pluginsWith(patch: Partial) { + return (auth.options.plugins ?? []).map((plugin) => { + if (plugin.id !== 'generic-oauth') return plugin; + const [config] = (plugin as unknown as { options: GenericOAuthOptions }).options.config; + return genericOAuth({ config: [{ ...config, ...patch }] }); + }); +} + +beforeEach(() => { + mockOidc.userinfoCalls.length = 0; + vi.spyOn(console, 'error').mockImplementation(() => {}); + vi.spyOn(console, 'warn').mockImplementation(() => {}); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe('refuseIdTokenSignIn (hooks.before) — primary control, route filter bypassed', () => { + it('refuses the id_token mode over HTTP with 404 ID_TOKEN_SIGN_IN_DISABLED, minting no session', async () => { + const response = await signInSocial(auth, attackBody()); + + expect(response.status).toBe(404); + expect(await response.json()).toMatchObject({ code: ID_TOKEN_SIGN_IN_DISABLED }); + expect(sessionCookies(response)).toEqual([]); + // Refused before better-auth ever used the caller's access token. + expect(mockOidc.userinfoCalls).toEqual([]); + }); + + it('refuses the id_token mode for in-process auth.api.signInSocial calls too', async () => { + await expect(auth.api.signInSocial({ body: attackBody() })).rejects.toMatchObject({ + statusCode: 404, + body: expect.objectContaining({ code: ID_TOKEN_SIGN_IN_DISABLED }), + }); + expect(mockOidc.userinfoCalls).toEqual([]); + }); + + it.each([ + ['null', null], + ['an empty object', {}], + ['false', false], + ])('keys on the presence of idToken, not its truthiness (idToken: %s)', async (_label, idToken) => { + const response = await signInSocial(auth, { provider: PROVIDER, idToken }); + + expect(response.status).toBe(404); + expect(await response.json()).toMatchObject({ code: ID_TOKEN_SIGN_IN_DISABLED }); + }); + + it('CONTROL: the normal redirect sign-in (no idToken) still returns the authorize URL', async () => { + const response = await signInSocial(auth, { provider: PROVIDER, callbackURL: '/' }); + + expect(response.status).toBe(200); + const body = (await response.json()) as { url: string; redirect: boolean }; + expect(body.redirect).toBe(true); + expect(body.url).toMatch(/^https:\/\/test-mp\.example\.com\/oauth\/connect\/authorize\?/); + }); + + it.each([ + ['JSON null', 'null'], + ['a JSON string', '"idToken"'], + ['a JSON array', '["idToken"]'], + ])('does not throw on a non-object body (%s); schema validation rejects it instead', async (_label, raw) => { + const response = await auth.handler( + new Request(`${ORIGIN}/api/auth/sign-in/social`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Origin: ORIGIN }, + body: raw, + }), + ); + + expect(response.status).toBe(400); + const body = (await response.json()) as { code?: string }; + expect(body.code).not.toBe(ID_TOKEN_SIGN_IN_DISABLED); + }); + + it('leaves other endpoints alone, even with an idToken key in the body', async () => { + const getSession = await auth.handler(new Request(`${ORIGIN}/api/auth/get-session`)); + expect(getSession.status).toBe(200); + + const signOut = await post(auth, '/sign-out', { idToken: 'x' }); + expect(await signOut.text()).not.toContain(ID_TOKEN_SIGN_IN_DISABLED); + }); +}); + +describe('with the hook removed — the mode is live on this config (discoveryUrl is set)', () => { + // Same config, same plugins, same getUserInfo — only the user hook is gone. + const authWithoutHook = betterAuth({ ...auth.options, hooks: {} }); + + it('CONTROL: a matched id_token + access token pair signs in, so the fixture really exercises the mode', async () => { + const response = await signInSocial(authWithoutHook, { + provider: PROVIDER, + idToken: { + token: mockOidc.signIdToken(mockOidc.subs.victim), + accessToken: 'access-token-victim', + }, + }); + + expect(response.status).toBe(200); + expect(sessionCookies(response)).not.toEqual([]); + }); + + it('KNOWN GAP until Step 4: without the hook, the substituted token signs in AS THE VICTIM', async () => { + // This is exactly why the hook (and the route filter) must stay. When the + // `id_token.sub` <-> userinfo `sub` binding lands in `getUserInfo` + // (Step 4), this test must be flipped to expect a refusal. + const response = await signInSocial(authWithoutHook, attackBody()); + + expect(response.status).toBe(200); + const body = (await response.json()) as { user: { userGuid: string } }; + expect(body.user.userGuid).toBe(mockOidc.subs.victim); + expect(mockOidc.userinfoCalls).toEqual(['access-token-victim']); + }); +}); + +describe('/link-social is disabled at the router (its own id_token branch)', () => { + it('404s POST /link-social with an idToken body, bypassing the route allowlist', async () => { + const response = await post(auth, '/link-social', attackBody()); + + expect(response.status).toBe(404); + expect(mockOidc.userinfoCalls).toEqual([]); + }); + + it('CONTROL: with /link-social re-enabled, the router reaches the endpoint (not a 404)', async () => { + const reopened = betterAuth({ + ...auth.options, + disabledPaths: (auth.options.disabledPaths ?? []).filter((p) => p !== '/link-social'), + }); + + const response = await post(reopened, '/link-social', attackBody()); + + // Without a session it fails on auth, but it is routed — proving the 404 + // above comes from `disabledPaths`, not from a missing endpoint. + expect(response.status).not.toBe(404); + }); +}); + +describe('requireIdTokenVerification — refuse to run with an unverified id_token', () => { + const noJwksDiscovery = `${mockOidc.base}/no-jwks/.well-known/openid-configuration`; + + it('skips the provider when discovery yields no jwks_uri, so sign-in 404s instead of downgrading', async () => { + const instance = betterAuth({ + ...auth.options, + plugins: pluginsWith({ discoveryUrl: noJwksDiscovery }), + }); + + const response = await signInSocial(instance, { provider: PROVIDER, callbackURL: '/' }); + + expect(response.status).toBe(404); + expect(await response.json()).toMatchObject({ code: 'PROVIDER_NOT_FOUND' }); + }); + + it('CONTROL: without the flag, the same discovery document registers the provider with NO id_token verification', async () => { + const instance = betterAuth({ + ...auth.options, + plugins: pluginsWith({ discoveryUrl: noJwksDiscovery, requireIdTokenVerification: false }), + }); + + const response = await signInSocial(instance, { provider: PROVIDER, callbackURL: '/' }); + + expect(response.status).toBe(200); + }); +}); diff --git a/src/auth.test.ts b/src/auth.test.ts index 45f7a92..8f71e54 100644 --- a/src/auth.test.ts +++ b/src/auth.test.ts @@ -9,6 +9,7 @@ import { extractUserGuid, buildDisplayName, ministryPlatformProviderConfig, + refuseIdTokenSignIn, } from '@/lib/auth'; /** @@ -483,6 +484,18 @@ describe('Auth - Ministry Platform provider config (better-auth 1.7)', () => { expect(ministryPlatformProviderConfig.pkce).toBe(false); }); + it('requires id_token verification, so a discovery document without issuer/jwks_uri is refused', () => { + // Without it, a document missing either field silently leaves the normal + // flow's id_token UNVERIFIED. Behaviour is proven end to end in + // src/auth.id-token-sign-in.test.ts. + expect(ministryPlatformProviderConfig.requireIdTokenVerification).toBe(true); + }); + + it('wires refuseIdTokenSignIn as the hooks.before (F12)', () => { + expect(auth.options.hooks?.before).toBe(refuseIdTokenSignIn); + expect(disabledAuthPaths).toContain('/link-social'); + }); + it('uses discovery rather than hardcoded endpoints', () => { expect(ministryPlatformProviderConfig.discoveryUrl).toContain( '/oauth/.well-known/openid-configuration', diff --git a/src/lib/auth.ts b/src/lib/auth.ts index cbaeb4b..95a3a7c 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -1,4 +1,5 @@ import { betterAuth, BetterAuthOptions } from "better-auth"; +import { APIError, createAuthMiddleware } from "better-auth/api"; import { genericOAuth, type GenericOAuthConfig, @@ -84,8 +85,69 @@ export const disabledAuthPaths = [ "/set-password", "/delete-user", "/delete-user/callback", + // `/link-social` has its own id_token branch — a second route to the + // token-substitution takeover `refuseIdTokenSignIn` closes on + // `/sign-in/social`. It needs a session and is already outside the route + // allowlist, but this app never links accounts, so close it outright rather + // than rely on the allowlist alone. + "/link-social", ]; +/** + * Error code for a refused id_token sign-in (F12). Exported so tests can + * assert on it; it is what distinguishes this refusal from better-auth's own + * `ID_TOKEN_NOT_SUPPORTED` in logs. + */ +export const ID_TOKEN_SIGN_IN_DISABLED = "ID_TOKEN_SIGN_IN_DISABLED"; + +/** + * F12 — closes the id_token branch of `POST /sign-in/social`. This is the + * PRIMARY control for the token-substitution account takeover reported + * upstream on 2026-09-25. + * + * better-auth 1.7's `/sign-in/social` has two modes. Without `idToken` it + * starts the normal authorization-code redirect — the only mode this app uses. + * With `idToken: { token, accessToken }` it signs the caller in DIRECTLY: no + * `state`, no code, no exchange. It verifies the id_token (signature, iss, + * aud) and then calls our `getUserInfo` with the CALLER-SUPPLIED + * `accessToken` (`node_modules/better-auth/dist/api/routes/sign-in.mjs`). + * + * Because `ministryPlatformProviderConfig` sets `discoveryUrl`, genericOAuth + * builds an id_token config for the provider, which switches that mode ON + * (`supportsIdTokenSignIn`) — and genericOAuth has no option to turn it off. + * Nothing binds the verified id_token's `sub` to the access token's userinfo + * `sub`, so an attacker's own valid id_token plus ANY other user's MP access + * token (from any MP client `/connect/userinfo` accepts) would mint a session + * as that other user: their roles on every authorization check, their + * `User_ID` on every write. + * + * `hooks.before` runs for HTTP requests AND in-process `auth.api.*` calls, so + * this covers callers the route filter in `src/app/api/auth/[...all]/route.ts` + * never sees. It keys on the key's PRESENCE (`"idToken" in body`), not its + * truthiness, so `idToken: null` / `idToken: {}` cannot slip past. + * + * 404 (NOT_FOUND), not 400, deliberately: from outside, this mode simply does + * not exist here — matching the route's deny posture and better-auth's own + * `ID_TOKEN_NOT_SUPPORTED` (also 404). + * + * Defence in depth: the route filter refuses any body key but `provider` and + * `callbackURL`. The remaining layer — binding `id_token.sub` to userinfo + * `sub` inside `getUserInfo` — is NOT here yet; it lands with the OIDC + * lazy-discovery rewrite (security Step 4). Until then this hook and the + * route filter are what close the path. `src/auth.id-token-sign-in.test.ts` + * proves each independently. + */ +export const refuseIdTokenSignIn = createAuthMiddleware(async (ctx) => { + if (ctx.path !== "/sign-in/social") return; + const body: unknown = ctx.body; + if (typeof body === "object" && body !== null && "idToken" in body) { + throw APIError.from("NOT_FOUND", { + message: "id_token sign-in is disabled", + code: ID_TOKEN_SIGN_IN_DISABLED, + }); + } +}); + /** * Custom fields added to the Better Auth `user` record. * @@ -230,6 +292,19 @@ export const ministryPlatformProviderConfig: GenericOAuthConfig = { * discovery means a broken sign-in. */ disableIdTokenNonceBinding: true, + /** + * Refuse to register the provider if discovery yields no usable `issuer` + + * `jwks_uri`, instead of silently skipping id_token verification. + * + * Without this, a discovery document missing either field leaves the + * id_token config undefined and the normal flow's id_token goes UNVERIFIED + * — exactly the "working discovery breaks sign-in, broken discovery works" + * inversion described above, in its dangerous direction. With it, genericOAuth + * logs and skips the provider (sign-in 404s `PROVIDER_NOT_FOUND`): an outage, + * not a silent downgrade. MP's document publishes both (checked against + * `mpi.ministryplatform.com` on 2026-09-30). + */ + requireIdTokenVerification: true, authorizationUrlParams: { realm: "realm", }, @@ -345,6 +420,13 @@ const options = { baseURL: process.env.BETTER_AUTH_URL || process.env.NEXTAUTH_URL, secret: process.env.BETTER_AUTH_SECRET || process.env.NEXTAUTH_SECRET, disabledPaths: disabledAuthPaths, + // User-level hooks. customSession and nextCookies register their own hooks + // on the plugin objects; these run alongside them, not instead. If another + // `before` check is ever needed, compose it INTO this one — there is only + // one `hooks.before` slot. See `refuseIdTokenSignIn` above. + hooks: { + before: refuseIdTokenSignIn, + }, /** * Send OAuth callback failures to a page this app owns. * From e5b8639bdcd1cc8340508f623142cb2dbce88a54 Mon Sep 17 00:00:00 2001 From: Chris Kehayias Date: Wed, 30 Sep 2026 07:19:16 -0400 Subject: [PATCH 04/24] =?UTF-8?q?fix:=20Step=202=20=E2=80=94=20fail-closed?= =?UTF-8?q?=20MP=5FSECURITY=5FROLES,=20group-wizard=20mass=20assignment,?= =?UTF-8?q?=20page-metadata=20injection,=20F11,=20page=20self-gating=20(#3?= =?UTF-8?q?6)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit > ## ⚠️ Deploy note: set `MP_SECURITY_ROLES=*` in every environment BEFORE this deploys > `MP_SECURITY_ROLES` now **fails closed**. If it's unset, blank or `","`, **every user is locked out** of every tool (they get redirected to `/no-access`). Set `MP_SECURITY_ROLES=*` in Vercel **Production, Preview and Development**, and in local `.env.local` files. `*` means any MP security role. A user with no role is still refused. See `.claude/TODO/2026-09-30-ops-set-mp-security-roles-env.md`. ## What / why Step 2 of the 2026-09-30 playbook remediation (`.claude/TODO/2026-09-30-step2-authorization-and-input-validation.md`). This PR has one commit per item: 1. **Fail-closed roles** (`authorizationService.ts`): unset, blank or `","` now means nobody gets in (denial reason `roles_not_configured`). `*` means any role, but only when it is the whole value. A comma list means only those roles. Before this change, a blank value silently meant "any role". `.env.example` now ships `*` with a comment explaining the control. The setup wizard treats the variable as required, with `*` as the default. CLAUDE.md and the security reference are updated. 2. **Group-wizard mass assignment** (fork-only): the actions spread the unparsed client payload into the Groups POST/PUT. That let any role holder write *any* Groups column, and a payload `Group_ID` could retarget an update at a different record. Now: - the action parses the payload with `groupWizardSchema` on the server; - the service keeps only an explicit `GROUP_WRITABLE_FIELDS` allowlist; - `groupId` is validated and `Group_ID` is written last. 3. **`resolveContactIdFromPage` query injection**: - A new `validateFkPath` check applies a strict regex to each `_TABLE` segment and to the final column. - The service validates every identifier before any MP call. - The action now takes `{ pageId, recordId }` and looks up the page metadata on the server, instead of accepting table and column names from the caller. 4. **F11**: `getMpTimezone` now requires a session. The file explains in writing why it is a carve-out. 5. **Pages gate themselves**: all six `tools/*/page.tsx` await `requireToolAccess()` before doing anything else. A refusal redirects to `/no-access`; infrastructure errors still throw. The layout comment wrongly claimed its redirect stopped the page from running (it doesn't in Next 16), so that is corrected. A structural test pins the gate on every page. 6. **Filter / error hygiene**: - `escapeFilterString` now escapes `[`, refuses non-strings and control characters, caps input at 100 characters, and maps quote look-alikes to `_`. - `listRoles` uses `escapeFilterString`. - Validators name the field and never echo the value. - The `domainTimezoneService` messages are ported from upstream, along with its two injection tests. ### Derived group-wizard writable allowlist (40 fields) I derived this list by probing the five editing step components (one `FormField name=` each). It equals the union of `STEP_FIELDS` and the schema keys, and a test pins that: `Group_Name, Group_Type_ID, Description, Start_Date, End_Date, Reason_Ended, Congregation_ID, Ministry_ID, Primary_Contact, Parent_Group, Priority_ID, Meeting_Day_ID, Meeting_Time, Meeting_Frequency_ID, Meeting_Duration_ID, Meets_Online, Default_Meeting_Room, Offsite_Meeting_Address, Target_Size, Life_Stage_ID, Group_Focus_ID, Required_Book, SMS_Number, Group_Is_Full, Available_Online, Available_On_App, Enable_Discussion, Send_Attendance_Notification, Send_Service_Notification, Create_Next_Meeting, Secure_Check-in, Suppress_Nametag, Suppress_Care_Note, On_Classroom_Manager, Promote_to_Group, Age_in_Months_to_Promote, Promote_Weekly, Promote_Participants_Only, Promotion_Date, Descended_From` `Group_ID`, `Domain_ID`, `_`-prefixed system columns and everything else are dropped. ### Behaviour changes worth knowing - An `updateGroup` payload that leaves out a date field no longer sends it as `null`. - Dotted `Participant_ID_Table.Contact_ID` paths from `api_Tools_GetPageData` now resolve. The old case-sensitive `_TABLE` check rejected them. - A search term longer than 100 characters now throws `Invalid search value: longer than 100 characters`. ## Tests - Local gates all pass: `npm run lint`, `npm run typecheck`, `npm run test:coverage` (118 files, 1,787 tests; 98.82% statements / 99.68% lines; thresholds 97/98 hold), and `npm run build`. - Adversarial tests cover: - `MP_SECURITY_ROLES` unset, blank, whitespace, `","` and `" , , "`, plus `*` and `*`-inside-a-list; - smuggled `Group_ID`, unknown columns, `$userId` and prototype keys; - `groupId` of 0, -1, 1.5, NaN or a string; - create and update each write nothing when the gate throws; - nine hostile FK paths and several hostile table names; - `getMpTimezone` with a missing session; - gate-before-parse on all six pages; - validator messages never echo the value. ## Mutation checks (break → red → revert → green) | Mutation | Red | |---|---| | role policy: blank → `any` | 8 | | allowlist pick → spread whole payload | 2 | | `Group_ID` spread first | 1 | | `validatePositiveInt(groupId)` removed | 5 | | action passes raw (unparsed) payload | 1 | | old `_TABLE` passthrough in `resolveContactIdFromPage` | all hostile-path tests | | `getMpTimezone` session check removed | 3 | | group-wizard page gate removed | 2 | | `toMpSqlDatetime` echoes the value | 1 | ## Deferred - `ToolService.resolveContactIds` (dev panel) still takes table and column names from its caller. They are identifier-validated, and that path is dev-only. - The `(web)/layout.tsx` session redirect has the same segment limitation as the tools layout. Tool pages are now covered by `requireToolAccess`. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 (1M context) --- ...tep2-authorization-and-input-validation.md | 63 ++++++++- .claude/TODO/INDEX.md | 11 +- .claude/references/routing/app-router.md | 5 +- .claude/references/security/README.md | 10 +- .env.example | 19 ++- CLAUDE.md | 1 + scripts/setup.ts | 10 ++ .../(web)/tools/addeditfamily/actions.test.ts | 86 +++++++++--- src/app/(web)/tools/addeditfamily/actions.ts | 34 +++-- .../(web)/tools/addeditfamily/page.test.tsx | 21 +++ src/app/(web)/tools/addeditfamily/page.tsx | 4 + .../(web)/tools/addresslabels/page.test.tsx | 21 +++ src/app/(web)/tools/addresslabels/page.tsx | 4 + .../(web)/tools/fieldmanagement/page.test.tsx | 21 +++ src/app/(web)/tools/fieldmanagement/page.tsx | 4 + src/app/(web)/tools/groupwizard/page.test.tsx | 21 +++ src/app/(web)/tools/groupwizard/page.tsx | 4 + src/app/(web)/tools/layout.test.tsx | 8 +- src/app/(web)/tools/layout.tsx | 27 ++-- .../(web)/tools/require-tool-access.test.ts | 81 +++++++++++ src/app/(web)/tools/require-tool-access.ts | 36 +++++ src/app/(web)/tools/template/page.test.tsx | 21 +++ src/app/(web)/tools/template/page.tsx | 4 + .../(web)/tools/templateeditor/page.test.tsx | 21 +++ src/app/(web)/tools/templateeditor/page.tsx | 4 + src/components/group-wizard/actions.test.ts | 86 ++++++++++++ src/components/group-wizard/actions.ts | 28 +++- src/components/shared-actions/domain.test.ts | 28 +++- src/components/shared-actions/domain.ts | 21 +++ src/lib/tool-params.server.ts | 6 +- src/lib/validation.test.ts | 127 +++++++++++++++++- src/lib/validation.ts | 119 ++++++++++++++-- src/services/addressLabelService.ts | 2 +- src/services/authorizationService.test.ts | 88 +++++++++++- src/services/authorizationService.ts | 61 +++++++-- src/services/domainTimezoneService.test.ts | 33 ++++- src/services/domainTimezoneService.ts | 7 +- src/services/familyService.test.ts | 65 ++++++++- src/services/familyService.ts | 40 ++++-- src/services/groupService.test.ts | 114 +++++++++++++++- src/services/groupService.ts | 100 ++++++++++++-- src/services/toolService.ts | 6 +- 42 files changed, 1357 insertions(+), 115 deletions(-) create mode 100644 src/app/(web)/tools/require-tool-access.test.ts create mode 100644 src/app/(web)/tools/require-tool-access.ts diff --git a/.claude/TODO/2026-09-30-step2-authorization-and-input-validation.md b/.claude/TODO/2026-09-30-step2-authorization-and-input-validation.md index 9fd0824..fce93a8 100644 --- a/.claude/TODO/2026-09-30-step2-authorization-and-input-validation.md +++ b/.claude/TODO/2026-09-30-step2-authorization-and-input-validation.md @@ -3,10 +3,10 @@ title: "Step 2 — Authorization and input validation: fail-closed roles, group- severity: high tags: [security, missing-test] area: services -files: [src/services/authorizationService.ts, src/services/groupService.ts, src/components/group-wizard/actions.ts, src/services/familyService.ts, src/components/shared-actions/domain.ts, src/app/(web)/tools/layout.tsx, src/lib/validation.ts, src/services/domainTimezoneService.ts, .env.example] +files: [src/services/authorizationService.ts, src/services/groupService.ts, src/components/group-wizard/actions.ts, src/services/familyService.ts, src/app/(web)/tools/addeditfamily/actions.ts, src/components/shared-actions/domain.ts, src/app/(web)/tools/layout.tsx, src/app/(web)/tools/require-tool-access.ts, src/lib/validation.ts, src/services/domainTimezoneService.ts, src/services/toolService.ts, .env.example, scripts/setup.ts] discovered: 2026-09-30 discovered_by: playbook-audit (port-downstream-hardening.md, port-mp-write-attribution.md, port-security-review-2026-09-28.md P7/P10, port-mp-datetime-handling.md) -status: open +status: resolved --- ## Problem @@ -48,3 +48,62 @@ status: open ## Impact if not fixed Any MP user with any security role can use the app's service account to write arbitrary Groups columns, read arbitrary numeric columns, and edit domain-wide page-field config. + +## Resolution (2026-09-30) + +Branch `fix/step2-authz-input-validation`, one commit per item. + +1. **Fail-closed roles** — `resolveRolePolicy()` in `authorizationService.ts`: unset / blank / `","` → + `unconfigured` (nobody; denial reason `roles_not_configured`, no `dp_User_Roles` read); `*` (whole value only) + → any MP role; a list → those roles, case-insensitive. `*` inside a list is a literal name. `.env.example` + ships `MP_SECURITY_ROLES=*` with the control explained; `scripts/setup.ts` treats it as required, default `*`; + CLAUDE.md and `references/security/README.md` updated. **Ops TODO stays open** until the owner sets `*` in + every Vercel environment. +2. **Group-wizard mass assignment** — actions parse with `groupWizardSchema` server-side after the gate (errors + name fields only); `GroupService` picks an explicit `GROUP_WRITABLE_FIELDS` allowlist (own properties only); + `updateGroup` runs `validatePositiveInt(groupId)` and writes `Group_ID` **last**. Date fields absent from a + partial update are no longer sent as `null`. + **Derived allowlist (40 fields)** — probed from the five editing step components (`step-identity`, + `step-organization`, `step-meeting`, `step-attributes`, `step-settings`, one `FormField name=` each); it equals + the union of `STEP_FIELDS` and the schema's keys (pinned by a test): + `Group_Name, Group_Type_ID, Description, Start_Date, End_Date, Reason_Ended, Congregation_ID, Ministry_ID, + Primary_Contact, Parent_Group, Priority_ID, Meeting_Day_ID, Meeting_Time, Meeting_Frequency_ID, + Meeting_Duration_ID, Meets_Online, Default_Meeting_Room, Offsite_Meeting_Address, Target_Size, Life_Stage_ID, + Group_Focus_ID, Required_Book, SMS_Number, Group_Is_Full, Available_Online, Available_On_App, + Enable_Discussion, Send_Attendance_Notification, Send_Service_Notification, Create_Next_Meeting, + Secure_Check-in, Suppress_Nametag, Suppress_Care_Note, On_Classroom_Manager, Promote_to_Group, + Age_in_Months_to_Promote, Promote_Weekly, Promote_Participants_Only, Promotion_Date, Descended_From`. +3. **`resolveContactIdFromPage` injection** — new `validateFkPath` (identifier segments ending `_TABLE`, any case, + + final identifier column; ≤4 segments, ≤256 chars). The service validates `tableName`, `primaryKey`, + `recordId`, `contactIdField` before the gate and any MP call. The action now takes `{ pageId, recordId }` and + resolves page metadata server-side via `ToolService.getPageData`. Side fix: dotted `_Table.` paths as returned by + `api_Tools_GetPageData` were previously rejected by the case-sensitive `_TABLE` check. +4. **F11** — `getMpTimezone` requires `session.user.id`; written carve-out justification in the file. +5. **Pages self-gate** — `tools/require-tool-access.ts` (`requireSecurityRole`; `UnauthorizedError` → + `redirect("/no-access")`, other errors rethrow) awaited first in all six `tools/*/page.tsx`; layout comment + corrected. A structural test fails if a tool page lacks the gate or calls it after `parseToolParams`. +6. **Filter / error hygiene** — `escapeFilterString` escapes `[` first, refuses non-strings and control chars, + caps at `MAX_SEARCH_TERM_LENGTH` (100), maps quote look-alikes to `_`. `listRoles` now uses it. Validators do + runtime type checks, `validatePositiveInt` uses the safe-integer bound, and messages carry no value. + `domainTimezoneService` messages ported from upstream + both injection tests; guard-clause suite uses + `mockReset`. + +**Tests:** all adversarial — blank/`","`/unset/whitespace refuse, `*`, list, `*`-in-list; smuggled `Group_ID`, +unknown columns, `$userId`, prototype keys, `groupId` 0/-1/1.5/NaN/string, gate-throws-no-write for create and +update, schema failure naming fields; nine hostile FK paths + hostile table names; missing-session for +`getMpTimezone`; page gate-before-parse for all six pages; escaping/length/control-char/no-echo validators. +Suite: 118 files, 1,787 tests; coverage 98.82% statements / 99.68% lines. + +**Mutation checks (break → red → revert → green):** role policy blank → `any` (8 red); allowlist pick → spread +(2 red); `Group_ID` moved first (1 red); `validatePositiveInt(groupId)` removed (5 red); action passes raw payload +(1 red); old `_TABLE` passthrough in `resolveContactIdFromPage` (all hostile-path tests red); `getMpTimezone` +session check removed (3 red); group-wizard page gate removed (2 red); `toMpSqlDatetime` echoing the value (1 red). + +**Deferred / observations:** +- `(web)/layout.tsx`'s `AuthWrapper` session redirect has the same segment limitation as the tools layout; tool + pages are now covered by `requireToolAccess` (which also requires a session). Non-tool pages hold no MP data. +- `ToolService.resolveContactIds` (dev panel) still takes table/column names from its caller; they are + identifier-validated and the path is dev-only. Candidate for the same page-ID treatment in a later step. +- `resolveIanaTimezone`'s "Unknown time zone" message still includes the MP-provided zone name (not caller + input; matches upstream). + diff --git a/.claude/TODO/INDEX.md b/.claude/TODO/INDEX.md index c9c82e8..3ca7b02 100644 --- a/.claude/TODO/INDEX.md +++ b/.claude/TODO/INDEX.md @@ -11,6 +11,7 @@ last_updated: 2026-09-30 + @@ -24,7 +25,7 @@ Severity tiers: - **medium**: doc drift, missing test, refactor with real cost - **low**: nits, minor doc fixes, stylistic improvements -Total: **17 open TODOs**. +Total: **16 open TODOs**. > **2026-09-30 — MPNext playbook audit.** The five `S:\MP\MPNext\.claude\playbooks` > port playbooks were audited against this fork. Remediation is split into nine @@ -50,10 +51,9 @@ Total: **17 open TODOs**. ### Critical (0) _none open_ -### High (5) +### High (4) | Area | Tags | Title | File | |---|---|---|---| -| services | security, missing-test | Step 2 — Fail-closed roles, group-wizard mass assignment, page-metadata injection, F11 | [→](2026-09-30-step2-authorization-and-input-validation.md) | | auth | security, missing-test, doc | Step 3 — Session lifetime, token-in-cookie, boot guards, secrets hygiene, client-IP header | [→](2026-09-30-step3-session-lifetime-and-secrets-hygiene.md) | | auth | security, missing-test, doc | Step 4 — OIDC lazy discovery, sub binding, dedicated OIDC client, sign-out | [→](2026-09-30-step4-oidc-lazy-discovery-and-sub-binding.md) | | auth | security, doc | Ops — Rotate BETTER_AUTH_SECRET; advise downstream clones | [→](2026-09-30-ops-rotate-better-auth-secret.md) | @@ -83,8 +83,8 @@ _none open_ ## By tag -### security (12) -- all open 2026-09-30 step/ops/held items except step7 (step1 resolved) +### security (11) +- all open 2026-09-30 step/ops/held items except step7 (step1, step2 resolved) ### bug (3) - components-template-editor-no-mp-persistence — medium @@ -134,6 +134,7 @@ _none open_ | Date | Title | File | |---|---|---| +| 2026-09-30 | Step 2 — Fail-closed MP_SECURITY_ROLES, group-wizard mass assignment, page-metadata injection, F11, page self-gating, filter/error hygiene | [→](2026-09-30-step2-authorization-and-input-validation.md) | | 2026-09-30 | Step 1 — F12 id_token sign-in refused, F3b callbackUrl bypass closed, /sign-in/social boundary + no-store | [→](2026-09-30-step1-close-live-auth-holes-f12-f3b.md) | | 2026-09-13 | Coverage config omitted `coverage.include`, hiding every untested file | [→](2026-09-13-testing-coverage-report-masked-untested-files.md) | | 2026-09-13 | No tests for `src/components/template-editor/` | [→](2026-04-17-components-template-editor-missing-tests.md) | diff --git a/.claude/references/routing/app-router.md b/.claude/references/routing/app-router.md index 9004e04..98cfe8d 100644 --- a/.claude/references/routing/app-router.md +++ b/.claude/references/routing/app-router.md @@ -16,7 +16,8 @@ Next.js 16 App Router tree. A `(web)` route group gates all authenticated pages - `src/app/(web)/layout.tsx` — group layout; wraps children in `AuthWrapper` then `Providers`, injects Geist fonts, sets `metadata` + `viewport` - `src/app/(web)/page.tsx` — dashboard `/` — 5 tool cards (Template, Template Editor, Address Labels, Group Wizard, Field Management) - `src/app/(web)/home/page.tsx` — `/home` → `redirect('/')` -- `src/app/(web)/tools/layout.tsx` — tools sub-layout, `flex flex-col h-screen bg-gray-50` +- `src/app/(web)/tools/layout.tsx` — tools sub-layout, `flex flex-col h-screen bg-gray-50`; role-less users get a UX redirect to `/no-access` — **not** enforcement (a layout redirect does not stop a page segment rendering in Next 16) +- `src/app/(web)/tools/require-tool-access.ts` — `requireToolAccess()`, the page-level enforcement every tool page awaits first - `src/app/(web)/tools//page.tsx` — per-tool entry (`addresslabels`, `fieldmanagement`, `groupwizard`, `template`, `templateeditor`) - `src/app/(web)/error.tsx` — `"use client"` error boundary for the group - `src/app/signin/page.tsx` — public OAuth redirect page @@ -118,7 +119,7 @@ export function Providers({ children }: ProvidersProps) { - **ESLint**: `next lint` was removed; `package.json` uses `"lint": "eslint ."` with flat config in `eslint.config.mjs` ## Usage -- Add a tool route: create `src/app/(web)/tools//page.tsx` — inherits `WebLayout` (auth) + `ToolsLayout` (gray bg) +- Add a tool route: create `src/app/(web)/tools//page.tsx` — inherits `WebLayout` (auth) + `ToolsLayout` (gray bg). Its first statement must be `await requireToolAccess({ table, operation: 'read' })`, before parsing params; `require-tool-access.test.ts` fails if a page is missing it - Add a public route: create a sibling of `(web)/` (e.g. `src/app/public-thing/page.tsx`) **and** whitelist in `src/proxy.ts` — see `proxy.md` ## Gotchas diff --git a/.claude/references/security/README.md b/.claude/references/security/README.md index 0d52c41..e259740 100644 --- a/.claude/references/security/README.md +++ b/.claude/references/security/README.md @@ -64,8 +64,11 @@ and only renders `children` once it returns. means refused. - **Infrastructure failures throw; they do not return `permitted: false`.** A caller must never mistake "MP is down" for "this user is not allowed". -- **Config, not code:** `MP_SECURITY_ROLES` (comma-separated, case-insensitive). - Blank or unset means "any MP security role will do". Tighten without a deploy. +- **Config, not code — and fail-closed:** `MP_SECURITY_ROLES` (comma-separated, + case-insensitive). `*` means "any MP security role will do"; a list means only + those roles; **unset, blank or `","` means nobody** (denials log reason + `roles_not_configured`). `*` is a wildcard only as the whole value. Changed + 2026-09-30 — blank used to mean "any role". Tighten without a deploy. ### Write attribution @@ -84,7 +87,8 @@ justification, in the file, in writing. - `components/layout/auth-wrapper.tsx` — it *is* the session gate - `components/shared-actions/user.ts` — the user's own profile; enforced by the signature (no parameter to forge), not just asserted -- `components/shared-actions/domain.ts` — the domain-wide time zone (one string) +- `components/shared-actions/domain.ts` — the domain-wide time zone (one string); + session-checked since 2026-09-30 (F11 — it previously had no check at all) - `components/dev-panel/panels/require-dev-session.ts` — dev-only (`NODE_ENV !== "production"`), and the services it calls gate anyway diff --git a/.env.example b/.env.example index c8e14eb..a3f5b37 100644 --- a/.env.example +++ b/.env.example @@ -70,14 +70,25 @@ NEXT_PUBLIC_PROD_URL= # --- Security --- -# Comma-separated Ministry Platform security role names permitted to use the -# MP-data tools (READS as well as writes). Blank or unset means "any MP -# security role will do" — a signed-in user with NO role is still refused. +# Which Ministry Platform security roles may use the MP-data tools (READS as +# well as writes). This app reads MP with its own service account, so MP's +# per-user record security never applies to what it returns — this variable is +# the control that decides who gets that access. It FAILS CLOSED: +# +# * any MP security role will do (a signed-in user +# with NO role is still refused) +# Administrators,Staff only users holding one of these roles +# (comma-separated, case-insensitive) +# unset / blank / "," NOBODY — every user is sent to /no-access +# +# `*` is a wildcard only as the whole value; inside a list it is just a name. +# Roles are matched by NAME, so restrict who can create/rename Security Roles +# in MP. Replace `*` with specific role names once you know who needs access. # # Sign-in itself is deliberately NOT role-gated: a role-less user gets a # session, the app shell and a working sign-out, and is redirected from the # tools to /no-access. Refusing at sign-in would strand them with no way out. -MP_SECURITY_ROLES= +MP_SECURITY_ROLES=* # Content Security Policy. ENFORCES by default — only the exact string "false" # drops to report-only, so a typo fails loud (too-strict header) rather than diff --git a/CLAUDE.md b/CLAUDE.md index c2598b3..3d4af31 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -56,6 +56,7 @@ to keep them from diverging. - **Critical**: `session.user.id` is Better Auth's internal ID, NOT the MP User_GUID. Use `session.user.userGuid` for all MP API lookups. - **Stateless Sessions**: JWT cookie cache, no database; `customSession` does name splitting only (no API calls) - **Required Environment Variables**: `MINISTRY_PLATFORM_BASE_URL`, `BETTER_AUTH_URL` (or `NEXTAUTH_URL` fallback), `BETTER_AUTH_SECRET` (or `NEXTAUTH_SECRET` fallback) + - **`MP_SECURITY_ROLES` fails closed**: `*` = any MP security role, a comma list = only those roles, **unset/blank/`","` = nobody** (every tool redirects to `/no-access`). Set it in every environment. - **Services Layer**: Singleton service classes in `src/services/` wrap MPHelper for domain logic (ToolService, UserService, AddressLabelService, GroupService, FieldManagementService) - **Contexts**: React context providers in `src/contexts/` (UserProvider) composed in `src/app/providers.tsx`; `useAppSession()` wraps Better Auth's `authClient.useSession()` - **UI**: Radix UI primitives + shadcn/ui components in `src/components/ui/`, Tailwind CSS v4 diff --git a/scripts/setup.ts b/scripts/setup.ts index d6aed33..cebea0a 100644 --- a/scripts/setup.ts +++ b/scripts/setup.ts @@ -119,6 +119,16 @@ const ENV_VARS: EnvVar[] = [ sensitive: false, description: 'Ministry Platform API base URL', }, + { + // Fails closed: unset/blank means NOBODY may use the MP-data tools, so the + // wizard treats it as required and offers `*` (any MP security role). + name: 'MP_SECURITY_ROLES', + required: true, + sensitive: false, + description: + 'MP security roles allowed to use the tools: * = any role, or a comma list. Blank locks everyone out', + defaultValue: '*', + }, // Optional variables { name: 'MINISTRY_PLATFORM_DEV_CLIENT_ID', diff --git a/src/app/(web)/tools/addeditfamily/actions.test.ts b/src/app/(web)/tools/addeditfamily/actions.test.ts index 07bb76c..cd208cc 100644 --- a/src/app/(web)/tools/addeditfamily/actions.test.ts +++ b/src/app/(web)/tools/addeditfamily/actions.test.ts @@ -59,6 +59,14 @@ vi.mock("@/services/familyService", () => ({ PartialSaveError: FakePartialSaveError, })); +const { mockGetPageData } = vi.hoisted(() => ({ mockGetPageData: vi.fn() })); + +vi.mock("@/services/toolService", () => ({ + ToolService: { + getInstance: vi.fn(async () => ({ getPageData: mockGetPageData })), + }, +})); + const { mockIsEnabled, mockAutocomplete, mockGetPlaceDetails } = vi.hoisted(() => ({ mockIsEnabled: vi.fn(), mockAutocomplete: vi.fn(), @@ -206,45 +214,91 @@ describe("addeditfamily actions", () => { }); describe("resolveContactIdFromPage", () => { - const args = { - tableName: "Event_Participants", - primaryKey: "Event_Participant_ID", - recordId: 10, - contactIdField: "Participant_ID_TABLE_Contact_ID", + const args = { pageId: 292, recordId: 10 }; + const pageData = { + Page_ID: 292, + Table_Name: "Event_Participants", + Primary_Key: "Event_Participant_ID", + Contact_ID_Field: "Participant_ID_TABLE.Contact_ID", }; - it("authorizes against the given table and returns the contactId", async () => { + it("resolves page metadata server-side from the page ID and returns the contactId", async () => { + mockGetPageData.mockResolvedValueOnce(pageData); mockResolveContactIdFromPage.mockResolvedValueOnce(99); const result = await resolveContactIdFromPage(args); expect(mockRequireSecurityRole).toHaveBeenCalledWith({ - table: "Event_Participants", + table: "dp_Pages", operation: "read", }); + expect(mockGetPageData).toHaveBeenCalledWith(292); expect(mockResolveContactIdFromPage).toHaveBeenCalledWith( - args.tableName, - args.primaryKey, - args.recordId, - args.contactIdField, + "Event_Participants", + "Event_Participant_ID", + 10, + "Participant_ID_TABLE.Contact_ID", ); expect(result).toEqual({ success: true, contactId: 99 }); }); - it("returns null contactId when unresolved", async () => { - mockResolveContactIdFromPage.mockResolvedValueOnce(null); + it("IGNORES caller-supplied table/column names — only the page's metadata is used", async () => { + // Adversarial: the action used to take tableName/contactIdField from the + // POST body, letting a caller read any numeric column of any table. + mockGetPageData.mockResolvedValueOnce(pageData); + mockResolveContactIdFromPage.mockResolvedValueOnce(99); + await resolveContactIdFromPage({ + ...args, + tableName: "dp_Users", + primaryKey: "User_ID", + contactIdField: "User_ID_TABLE.Password", + } as unknown as typeof args); + expect(mockResolveContactIdFromPage).toHaveBeenCalledWith( + "Event_Participants", + "Event_Participant_ID", + 10, + "Participant_ID_TABLE.Contact_ID", + ); + }); + + it.each([ + [{ pageId: 0, recordId: 10 }], + [{ pageId: 292, recordId: -1 }], + [{ pageId: "292 OR 1=1", recordId: 10 }], + ])("refuses invalid ids %j before any lookup", async (bad) => { + const result = await resolveContactIdFromPage(bad as unknown as typeof args); + expect(result).toMatchObject({ success: false }); + expect(mockGetPageData).not.toHaveBeenCalled(); + expect(mockResolveContactIdFromPage).not.toHaveBeenCalled(); + }); + + it.each([null, { Table_Name: "Contacts", Primary_Key: "Contact_ID" }])( + "returns null without querying when the page has no usable metadata (%j)", + async (page) => { + mockGetPageData.mockResolvedValueOnce(page); + const result = await resolveContactIdFromPage(args); + expect(result).toEqual({ success: true, contactId: null }); + expect(mockResolveContactIdFromPage).not.toHaveBeenCalled(); + }, + ); + + it("does not look anything up when the gate refuses", async () => { + mockRequireSecurityRole.mockRejectedValueOnce(new Error("Not authorized")); const result = await resolveContactIdFromPage(args); - expect(result).toEqual({ success: true, contactId: null }); + expect(result).toEqual({ success: false, error: "Not authorized" }); + expect(mockGetPageData).not.toHaveBeenCalled(); }); it("returns a generic error message for a non-Error throw", async () => { + mockGetPageData.mockResolvedValueOnce(pageData); mockResolveContactIdFromPage.mockRejectedValueOnce("boom"); const result = await resolveContactIdFromPage(args); expect(result).toEqual({ success: false, error: "Failed to resolve contact" }); }); it("returns the Error message when the service throws an Error", async () => { - mockResolveContactIdFromPage.mockRejectedValueOnce(new Error("invalid column")); + mockGetPageData.mockResolvedValueOnce(pageData); + mockResolveContactIdFromPage.mockRejectedValueOnce(new Error("Invalid Contact_ID_Field")); const result = await resolveContactIdFromPage(args); - expect(result).toEqual({ success: false, error: "invalid column" }); + expect(result).toEqual({ success: false, error: "Invalid Contact_ID_Field" }); }); }); diff --git a/src/app/(web)/tools/addeditfamily/actions.ts b/src/app/(web)/tools/addeditfamily/actions.ts index 6c36b17..3f8190c 100644 --- a/src/app/(web)/tools/addeditfamily/actions.ts +++ b/src/app/(web)/tools/addeditfamily/actions.ts @@ -3,6 +3,8 @@ import { FamilyService, PartialSaveError } from "@/services/familyService"; import { AuthorizationService } from "@/services/authorizationService"; import { GooglePlacesService } from "@/services/googlePlacesService"; +import { ToolService } from "@/services/toolService"; +import { validatePositiveInt } from "@/lib/validation"; import { HouseholdSchema } from "@/lib/dto/family"; import type { ContactSearchResult, @@ -71,20 +73,36 @@ export async function fetchHousehold( } } +/** + * Resolve the Contact_ID behind an MP page record. + * + * Takes the page ID, NOT table/column names: the page's `Table_Name`, + * `Primary_Key` and `Contact_ID_Field` are looked up server-side from MP + * (`api_Tools_GetPageData`). This action used to accept those names from the + * caller, which — being a POST endpoint — let any role holder aim the query + * at an arbitrary table and column. The service validates the metadata again. + */ export async function resolveContactIdFromPage(args: { - tableName: string; - primaryKey: string; + pageId: number; recordId: number; - contactIdField: string; }): Promise<{ success: true; contactId: number | null } | ActionError> { try { - await requireAccess(args.tableName, "read"); + await requireAccess("dp_Pages", "read"); + const pageId = validatePositiveInt(args?.pageId); + const recordId = validatePositiveInt(args?.recordId); + + const toolService = await ToolService.getInstance(); + const page = await toolService.getPageData(pageId); + if (!page?.Table_Name || !page.Primary_Key || !page.Contact_ID_Field) { + return { success: true, contactId: null }; + } + const service = await FamilyService.getInstance(); const contactId = await service.resolveContactIdFromPage( - args.tableName, - args.primaryKey, - args.recordId, - args.contactIdField, + page.Table_Name, + page.Primary_Key, + recordId, + page.Contact_ID_Field, ); return { success: true, contactId }; } catch (error) { diff --git a/src/app/(web)/tools/addeditfamily/page.test.tsx b/src/app/(web)/tools/addeditfamily/page.test.tsx index d1c6d4d..21d667c 100644 --- a/src/app/(web)/tools/addeditfamily/page.test.tsx +++ b/src/app/(web)/tools/addeditfamily/page.test.tsx @@ -41,6 +41,12 @@ vi.mock("./add-edit-family", () => ({ ), })); +const mockRequireToolAccess = vi.hoisted(() => vi.fn(async () => 42)); + +vi.mock('@/app/(web)/tools/require-tool-access', () => ({ + requireToolAccess: mockRequireToolAccess, +})); + import AddEditFamilyPage, { generateMetadata } from "./page"; function searchParamsOf(obj: Record) { @@ -193,3 +199,18 @@ describe("AddEditFamilyPage", () => { await expect(generateMetadata()).resolves.toEqual({ title: "Add/Edit Family" }); }); }); + +describe('AddEditFamilyPage self-gate', () => { + it('gates before parsing params and renders nothing when refused', async () => { + // Adversarial: the tools layout's redirect does not stop a page segment + // rendering in Next 16, so the page itself must refuse. + mockParseToolParams.mockClear(); + mockRequireToolAccess.mockRejectedValueOnce(new Error('NEXT_REDIRECT:/no-access')); + + await expect(AddEditFamilyPage({ searchParams: Promise.resolve({}) })).rejects.toThrow( + 'NEXT_REDIRECT:/no-access', + ); + expect(mockRequireToolAccess).toHaveBeenCalledWith({ table: 'Contacts', operation: 'read' }); + expect(mockParseToolParams).not.toHaveBeenCalled(); + }); +}); diff --git a/src/app/(web)/tools/addeditfamily/page.tsx b/src/app/(web)/tools/addeditfamily/page.tsx index 096736b..5b193ed 100644 --- a/src/app/(web)/tools/addeditfamily/page.tsx +++ b/src/app/(web)/tools/addeditfamily/page.tsx @@ -1,4 +1,5 @@ import { AddEditFamily } from "./add-edit-family"; +import { requireToolAccess } from "@/app/(web)/tools/require-tool-access"; import { parseToolParams } from "@/lib/tool-params.server"; import { FamilyService } from "@/services/familyService"; @@ -7,6 +8,9 @@ interface AddEditFamilyPageProps { } export default async function AddEditFamilyPage({ searchParams }: AddEditFamilyPageProps) { + // Self-gating, before ANY param parsing or data call — see require-tool-access.ts. + await requireToolAccess({ table: "Contacts", operation: "read" }); + const params = await parseToolParams(await searchParams); let initialContactId: number | null = null; diff --git a/src/app/(web)/tools/addresslabels/page.test.tsx b/src/app/(web)/tools/addresslabels/page.test.tsx index 4859cae..2c1bb57 100644 --- a/src/app/(web)/tools/addresslabels/page.test.tsx +++ b/src/app/(web)/tools/addresslabels/page.test.tsx @@ -14,6 +14,12 @@ vi.mock('./address-labels', () => ({ ), })); +const mockRequireToolAccess = vi.hoisted(() => vi.fn(async () => 42)); + +vi.mock('@/app/(web)/tools/require-tool-access', () => ({ + requireToolAccess: mockRequireToolAccess, +})); + import AddressLabelsPage from './page'; afterEach(() => { @@ -34,3 +40,18 @@ describe('AddressLabelsPage', () => { expect(screen.getByTestId('address-labels').textContent).toBe(JSON.stringify(parsed)); }); }); + +describe('AddressLabelsPage self-gate', () => { + it('gates before parsing params and renders nothing when refused', async () => { + // Adversarial: the tools layout's redirect does not stop a page segment + // rendering in Next 16, so the page itself must refuse. + mockParseToolParams.mockClear(); + mockRequireToolAccess.mockRejectedValueOnce(new Error('NEXT_REDIRECT:/no-access')); + + await expect(AddressLabelsPage({ searchParams: Promise.resolve({}) })).rejects.toThrow( + 'NEXT_REDIRECT:/no-access', + ); + expect(mockRequireToolAccess).toHaveBeenCalledWith({ table: 'Contacts', operation: 'read' }); + expect(mockParseToolParams).not.toHaveBeenCalled(); + }); +}); diff --git a/src/app/(web)/tools/addresslabels/page.tsx b/src/app/(web)/tools/addresslabels/page.tsx index 83020b8..c42f4e3 100644 --- a/src/app/(web)/tools/addresslabels/page.tsx +++ b/src/app/(web)/tools/addresslabels/page.tsx @@ -1,4 +1,5 @@ import { AddressLabels } from './address-labels'; +import { requireToolAccess } from '@/app/(web)/tools/require-tool-access'; import { parseToolParams } from '@/lib/tool-params.server'; interface AddressLabelsPageProps { @@ -6,6 +7,9 @@ interface AddressLabelsPageProps { } export default async function AddressLabelsPage({ searchParams }: AddressLabelsPageProps) { + // Self-gating, before ANY param parsing or data call — see require-tool-access.ts. + await requireToolAccess({ table: 'Contacts', operation: 'read' }); + const params = await parseToolParams(await searchParams); return ; diff --git a/src/app/(web)/tools/fieldmanagement/page.test.tsx b/src/app/(web)/tools/fieldmanagement/page.test.tsx index 171142f..991dffa 100644 --- a/src/app/(web)/tools/fieldmanagement/page.test.tsx +++ b/src/app/(web)/tools/fieldmanagement/page.test.tsx @@ -12,6 +12,12 @@ vi.mock('./field-management', () => ({ FieldManagement: () => null, })); +const mockRequireToolAccess = vi.hoisted(() => vi.fn(async () => 42)); + +vi.mock('@/app/(web)/tools/require-tool-access', () => ({ + requireToolAccess: mockRequireToolAccess, +})); + import FieldManagementPage from './page'; describe('FieldManagementPage', () => { @@ -45,3 +51,18 @@ describe('FieldManagementPage', () => { ).rejects.toThrow('bad params'); }); }); + +describe('FieldManagementPage self-gate', () => { + it('gates before parsing params and renders nothing when refused', async () => { + // Adversarial: the tools layout's redirect does not stop a page segment + // rendering in Next 16, so the page itself must refuse. + mockParseToolParams.mockClear(); + mockRequireToolAccess.mockRejectedValueOnce(new Error('NEXT_REDIRECT:/no-access')); + + await expect(FieldManagementPage({ searchParams: Promise.resolve({}) })).rejects.toThrow( + 'NEXT_REDIRECT:/no-access', + ); + expect(mockRequireToolAccess).toHaveBeenCalledWith({ table: 'dp_Page_Fields', operation: 'read' }); + expect(mockParseToolParams).not.toHaveBeenCalled(); + }); +}); diff --git a/src/app/(web)/tools/fieldmanagement/page.tsx b/src/app/(web)/tools/fieldmanagement/page.tsx index 44565c3..f015ad4 100644 --- a/src/app/(web)/tools/fieldmanagement/page.tsx +++ b/src/app/(web)/tools/fieldmanagement/page.tsx @@ -1,4 +1,5 @@ import { FieldManagement } from "./field-management"; +import { requireToolAccess } from "@/app/(web)/tools/require-tool-access"; import { parseToolParams } from "@/lib/tool-params.server"; interface FieldManagementPageProps { @@ -6,6 +7,9 @@ interface FieldManagementPageProps { } export default async function FieldManagementPage({ searchParams }: FieldManagementPageProps) { + // Self-gating, before ANY param parsing or data call — see require-tool-access.ts. + await requireToolAccess({ table: "dp_Page_Fields", operation: "read" }); + const params = await parseToolParams(await searchParams); return ; diff --git a/src/app/(web)/tools/groupwizard/page.test.tsx b/src/app/(web)/tools/groupwizard/page.test.tsx index 5d33161..bf5b1b8 100644 --- a/src/app/(web)/tools/groupwizard/page.test.tsx +++ b/src/app/(web)/tools/groupwizard/page.test.tsx @@ -24,6 +24,12 @@ vi.mock('./group-wizard', () => ({ ), })); +const mockRequireToolAccess = vi.hoisted(() => vi.fn(async () => 42)); + +vi.mock('@/app/(web)/tools/require-tool-access', () => ({ + requireToolAccess: mockRequireToolAccess, +})); + import GroupWizardPage from './page'; afterEach(() => { @@ -59,3 +65,18 @@ describe('GroupWizardPage', () => { expect(mockParseToolParams).toHaveBeenCalledWith(rawParams); }); }); + +describe('GroupWizardPage self-gate', () => { + it('gates before parsing params and renders nothing when refused', async () => { + // Adversarial: the tools layout's redirect does not stop a page segment + // rendering in Next 16, so the page itself must refuse. + mockParseToolParams.mockClear(); + mockRequireToolAccess.mockRejectedValueOnce(new Error('NEXT_REDIRECT:/no-access')); + + await expect(GroupWizardPage({ searchParams: Promise.resolve({}) })).rejects.toThrow( + 'NEXT_REDIRECT:/no-access', + ); + expect(mockRequireToolAccess).toHaveBeenCalledWith({ table: 'Groups', operation: 'read' }); + expect(mockParseToolParams).not.toHaveBeenCalled(); + }); +}); diff --git a/src/app/(web)/tools/groupwizard/page.tsx b/src/app/(web)/tools/groupwizard/page.tsx index 2762e26..309e1b0 100644 --- a/src/app/(web)/tools/groupwizard/page.tsx +++ b/src/app/(web)/tools/groupwizard/page.tsx @@ -1,4 +1,5 @@ import { GroupWizard } from "./group-wizard"; +import { requireToolAccess } from "@/app/(web)/tools/require-tool-access"; import { parseToolParams } from "@/lib/tool-params.server"; import { getMpTimezone } from "@/components/shared-actions/domain"; @@ -7,6 +8,9 @@ interface GroupWizardPageProps { } export default async function GroupWizardPage({ searchParams }: GroupWizardPageProps) { + // Self-gating, before ANY param parsing or data call — see require-tool-access.ts. + await requireToolAccess({ table: "Groups", operation: "read" }); + const [params, mpTimezone] = await Promise.all([ parseToolParams(await searchParams), getMpTimezone(), diff --git a/src/app/(web)/tools/layout.test.tsx b/src/app/(web)/tools/layout.test.tsx index f606db1..d8802ef 100644 --- a/src/app/(web)/tools/layout.test.tsx +++ b/src/app/(web)/tools/layout.test.tsx @@ -4,12 +4,12 @@ import { isValidElement } from 'react'; /** * ToolsLayout Tests * - * This layout is the page-level authorization gate for every route under - * /tools. React renders a layout before its children, so a redirect() here - * means no tool page component ever runs. + * This layout is the UX redirect for every route under /tools. It does NOT + * stop a tool page from running (Next renders segments independently) — each + * page gates itself via requireToolAccess, see require-tool-access.test.ts. * * It is the UX layer, not the security control — enforcement lives in the - * server actions and service methods. These tests pin both halves of that + * pages, server actions and service methods. These tests pin both halves of that * contract: that a permitted user gets their children rendered, and that a * non-permitted user is redirected to /no-access before children are touched. */ diff --git a/src/app/(web)/tools/layout.tsx b/src/app/(web)/tools/layout.tsx index 9595a20..83a8809 100644 --- a/src/app/(web)/tools/layout.tsx +++ b/src/app/(web)/tools/layout.tsx @@ -2,17 +2,26 @@ import { redirect } from "next/navigation"; import { AuthorizationService } from "@/services/authorizationService"; /** - * Page-level authorization gate for every tool. + * UX redirect for every route under `/tools` — NOT an enforcement point for + * the tool pages. * - * React renders a layout before its children and only renders `children` once - * the layout returns, so a `redirect()` here means the tool page component - * never runs — one gate covers every route under `/tools`. + * It sends a role-less user to `/no-access` (which explains the refusal) + * instead of a tool full of failing actions. That is all it can do. * - * This is the UX layer, NOT the security control. It uses `hasSecurityRole()` - * (non-throwing, non-logging) purely to choose between rendering and - * redirecting. Enforcement lives in the server actions and in the service - * methods, both of which use `requireSecurityRole()` — a server action is a - * callable POST endpoint whether or not this layout ever rendered. + * **It does not protect any `tools//page.tsx`.** Next 16 renders each + * route segment independently: the page is built as its own segment and this + * layout only receives a placeholder as `children`, so a `redirect()` here + * does not stop the page running or its output reaching the RSC payload + * (node_modules/next/dist/docs/01-app/02-guides/authentication.md, "Layouts + * and auth checks"). Layouts are also not re-rendered on client navigation. + * (An earlier version of this comment claimed the opposite; corrected + * 2026-09-30, following upstream's 2026-09-29 fix.) So every tool page gates + * ITSELF via `requireToolAccess` before any data call, and every server action + * and service method gates again with `requireSecurityRole()` — a server action + * is a callable POST endpoint whether or not this layout ever rendered. + * + * Uses the non-throwing, non-logging `hasSecurityRole()` purely to choose + * between rendering and redirecting. */ export default async function ToolsLayout({ children, diff --git a/src/app/(web)/tools/require-tool-access.test.ts b/src/app/(web)/tools/require-tool-access.test.ts new file mode 100644 index 0000000..ee653d2 --- /dev/null +++ b/src/app/(web)/tools/require-tool-access.test.ts @@ -0,0 +1,81 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { readFileSync } from 'node:fs'; +import path from 'node:path'; + +const { mockRequireSecurityRole, mockRedirect, FakeUnauthorizedError } = vi.hoisted(() => ({ + mockRequireSecurityRole: vi.fn(), + mockRedirect: vi.fn((url: string) => { + // redirect() throws in real Next.js to abort rendering; mirror that. + throw new Error(`NEXT_REDIRECT:${url}`); + }), + FakeUnauthorizedError: class UnauthorizedError extends Error {}, +})); + +vi.mock('next/navigation', () => ({ redirect: mockRedirect })); + +vi.mock('@/services/authorizationService', () => ({ + AuthorizationService: { + getInstance: () => ({ requireSecurityRole: mockRequireSecurityRole }), + }, + UnauthorizedError: FakeUnauthorizedError, +})); + +import { requireToolAccess } from './require-tool-access'; + +const ctx = { table: 'Groups', operation: 'read' as const }; + +describe('requireToolAccess', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('returns the acting User_ID when permitted, without redirecting', async () => { + mockRequireSecurityRole.mockResolvedValueOnce(42); + + await expect(requireToolAccess(ctx)).resolves.toBe(42); + expect(mockRequireSecurityRole).toHaveBeenCalledWith(ctx); + expect(mockRedirect).not.toHaveBeenCalled(); + }); + + it('redirects a refused caller to /no-access', async () => { + mockRequireSecurityRole.mockRejectedValueOnce(new FakeUnauthorizedError()); + + await expect(requireToolAccess(ctx)).rejects.toThrow('NEXT_REDIRECT:/no-access'); + }); + + it('rethrows an infrastructure failure instead of reporting it as no-access', async () => { + mockRequireSecurityRole.mockRejectedValueOnce(new Error('ConnectTimeoutError')); + + await expect(requireToolAccess(ctx)).rejects.toThrow('ConnectTimeoutError'); + expect(mockRedirect).not.toHaveBeenCalled(); + }); +}); + +/** + * Structural guard: every tool page must gate ITSELF. The tools layout's + * redirect does not stop a page segment from rendering in Next 16, so a new + * tool page that forgets the call would render (and fetch) for a role-less + * user. This fails if any page.tsx under tools/ lacks the gate, or calls it + * after parsing params. + */ +describe('every tool page self-gates', () => { + const toolsDir = path.resolve(__dirname); + const pages = ['addeditfamily', 'addresslabels', 'fieldmanagement', 'groupwizard', 'template', 'templateeditor']; + + it('covers every tool directory', async () => { + const { readdirSync, existsSync } = await import('node:fs'); + const found = readdirSync(toolsDir, { withFileTypes: true }) + .filter((d) => d.isDirectory() && existsSync(path.join(toolsDir, d.name, 'page.tsx'))) + .map((d) => d.name) + .sort(); + expect(found).toEqual([...pages].sort()); + }); + + it.each(pages)('%s/page.tsx awaits requireToolAccess before parseToolParams', (dir) => { + const src = readFileSync(path.join(toolsDir, dir, 'page.tsx'), 'utf8'); + const gate = src.indexOf('await requireToolAccess('); + const parse = src.indexOf('parseToolParams(await'); + expect(gate).toBeGreaterThan(-1); + expect(parse).toBeGreaterThan(gate); + }); +}); diff --git a/src/app/(web)/tools/require-tool-access.ts b/src/app/(web)/tools/require-tool-access.ts new file mode 100644 index 0000000..b5d7493 --- /dev/null +++ b/src/app/(web)/tools/require-tool-access.ts @@ -0,0 +1,36 @@ +import { redirect } from "next/navigation"; +import { + AuthorizationService, + UnauthorizedError, + type AuthorizationContext, +} from "@/services/authorizationService"; + +/** + * Page-level ENFORCEMENT for a tool route. Every `tools//page.tsx` + * calls this first, before parsing params or touching any data. + * + * Why the page and not just the layout: Next 16 renders each route segment + * independently. The page is built as its own segment and the layout only + * receives a placeholder as `children`, so a `redirect()` in + * `tools/layout.tsx` does NOT stop the page component running or its output + * reaching the RSC payload (Next's own guide, "Layouts and auth checks": + * node_modules/next/dist/docs/01-app/02-guides/authentication.md). Layouts + * are also not re-rendered on client navigation. So each page gates itself; + * the actions and services it calls gate again. + * + * Uses `requireSecurityRole` (not `hasSecurityRole`) because this IS an + * enforcement point, so a refusal is logged. Only a refusal becomes a + * redirect to `/no-access`; an infrastructure failure (MP unreachable) still + * throws to the error boundary rather than masquerading as "no access". + * + * @returns the acting MP `User_ID` (callers normally ignore it — write + * attribution is assembled in the services). + */ +export async function requireToolAccess(context: AuthorizationContext): Promise { + try { + return await AuthorizationService.getInstance().requireSecurityRole(context); + } catch (err) { + if (err instanceof UnauthorizedError) redirect("/no-access"); + throw err; + } +} diff --git a/src/app/(web)/tools/template/page.test.tsx b/src/app/(web)/tools/template/page.test.tsx index 4645172..6db99f8 100644 --- a/src/app/(web)/tools/template/page.test.tsx +++ b/src/app/(web)/tools/template/page.test.tsx @@ -12,6 +12,12 @@ vi.mock('./template-tool', () => ({ TemplateTool: () => null, })); +const mockRequireToolAccess = vi.hoisted(() => vi.fn(async () => 42)); + +vi.mock('@/app/(web)/tools/require-tool-access', () => ({ + requireToolAccess: mockRequireToolAccess, +})); + import TemplateToolPage from './page'; describe('TemplateToolPage', () => { @@ -45,3 +51,18 @@ describe('TemplateToolPage', () => { ).rejects.toThrow('bad params'); }); }); + +describe('TemplateToolPage self-gate', () => { + it('gates before parsing params and renders nothing when refused', async () => { + // Adversarial: the tools layout's redirect does not stop a page segment + // rendering in Next 16, so the page itself must refuse. + mockParseToolParams.mockClear(); + mockRequireToolAccess.mockRejectedValueOnce(new Error('NEXT_REDIRECT:/no-access')); + + await expect(TemplateToolPage({ searchParams: Promise.resolve({}) })).rejects.toThrow( + 'NEXT_REDIRECT:/no-access', + ); + expect(mockRequireToolAccess).toHaveBeenCalledWith({ table: 'dp_Tools', operation: 'read' }); + expect(mockParseToolParams).not.toHaveBeenCalled(); + }); +}); diff --git a/src/app/(web)/tools/template/page.tsx b/src/app/(web)/tools/template/page.tsx index 37c2150..2f8e0c5 100644 --- a/src/app/(web)/tools/template/page.tsx +++ b/src/app/(web)/tools/template/page.tsx @@ -1,4 +1,5 @@ import { TemplateTool } from "./template-tool"; +import { requireToolAccess } from "@/app/(web)/tools/require-tool-access"; import { parseToolParams } from "@/lib/tool-params.server"; interface TemplatePageProps { @@ -6,6 +7,9 @@ interface TemplatePageProps { } export default async function TemplateToolPage({ searchParams }: TemplatePageProps) { + // Self-gating, before ANY param parsing or data call — see require-tool-access.ts. + await requireToolAccess({ table: "dp_Tools", operation: "read" }); + const params = await parseToolParams(await searchParams); return ; diff --git a/src/app/(web)/tools/templateeditor/page.test.tsx b/src/app/(web)/tools/templateeditor/page.test.tsx index e539270..d0809d1 100644 --- a/src/app/(web)/tools/templateeditor/page.test.tsx +++ b/src/app/(web)/tools/templateeditor/page.test.tsx @@ -12,6 +12,12 @@ vi.mock('./template-editor', () => ({ TemplateEditor: () => null, })); +const mockRequireToolAccess = vi.hoisted(() => vi.fn(async () => 42)); + +vi.mock('@/app/(web)/tools/require-tool-access', () => ({ + requireToolAccess: mockRequireToolAccess, +})); + import TemplateEditorPage from './page'; import { TemplateEditor } from './template-editor'; @@ -36,3 +42,18 @@ describe('TemplateEditorPage', () => { expect(element.props).toEqual({ params }); }); }); + +describe('TemplateEditorPage self-gate', () => { + it('gates before parsing params and renders nothing when refused', async () => { + // Adversarial: the tools layout's redirect does not stop a page segment + // rendering in Next 16, so the page itself must refuse. + mockParseToolParams.mockClear(); + mockRequireToolAccess.mockRejectedValueOnce(new Error('NEXT_REDIRECT:/no-access')); + + await expect(TemplateEditorPage({ searchParams: Promise.resolve({}) })).rejects.toThrow( + 'NEXT_REDIRECT:/no-access', + ); + expect(mockRequireToolAccess).toHaveBeenCalledWith({ table: 'dp_Tools', operation: 'read' }); + expect(mockParseToolParams).not.toHaveBeenCalled(); + }); +}); diff --git a/src/app/(web)/tools/templateeditor/page.tsx b/src/app/(web)/tools/templateeditor/page.tsx index fbaaad8..66bc9f1 100644 --- a/src/app/(web)/tools/templateeditor/page.tsx +++ b/src/app/(web)/tools/templateeditor/page.tsx @@ -1,4 +1,5 @@ import { TemplateEditor } from "./template-editor"; +import { requireToolAccess } from "@/app/(web)/tools/require-tool-access"; import { parseToolParams } from "@/lib/tool-params.server"; interface TemplateEditorPageProps { @@ -6,6 +7,9 @@ interface TemplateEditorPageProps { } export default async function TemplateEditorPage({ searchParams }: TemplateEditorPageProps) { + // Self-gating, before ANY param parsing or data call — see require-tool-access.ts. + await requireToolAccess({ table: "dp_Tools", operation: "read" }); + const params = await parseToolParams(await searchParams); return ; diff --git a/src/components/group-wizard/actions.test.ts b/src/components/group-wizard/actions.test.ts index 5e3b46c..a9ead26 100644 --- a/src/components/group-wizard/actions.test.ts +++ b/src/components/group-wizard/actions.test.ts @@ -397,3 +397,89 @@ describe('updateGroup', () => { expect(result).toEqual({ success: false, error: 'Failed to update group' }); }); }); + +describe('server-side payload parsing (mass assignment)', () => { + const signedIn = { + user: { id: 'user-1', userGuid: '550e8400-e29b-41d4-a716-446655440000' }, + }; + + it('createGroup strips keys the wizard schema does not define before calling the service', async () => { + // Adversarial: the action is a POST endpoint; the client zodResolver never + // runs for a hand-built request. Unknown keys must not reach the service. + mockGetSession.mockResolvedValueOnce(signedIn); + mockCreateGroup.mockResolvedValueOnce({ Group_ID: 200, Group_Name: 'Test Group' }); + + const hostile = { + ...BASE_FORM, + Group_ID: 999, + _Last_Attendance_Posted: '2020-01-01', + Domain_ID: 7, + $userId: 1, + } as unknown as GroupWizardFormData; + + const result = await createGroup(hostile); + + expect(result).toEqual({ success: true, groupId: 200, groupName: 'Test Group' }); + const sent = mockCreateGroup.mock.calls[0][0] as Record; + expect(sent).toEqual(BASE_FORM); + for (const key of ['Group_ID', '_Last_Attendance_Posted', 'Domain_ID', '$userId']) { + expect(Object.hasOwn(sent, key)).toBe(false); + } + }); + + it('updateGroup strips a smuggled Group_ID and unknown columns before calling the service', async () => { + mockGetSession.mockResolvedValueOnce(signedIn); + mockUpdateGroup.mockResolvedValueOnce({ Group_ID: 100, Group_Name: 'Updated' }); + + await updateGroup(100, { + ...BASE_FORM, + Group_ID: 999, + Domain_ID: 7, + } as unknown as GroupWizardFormData); + + expect(mockUpdateGroup).toHaveBeenCalledWith(100, BASE_FORM); + }); + + it.each([ + ['createGroup', () => createGroup({ ...BASE_FORM, Group_Name: '' })], + ['updateGroup', () => updateGroup(100, { ...BASE_FORM, Group_Name: '' })], + ])('%s refuses a payload that fails the schema, naming fields not values', async (_n, call) => { + mockGetSession.mockResolvedValueOnce(signedIn); + + const result = await call(); + + expect(result).toEqual({ success: false, error: 'Invalid group data: Group_Name' }); + expect(mockCreateGroup).not.toHaveBeenCalled(); + expect(mockUpdateGroup).not.toHaveBeenCalled(); + }); + + it('refuses a wrongly-typed field without echoing the submitted value', async () => { + mockGetSession.mockResolvedValueOnce(signedIn); + + const result = await createGroup({ + ...BASE_FORM, + Congregation_ID: '1; DROP TABLE Groups' as unknown as number, + }); + + expect(result).toEqual({ success: false, error: 'Invalid group data: Congregation_ID' }); + expect(JSON.stringify(result)).not.toContain('DROP'); + expect(mockCreateGroup).not.toHaveBeenCalled(); + }); + + it('refuses a non-object payload', async () => { + mockGetSession.mockResolvedValueOnce(signedIn); + + const result = await createGroup(null as unknown as GroupWizardFormData); + + expect(result).toEqual({ success: false, error: 'Invalid group data: (root)' }); + expect(mockCreateGroup).not.toHaveBeenCalled(); + }); + + it('gates BEFORE parsing: an unauthorized caller learns nothing about the schema', async () => { + mockRequireSecurityRole.mockRejectedValueOnce(new Error('Not authorized')); + + const result = await createGroup({ ...BASE_FORM, Group_Name: '' }); + + expect(result).toEqual({ success: false, error: 'Not authorized' }); + }); +}); diff --git a/src/components/group-wizard/actions.ts b/src/components/group-wizard/actions.ts index 59328a8..661826f 100644 --- a/src/components/group-wizard/actions.ts +++ b/src/components/group-wizard/actions.ts @@ -10,7 +10,7 @@ import type { UpdateGroupResult, ActionError, } from './types'; -import type { GroupWizardFormData } from './schema'; +import { groupWizardSchema, type GroupWizardFormData } from './schema'; /** * Authorization gate for this feature's server actions. @@ -32,6 +32,23 @@ async function requireAccess( return AuthorizationService.getInstance().requireSecurityRole({ table, operation }); } +/** + * Server-side parse of the wizard payload. The client's zodResolver is a UX + * nicety, not a control: this action is a POST endpoint and its body is + * whatever the caller sends. Zod's object parse also strips unknown keys, + * and the service applies its own column allowlist on top (defence in depth). + * + * The error names the offending FIELDS only, never their values (CLAUDE.md + * rule 14 — thrown/returned messages travel further than logs do). + */ +function parseGroupPayload( + data: unknown, +): { ok: true; data: GroupWizardFormData } | { ok: false; error: string } { + const parsed = groupWizardSchema.safeParse(data); + if (parsed.success) return { ok: true, data: parsed.data }; + const fields = [...new Set(parsed.error.issues.map((i) => i.path.join('.') || '(root)'))]; + return { ok: false, error: `Invalid group data: ${fields.join(', ')}` }; +} export async function fetchGroupWizardLookups(): Promise { await requireAccess('Groups', 'read'); @@ -79,8 +96,10 @@ export async function createGroup( ): Promise { try { await requireAccess('Groups', 'create'); + const parsed = parseGroupPayload(data); + if (!parsed.ok) return { success: false, error: parsed.error }; const service = await GroupService.getInstance(); - const result = await service.createGroup(data); + const result = await service.createGroup(parsed.data); return { success: true, groupId: result.Group_ID, groupName: result.Group_Name }; } catch (error) { return { success: false, error: error instanceof Error ? error.message : 'Failed to create group' }; @@ -93,8 +112,11 @@ export async function updateGroup( ): Promise { try { await requireAccess('Groups', 'update'); + const parsed = parseGroupPayload(data); + if (!parsed.ok) return { success: false, error: parsed.error }; const service = await GroupService.getInstance(); - const result = await service.updateGroup(groupId, data); + // The service validates groupId (positive integer) and writes it last. + const result = await service.updateGroup(groupId, parsed.data); return { success: true, groupId: result.Group_ID, groupName: result.Group_Name }; } catch (error) { return { success: false, error: error instanceof Error ? error.message : 'Failed to update group' }; diff --git a/src/components/shared-actions/domain.test.ts b/src/components/shared-actions/domain.test.ts index 81800f0..678c967 100644 --- a/src/components/shared-actions/domain.test.ts +++ b/src/components/shared-actions/domain.test.ts @@ -8,6 +8,16 @@ const { mockGetMpTimezone, mockGetInstance } = vi.hoisted(() => { }; }); +const { mockGetSession } = vi.hoisted(() => ({ mockGetSession: vi.fn() })); + +vi.mock('@/lib/auth', () => ({ + auth: { api: { getSession: mockGetSession } }, +})); + +vi.mock('next/headers', () => ({ + headers: vi.fn().mockResolvedValue(new Headers()), +})); + vi.mock('@/services/domainTimezoneService', () => ({ DomainTimezoneService: { getInstance: mockGetInstance, @@ -19,12 +29,28 @@ import { getMpTimezone } from './domain'; /** * This action is one of the documented authorization carve-outs in CLAUDE.md * rule 12: it returns a single domain-wide configuration string (the IANA - * time zone) and exposes no per-record data, so it is deliberately ungated. + * time zone) and exposes no per-record data, so it is not ROLE-gated — but it + * does require a session (F11), since a server action is a POST endpoint. */ describe('getMpTimezone', () => { beforeEach(() => { vi.clearAllMocks(); + mockGetSession.mockResolvedValue({ user: { id: 'ba-1' } }); + }); + + it.each([ + ['no session', null], + ['a session with no user', {}], + ['a user with no id', { user: {} }], + ])('REFUSES with %s and never touches MP (F11)', async (_label, session) => { + // Adversarial: before F11 this action had no check at all and answered + // an unauthenticated POST. + mockGetSession.mockResolvedValueOnce(session); + + await expect(getMpTimezone()).rejects.toThrow('Authentication required'); + expect(mockGetInstance).not.toHaveBeenCalled(); + expect(mockGetMpTimezone).not.toHaveBeenCalled(); }); it('returns the IANA zone from DomainTimezoneService', async () => { diff --git a/src/components/shared-actions/domain.ts b/src/components/shared-actions/domain.ts index c75c1dc..a1a3fdf 100644 --- a/src/components/shared-actions/domain.ts +++ b/src/components/shared-actions/domain.ts @@ -1,5 +1,7 @@ 'use server'; +import { headers } from 'next/headers'; +import { auth } from '@/lib/auth'; import { DomainTimezoneService } from '@/services/domainTimezoneService'; /** @@ -8,9 +10,28 @@ import { DomainTimezoneService } from '@/services/domainTimezoneService'; * of MP-sourced datetime values so the displayed wall-clock matches MP's * database regardless of the user's browser zone. * + * AUTHORIZATION CARVE-OUT (CLAUDE.md rule 12) — written justification: + * this requires an authenticated session but deliberately NOT the + * `AuthorizationService` role gate, because + * 1. the value is ONE domain-wide configuration string (e.g. + * "America/New_York"), not per-person or per-record MP data; + * 2. its only consumers are tool pages that are themselves role-gated + * (the group wizard page gates before calling it); and + * 3. role-gating it would add a second MP round-trip to every page load + * for no confidentiality gain. + * It must still check the session (F11, ported 2026-09-30): a compiled server + * action is a callable POST endpoint, and this was reachable with no session + * at all. The check is on `session.user.id` — presence of a Better Auth + * session — which is all a carve-out of this kind needs. + * * Result is cached for the lifetime of the server process. */ export async function getMpTimezone(): Promise { + const session = await auth.api.getSession({ headers: await headers() }); + if (!session?.user?.id) { + throw new Error('Authentication required'); + } + const tz = DomainTimezoneService.getInstance(); return tz.getMpTimezone(); } diff --git a/src/lib/tool-params.server.ts b/src/lib/tool-params.server.ts index eade546..4a1907b 100644 --- a/src/lib/tool-params.server.ts +++ b/src/lib/tool-params.server.ts @@ -54,9 +54,9 @@ export async function parseToolParams(searchParams: URLSearchParams | { [key: st const toolService = await ToolService.getInstance(); pageData = await toolService.getPageData(parsedPageID) || undefined; } catch { - // Identifier only. A caller without an MP security role also lands - // here — the tools layout redirects them to /no-access, and the page - // simply renders without page metadata in the meantime. + // Identifier only. Every tool page gates (requireToolAccess) before + // calling this, so a refusal here is unexpected; the page simply + // renders without page metadata. console.warn('tool_params.page_data_unavailable', { pageID: parsedPageID }); pageData = undefined; } diff --git a/src/lib/validation.test.ts b/src/lib/validation.test.ts index d1d3487..2bc3db5 100644 --- a/src/lib/validation.test.ts +++ b/src/lib/validation.test.ts @@ -1,5 +1,13 @@ import { describe, it, expect } from 'vitest'; -import { validateGuid, validatePositiveInt, validateColumnName, escapeFilterString, validateMailerId } from './validation'; +import { + validateGuid, + validatePositiveInt, + validateColumnName, + validateFkPath, + escapeFilterString, + validateMailerId, + MAX_SEARCH_TERM_LENGTH, +} from './validation'; describe('validation', () => { describe('validateGuid', () => { @@ -68,6 +76,37 @@ describe('validation', () => { }); }); + describe('validateFkPath', () => { + it.each([ + 'Contact_ID', + 'Participant_ID_TABLE.Contact_ID', + 'Participant_ID_Table.Contact_ID', + 'Building_ID_TABLE_Location_ID_TABLE.Congregation_ID', + 'A_TABLE.B_TABLE.Contact_ID', + ])('accepts %j', (path) => { + expect(validateFkPath(path)).toBe(path); + }); + + it.each([ + '', + 'Contact_ID, Password', + 'Participant_ID_TABLE.Contact_ID AS X', + 'Participant_ID.Contact_ID', + '.Contact_ID', + 'Participant_ID_TABLE.', + 'A_TABLE.B_TABLE.C_TABLE.D_TABLE.E_TABLE.Contact_ID', + 'x'.repeat(257), + 42, + null, + ])('refuses %j, naming the field and never the value', (bad) => { + expect(() => validateFkPath(bad, 'Contact_ID_Field')).toThrow(/^Invalid Contact_ID_Field$/); + }); + + it('uses a generic field name by default', () => { + expect(() => validateFkPath('a b')).toThrow('Invalid column path'); + }); + }); + describe('validateMailerId', () => { it('accepts a 6-digit mailer ID', () => { expect(validateMailerId('123456')).toBe('123456'); @@ -110,5 +149,91 @@ describe('validation', () => { it('returns empty string unchanged', () => { expect(escapeFilterString('')).toBe(''); }); + + it('escapes [ so it cannot open a LIKE character class', () => { + // Unescaped, `[a-z]` would match any single letter instead of itself. + expect(escapeFilterString('[a-z]')).toBe('[[]a-z]'); + }); + + it('escapes [ before % and _ so their bracket escapes are not re-escaped', () => { + expect(escapeFilterString('[%_')).toBe('[[][%][_]'); + }); + + it('turns quote look-alikes into the single-char wildcard, never a raw quote', () => { + expect(escapeFilterString('O’Brien')).toBe('O_Brien'); + expect(escapeFilterString('‘‛ʼ'')).toBe('____'); + }); + + it.each([['array', ['x']], ['object', { a: 1 }], ['number', 1], ['null', null]])( + 'refuses a non-string (%s) rather than stringifying it into the filter', + (_label, value) => { + expect(() => escapeFilterString(value as unknown as string)).toThrow( + 'Invalid search value: expected a string', + ); + }, + ); + + it.each(['a\u0000b', 'line\nbreak', 'tab\there', 'del\u007F'])( + 'refuses control characters (%j)', + (value) => { + expect(() => escapeFilterString(value)).toThrow( + 'Invalid search value: control characters are not allowed', + ); + }, + ); + + it('caps the search term length', () => { + expect(escapeFilterString('a'.repeat(MAX_SEARCH_TERM_LENGTH))).toHaveLength(MAX_SEARCH_TERM_LENGTH); + expect(() => escapeFilterString('a'.repeat(MAX_SEARCH_TERM_LENGTH + 1))).toThrow( + /longer than 100 characters/, + ); + }); + + it('never echoes the refused value', () => { + const err = (() => { + try { + escapeFilterString("secret'" + String.fromCharCode(10) + '--'); + } catch (e) { + return e as Error; + } + })(); + expect(err?.message).not.toContain('secret'); + }); + }); + + describe('error messages name the field, never the value (rule 14)', () => { + const NEWLINE = String.fromCharCode(10); + const HOSTILE = `evil'${NEWLINE}{"event":"forged"}`; + + it.each([ + ['validateGuid', () => validateGuid(HOSTILE)], + ['validateColumnName', () => validateColumnName(HOSTILE)], + ['validatePositiveInt', () => validatePositiveInt(HOSTILE as unknown as number)], + ['validateFkPath', () => validateFkPath(HOSTILE)], + ])('%s', (_name, call) => { + let message = ''; + try { + call(); + } catch (e) { + message = (e as Error).message; + } + expect(message).not.toBe(''); + expect(message).not.toContain('evil'); + expect(message).not.toContain(NEWLINE); + }); + + it('validatePositiveInt names the field when given one', () => { + expect(() => validatePositiveInt(0, 'groupId')).toThrow('Expected positive integer for groupId'); + }); + + it('validatePositiveInt refuses unsafe integers', () => { + expect(() => validatePositiveInt(2 ** 53)).toThrow('Expected positive integer'); + }); + + it('validateGuid refuses a non-string that would stringify into a valid GUID', () => { + expect(() => + validateGuid(['550e8400-e29b-41d4-a716-446655440000'] as unknown as string), + ).toThrow('Invalid GUID format'); + }); }); }); diff --git a/src/lib/validation.ts b/src/lib/validation.ts index 20a1baf..22eeb73 100644 --- a/src/lib/validation.ts +++ b/src/lib/validation.ts @@ -1,24 +1,78 @@ +/** + * Input validators for values interpolated into Ministry Platform `$filter` / + * `$select` strings. + * + * ERROR MESSAGES NAME THE FIELD, NEVER THE VALUE (CLAUDE.md rule 14). The + * value is caller-controlled; a thrown message travels further than a log + * line does (action error results, error boundaries, log aggregators), and a + * value with a newline in it can forge a structured log line wherever the + * message is logged. + * + * Every validator also checks the runtime type: TypeScript's `string` / + * `number` annotations are erased, and server-action payloads are + * caller-shaped, so an array or object must be refused, not coerced. + */ + const GUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; const COLUMN_NAME_REGEX = /^[A-Za-z_][A-Za-z0-9_]*$/; const MAILER_ID_REGEX = /^(\d{6}|\d{9})$/; export function validateGuid(value: string): string { - if (!GUID_REGEX.test(value)) { - throw new Error(`Invalid GUID format: ${value}`); + if (typeof value !== 'string' || !GUID_REGEX.test(value)) { + throw new Error('Invalid GUID format'); } return value; } -export function validatePositiveInt(value: number): number { - if (!Number.isInteger(value) || value <= 0) { - throw new Error(`Expected positive integer, got: ${value}`); +/** + * A positive SAFE integer — the safe-integer bound keeps a huge value from + * stringifying into exponent notation (`1e+21`) inside a filter. + * + * @param field - optional name for the error message (never the value) + */ +export function validatePositiveInt(value: number, field?: string): number { + if (typeof value !== 'number' || !Number.isSafeInteger(value) || value <= 0) { + throw new Error(field ? `Expected positive integer for ${field}` : 'Expected positive integer'); } return value; } export function validateColumnName(value: string): string { - if (!COLUMN_NAME_REGEX.test(value)) { - throw new Error(`Invalid column name: ${value}`); + if (typeof value !== 'string' || !COLUMN_NAME_REGEX.test(value)) { + throw new Error('Invalid column name'); + } + return value; +} + +/** A foreign-key traversal segment: an identifier ending in `_TABLE` (any case). */ +const FK_SEGMENT_REGEX = /^[A-Za-z][A-Za-z0-9_]*_TABLE$/i; +const MAX_FK_PATH_LENGTH = 256; +const MAX_FK_SEGMENTS = 4; + +/** + * Validate a column reference that may traverse foreign keys, for use in a + * `$select` — e.g. `Contact_ID`, `Participant_ID_TABLE.Contact_ID`, + * `Participant_ID_Table.Contact_ID` (the casing `api_Tools_GetPageData` + * returns), or the underscore-chained `A_ID_TABLE_B_ID_TABLE.Column`. + * + * Every segment before the final column must be an identifier ending in + * `_TABLE`; the final column must be a plain identifier. Nothing else — no + * spaces, commas, parentheses, quotes, `AS`, or functions — so the value can + * never add a second column, an alias, or an expression to the `$select`. + * The error names the field, never the value (CLAUDE.md rule 14). + */ +export function validateFkPath(value: unknown, field = 'column path'): string { + if (typeof value !== 'string' || value.length === 0 || value.length > MAX_FK_PATH_LENGTH) { + throw new Error(`Invalid ${field}`); + } + const segments = value.split('.'); + const column = segments.pop() as string; + if ( + segments.length > MAX_FK_SEGMENTS || + !COLUMN_NAME_REGEX.test(column) || + !segments.every((s) => FK_SEGMENT_REGEX.test(s)) + ) { + throw new Error(`Invalid ${field}`); } return value; } @@ -35,9 +89,56 @@ export function validateMailerId(value: string): string { return value; } +/** Longest search term accepted by {@link escapeFilterString}. */ +export const MAX_SEARCH_TERM_LENGTH = 100; + +/** + * ASCII control characters (C0 range and DEL). None belongs in a search + * term; a NUL or newline inside a filter string is at best an accident and at + * worst an attempt to confuse something downstream of it. + */ +const CONTROL_CHARS = /[\u0000-\u001F\u007F]/; + +/** + * Non-ASCII look-alikes of the single quote (left/right single quotation + * marks, single high-reversed-9 quote, modifier-letter apostrophe, fullwidth + * apostrophe). Doubling `'` escapes only U+0027; if anything between here and + * SQL Server ever narrowed one of these to `'` (an implicit NVARCHAR→VARCHAR + * conversion, say) it would arrive unescaped. Each becomes the LIKE + * single-character wildcard `_`, which can never be narrowed into a quote and + * keeps a smart-quote search working (`O’Brien` matches `O'Brien`). + */ +const QUOTE_LOOKALIKES = /[‘’‛ʼ']/g; + +/** + * Escape a search term for a T-SQL `LIKE` pattern inside a single-quoted + * filter value, e.g. `Display_Name LIKE '${escapeFilterString(term)}%'`, so + * the term matches literally. + * + * Uses bracket escaping (no `ESCAPE` clause needed): `[` → `[[]` FIRST (it + * opens a character class such as `[0-9]`, and escaping it after `%`/`_` + * would re-escape their brackets), then `%` → `[%]`, `_` → `[_]`, then `'` is + * doubled, then quote look-alikes become `_`. + * + * Throws on a non-string, on a term longer than + * {@link MAX_SEARCH_TERM_LENGTH}, and on any ASCII control character. Errors + * describe the problem, never the value. + */ export function escapeFilterString(value: string): string { + if (typeof value !== 'string') { + throw new Error('Invalid search value: expected a string'); + } + if (value.length > MAX_SEARCH_TERM_LENGTH) { + throw new Error(`Invalid search value: longer than ${MAX_SEARCH_TERM_LENGTH} characters`); + } + if (CONTROL_CHARS.test(value)) { + throw new Error('Invalid search value: control characters are not allowed'); + } return value - .replace(/'/g, "''") + .replace(/\[/g, '[[]') .replace(/%/g, '[%]') - .replace(/_/g, '[_]'); + .replace(/_/g, '[_]') + .replace(/'/g, "''") + // After the `_` escape above, so this wildcard is the only unescaped one. + .replace(QUOTE_LOOKALIKES, '_'); } diff --git a/src/services/addressLabelService.ts b/src/services/addressLabelService.ts index d4d5031..325a7ec 100644 --- a/src/services/addressLabelService.ts +++ b/src/services/addressLabelService.ts @@ -79,7 +79,7 @@ export class AddressLabelService { for (let i = 0; i < contactIds.length; i += MP_FETCH_BATCH_SIZE) { const batch = contactIds.slice(i, i + MP_FETCH_BATCH_SIZE); - batch.forEach(validatePositiveInt); + batch.forEach((id) => validatePositiveInt(id)); const idList = batch.join(', '); const rows = await this.mp!.getTableRecords({ diff --git a/src/services/authorizationService.test.ts b/src/services/authorizationService.test.ts index ba466fa..b999336 100644 --- a/src/services/authorizationService.test.ts +++ b/src/services/authorizationService.test.ts @@ -26,7 +26,7 @@ vi.mock('react', async () => { return { ...actual, cache: (fn: T) => fn }; }); -import { AuthorizationService, UnauthorizedError } from './authorizationService'; +import { AuthorizationService, UnauthorizedError, resolveRolePolicy } from './authorizationService'; const GUID = '550e8400-e29b-41d4-a716-446655440000'; const signedIn = { user: { id: 'ba-1', userGuid: GUID } }; @@ -44,12 +44,15 @@ describe('AuthorizationService', () => { beforeEach(() => { vi.clearAllMocks(); (AuthorizationService as unknown as { instance?: unknown }).instance = undefined; - delete process.env.MP_SECURITY_ROLES; + // `*` = any MP security role. Tests of the unset / blank / list policies + // override this explicitly; see 'MP_SECURITY_ROLES policy (fails closed)'. + process.env.MP_SECURITY_ROLES = '*'; vi.spyOn(console, 'warn').mockImplementation(() => {}); }); afterEach(() => { vi.restoreAllMocks(); + delete process.env.MP_SECURITY_ROLES; }); const gate = () => AuthorizationService.getInstance(); @@ -57,7 +60,7 @@ describe('AuthorizationService', () => { const writeCtx = { table: 'Groups', operation: 'update' as const }; describe('requireSecurityRole', () => { - it('returns the acting MP User_ID when the user holds any role and none are configured', async () => { + it('returns the acting MP User_ID when the user holds any role and MP_SECURITY_ROLES is *', async () => { mockGetSession.mockResolvedValue(signedIn); mpReturns(42, ['Administrators']); @@ -205,6 +208,85 @@ describe('AuthorizationService', () => { }); }); + describe('MP_SECURITY_ROLES policy (fails closed)', () => { + // Adversarial: before 2026-09-30 a blank/unset value meant "any role will + // do", so a deployment that forgot the variable silently let every MP user + // holding ANY security role in. Each refusal below fails if that returns. + it.each([ + ['unset', undefined], + ['blank', ''], + ['whitespace', ' '], + ['only commas', ','], + ['commas and spaces', ' , , '], + ])('REFUSES everyone when the variable is %s', async (_label, value) => { + if (value === undefined) delete process.env.MP_SECURITY_ROLES; + else process.env.MP_SECURITY_ROLES = value; + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + mockGetSession.mockResolvedValue(signedIn); + mpReturns(42, ['Administrators']); + + await expect(gate().requireSecurityRole(readCtx)).rejects.toThrow(UnauthorizedError); + await expect(gate().hasSecurityRole()).resolves.toBe(false); + expect(warn).toHaveBeenCalledWith( + 'mp.read.unauthorized', + expect.objectContaining({ reason: 'roles_not_configured', userId: 42 }), + ); + // No role read is wasted on an app that refuses everyone. + expect(mockGetTableRecords).not.toHaveBeenCalledWith( + expect.objectContaining({ table: 'dp_User_Roles' }), + ); + }); + + it('permits any role holder when the variable is * (surrounding whitespace ignored)', async () => { + process.env.MP_SECURITY_ROLES = ' * '; + mockGetSession.mockResolvedValue(signedIn); + mpReturns(42, ['Some Unrelated Role']); + + await expect(gate().requireSecurityRole(readCtx)).resolves.toBe(42); + }); + + it('still refuses a user with NO role when the variable is *', async () => { + process.env.MP_SECURITY_ROLES = '*'; + mockGetSession.mockResolvedValue(signedIn); + mpReturns(42, []); + + await expect(gate().requireSecurityRole(readCtx)).rejects.toThrow(UnauthorizedError); + }); + + it('treats * inside a list as a literal name, not a wildcard', async () => { + process.env.MP_SECURITY_ROLES = 'Administrators,*'; + mockGetSession.mockResolvedValue(signedIn); + mpReturns(42, ['Some Unrelated Role']); + + await expect(gate().requireSecurityRole(readCtx)).rejects.toThrow(UnauthorizedError); + }); + + it('ignores empty entries in a list', async () => { + process.env.MP_SECURITY_ROLES = ',Tools Users,,'; + mockGetSession.mockResolvedValue(signedIn); + mpReturns(42, ['tools users']); + + await expect(gate().requireSecurityRole(readCtx)).resolves.toBe(42); + }); + }); + + describe('resolveRolePolicy', () => { + it.each([ + ['', { kind: 'unconfigured' }], + [',', { kind: 'unconfigured' }], + ['*', { kind: 'any' }], + [' A , b ', { kind: 'list', names: ['a', 'b'] }], + ['A,*', { kind: 'list', names: ['a', '*'] }], + ])('parses %j', (raw, expected) => { + expect(resolveRolePolicy(raw)).toEqual(expected); + }); + + it('reads MP_SECURITY_ROLES when called without an argument, unset = unconfigured', () => { + delete process.env.MP_SECURITY_ROLES; + expect(resolveRolePolicy()).toEqual({ kind: 'unconfigured' }); + }); + }); + it('escapes the userGuid into the dp_Users filter via validateGuid', async () => { // A malformed/injected guid must be rejected before it reaches a $filter. mockGetSession.mockResolvedValue({ user: { id: 'ba-1', userGuid: "' OR 1=1--" } }); diff --git a/src/services/authorizationService.ts b/src/services/authorizationService.ts index e77415a..6d198e6 100644 --- a/src/services/authorizationService.ts +++ b/src/services/authorizationService.ts @@ -63,22 +63,53 @@ const loadSecurityRoles = cache(async (userId: number): Promise => { .filter((n): n is string => typeof n === "string" && n.trim().length > 0); }); +/** The whole-value wildcard meaning "any MP security role". */ +const ANY_ROLE = "*"; + /** - * Roles permitted to use gated features, from `MP_SECURITY_ROLES` - * (comma-separated). Unset or blank means "any MP security role will do", which - * lets a deployment tighten access without a code change. + * The effective role policy, from `MP_SECURITY_ROLES`. + * + * - `any`: holding at least one MP security role is sufficient (`*`). + * - `list`: the user must hold one of `names` (normalized, case-insensitive). + * - `unconfigured`: no usable policy — NOBODY is permitted. + * + * FAILS CLOSED (2026-09-30, ported from upstream's 2026-09-28 review): unset, + * blank, or a value that parses to no role names (e.g. `","`) permits nobody. + * "Any role will do" used to be what a blank value silently meant; it is now + * something an operator has to opt into with `*`. `*` means "any role" only as + * the WHOLE value — inside a list it is just a (non-matching) name, so + * `"Administrators,*"` does not widen the gate. + * + * Read per call, not at module load, so tests and a redeployed env var take + * effect without a restart of anything else. */ -function configuredRoles(): string[] { - return (process.env.MP_SECURITY_ROLES ?? "") +export type RolePolicy = + | { kind: "any" } + | { kind: "list"; names: string[] } + | { kind: "unconfigured" }; + +export function resolveRolePolicy(raw = process.env.MP_SECURITY_ROLES): RolePolicy { + const trimmed = raw?.trim() ?? ""; + if (!trimmed) return { kind: "unconfigured" }; + if (trimmed === ANY_ROLE) return { kind: "any" }; + const names = trimmed .split(",") .map((r) => r.trim().toLowerCase()) .filter((r) => r.length > 0); + return names.length > 0 ? { kind: "list", names } : { kind: "unconfigured" }; } interface Decision { permitted: boolean; userId: number | null; - reason: "ok" | "no_session" | "no_user_guid" | "no_mp_user" | "no_role"; + reason: + | "ok" + | "no_session" + | "no_user_guid" + | "no_mp_user" + | "no_role" + /** `MP_SECURITY_ROLES` is unset, blank, or names no role — nobody passes. */ + | "roles_not_configured"; } /** @@ -94,7 +125,8 @@ interface Decision { * through field management. * * POLICY: any MP user may sign in and use the app shell. The MP-data tools - * require an MP security role. Sign-in is deliberately NOT role-gated — a + * require an MP security role named in `MP_SECURITY_ROLES` (or any role when + * it is `*`); with no roles configured, nobody may use them. Sign-in is deliberately NOT role-gated — a * role-less user still gets a session, the header and a working sign-out, * because refusing at sign-in strands them with no way out. */ @@ -133,19 +165,24 @@ export class AuthorizationService { return { permitted: false, userId: null, reason: "no_mp_user" }; } + // Checked before the role read: an unconfigured app refuses everyone, and + // the MP round-trip would be wasted. + const policy = resolveRolePolicy(); + if (policy.kind === "unconfigured") { + return { permitted: false, userId, reason: "roles_not_configured" }; + } + const roles = await loadSecurityRoles(userId); if (roles.length === 0) { return { permitted: false, userId, reason: "no_role" }; } - const allowed = configuredRoles(); - if (allowed.length === 0) { - // Blank config: holding any MP security role is sufficient. + if (policy.kind === "any") { + // `*`: holding any MP security role is sufficient. return { permitted: true, userId, reason: "ok" }; } - const held = roles.map((r) => r.toLowerCase()); - const match = held.some((r) => allowed.includes(r)); + const match = roles.some((r) => policy.names.includes(r.trim().toLowerCase())); return match ? { permitted: true, userId, reason: "ok" } : { permitted: false, userId, reason: "no_role" }; diff --git a/src/services/domainTimezoneService.test.ts b/src/services/domainTimezoneService.test.ts index 4069eac..a13135a 100644 --- a/src/services/domainTimezoneService.test.ts +++ b/src/services/domainTimezoneService.test.ts @@ -161,8 +161,11 @@ describe('DomainTimezoneService', () => { * of bug CLAUDE.md rule 16 exists to prevent, so they are pinned explicitly. */ describe('DomainTimezoneService guard clauses', () => { + const NEWLINE = String.fromCharCode(10); beforeEach(() => { - vi.clearAllMocks(); + // mockReset (not clearAllMocks) so a leftover mockResolvedValueOnce queue + // from an earlier test cannot leak into these. + mockGetDomainInfo.mockReset(); }); it('rejects a whitespace-only time zone identifier', () => { @@ -173,10 +176,36 @@ describe('DomainTimezoneService guard clauses', () => { const service = freshService(); await expect(service.parseMpDatetime('not-a-date')).rejects.toThrow( - /unable to parse "not-a-date"/, + 'parseMpDatetime: value could not be parsed as a date', ); }); + // Log injection (ported from upstream's 2026-09-28 review): the values are + // caller-controlled (group wizard dates), and the messages used to embed + // them verbatim — newline and all — so wherever an error was logged a + // caller could forge a separate structured log line. + it('toMpSqlDatetime does not echo the unparseable value in its error', async () => { + const service = freshService(); + const input = 'x' + NEWLINE + '{"event":"mp.write.unauthorized","reason":"forged"}'; + + const error = await service.toMpSqlDatetime(input).catch((e: Error) => e); + + expect(error).toBeInstanceOf(Error); + expect((error as Error).message).toBe('toMpSqlDatetime: value could not be parsed as a date'); + expect((error as Error).message).not.toContain('mp.write.unauthorized'); + expect((error as Error).message).not.toContain(NEWLINE); + }); + + it('parseMpDatetime does not echo the unparseable value in its error', async () => { + const service = freshService(); + + const error = await service.parseMpDatetime('secret' + NEWLINE + 'value+05:00').catch((e: Error) => e); + + expect(error).toBeInstanceOf(Error); + expect((error as Error).message).not.toContain('secret'); + expect((error as Error).message).not.toContain(NEWLINE); + }); + it('parses a non-wall-clock but valid datetime through the Date fallback', async () => { const service = freshService(); diff --git a/src/services/domainTimezoneService.ts b/src/services/domainTimezoneService.ts index a568ba8..5d8a655 100644 --- a/src/services/domainTimezoneService.ts +++ b/src/services/domainTimezoneService.ts @@ -291,7 +291,9 @@ export class DomainTimezoneService { } const parsed = new Date(value); if (Number.isNaN(parsed.getTime())) { - throw new Error(`toMpSqlDatetime: unable to parse "${value}"`); + // Never echo the value: it is caller-controlled (a form field), and a + // newline in it would forge a log line wherever this is logged. + throw new Error('toMpSqlDatetime: value could not be parsed as a date'); } const iana = await this.getMpTimezone(); return formatInstantAsMpSql(parsed, iana); @@ -307,7 +309,8 @@ export class DomainTimezoneService { if (!wallClock) { const direct = new Date(value); if (Number.isNaN(direct.getTime())) { - throw new Error(`parseMpDatetime: unable to parse "${value}"`); + // Never echo the value (CLAUDE.md rule 14). + throw new Error('parseMpDatetime: value could not be parsed as a date'); } return direct; } diff --git a/src/services/familyService.test.ts b/src/services/familyService.test.ts index 447912a..071bc6b 100644 --- a/src/services/familyService.test.ts +++ b/src/services/familyService.test.ts @@ -176,7 +176,70 @@ describe("FamilyService", () => { const service = await FamilyService.getInstance(); await expect( service.resolveContactIdFromPage("Event_Participants", "Event_Participant_ID", 10, "bad; name"), - ).rejects.toThrow("Invalid column name"); + ).rejects.toThrow("Invalid Contact_ID_Field"); + }); + + // Adversarial (query injection): anything containing `_TABLE` used to go + // into $select verbatim, so a caller could append columns, aliases or + // expressions, or read a different table's column. Each must be refused + // before any MP call, and without echoing the hostile value. + it.each([ + "Participant_ID_TABLE.Contact_ID, dp_Users_TABLE.Password", + "Participant_ID_TABLE.Contact_ID AS X", + "(SELECT TOP 1 User_ID FROM dp_Users) AS Participant_ID_TABLE", + "Participant_ID_TABLE.Contact_ID--", + "Participant_ID_TABLE..Contact_ID", + "Participant_ID.Contact_ID", + "Participant_ID_TABLE.", + "A_TABLE.B_TABLE.C_TABLE.D_TABLE.E_TABLE.Contact_ID", + "x_TABLE." + "a".repeat(300), + ])("refuses the hostile contactIdField %j before any MP call", async (hostile) => { + const service = await FamilyService.getInstance(); + const err = await service + .resolveContactIdFromPage("Event_Participants", "Event_Participant_ID", 10, hostile) + .catch((e: Error) => e); + expect(err).toBeInstanceOf(Error); + expect((err as Error).message).toBe("Invalid Contact_ID_Field"); + expect(mockGetTableRecords).not.toHaveBeenCalled(); + expect(mockRequireSecurityRole).not.toHaveBeenCalled(); + }); + + it.each(["dp_Users; --", "Contacts WHERE 1=1", "", 42 as unknown as string])( + "refuses a hostile tableName %j before any MP call", + async (hostile) => { + const service = await FamilyService.getInstance(); + await expect( + service.resolveContactIdFromPage(hostile, "Event_Participant_ID", 10, "Contact_ID"), + ).rejects.toThrow(); + expect(mockGetTableRecords).not.toHaveBeenCalled(); + }, + ); + + it("refuses a non-string contactIdField", async () => { + const service = await FamilyService.getInstance(); + await expect( + service.resolveContactIdFromPage("Event_Participants", "Event_Participant_ID", 10, ["Contact_ID"] as unknown as string), + ).rejects.toThrow("Invalid Contact_ID_Field"); + expect(mockGetTableRecords).not.toHaveBeenCalled(); + }); + + it.each([ + "Participant_ID_TABLE.Contact_ID", + "Participant_ID_Table.Contact_ID", + "Building_ID_TABLE_Location_ID_TABLE.Congregation_ID", + ])("accepts the legitimate FK path %j", async (path) => { + mockGetTableRecords.mockResolvedValueOnce([{ Resolved_Contact_ID: 7 }]); + const service = await FamilyService.getInstance(); + const result = await service.resolveContactIdFromPage( + "Event_Participants", + "Event_Participant_ID", + 10, + path, + ); + expect(result).toBe(7); + expect(mockGetTableRecords).toHaveBeenCalledWith( + expect.objectContaining({ select: `${path} AS Resolved_Contact_ID` }), + ); }); it("uses the FK path directly when contactIdField traverses a _TABLE join", async () => { diff --git a/src/services/familyService.ts b/src/services/familyService.ts index ba24c8c..f8126e6 100644 --- a/src/services/familyService.ts +++ b/src/services/familyService.ts @@ -1,6 +1,11 @@ import { MPHelper } from "@/lib/providers/ministry-platform"; import { AuthorizationService } from "@/services/authorizationService"; -import { escapeFilterString, validatePositiveInt, validateColumnName } from "@/lib/validation"; +import { + escapeFilterString, + validatePositiveInt, + validateColumnName, + validateFkPath, +} from "@/lib/validation"; import { DomainTimezoneService } from "@/services/domainTimezoneService"; import type { ContactSearchResult, @@ -137,28 +142,45 @@ export class FamilyService { })); } + /** + * Resolve the Contact_ID a page record points at, using the page's MP + * metadata (`Table_Name`, `Primary_Key`, `Contact_ID_Field`). + * + * Every identifier is validated BEFORE it is interpolated: `tableName` and + * `primaryKey` must be plain identifiers and `contactIdField` a strict FK + * path (`validateFkPath`). Previously any `contactIdField` containing + * `_TABLE` went into `$select` verbatim, and the action let the caller + * choose `tableName`, so a role holder could read a numeric value from any + * table/column through the service account. Callers must pass metadata + * resolved server-side from a page ID, never caller-supplied names. + * + * Validation runs before the gate (it is pure — no I/O) so the gate's + * denial log never carries an unvalidated table name. + */ async resolveContactIdFromPage( tableName: string, primaryKey: string, recordId: number, contactIdField: string, ): Promise { + validateColumnName(tableName); + validateColumnName(primaryKey); + validatePositiveInt(recordId); + if (typeof contactIdField !== "string") { + throw new Error("Invalid Contact_ID_Field"); + } + const fkPath = contactIdField.trim(); + if (fkPath) validateFkPath(fkPath, "Contact_ID_Field"); + await AuthorizationService.getInstance().requireSecurityRole({ table: tableName, operation: 'read', }); - validatePositiveInt(recordId); - validateColumnName(primaryKey); - const fkPath = contactIdField.trim(); if (!fkPath) return null; - const select = fkPath.includes("_TABLE") - ? `${fkPath} AS Resolved_Contact_ID` - : `${validateColumnName(fkPath)} AS Resolved_Contact_ID`; - const rows = await this.mp!.getTableRecords<{ Resolved_Contact_ID: number | null }>({ table: tableName, - select, + select: `${fkPath} AS Resolved_Contact_ID`, filter: `${primaryKey} = ${recordId}`, top: 1, }); diff --git a/src/services/groupService.test.ts b/src/services/groupService.test.ts index 6dba31d..12e3df1 100644 --- a/src/services/groupService.test.ts +++ b/src/services/groupService.test.ts @@ -44,7 +44,12 @@ vi.mock('@/services/authorizationService', () => ({ })); -import { GroupService } from './groupService'; +import { GroupService, GROUP_WRITABLE_FIELDS } from './groupService'; +import { + STEP_FIELDS, + groupWizardSchema, + GROUP_WIZARD_DEFAULTS, +} from '@/components/group-wizard/schema'; import { DomainTimezoneService } from './domainTimezoneService'; describe('GroupService', () => { @@ -449,6 +454,113 @@ describe('GroupService', () => { }); }); + describe('Groups column allowlist (mass assignment)', () => { + const FULL = { + ...GROUP_WIZARD_DEFAULTS, + Group_Name: 'G', + Group_Type_ID: 1, + Start_Date: '2024-03-01', + Congregation_ID: 5, + Ministry_ID: 10, + Primary_Contact: 42, + }; + const hostileExtras = () => { + const extras: Record = Object.create({ Inherited_Column: 1 }); + Object.assign(extras, { + Group_ID: 999, + _Last_Attendance_Posted: '2020-01-01', + Domain_ID: 7, + $userId: 1, + }); + return extras; + }; + + it('equals exactly the fields the wizard UI edits (STEP_FIELDS union = schema keys)', () => { + const stepUnion = Object.values(STEP_FIELDS).flat().sort(); + expect([...GROUP_WRITABLE_FIELDS].sort()).toEqual(stepUnion); + expect([...GROUP_WRITABLE_FIELDS].sort()).toEqual( + Object.keys(groupWizardSchema.shape).sort(), + ); + expect(GROUP_WRITABLE_FIELDS).not.toContain('Group_ID'); + }); + + it('createGroup drops every non-allowlisted key, including Group_ID and $userId', async () => { + mockCreateTableRecords.mockResolvedValueOnce([{ Group_ID: 200, Group_Name: 'G' }]); + const service = await GroupService.getInstance(); + + const payload = Object.assign(hostileExtras(), FULL); + await service.createGroup(payload as any); + + const [table, [record], options] = mockCreateTableRecords.mock.calls[0]; + expect(table).toBe('Groups'); + expect(Object.keys(record).sort()).toEqual([...GROUP_WRITABLE_FIELDS].sort()); + expect(record).not.toHaveProperty('Inherited_Column'); + // Attribution comes from the gate, never from the payload. + expect(options.$userId).toBe(42); + }); + + it('updateGroup ignores a smuggled Group_ID: the record targeted is the argument', async () => { + mockUpdateTableRecords.mockResolvedValueOnce([{ Group_ID: 100, Group_Name: 'G' }]); + const service = await GroupService.getInstance(); + + const payload = Object.assign(hostileExtras(), { Group_Name: 'G' }); + await service.updateGroup(100, payload as any); + + const [, [record], options] = mockUpdateTableRecords.mock.calls[0]; + expect(record).toEqual({ Group_Name: 'G', Group_ID: 100 }); + expect(options.$userId).toBe(42); + }); + + it('updateGroup writes Group_ID last so no payload key can override it', async () => { + // Independent of the allowlist: even if a Group_ID got through the + // pick, the spread order must leave the argument in force. + mockUpdateTableRecords.mockResolvedValueOnce([{ Group_ID: 100, Group_Name: 'G' }]); + const service = await GroupService.getInstance(); + + await service.updateGroup(100, { Group_Name: 'G' } as any); + + const [, [record]] = mockUpdateTableRecords.mock.calls[0]; + expect(Object.keys(record).at(-1)).toBe('Group_ID'); + }); + + it('updateGroup does not null date fields the payload omitted', async () => { + mockUpdateTableRecords.mockResolvedValueOnce([{ Group_ID: 100, Group_Name: 'G' }]); + const service = await GroupService.getInstance(); + + await service.updateGroup(100, { Group_Name: 'G' } as any); + + const [, [record]] = mockUpdateTableRecords.mock.calls[0]; + expect(record).not.toHaveProperty('End_Date'); + expect(record).not.toHaveProperty('Promotion_Date'); + }); + + it.each([0, -1, 1.5, Number.NaN, '100' as unknown as number])( + 'updateGroup refuses groupId %s before any MP call', + async (bad) => { + const service = await GroupService.getInstance(); + + await expect(service.updateGroup(bad, { Group_Name: 'G' } as any)).rejects.toThrow(); + expect(mockUpdateTableRecords).not.toHaveBeenCalled(); + }, + ); + + it.each(['createGroup', 'updateGroup'] as const)( + '%s writes nothing when the gate throws', + async (method) => { + mockRequireSecurityRole.mockRejectedValueOnce(new Error('Not authorized')); + const service = await GroupService.getInstance(); + + const call = + method === 'createGroup' + ? service.createGroup(FULL as any) + : service.updateGroup(100, FULL as any); + await expect(call).rejects.toThrow('Not authorized'); + expect(mockCreateTableRecords).not.toHaveBeenCalled(); + expect(mockUpdateTableRecords).not.toHaveBeenCalled(); + }, + ); + }); + describe('error propagation', () => { it('should propagate errors from getTableRecords', async () => { mockGetTableRecords.mockRejectedValueOnce(new Error('API error')); diff --git a/src/services/groupService.ts b/src/services/groupService.ts index 9e25948..8219919 100644 --- a/src/services/groupService.ts +++ b/src/services/groupService.ts @@ -21,19 +21,92 @@ export interface GetGroupResult { displayNames: GroupWizardDisplayNames; } -/** Prepare form data for the MP API by converting date fields to MP-TZ SQL datetime */ +/** + * The ONLY Groups columns this app may write — exactly the fields the group + * wizard UI edits (one per form control across the five editing steps; see + * `STEP_FIELDS` in `group-wizard/schema.ts`, whose union this equals — pinned + * by a test so the two cannot drift). + * + * WHY AN ALLOWLIST: the create/update payload arrives from a server action, + * i.e. a callable POST endpoint whose body the caller controls. Spreading it + * into the Groups POST/PUT let any role holder write ANY Groups column through + * the app's service account (e.g. `_Last_Attendance_Posted`, `Domain_ID`), and + * a smuggled `Group_ID` retargeted an update at a different record. Keys not + * listed here are dropped, never forwarded. Deliberately an explicit list, + * not `Object.keys(schema.shape)`, so widening what the app can write is a + * visible change to this file. + */ +export const GROUP_WRITABLE_FIELDS = Object.freeze([ + // Step 1: Identity + 'Group_Name', + 'Group_Type_ID', + 'Description', + 'Start_Date', + 'End_Date', + 'Reason_Ended', + // Step 2: Organization & People + 'Congregation_ID', + 'Ministry_ID', + 'Primary_Contact', + 'Parent_Group', + 'Priority_ID', + // Step 3: Meeting Schedule + 'Meeting_Day_ID', + 'Meeting_Time', + 'Meeting_Frequency_ID', + 'Meeting_Duration_ID', + 'Meets_Online', + 'Default_Meeting_Room', + 'Offsite_Meeting_Address', + // Step 4: Attributes + 'Target_Size', + 'Life_Stage_ID', + 'Group_Focus_ID', + 'Required_Book', + 'SMS_Number', + 'Group_Is_Full', + // Step 5: Settings & Promotion + 'Available_Online', + 'Available_On_App', + 'Enable_Discussion', + 'Send_Attendance_Notification', + 'Send_Service_Notification', + 'Create_Next_Meeting', + 'Secure_Check-in', + 'Suppress_Nametag', + 'Suppress_Care_Note', + 'On_Classroom_Manager', + 'Promote_to_Group', + 'Age_in_Months_to_Promote', + 'Promote_Weekly', + 'Promote_Participants_Only', + 'Promotion_Date', + 'Descended_From', +] as const satisfies readonly (keyof GroupWizardFormData)[]); + +const DATE_FIELDS = ['Start_Date', 'End_Date', 'Promotion_Date'] as const; + +/** + * Prepare form data for the MP API: keep only {@link GROUP_WRITABLE_FIELDS} + * (own properties only — nothing from the prototype), then convert the date + * fields that are present to MP-TZ SQL datetime. + */ async function prepareForApi( - data: GroupWizardFormData, + data: Partial, ): Promise> { + const source = data as Record; + const picked: Record = {}; + for (const field of GROUP_WRITABLE_FIELDS) { + if (Object.hasOwn(source, field)) picked[field] = source[field]; + } + const tz = DomainTimezoneService.getInstance(); - const convert = async (value: string | null | undefined) => - value ? await tz.toMpSqlDatetime(value) : null; - return { - ...data, - Start_Date: await convert(data.Start_Date), - End_Date: await convert(data.End_Date), - Promotion_Date: await convert(data.Promotion_Date), - }; + for (const field of DATE_FIELDS) { + if (!Object.hasOwn(picked, field)) continue; + const value = picked[field] as string | null | undefined; + picked[field] = value ? await tz.toMpSqlDatetime(value) : null; + } + return picked; } export class GroupService { @@ -312,9 +385,14 @@ export class GroupService { table: 'Groups', operation: 'update', }); + // Validated after the gate (gate first, always) and before any MP call. + validatePositiveInt(groupId); + // `Group_ID` LAST, from the validated argument: nothing in the payload can + // retarget the update at another record (and prepareForApi drops a + // payload `Group_ID` anyway — two independent guards). const apiData = { + ...(await prepareForApi(data)), Group_ID: groupId, - ...(await prepareForApi(data as GroupWizardFormData)), }; const result = await this.mp!.updateTableRecords('Groups', [apiData], { partial: true, diff --git a/src/services/toolService.ts b/src/services/toolService.ts index 6f41f86..55b60e2 100644 --- a/src/services/toolService.ts +++ b/src/services/toolService.ts @@ -1,7 +1,7 @@ import { MPHelper } from "@/lib/providers/ministry-platform"; import { PageData } from "@/lib/tool-params"; import { AuthorizationService } from "@/services/authorizationService"; -import { validatePositiveInt, validateColumnName } from "@/lib/validation"; +import { validatePositiveInt, validateColumnName, escapeFilterString } from "@/lib/validation"; import { MP_FETCH_BATCH_SIZE } from "@/lib/constants"; export interface ContactRecord { @@ -250,7 +250,7 @@ export class ToolService { }); const term = search?.trim(); const filter = term - ? `Role_Name LIKE '%${term.replace(/'/g, "''")}%'` + ? `Role_Name LIKE '%${escapeFilterString(term)}%'` : undefined; return this.mp!.getTableRecords({ @@ -361,7 +361,7 @@ export class ToolService { for (let i = 0; i < recordIds.length; i += MP_FETCH_BATCH_SIZE) { const batch = recordIds.slice(i, i + MP_FETCH_BATCH_SIZE); - batch.forEach(validatePositiveInt); + batch.forEach((id) => validatePositiveInt(id)); const rows = await this.mp!.getTableRecords>({ table: tableName, select: `${primaryKey}, ${contactIdField}`, From 5e5a71a801b5d80fabb880771b9ddfa10cd8071b Mon Sep 17 00:00:00 2001 From: Chris Kehayias Date: Wed, 30 Sep 2026 07:39:36 -0400 Subject: [PATCH 05/24] =?UTF-8?q?fix(security):=20Step=203=20=E2=80=94=201?= =?UTF-8?q?2h=20sessions,=20JWE=20cookie,=20no=20OAuth=20tokens,=20boot=20?= =?UTF-8?q?guards,=20secrets=20hygiene,=20client-IP=20header=20(#37)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Security remediation **Step 3** — `.claude/TODO/2026-09-30-step3-session-lifetime-and-secrets-hygiene.md` (playbook `port-security-review-2026-09-28.md` Phases 2, 3 and the client-IP part of 6). ## What and why ### Sessions (P3), in `src/lib/auth.ts` Stateless better-auth 1.7.6 quietly defaults `cookieCache.refreshCache` to **true**. Because of that, a copied or forged `session_data` cookie kept re-signing itself for **up to 7 days**, sign-out or not. The cookie was also a readable JWT, and the user's MP access and refresh tokens (full `dataplatform/scopes/all`) sat in an `account_data` cookie. - `expiresIn` 12 h, `disableSessionRefresh: true`, `strategy: "jwe"`, `refreshCache: false` (set explicitly). A cookie with no live in-memory row behind it now dies within 1 h. - `storeAccountCookie: false`. `databaseHooks` null the access and refresh tokens in the in-memory account row. The `idToken` is kept for Step 4's `id_token_hint`. - `offline_access` is no longer requested. - `customSession` → `enrichSession`: it keeps `firstName`/`lastName` (the fork's documented behaviour) and withholds `token`, `ipAddress` and `userAgent`. ### Boot guards (P2) - `assertAuthEnvironment` refuses to start when the secret is missing, is better-auth's public default, or is **shorter than 32 characters**. It also refuses when `BETTER_AUTH_SECRETS` is set or when `TEST` is set in production. It runs everywhere except Vitest, and that includes `next build`. - `advanced.disableOriginCheck: false` is pinned. - New `src/lib/env.ts` (`getMpBaseUrl`, `getAuthBaseUrl`) validates the two URLs (https, no credentials, query or fragment, app URL origin-only, `NEXTAUTH_URL` fallback kept). `auth.ts`, the MP client, client-credentials and the sign-out action now go through it. ### Secrets hygiene - `.gitignore` now covers `.env*` (except `.env.example`), `.vercel`, `.claude/settings.local.json` and `.claude/worktrees/`. - `.claude/settings.local.json` is untracked. It granted `Bash(sed:*)` and `rm -rf .claude/*` to anyone who cloned. - New `.githooks/pre-commit` refuses staged env files. It is installed by a `prepare` script and adds no dependency. - New `SECURITY.md`, which includes what downstream clones should do: port `disabledPaths`, **rotate their own `BETTER_AUTH_SECRET`**, and review `dp_Audit_Log`. ### Client IP (P6): recommendation `AUTH_IP_ADDRESS_HEADERS` and `AUTH_TRUSTED_PROXIES` feed `advanced.ipAddress`. Invalid entries refuse startup. **Default:** `x-vercel-forwarded-for`, then `x-real-ip`, **applied only when `VERCEL` is set**. - Checked against on 2026-09-30. Vercel overwrites `x-forwarded-for` and "does not forward external IPs … to prevent IP spoofing". `x-vercel-forwarded-for` and `x-real-ip` are "identical to" it. - `x-vercel-forwarded-for` comes first because it survives a proxy placed in front of Vercel, which may overwrite `x-forwarded-for`. - Off Vercel, those same headers are just values any client can send. Defaulting to them there would let a client dodge the limit or lock out someone else. So an unconfigured non-Vercel clone keeps better-auth's default (single-value `x-forwarded-for`). - This is a slight refinement of "always default to them". For this Vercel deployment the behaviour is identical. - `.env.example` lists what other edges should set: Cloudflare `cf-connecting-ip`, Azure Front Door `x-azure-clientip`, App Service `x-client-ip`, and for nginx the header it *sets*. ## Tests New suites drive the real `auth` instance through a mock MP OIDC server with RS256-signed id_tokens (`src/test-utils/mock-oidc.ts`, excluded from coverage). Every unserved fetch throws. - `auth.session-lifetime.test.ts`: clock walks. The 12 h ceiling holds on both paths. A cookie copied before sign-out dies within 1 h, and so does one replayed at a serverless instance that never saw the sign-in. A negative control with the pre-fix config survives ~7 days. - `auth.session-config.test.ts`: the only cookies are `session_token` + `session_data`, and no `account_data`. **`session_data` is decrypted with `symmetricDecodeJWT`** and holds no token material. The response withholds its fields over HTTP and via `auth.api.getSession`. - `auth.user-oauth-tokens.test.ts`: no `offline_access`; the in-memory row has null access and refresh tokens; the idToken is kept; sign-out on the same instance returns MP endsession with `id_token_hint`. - `auth.secret-guard.test.ts`: import-time refusals, including with `VITEST` cleared. - `lib/env.test.ts` and `auth.ip-address.test.ts` (parsing, per-edge resolution, rate-limit buckets). **Mutation checks** (break it → red → revert). Each one turned the suite red: - `refreshCache: false` removed - `storeAccountCookie: true` - token stripping removed - secret-length guard weakened - module-level guard removed - `jwe`→`jwt` - `expiresIn` removed - `offline_access` re-added - Vercel default dropped - withheld fields kept **Local gates:** lint, typecheck and `test:coverage` all pass (1896 tests; 98.87% statements, 99.72% lines). `npm run build` passes, and its `_INSTALL`/`next-env.d.ts` side effects were reverted. ## Deploy note - **Every user is signed out once**, because the cookie strategy and session config change. - **Rotate `BETTER_AUTH_SECRET` at this deploy** in Production, Preview and Development on Vercel, and in every `.env.local`: `openssl rand -base64 32`. It must be **≥ 32 characters**, or the app (and the Vercel build) refuses to start. Ops TODO `2026-09-30-ops-rotate-better-auth-secret.md` stays open for this and the audit-log review. - `BETTER_AUTH_URL` must be an https origin with no path (loopback http is fine). `MINISTRY_PLATFORM_BASE_URL` must be https. `BETTER_AUTH_SECRETS` and a production `TEST` must not be set. - There are no new *required* variables. `AUTH_IP_ADDRESS_HEADERS` and `AUTH_TRUSTED_PROXIES` are optional, and the default is right for Vercel. - Trade-off: on serverless, after 1 h a request can land on an instance without the in-memory row. That user goes back through MP sign-in, which is also an hourly check against MP. ## Owner follow-ups (not doable from here) - GitHub private vulnerability reporting, secret scanning, push protection and Dependabot security updates are all **disabled** on this public repo. `SECURITY.md` points reporters at private reporting (held TODO `2026-09-30-held-github-repo-security-settings.md`). 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 (1M context) --- ...026-09-30-ops-rotate-better-auth-secret.md | 10 + ...p3-session-lifetime-and-secrets-hygiene.md | 32 +- .claude/TODO/INDEX.md | 10 +- .claude/references/DECISIONS.md | 1 + .claude/references/GLOSSARY.md | 4 +- .claude/references/auth/README.md | 4 +- .claude/references/auth/oauth-flow.md | 17 +- .claude/references/auth/sessions.md | 220 ++++++------ .claude/references/security/README.md | 72 +++- .claude/references/testing/setup.md | 3 +- .claude/settings.local.json | 30 -- .env.example | 36 +- .githooks/pre-commit | 32 ++ .gitignore | 14 +- CLAUDE.md | 6 +- README.md | 4 +- SECURITY.md | 66 ++++ package.json | 3 +- src/auth.ip-address.test.ts | 155 ++++++++ src/auth.secret-guard.test.ts | 203 +++++++++++ src/auth.session-config.test.ts | 165 +++++++++ src/auth.session-lifetime.test.ts | 191 ++++++++++ src/auth.user-oauth-tokens.test.ts | 136 +++++++ src/components/user-menu/actions.test.ts | 24 +- src/components/user-menu/actions.ts | 18 +- src/lib/auth.ts | 334 ++++++++++++++++-- src/lib/env.test.ts | 119 +++++++ src/lib/env.ts | 109 ++++++ .../auth/client-credentials.ts | 6 +- src/lib/providers/ministry-platform/client.ts | 4 +- src/test-setup.ts | 3 +- src/test-utils/mock-oidc.ts | 219 ++++++++++++ vitest.config.mts | 1 + 33 files changed, 2051 insertions(+), 200 deletions(-) delete mode 100644 .claude/settings.local.json create mode 100755 .githooks/pre-commit create mode 100644 SECURITY.md create mode 100644 src/auth.ip-address.test.ts create mode 100644 src/auth.secret-guard.test.ts create mode 100644 src/auth.session-config.test.ts create mode 100644 src/auth.session-lifetime.test.ts create mode 100644 src/auth.user-oauth-tokens.test.ts create mode 100644 src/lib/env.test.ts create mode 100644 src/lib/env.ts create mode 100644 src/test-utils/mock-oidc.ts diff --git a/.claude/TODO/2026-09-30-ops-rotate-better-auth-secret.md b/.claude/TODO/2026-09-30-ops-rotate-better-auth-secret.md index fd49046..565ab4a 100644 --- a/.claude/TODO/2026-09-30-ops-rotate-better-auth-secret.md +++ b/.claude/TODO/2026-09-30-ops-rotate-better-auth-secret.md @@ -28,3 +28,13 @@ Only rotating the signing secret invalidates them. Owner decision (2026-09-30): ## Impact if not fixed A forged-identity cookie from the exposure window stays valid. + +## Progress (2026-09-30, Step 3) + +- Item 4 (downstream recommendation) is done: `SECURITY.md` § Downstream clones and + `.claude/references/security/README.md` § Downstream clones. +- The app now refuses to start with a secret shorter than 32 characters, so the rotated value must be + ≥ 32 chars (`openssl rand -base64 32` gives 44). Step 3's session-config change signs everyone out + once on deploy anyway — rotate at that same deploy. +- Still open: items 1–3 (owner sets the Vercel env vars and reviews `dp_Audit_Log`). + diff --git a/.claude/TODO/2026-09-30-step3-session-lifetime-and-secrets-hygiene.md b/.claude/TODO/2026-09-30-step3-session-lifetime-and-secrets-hygiene.md index 5223f97..a3a11b3 100644 --- a/.claude/TODO/2026-09-30-step3-session-lifetime-and-secrets-hygiene.md +++ b/.claude/TODO/2026-09-30-step3-session-lifetime-and-secrets-hygiene.md @@ -6,7 +6,7 @@ area: auth files: [src/lib/auth.ts, src/lib/env.ts, .gitignore, .claude/settings.local.json, src/lib/providers/ministry-platform/client.ts, src/lib/providers/ministry-platform/auth/client-credentials.ts] discovered: 2026-09-30 discovered_by: playbook-audit (port-security-review-2026-09-28.md P2/P3/P6) -status: open +status: resolved --- ## Problem @@ -42,3 +42,33 @@ Follow `port-security-review-2026-09-28.md` Phases 2, 3 and the IP part of 6. ## Impact if not fixed A stolen cookie grants up to 7 days of access and carries a full-scope MP refresh token; mis-set env vars fail silently at runtime instead of at boot. + +## Resolution (2026-09-30) + +Branch `fix/step3-session-secrets`. + +1. **Session (P3)** — `src/lib/auth.ts`: `expiresIn` 12 h, `disableSessionRefresh`, `cookieCache` + `strategy: "jwe"` + `refreshCache: false` (explicit; better-auth 1.7.6 defaults it on when stateless), + `storeAccountCookie: false`, `databaseHooks.account.{create,update}.before` → `stripUserOAuthTokens` + (access/refresh + expiries nulled, `idToken` kept), `offline_access` dropped. `customSession` → + `enrichSession`, which keeps `firstName`/`lastName` (documented fork behaviour) and withholds + `token`/`ipAddress`/`userAgent`. Verified by decoding `session_data` with `symmetricDecodeJWT` in + `src/auth.session-config.test.ts` (no token material; no `account_data` cookie). +2. **Boot guards (P2)** — `assertAuthEnvironment` (unset / default / < 32 chars / `BETTER_AUTH_SECRETS` / + `TEST` in production; skipped only under Vitest), `advanced.disableOriginCheck: false`, and + `src/lib/env.ts` (`getMpBaseUrl`, `getAuthBaseUrl`; `NEXTAUTH_*` fallbacks kept) used by `auth.ts`, + the MP client, client-credentials and the sign-out action. `test-setup.ts` secret lengthened to ≥ 32. +3. **Secrets hygiene** — `.gitignore` → `.env*` / `!.env.example`, `.vercel`, `.claude/settings.local.json`, + `.claude/worktrees/`; `git rm --cached .claude/settings.local.json` (file kept locally); + `.githooks/pre-commit` + `prepare` script (no new dependency); `SECURITY.md`. +4. **Client IP (P6)** — `parseIpAddressOptions` (`AUTH_IP_ADDRESS_HEADERS`, `AUTH_TRUSTED_PROXIES`, invalid + entries refuse startup). Default `x-vercel-forwarded-for,x-real-ip` **only when `VERCEL` is set**; off + Vercel those headers are client-controlled, so better-auth's default applies. Verified against + (2026-09-30). `.env.example` documents other edges. +5. **Tests** — `src/auth.{session-lifetime,session-config,user-oauth-tokens,secret-guard,ip-address}.test.ts`, + `src/lib/env.test.ts`, via `src/test-utils/mock-oidc.ts` (excluded from coverage). Mutation-checked: + refreshCache, storeAccountCookie, token stripping, secret-length guard, module-level guard, jwe→jwt, + expiresIn, offline_access, Vercel default, withheld fields — each turns the suite red. + +Not done here: secret rotation itself (ops TODO, owner), GitHub repo security settings (held TODO). + diff --git a/.claude/TODO/INDEX.md b/.claude/TODO/INDEX.md index 3ca7b02..62932fe 100644 --- a/.claude/TODO/INDEX.md +++ b/.claude/TODO/INDEX.md @@ -25,7 +25,7 @@ Severity tiers: - **medium**: doc drift, missing test, refactor with real cost - **low**: nits, minor doc fixes, stylistic improvements -Total: **16 open TODOs**. +Total: **15 open TODOs**. > **2026-09-30 — MPNext playbook audit.** The five `S:\MP\MPNext\.claude\playbooks` > port playbooks were audited against this fork. Remediation is split into nine @@ -51,10 +51,9 @@ Total: **16 open TODOs**. ### Critical (0) _none open_ -### High (4) +### High (3) | Area | Tags | Title | File | |---|---|---|---| -| auth | security, missing-test, doc | Step 3 — Session lifetime, token-in-cookie, boot guards, secrets hygiene, client-IP header | [→](2026-09-30-step3-session-lifetime-and-secrets-hygiene.md) | | auth | security, missing-test, doc | Step 4 — OIDC lazy discovery, sub binding, dedicated OIDC client, sign-out | [→](2026-09-30-step4-oidc-lazy-discovery-and-sub-binding.md) | | auth | security, doc | Ops — Rotate BETTER_AUTH_SECRET; advise downstream clones | [→](2026-09-30-ops-rotate-better-auth-secret.md) | | auth | security, doc | Ops — Set MP_SECURITY_ROLES=* in every environment | [→](2026-09-30-ops-set-mp-security-roles-env.md) | @@ -83,8 +82,8 @@ _none open_ ## By tag -### security (11) -- all open 2026-09-30 step/ops/held items except step7 (step1, step2 resolved) +### security (10) +- all open 2026-09-30 step/ops/held items except step7 (step1, step2, step3 resolved) ### bug (3) - components-template-editor-no-mp-persistence — medium @@ -134,6 +133,7 @@ _none open_ | Date | Title | File | |---|---|---| +| 2026-09-30 | Step 3 — 12 h session ceiling, JWE cookie, no user OAuth tokens, boot guards + env.ts, secrets hygiene, client-IP header | [→](2026-09-30-step3-session-lifetime-and-secrets-hygiene.md) | | 2026-09-30 | Step 2 — Fail-closed MP_SECURITY_ROLES, group-wizard mass assignment, page-metadata injection, F11, page self-gating, filter/error hygiene | [→](2026-09-30-step2-authorization-and-input-validation.md) | | 2026-09-30 | Step 1 — F12 id_token sign-in refused, F3b callbackUrl bypass closed, /sign-in/social boundary + no-store | [→](2026-09-30-step1-close-live-auth-holes-f12-f3b.md) | | 2026-09-13 | Coverage config omitted `coverage.include`, hiding every untested file | [→](2026-09-13-testing-coverage-report-masked-untested-files.md) | diff --git a/.claude/references/DECISIONS.md b/.claude/references/DECISIONS.md index a3ea270..df5ab6d 100644 --- a/.claude/references/DECISIONS.md +++ b/.claude/references/DECISIONS.md @@ -71,6 +71,7 @@ Architectural decisions captured by the context-engineering review at SHA `971c4 **Context:** The tools suite is an internal app for MP staff. Adding a dedicated Postgres/Redis instance for session storage would be operational overhead disproportionate to the feature set. `auth.api.getSession()` is called on every authenticated server-component render, so the happy path must be fast. **Decision:** Enable `session.cookieCache` with `strategy: "jwt"` and a 1-hour `maxAge`, plus `account.storeAccountCookie: true` and `storeStateStrategy: "cookie"`. Do not configure a database adapter (`src/lib/auth.ts`). **Consequences:** Zero infrastructure beyond env vars. `getSession()` verifies the JWT cookie without a DB roundtrip. No server-side session list, no admin revocation, no cross-device logout. Sub-1-hour cookie TTL caps how long a stolen cookie is usable; refresh-token rotation behaviour in pure-cookie mode is unverified in this repo. +**Amended 2026-09-30 (security Step 3):** the "sub-1-hour" claim was wrong — stateless better-auth defaults `cookieCache.refreshCache` to `true`, so a copied cookie re-signed itself for up to 7 days. Now: `expiresIn` 12 h, `disableSessionRefresh`, `strategy: "jwe"`, `refreshCache: false` (an unbacked cookie dies within 1 h), `storeAccountCookie: false` and no `offline_access`. Still no database. See `auth/sessions.md`. **Alternatives considered:** - **DB adapter (Postgres/SQLite via Drizzle)** — requires operating an auth DB and migration pipeline. - **Redis adapter** — another external dependency and another ops surface. diff --git a/.claude/references/GLOSSARY.md b/.claude/references/GLOSSARY.md index cfb0817..32e765d 100644 --- a/.claude/references/GLOSSARY.md +++ b/.claude/references/GLOSSARY.md @@ -80,7 +80,7 @@ OAuth2 grant type used by the provider to acquire tokens for server-to-MP calls ## cookieCache (aliases: JWT cookie cache) -Better Auth stateless session strategy: 1-hour TTL JWT stored in a cookie with no database — subsequent `getSession()` calls decode the cookie directly. +Better Auth stateless session strategy: the `session_data` cookie (encrypted JWE, 1-hour `maxAge`, `refreshCache: false`) caches the session with no database — subsequent `getSession()` calls decrypt the cookie directly. The session itself has a hard 12 h ceiling (`expiresIn`). See `auth/sessions.md`. **Not to be confused with:** server-side session storage (not used here). **Defined in:** `src/lib/auth.ts:12` @@ -142,7 +142,7 @@ Helper that escapes `'` -> `''`, `%` -> `[%]`, `_` -> `[_]` before interpolating ## genericOAuth (aliases: generic OAuth plugin) -Better Auth plugin wiring arbitrary OIDC providers; configured with `providerId: "ministryplatform"`, OIDC discovery URL, `offline_access` + MP all scope, `pkce: false` (MP advertises `S256` in discovery but rejects the token exchange with `invalid_grant`), `disableIdTokenNonceBinding: true` (MP omits the claim), and `realm=realm` authorization param. As of Better Auth 1.7 it registers providers as first-class **social** providers, so the callback URL is the core `/api/auth/callback/{providerId}` — not the former `/api/auth/oauth2/callback/{providerId}`. +Better Auth plugin wiring arbitrary OIDC providers; configured with `providerId: "ministryplatform"`, OIDC discovery URL, `openid` + MP all scope (no `offline_access`), `pkce: false` (MP advertises `S256` in discovery but rejects the token exchange with `invalid_grant`), `disableIdTokenNonceBinding: true` (MP omits the claim), and `realm=realm` authorization param. As of Better Auth 1.7 it registers providers as first-class **social** providers, so the callback URL is the core `/api/auth/callback/{providerId}` — not the former `/api/auth/oauth2/callback/{providerId}`. **Not to be confused with:** `client credentials flow` (server-to-server, no user). **Defined in:** `src/lib/auth.ts:32` diff --git a/.claude/references/auth/README.md b/.claude/references/auth/README.md index 6ebb577..f60e708 100644 --- a/.claude/references/auth/README.md +++ b/.claude/references/auth/README.md @@ -5,13 +5,13 @@ domain: auth --- ## What's in this domain -Better Auth (`^1.5.5`) wired to Ministry Platform OIDC via the `genericOAuth` plugin, with stateless JWT cookie sessions, a `customSession` name-splitter, and dual-layer route protection (proxy + `AuthWrapper`). +Better Auth (`^1.5.5`) wired to Ministry Platform OIDC via the `genericOAuth` plugin, with stateless sessions (12 h ceiling, encrypted `session_data` cookie, no user OAuth tokens), boot-time secret/URL refusals, a `customSession` name-splitter, and dual-layer route protection (proxy + `AuthWrapper`). ## File map | File | Purpose | When to read | |------|---------|--------------| | `oauth-flow.md` | `genericOAuth` config, callback URL, `getUserInfo`, `mapProfileToUser`, OIDC `endsession` logout | Wiring the MP OAuth client, debugging callbacks or logout | -| `sessions.md` | Stateless JWT cookie strategy, `cookieCache`, `customSession`, session shape, access patterns | Reading/modifying session data, any `getSession()` call | +| `sessions.md` | Session lifetime, JWE `cookieCache`, no OAuth tokens, boot refusals (`assertAuthEnvironment`, `src/lib/env.ts`), client-IP headers, `customSession`, session shape | Reading/modifying session data, any `getSession()` call | | `route-protection.md` | `src/proxy.ts` + `AuthWrapper` dual-layer pattern, `x-pathname` forwarding, `callbackUrl` preservation | Touching route guards or sign-in redirect behavior | | `user-identity.md` | `session.user.id` (Better Auth internal) vs `session.user.userGuid` (MP `User_GUID`) — gotcha | Before any MP API lookup keyed by the signed-in user | diff --git a/.claude/references/auth/oauth-flow.md b/.claude/references/auth/oauth-flow.md index e7af464..f040078 100644 --- a/.claude/references/auth/oauth-flow.md +++ b/.claude/references/auth/oauth-flow.md @@ -40,7 +40,7 @@ End-to-end OAuth2/OIDC flow against Ministry Platform: sign-in, token exchange, | `discoveryUrl` | `${MP_BASE_URL}/oauth/.well-known/openid-configuration` | `auth.ts:36` | | `clientId` | `process.env.MINISTRY_PLATFORM_CLIENT_ID!` | `auth.ts:37` | | `clientSecret` | `process.env.MINISTRY_PLATFORM_CLIENT_SECRET!` | `auth.ts:38` | -| `scopes` | `["openid", "offline_access", "http://www.thinkministry.com/dataplatform/scopes/all"]` | `auth.ts:39-43` | +| `scopes` | `["openid", "http://www.thinkministry.com/dataplatform/scopes/all"]` — no `offline_access` since 2026-09-30 (the user's own tokens are never used) | `ministryPlatformProviderConfig` | | `pkce` | `false` (MP advertises S256 but rejects the exchange) | `ministryPlatformProviderConfig` | | `disableIdTokenNonceBinding` | `true` (MP omits the claim) | `ministryPlatformProviderConfig` | | `authorizationUrlParams` | `{ realm: "realm" }` | `auth.ts:45-47` | @@ -116,8 +116,9 @@ mapProfileToUser: (profile) => { b2. accountSubject({ profile }) → sub (the provider account key) c. mapProfileToUser(profile) → { userGuid: sub, email: @mp.invalid, mpEmail } d. Creates user (id=generated, userGuid=sub, email, name) - e. Creates account (accountId=sub, tokens) — storeAccountCookie: true - f. Creates session → sets JWT cookie (cookieCache) + e. Creates account (accountId=sub) in the in-memory adapter — access/refresh tokens + nulled by databaseHooks (idToken kept); storeAccountCookie: false (no account_data cookie) + f. Creates session (expiresAt = now + 12h) → sets session_token + encrypted (JWE) session_data 8. Browser lands on callbackURL (app page) 9. Client-side UserProvider reads session.user.userGuid (to decide whether to load) → getCurrentUserProfile() [server action re-derives the GUID from the session] @@ -167,13 +168,15 @@ export async function handleSignOut() { | Variable | Required | Purpose | |---|---|---| -| `MINISTRY_PLATFORM_BASE_URL` | yes | OIDC discovery root + userinfo + endsession | +| `MINISTRY_PLATFORM_BASE_URL` | yes | OIDC discovery root + userinfo + endsession. Validated at boot by `getMpBaseUrl()` (https; loopback http outside production) | | `MINISTRY_PLATFORM_CLIENT_ID` | yes | OAuth + API client ID registered on MP | | `MINISTRY_PLATFORM_CLIENT_SECRET` | yes | OAuth + API client secret | -| `BETTER_AUTH_URL` | yes (fallback `NEXTAUTH_URL`) | App base URL for callbacks + `post_logout_redirect_uri` | -| `BETTER_AUTH_SECRET` | yes (fallback `NEXTAUTH_SECRET`) | Session signing secret | +| `BETTER_AUTH_URL` | yes (fallback `NEXTAUTH_URL`) | App base URL for callbacks + `post_logout_redirect_uri`. Validated at boot by `getAuthBaseUrl()` (origin only; https except loopback) | +| `BETTER_AUTH_SECRET` | yes (fallback `NEXTAUTH_SECRET`) | Session signing/encryption secret. Boot refuses unset, better-auth's default, or < 32 chars | +| `AUTH_IP_ADDRESS_HEADERS` | no | Client-IP header(s) for rate limiting. Default on Vercel: `x-vercel-forwarded-for,x-real-ip` | +| `AUTH_TRUSTED_PROXIES` | no | Proxy IPs/CIDRs to skip in an appended `x-forwarded-for` chain | -`NEXTAUTH_*` fallbacks exist for gradual migration from NextAuth (`src/lib/auth.ts:9-10`, `src/components/user-menu/actions.ts:20`). +`NEXTAUTH_*` fallbacks exist for gradual migration from NextAuth (`src/lib/auth.ts`, `src/lib/env.ts`). ## MP OAuth client setup diff --git a/.claude/references/auth/sessions.md b/.claude/references/auth/sessions.md index d1364d5..4772c0b 100644 --- a/.claude/references/auth/sessions.md +++ b/.claude/references/auth/sessions.md @@ -1,137 +1,164 @@ --- -title: Sessions (stateless JWT + customSession) +title: Sessions (stateless, encrypted cookie cache + customSession) domain: auth type: reference -applies_to: [src/lib/auth.ts, src/lib/auth-client.ts, src/contexts/session-context.tsx] -symbols: [auth, authClient, useAppSession, Session, SessionData] -related: [oauth-flow.md, user-identity.md, route-protection.md] -last_verified: 2026-07-09 +applies_to: [src/lib/auth.ts, src/lib/env.ts, src/lib/auth-client.ts, src/contexts/session-context.tsx] +symbols: [auth, authClient, useAppSession, Session, SessionData, enrichSession, stripUserOAuthTokens, assertAuthEnvironment, parseIpAddressOptions] +related: [oauth-flow.md, user-identity.md, route-protection.md, ../security/README.md] +last_verified: 2026-09-30 --- ## Purpose -Session strategy: stateless JWT cookie cache (no DB), a lightweight `customSession` that splits `name` into `firstName`/`lastName`, and the access patterns for reading sessions server- and client-side. +Session strategy: stateless (no database), a hard 12 h lifetime, an encrypted +(JWE) `session_data` cookie, no user OAuth tokens anywhere, a lightweight +`customSession` that splits `name` and withholds `token`/`ipAddress`/`userAgent`, +and the boot-time refusals that keep the signing secret and base URLs sane. +Changed 2026-09-30 (security Step 3) — before that, sessions could live 7 days, +the cookie was a readable JWT, and the user's MP access/refresh tokens rode in an +`account_data` cookie. ## Files -- `src/lib/auth.ts` — `session.cookieCache`, `account.storeAccountCookie`, `customSession` plugin +- `src/lib/auth.ts` — `session`, `account`, `databaseHooks`, `advanced`, + `assertAuthEnvironment`, `parseIpAddressOptions`, `enrichSession`, + `stripUserOAuthTokens` +- `src/lib/env.ts` — `getMpBaseUrl()`, `getAuthBaseUrl()` - `src/lib/auth-client.ts` — client `authClient` with `customSessionClient()` - `src/contexts/session-context.tsx` — `useAppSession()` hook -- `src/auth.test.ts` — `customSession` logic tests (name splitting + session shape) +- Tests: `src/auth.session-lifetime.test.ts` (clock walks), `src/auth.session-config.test.ts` + (cookie names/attrs, decrypted `session_data`, withheld fields), + `src/auth.user-oauth-tokens.test.ts`, `src/auth.secret-guard.test.ts`, + `src/auth.ip-address.test.ts`, `src/lib/env.test.ts`. They drive the real + `auth` instance through the mock MP OIDC server in `src/test-utils/mock-oidc.ts`. -## Key concepts -- **Stateless**: no database adapter configured. Session state lives in a signed JWT cookie. -- **Cookie cache**: 1-hour TTL (`maxAge: 60 * 60`), `strategy: "jwt"`. Subsequent `getSession()` calls within the TTL decode the cookie, bypassing any DB (there is none anyway). -- **OAuth tokens in cookie**: `account.storeAccountCookie: true` + `storeStateStrategy: "cookie"` — there's no DB to persist them in. -- **`customSession` runs every `getSession()` call** when the cache expires. It **must stay cheap**: no MP API calls here. Profile enrichment happens in `UserProvider` (see `../contexts/user-provider.md`). -- **`additionalFields.userGuid`** is declared with `input: true` (extracted to the exported `userAdditionalFields` const) and populated only by `mapProfileToUser`. It **must not** be `input: false`: as of better-auth 1.6 that flag blocks the field from the OAuth profile → `userGuid` never persists → broken avatar/menu. See `user-identity.md` and the guard in `src/auth.test.ts`. -- **Type export:** `export type Session = typeof auth.$Infer.Session;` — server-side consumers import this (`src/lib/auth.ts:117`). -- Client-side mirror: `SessionData = typeof authClient.$Infer.Session` (`src/contexts/session-context.tsx:5`). - -## Configuration (verbatim from `src/lib/auth.ts`) +## Configuration (from `src/lib/auth.ts`) ```typescript -// src/lib/auth.ts:11-30 session: { + expiresIn: 12 * 60 * 60, // hard ceiling: expiresAt = sign-in + 12h + disableSessionRefresh: true, // expiresAt never slides cookieCache: { enabled: true, - maxAge: 60 * 60, // 1 hour cache - strategy: "jwt" as const, + maxAge: 60 * 60, // an unbacked cookie dies within 1h + strategy: "jwe", // encrypted, not just signed + refreshCache: false, // MUST be explicit — stateless mode defaults it to true }, }, account: { - storeStateStrategy: "cookie" as const, - storeAccountCookie: true, -}, -user: { - // extracted to an exported `userAdditionalFields` const (see user-identity.md) - additionalFields: userAdditionalFields, // { userGuid: { type: "string", required: false, input: true } } + storeStateStrategy: "cookie", + storeAccountCookie: false, // no account_data cookie + accountLinking: { enabled: false }, }, -``` - -## `customSession` (verbatim) - -```typescript -// src/lib/auth.ts:398-413 -customSession( - async ({ user, session }) => { - // No API calls here — profile loading is handled by UserProvider - // on the client side via getCurrentUserProfile(). This keeps - // getSession() fast and avoids hitting the MP API on every request. - return { - user: { - ...user, - firstName: user.name?.split(" ")[0] || "", - lastName: user.name?.split(" ").slice(1).join(" ") || "", - }, - session, - }; +databaseHooks: { // applied by the in-memory adapter + account: { + create: { before: async (a) => ({ data: stripUserOAuthTokens(a) }) }, + update: { before: async (a) => ({ data: stripUserOAuthTokens(a) }) }, }, - options, -), -``` - -- `firstName` / `lastName` are derived from `user.name` by space-splitting. Single-name users → `firstName` only, `lastName: ""`. `undefined` or empty `name` → both `""`. Covered by `src/auth.test.ts:22-87`. - -## Client auth wiring (verbatim from `src/lib/auth-client.ts`) - -```typescript -// src/lib/auth-client.ts:1-11 -import { createAuthClient } from "better-auth/react"; -import { genericOAuthClient, customSessionClient } from "better-auth/client/plugins"; -import type { auth } from "./auth"; - -export const authClient = createAuthClient({ - plugins: [ - genericOAuthClient(), - customSessionClient(), - ], -}); +}, +advanced: { + disableOriginCheck: false, // a truthy TEST env var must not switch it off + ipAddress: parseIpAddressOptions(process.env), +}, ``` -`customSessionClient()` gives the client type inference for `firstName`/`lastName`, but **does not** infer `additionalFields` (e.g., `userGuid`) — see `user-identity.md#type-casts`. +## Key concepts +- **Stateless.** No database adapter. better-auth keeps a per-process in-memory + adapter; the signed `session_token` + encrypted `session_data` cookies are the + session. There is no server-side revocation across instances. +- **12 h ceiling.** `expiresAt` is set once at sign-in and both `/get-session` + paths refuse a session past it. Pinned by a clock walk. +- **`refreshCache: false` is load-bearing.** better-auth 1.7.6 defu-merges + `refreshCache: true` under the config when there is no database + (`context/create-context.mjs`). With it on, `/get-session` re-signs + `session_data` from the cookie alone, so a copied cookie outlived sign-out for + up to 7 days. With it off, a cookie not backed by a live in-memory row (copied + before sign-out, forged from a leaked secret, or presented to a serverless + instance that never saw the sign-in) dies `maxAge` (1 h) after minting. + **Trade-off:** on serverless, a request after the hour that lands on an + instance without the row gets no session and goes back through MP sign-in — + which is also an hourly re-check that a disabled MP login fails. +- **No user OAuth tokens.** `offline_access` is not requested; + `storeAccountCookie: false` keeps tokens out of the browser; the account-row + hooks null the access/refresh tokens in memory. The **`idToken` is kept** in + the in-memory row: on the instance that handled sign-in, better-auth's own + `auth.api.signOut({ body: { disableRedirect: true } })` returns MP's + endsession URL with `id_token_hint` (pinned in `auth.user-oauth-tokens.test.ts`). + The sign-out action does not use it yet (security Step 4); on a different + serverless instance there is no row and so no hint. +- **`customSession` (`enrichSession`)** runs on every `getSession()`. It must stay + cheap: no MP API calls. It adds `firstName`/`lastName` and strips + `WITHHELD_SESSION_FIELDS` (`token`, `ipAddress`, `userAgent`) from the + session. They remain inside the encrypted cookie and the in-memory row. +- **`additionalFields.userGuid`** must stay `input: true` — see `user-identity.md`. + +## Boot-time refusals +`assertAuthEnvironment` runs at module load in every environment except Vitest +(including `next build`, which imports `auth.ts`). It throws — never echoing the +secret — when: +- no `BETTER_AUTH_SECRET` (or `NEXTAUTH_SECRET` fallback) is set; +- the secret is better-auth's public default; +- the secret is shorter than **32** characters (`openssl rand -base64 32`); +- `BETTER_AUTH_SECRETS` is set (it would silently override the validated secret); +- `NODE_ENV=production` with a truthy `TEST`. + +`getMpBaseUrl()` / `getAuthBaseUrl()` (`src/lib/env.ts`) run at module load +under Vitest too. `MINISTRY_PLATFORM_BASE_URL`: https (loopback http outside +production), path allowed, trailing slash stripped. `BETTER_AUTH_URL` (fallback +`NEXTAUTH_URL`): required, origin only, https for real hosts, loopback http +allowed everywhere so local/CI `next build` works. Both refuse credentials, +query strings and fragments. The MP client, the client-credentials token request +and the sign-out action use the same readers. + +## Client IP for rate limiting +`AUTH_IP_ADDRESS_HEADERS` (comma list, tried in order) and `AUTH_TRUSTED_PROXIES` +(IPs/CIDRs) feed `advanced.ipAddress`. Invalid entries refuse startup. Unset: +on Vercel (`VERCEL` set) → `x-vercel-forwarded-for, x-real-ip`, which Vercel's +edge overwrites; elsewhere → better-auth's default single-value +`x-forwarded-for`. Clones behind another edge must name the header **their** +edge overwrites (`cf-connecting-ip`, `x-azure-clientip`, ...). See `.env.example`. + +## Emergency "sign everyone out" +Bump `session.cookieCache.version` and redeploy (every existing `session_data` +is refused), or rotate `BETTER_AUTH_SECRET` (invalidates every signed cookie, +including forged ones). Changing any of the settings above also invalidates +existing cookies once. ## Session shape (post-customSession) ```typescript session.user = { - id: string; // Better Auth internal (nanoid). NOT the MP User_GUID. - email: string; - name: string; // "First Last" from getUserInfo + id: string; // Better Auth internal. NOT the MP User_GUID. + email: string; // synthetic @mp.invalid + name: string; firstName: string; // user.name.split(" ")[0] lastName: string; // user.name.split(" ").slice(1).join(" ") userGuid?: string; // MP User_GUID (OIDC sub). Type requires cast — see user-identity.md - // ... other Better Auth user fields + mpEmail?: string | null; }; session.session = { id: string; - expiresAt: Date; - // ... Better Auth session metadata + userId: string; + expiresAt: Date; // sign-in + 12h + createdAt: Date; + updatedAt: Date; + // token, ipAddress, userAgent: withheld }; ``` -`userProfile` is **not** on the session — it's loaded client-side by `UserProvider` (asserted by `src/auth.test.ts:113-128`). - ## Access patterns ### Server Component / Server Action ```typescript -// real pattern: src/components/layout/auth-wrapper.tsx:6-7 import { auth } from "@/lib/auth"; import { headers } from "next/headers"; const session = await auth.api.getSession({ headers: await headers() }); if (!session) redirect("/signin"); ``` - -### Server Action needing userGuid -```typescript -// real pattern: src/contexts/user-context.tsx:29 (same cast server-side) -const session = await auth.api.getSession({ headers: await headers() }); -const userGuid = (session?.user as { userGuid?: string } | undefined)?.userGuid; -``` +Feature actions and services use `AuthorizationService`, not a bare session +check — see `../security/README.md`. ### Client Component (hook) ```typescript -// real pattern: src/contexts/user-context.tsx:22, 29 "use client"; import { authClient } from "@/lib/auth-client"; @@ -139,24 +166,17 @@ const { data: session, isPending } = authClient.useSession(); const userGuid = (session?.user as { userGuid?: string } | undefined)?.userGuid; ``` -### `useAppSession` wrapper -```typescript -// src/contexts/session-context.tsx:7-10 -export function useAppSession() { - const { data } = authClient.useSession(); - return data; -} -``` -Returns `data` directly (no `isPending` exposure). - ## Gotchas -- **⚠️ No DB adapter = in-memory session store (production risk).** No `database` key is configured in `src/lib/auth.ts`, so better-auth falls back to its in-memory adapter. Session state lives only in-memory + the signed cookie. A process restart drops the store, and on serverless/Vercel **every cold start begins with an empty store** — so once the 1-hour cookie cache expires, a request that lands on a fresh instance returns `null` and the user intermittently appears logged out. **Recommended follow-up: configure a persistent adapter before production.** This is the top auth refactor, independent of the userGuid fix. -- **`userGuid` requires a cast** on both server and client because `customSessionClient` and `customSession`'s inferred output don't expose `additionalFields`. This is a Better Auth type limitation. See `user-identity.md#type-casts`. -- **1-hour `cookieCache` staleness**: Changes to `customSession` logic (e.g., adding a field) won't appear in returning users' sessions until the cookie cache expires or they re-auth. -- **Refresh tokens**: `offline_access` scope is requested and `storeAccountCookie: true` persists tokens, but automatic refresh behavior in stateless cookie mode is **not explicitly implemented or tested** in the codebase. +- **Serverless re-sign-in after an hour.** See the `refreshCache` trade-off above. + If that proves too disruptive, the fix is a shared session store, not turning + `refreshCache` back on. +- **`userGuid` requires a cast** on both server and client — a Better Auth type + limitation. See `user-identity.md#type-casts`. +- **Don't test with `auth.api.getSession` on a fresh instance and expect a + cookie-only session to live past 1 h** — that is the point. ## Related docs - `oauth-flow.md` — how the session is created -- `user-identity.md` — `user.id` vs `userGuid` — critical +- `user-identity.md` — `user.id` vs `userGuid` - `route-protection.md` — how the session cookie gates protected routes -- `../contexts/README.md` — `UserProvider` that enriches with the MP profile +- `../security/README.md` — secrets, rotation, findings diff --git a/.claude/references/security/README.md b/.claude/references/security/README.md index e259740..c8af96e 100644 --- a/.claude/references/security/README.md +++ b/.claude/references/security/README.md @@ -10,6 +10,8 @@ policy. Read this before changing anything under `src/lib/auth.ts`, | Which Better Auth endpoints are reachable? | [Endpoint surface](#endpoint-surface) | | Why is `style-src 'unsafe-inline'`? Can I tighten it? | [Headers and CSP](#headers-and-csp) | | How is a user identified, and why is their email `@mp.invalid`? | [Identity](#identity) | +| How long does a session live? What must the secret look like? | [Sessions and secrets](#sessions-and-secrets) | +| We forked this repo — what do we have to do? | [Downstream clones](#downstream-clones) | | What was fixed, and what is still open? | [Findings](#findings) | --- @@ -320,6 +322,61 @@ path too, so it can constrain the GUID's *shape* but cannot tell --- +## Sessions and secrets + +Full detail in [`../auth/sessions.md`](../auth/sessions.md). The security-relevant +points (security Step 3, 2026-09-30): + +- **Hard 12 h ceiling** (`expiresIn`, `disableSessionRefresh`). A cookie pair + not backed by a live in-memory row — copied before sign-out, forged from a + leaked secret, or replayed at another serverless instance — dies within + **1 h** (`cookieCache.maxAge` with `refreshCache: false`). The earlier + "up to an hour" claim for forged sessions was **wrong**: stateless + better-auth defaults `refreshCache` on, and such a cookie re-signed itself + for up to 7 days. +- **`session_data` is encrypted (JWE)**, not a readable JWT; `/get-session` + withholds `token`, `ipAddress` and `userAgent`. +- **No user MP OAuth tokens**: no `offline_access`, no `account_data` cookie, + access/refresh tokens nulled in the in-memory account row. The `idToken` is + kept in memory for a future `id_token_hint` (Step 4). +- **Boot refusals**: missing, default or < 32-char `BETTER_AUTH_SECRET`, + `BETTER_AUTH_SECRETS`, `TEST` in production, or a malformed/non-https + `BETTER_AUTH_URL` / `MINISTRY_PLATFORM_BASE_URL` all stop the process + (including `next build`). `advanced.disableOriginCheck` is pinned `false`. +- **Rate-limit client IP**: `AUTH_IP_ADDRESS_HEADERS` / `AUTH_TRUSTED_PROXIES`, + invalid entries refuse startup. On Vercel the default is + `x-vercel-forwarded-for,x-real-ip` — Vercel's edge sets both to the client IP + and does not forward client-supplied values + (, checked 2026-09-30); + `x-vercel-forwarded-for` is first because it survives a proxy in front of + Vercel. Off Vercel the default stays better-auth's single-value + `x-forwarded-for`, because there these headers are client-controlled. +- **Git hygiene**: `.env*` (except `.env.example`), `.vercel`, + `.claude/settings.local.json` and `.claude/worktrees/` are ignored; + `.githooks/pre-commit` refuses staged env files. `.claude/settings.local.json` + was untracked on 2026-09-30 (it granted `Bash(sed:*)` and `rm -rf .claude/*`). +- **Emergency sign-out of everyone**: bump `cookieCache.version` and redeploy, + or rotate `BETTER_AUTH_SECRET`. + +## Downstream clones + +No advisory or Dependabot alert reaches a copy of this repo. **Every downstream +clone made before 2026-09-30 should:** + +1. Port `disabledPaths` + the deny-by-default auth route allowlist + (F-UPDATE-USER, exposure window 2026-07-09 → 2026-09-13 here). +2. **Rotate its own `BETTER_AUTH_SECRET`** (`openssl rand -base64 32`) in every + environment. Only rotation invalidates a forged cookie minted during the + window; without `refreshCache: false` such a cookie keeps re-signing itself. +3. Review its own `dp_Audit_Log` for writes via the app during its exposure + window whose `User_ID` doesn't match the signed-in person. +4. Port the session hardening above. + +`SECURITY.md` at the repo root carries the same list for people who never open +`.claude/`. + +--- + ## Logging The rule: **identifiers and shape, never content.** `no-console` is enforced by @@ -392,6 +449,7 @@ Closed in this repo, from the upstream MPNext hardening playbook | F3b | Low–Medium | F3 bypass: `?callbackUrl=/%09/evil` — the URL parser strips tab/LF/CR after string checks. Closed 2026-09-30 (sanitizer mirrors `isSafeRelativeURL`, returns the raw value) | | F12 | Low–Medium | `/sign-in/social` id_token branch + caller-supplied access token = sign in as another user. Closed 2026-09-30 by `hooks.before` (`ID_TOKEN_SIGN_IN_DISABLED`) + route body filter + `/link-social` disabled. MP's client allows implicit/hybrid, hence Low–Medium. The `sub` binding in `getUserInfo` is **still open** (Step 4) | | F7 | Low | ~30 Better Auth endpoints publicly mounted; OAuth errors on a third-party page | +| P2/P3/P6 (2026-09-28 review) | Medium | Sessions up to 7 days via default `refreshCache`; readable JWT cookie; user MP refresh token in an `account_data` cookie; no boot-time secret/URL guards; rate-limit IP unconfigured; `.env*` not ignored; `settings.local.json` tracked. Closed 2026-09-30 (security Step 3) — see [Sessions and secrets](#sessions-and-secrets) | | F8 | Low | **Resolved as WONTFIX.** PKCE stays `false`: MP advertises `S256` in discovery but rejects the token exchange with `invalid_grant`. See below. | **Not applicable to this repo:** F4 (no `Made_By` / contact-log feature, and @@ -432,10 +490,16 @@ which is a confidential client's normal posture. - **The enforced-CSP browser walk has not been done.** Headers and nonce coverage were verified against `next start`; a human still needs to click through every Radix surface under enforcement. -- **`BETTER_AUTH_SECRET` should be rotated** if this deployment was ever exposed - to F-UPDATE-USER. Patching does not revoke sessions already forged — they - survive in the JWT cookie cache for up to an hour, and with no database there - is no session table to clear. +- **`BETTER_AUTH_SECRET` must be rotated** (owner decision 2026-09-30) in every + Vercel environment and `.env.local` — this deployment was exposed to + F-UPDATE-USER from 2026-07-09 to 2026-09-13. Patching does not revoke sessions + already forged, and with `refreshCache` on (fixed 2026-09-30) they could keep + re-signing themselves; with no database there is no session table to clear. + Tracked in `.claude/TODO/2026-09-30-ops-rotate-better-auth-secret.md`. +- **Repo settings**: GitHub private vulnerability reporting, secret scanning, + push protection and Dependabot security updates were all **disabled** on + 2026-09-30 (public repo). `SECURITY.md` points reporters at private + vulnerability reporting, so it needs enabling. - A transient **discovery failure at boot disables the OAuth provider for the life of the process**, with no retry (inherited upstream issue). It also inverts the sign-in failure mode: a *working* discovery is what turns on diff --git a/.claude/references/testing/setup.md b/.claude/references/testing/setup.md index 1697354..87777fa 100644 --- a/.claude/references/testing/setup.md +++ b/.claude/references/testing/setup.md @@ -136,7 +136,8 @@ vi.stubEnv('MINISTRY_PLATFORM_CLIENT_ID', 'test-client-id'); vi.stubEnv('MINISTRY_PLATFORM_CLIENT_SECRET', 'test-client-secret'); vi.stubEnv('MINISTRY_PLATFORM_DEV_CLIENT_ID', 'test-dev-client-id'); vi.stubEnv('MINISTRY_PLATFORM_DEV_CLIENT_SECRET', 'test-dev-client-secret'); -vi.stubEnv('NEXTAUTH_SECRET', 'test-secret-key-for-testing'); +// >= 32 chars, so it would also pass assertAuthEnvironment (src/lib/auth.ts). +vi.stubEnv('NEXTAUTH_SECRET', 'test-secret-key-for-testing-0123456789'); vi.stubEnv('NEXTAUTH_URL', 'http://localhost:3000'); vi.stubEnv('NODE_ENV', 'test'); ``` diff --git a/.claude/settings.local.json b/.claude/settings.local.json deleted file mode 100644 index db1ace0..0000000 --- a/.claude/settings.local.json +++ /dev/null @@ -1,30 +0,0 @@ -{ - "permissions": { - "allow": [ - "Bash(node --version:*)", - "Bash(sed:*)", - "Bash(node cleanup-models.js:*)", - "Bash(npm run build:*)", - "Bash(git rm *)", - "Bash(rm \"S:/MP/MPNext-Tools/.claude/TODO/2026-04-17-components-field-management-claudemd-missing.md\" \"S:/MP/MPNext-Tools/.claude/TODO/2026-04-17-components-field-management-readme-missing.md\")", - "Bash(git -C \"S:/MP/MPNext-Tools\" status)", - "Read(.claude/**)", - "Edit(.claude/**)", - "Write(.claude/**)", - "Glob(.claude/**)", - "Grep(.claude/**)", - "Bash(ls .claude*)", - "Bash(cat .claude/*)", - "Bash(mkdir .claude/*)", - "Bash(mkdir -p .claude/*)", - "Bash(touch .claude/*)", - "Bash(cp .claude/*)", - "Bash(mv .claude/*)", - "Bash(rm .claude/*)", - "Bash(rm -r .claude/*)", - "Bash(rm -rf .claude/*)" - ], - "deny": [], - "ask": [] - } -} diff --git a/.env.example b/.env.example index a3f5b37..4620df4 100644 --- a/.env.example +++ b/.env.example @@ -4,14 +4,21 @@ # Better Auth handles user authentication via OAuth against Ministry Platform. # Docs: https://www.better-auth.com/docs -# Secret used by Better Auth for signing session cookies and tokens. -# Must be at least 32 characters with high entropy. +# Secret used by Better Auth to sign and encrypt session cookies. +# REQUIRED. The app REFUSES TO START (including `next build`) when it is unset, +# shorter than 32 characters, or better-auth's public default secret — a known +# secret lets anyone forge a session for any user. # Generate one with: openssl rand -base64 32 +# Rotating it signs every user out once and invalidates any forged cookie. +# Do NOT set BETTER_AUTH_SECRETS (versioned secrets): it would silently take +# precedence over this value, so the app refuses to start when it is set. # Fallback: NEXTAUTH_SECRET (for migrating from NextAuth) BETTER_AUTH_SECRET= # Base URL of this application (used for OAuth callback URLs, etc.) -# Update to match your production environment. +# Update to match your production environment. REQUIRED and validated at start: +# an ORIGIN only (no path), https:// for every real host (http:// is accepted +# only for localhost / 127.0.0.1 / [::1]), no credentials, query or fragment. # Fallback: NEXTAUTH_URL (for migrating from NextAuth) BETTER_AUTH_URL=http://localhost:3000 @@ -23,6 +30,8 @@ BETTER_AUTH_URL=http://localhost:3000 MINISTRY_PLATFORM_CLIENT_ID=MPNext MINISTRY_PLATFORM_CLIENT_SECRET= +# Validated at start: https:// (http://localhost only outside production), +# no credentials, query or fragment. A trailing slash is stripped. MINISTRY_PLATFORM_BASE_URL=https://mpi.ministryplatform.com/ministryplatformapi # ----------------------------------------------------------------------------- @@ -94,3 +103,24 @@ MP_SECURITY_ROLES=* # drops to report-only, so a typo fails loud (too-strict header) rather than # silent (no policy). Set to "false" only to diagnose a violation. CSP_ENFORCE= + +# Client IP for sign-in rate limiting (comma-separated header names, tried in +# order). Name ONLY a header your edge always OVERWRITES — a header clients can +# set themselves lets them dodge the limit or lock someone else out. Never use a +# raw, client-appendable X-Forwarded-For chain here. Invalid names refuse startup. +# +# unset, on Vercel x-vercel-forwarded-for,x-real-ip (the default: Vercel's +# edge sets both to the client IP and drops client values) +# unset, elsewhere better-auth's default: a single-value X-Forwarded-For +# Cloudflare cf-connecting-ip +# Azure Front Door x-azure-clientip +# Azure App Service x-client-ip +# nginx / other proxy the header it SETS (e.g. x-real-ip with +# `proxy_set_header X-Real-IP $remote_addr;`) +# Vercel behind Cloudflare cf-connecting-ip (and lock the origin to Cloudflare) +AUTH_IP_ADDRESS_HEADERS= + +# For a proxy that APPENDS to X-Forwarded-For instead: comma-separated proxy IPs +# or CIDR ranges. The chain is walked right to left past these; the first +# untrusted hop is the client. Invalid entries refuse startup. +AUTH_TRUSTED_PROXIES= diff --git a/.githooks/pre-commit b/.githooks/pre-commit new file mode 100755 index 0000000..b23fafa --- /dev/null +++ b/.githooks/pre-commit @@ -0,0 +1,32 @@ +#!/bin/sh +# +# Blocks commits that would land an environment file other than .env.example +# (.env, .env.local, .env.production, sub/dir/.env.test.local, ...). These hold +# the admin-level MP service-account secret and BETTER_AUTH_SECRET, and this +# repo's forks are often public. .gitignore already ignores .env*, so this +# catches `git add -f` and an edited .gitignore. Deleting a tracked one is +# allowed (that is the fix, not the leak). +# +# Installed by the `prepare` script in package.json, which points +# core.hooksPath at this directory. Ported from upstream MPNext. + +# -z + tr: NUL-separated names are never C-quoted (core.quotePath), so a +# non-ASCII directory name cannot wrap the path in quotes and slip a trailing +# .env past the anchored match below. +staged_env_files=$( + git diff --cached --name-only --diff-filter=ACMR -z | + tr '\000' '\n' | + grep -E '(^|/)\.env[^/]*$' | + grep -Ev '(^|/)\.env\.example$' +) +if [ -n "$staged_env_files" ]; then + echo "Commit blocked: environment files are staged. They can hold secrets" + echo "(e.g. MINISTRY_PLATFORM_CLIENT_SECRET, BETTER_AUTH_SECRET) and must never be committed:" + echo "$staged_env_files" | sed 's/^/ /' + echo "" + echo "Unstage them with: git restore --staged " + echo "Only .env.example (placeholder values only) may be committed." + exit 1 +fi + +exit 0 diff --git a/.gitignore b/.gitignore index 00e8dd6..61a898d 100644 --- a/.gitignore +++ b/.gitignore @@ -412,7 +412,19 @@ FodyWeavers.xsd *.sln.iml # Environment -.env.local +# Every .env* file (.env, .env.local, .env.production, .env.*.local, ...) can +# hold the MP service-account secret or BETTER_AUTH_SECRET. Only the +# placeholder template is committed. .githooks/pre-commit enforces this for +# `git add -f` too. +.env* +!.env.example + +# Vercel CLI project link / pulled env (vercel env pull writes secrets here) +.vercel + +# Claude Code personal settings (per-developer permissions; never shared) +.claude/settings.local.json +.claude/worktrees/ # nextJS .next/* diff --git a/CLAUDE.md b/CLAUDE.md index 3d4af31..4c4f1ac 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -54,8 +54,10 @@ to keep them from diverging. - **Auth**: Better Auth with Ministry Platform OAuth via genericOAuth plugin — see **[Auth Reference](.claude/references/auth/README.md)** for full details - **Key files**: `src/lib/auth.ts` (server config), `src/lib/auth-client.ts` (client), `src/proxy.ts` (route protection) - **Critical**: `session.user.id` is Better Auth's internal ID, NOT the MP User_GUID. Use `session.user.userGuid` for all MP API lookups. - - **Stateless Sessions**: JWT cookie cache, no database; `customSession` does name splitting only (no API calls) - - **Required Environment Variables**: `MINISTRY_PLATFORM_BASE_URL`, `BETTER_AUTH_URL` (or `NEXTAUTH_URL` fallback), `BETTER_AUTH_SECRET` (or `NEXTAUTH_SECRET` fallback) + - **Stateless Sessions**: no database; hard 12 h lifetime, encrypted (JWE) cookie cache with `refreshCache: false`, no user OAuth tokens in cookies or memory (`storeAccountCookie: false`, no `offline_access`); `customSession` does name splitting and withholds `token`/`ipAddress`/`userAgent` (no API calls). See [sessions](.claude/references/auth/sessions.md) + - **Required Environment Variables**: `MINISTRY_PLATFORM_BASE_URL`, `BETTER_AUTH_URL` (or `NEXTAUTH_URL` fallback), `BETTER_AUTH_SECRET` (or `NEXTAUTH_SECRET` fallback). **The app refuses to boot** (including `next build`) on a missing/default/<32-char secret, `BETTER_AUTH_SECRETS`, `TEST` in production, or a non-https/malformed URL — read URLs through `src/lib/env.ts` (`getMpBaseUrl()`, `getAuthBaseUrl()`), never raw `process.env` + - **Optional**: `AUTH_IP_ADDRESS_HEADERS` / `AUTH_TRUSTED_PROXIES` — client-IP source for rate limiting (default on Vercel: `x-vercel-forwarded-for,x-real-ip`; see `.env.example`) + - **Git hooks**: `npm install` runs `prepare`, which sets `core.hooksPath=.githooks`; `.githooks/pre-commit` refuses staged `.env*` files other than `.env.example` - **`MP_SECURITY_ROLES` fails closed**: `*` = any MP security role, a comma list = only those roles, **unset/blank/`","` = nobody** (every tool redirects to `/no-access`). Set it in every environment. - **Services Layer**: Singleton service classes in `src/services/` wrap MPHelper for domain logic (ToolService, UserService, AddressLabelService, GroupService, FieldManagementService) - **Contexts**: React context providers in `src/contexts/` (UserProvider) composed in `src/app/providers.tsx`; `useAppSession()` wraps Better Auth's `authClient.useSession()` diff --git a/README.md b/README.md index fa27a1a..505eb9a 100644 --- a/README.md +++ b/README.md @@ -41,7 +41,7 @@ A Ministry Platform page tools application powered by Next.js 16, React 19, Bett ## Features -- **Authentication**: Better Auth with Ministry Platform OAuth (genericOAuth plugin), stateless JWT sessions, and OIDC RP-initiated logout +- **Authentication**: Better Auth with Ministry Platform OAuth (genericOAuth plugin), stateless encrypted-cookie sessions, and OIDC RP-initiated logout - **Tools Framework**: Reusable components for building Ministry Platform page tools with URL parameter parsing and dual-mode support (create/edit) - **Address Labels**: Print address labels with USPS Intelligent Mail Barcodes (IMb), POSTNET fallback, and Word document mail merge - **Template Editor**: Visual email/document template editor with GrapesJS, merge field support, and MP template integration @@ -84,7 +84,7 @@ MPHelper (Public API Facade) Better Auth with Ministry Platform OAuth via genericOAuth plugin (`src/lib/auth.ts`): -- **Stateless JWT cookie sessions** with 1-hour cache (no database required) +- **Stateless sessions** (no database required): hard 12-hour lifetime, encrypted cookie cache, no user OAuth tokens stored, and boot-time refusal of a weak/missing `BETTER_AUTH_SECRET` or bad base URLs - **Custom session enrichment** with name splitting via `customSession` plugin - **OIDC RP-initiated logout** for proper MP session termination - **Dual-layer route protection**: proxy-level cookie check (`src/proxy.ts`) + component-level session validation (`AuthWrapper`) diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..16ebf26 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,66 @@ +# Security Policy + +MPNext-Tools is a template that churches fork and copy. There is no package to +upgrade, so no Dependabot alert reaches a fork when something is fixed here. +If you run a copy, you are responsible for porting fixes into it — see +[Downstream clones](#downstream-clones) below. + +## Supported versions + +| Version | Supported | +|---|---| +| `main` (latest commit) | Yes | +| Anything else — older commits, tags, forks | No. Forks are maintained by their owners. | + +## Reporting a vulnerability + +**Please do not open a public issue, discussion or pull request for a +security problem.** + +Report privately through **GitHub private vulnerability reporting**: the +repository's **Security** tab → **Report a vulnerability**, or directly at +. + +It helps to include: + +- The commit (or date of your fork) you tested against, and the better-auth and + Next.js versions from your lockfile +- The affected file(s) and a description of the mechanism +- Steps to reproduce or a proof of concept — against a **non-production** + instance or a mock; please never test against a live Ministry Platform + database with real member data +- The impact as you understand it, and any fix you have in mind + +We aim to acknowledge a report within 5 business days and to agree a +disclosure date with you once a fix is available. + +## Downstream clones + +**If your repository was forked or copied from MPNext-Tools (or from MPNext) +before 2026-09-30, do all of the following:** + +1. **Port the `/update-user` fix** (`disabledPaths` in `src/lib/auth.ts` plus the + deny-by-default allowlist in `src/app/api/auth/[...all]/route.ts`). Between + 2026-07-09 and 2026-09-13 any signed-in user could rewrite their own session's + MP `User_GUID` and act as another user. +2. **Rotate your `BETTER_AUTH_SECRET`** (`openssl rand -base64 32`, at least 32 + characters) in every environment, and redeploy. Patching does not revoke a + forged session cookie; before the 2026-09-30 session changes such a cookie + could keep re-signing itself for up to 7 days. Rotating the secret is the + only thing that invalidates it. Every user is signed out once. +3. **Review your MP audit log** (`dp_Audit_Log`) for writes made through the app + during the window your deployment was exposed, looking for `User_ID`s that + do not match the person who was signed in. +4. **Port the session hardening** (12 h ceiling, encrypted cookie cache, + `refreshCache: false`, no user OAuth tokens, boot-time secret/URL refusals). + See `.claude/references/auth/sessions.md` and + `.claude/references/security/README.md`. + +## Secrets in this repository + +- `.env*` files other than `.env.example` are git-ignored, and + `.githooks/pre-commit` (installed by `npm install` via the `prepare` script) + refuses to commit one. +- `.env.example` holds placeholders only. Never put a real value in it. +- If a secret is ever committed, rotate it — removing it from history is not + enough once it has been pushed. diff --git a/package.json b/package.json index 380ac57..9bf3c10 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,8 @@ "mp:generate:storedprocs": "tsx src/lib/providers/ministry-platform/scripts/generate-storedprocs.ts", "mp:build:install": "tsx src/lib/providers/ministry-platform/scripts/build-sql-install.ts", "setup": "tsx scripts/setup.ts", - "setup:check": "tsx scripts/setup.ts --check" + "setup:check": "tsx scripts/setup.ts --check", + "prepare": "git config core.hooksPath .githooks || exit 0" }, "dependencies": { "@dnd-kit/abstract": "^0.5.0", diff --git a/src/auth.ip-address.test.ts b/src/auth.ip-address.test.ts new file mode 100644 index 0000000..dfee8ea --- /dev/null +++ b/src/auth.ip-address.test.ts @@ -0,0 +1,155 @@ +// @vitest-environment node +import { describe, it, expect, vi } from 'vitest'; +import { betterAuth } from 'better-auth'; +import { getIP, getIPFromHeader } from '@better-auth/core/utils/ip'; + +/** + * Guard for the client-IP header configuration (security Step 3, playbook + * Phase 6): which client IP better-auth's rate limiter keys on is + * host-specific, so it is configured by `AUTH_IP_ADDRESS_HEADERS` / + * `AUTH_TRUSTED_PROXIES`, defaulting to Vercel's edge-set headers only when + * running on Vercel (see `parseIpAddressOptions` in src/lib/auth.ts). Without + * it, clients whose IP can't be resolved all share one bucket and a handful of + * requests locks everyone out of sign-in. No network: the mock OIDC server + * throws for anything it doesn't serve, and the rate-limit test uses a bare + * better-auth instance with no providers. + */ + +await vi.hoisted(async () => (await import('@/test-utils/mock-oidc')).installMockOidc()); + +import { auth, parseIpAddressOptions, VERCEL_IP_ADDRESS_HEADERS } from '@/lib/auth'; + +const resolve = (headers: Record, env: Record) => + getIP(new Headers(headers), { advanced: { ipAddress: parseIpAddressOptions(env) } }); + +describe('parseIpAddressOptions', () => { + it('off Vercel and unconfigured: returns nothing (better-auth default, single-value x-forwarded-for)', () => { + expect(parseIpAddressOptions({})).toEqual({}); + expect(parseIpAddressOptions({ AUTH_IP_ADDRESS_HEADERS: ' , ', AUTH_TRUSTED_PROXIES: '' })).toEqual({}); + }); + + it('on Vercel and unconfigured: x-vercel-forwarded-for, then x-real-ip', () => { + expect(VERCEL_IP_ADDRESS_HEADERS).toEqual(['x-vercel-forwarded-for', 'x-real-ip']); + expect(parseIpAddressOptions({ VERCEL: '1' })).toEqual({ + ipAddressHeaders: ['x-vercel-forwarded-for', 'x-real-ip'], + }); + }); + + it('the Vercel default is a copy, so a caller cannot mutate the exported list', () => { + const { ipAddressHeaders } = parseIpAddressOptions({ VERCEL: '1' }); + ipAddressHeaders!.push('x-forwarded-for'); + expect(VERCEL_IP_ADDRESS_HEADERS).toEqual(['x-vercel-forwarded-for', 'x-real-ip']); + }); + + it('an explicit AUTH_IP_ADDRESS_HEADERS wins over the Vercel default', () => { + expect(parseIpAddressOptions({ VERCEL: '1', AUTH_IP_ADDRESS_HEADERS: 'cf-connecting-ip' })).toEqual({ + ipAddressHeaders: ['cf-connecting-ip'], + }); + }); + + it('parses, trims and lower-cases header names in order', () => { + expect( + parseIpAddressOptions({ AUTH_IP_ADDRESS_HEADERS: ' CF-Connecting-IP , x-forwarded-for ' }), + ).toEqual({ ipAddressHeaders: ['cf-connecting-ip', 'x-forwarded-for'] }); + }); + + it('parses trusted proxies (IPv4, IPv6, CIDR)', () => { + expect( + parseIpAddressOptions({ AUTH_TRUSTED_PROXIES: '10.0.0.5, 10.1.0.0/16,2001:db8::/32, ::1' }), + ).toEqual({ trustedProxies: ['10.0.0.5', '10.1.0.0/16', '2001:db8::/32', '::1'] }); + }); + + it.each(['x forwarded for', 'x-ip;evil', 'x_real_ip'])('refuses an invalid header name: %s', (h) => { + expect(() => parseIpAddressOptions({ AUTH_IP_ADDRESS_HEADERS: h })).toThrow( + /AUTH_IP_ADDRESS_HEADERS has invalid header names/, + ); + }); + + it.each(['10.0.0.300', 'proxy.local', '10.0.0.0/33', '2001:db8::/129', '10.0.0.0/', '10.0.0.0/x', '10.0.0.0/0016'])( + 'refuses an invalid trusted proxy: %s', + (p) => { + expect(() => parseIpAddressOptions({ AUTH_TRUSTED_PROXIES: p })).toThrow( + /AUTH_TRUSTED_PROXIES has entries that are not an IP address or CIDR range/, + ); + }, + ); + + it('is what the real auth instance uses', () => { + expect(auth.options.advanced?.ipAddress).toEqual(parseIpAddressOptions(process.env)); + }); +}); + +describe('resolved client IP per host header format', () => { + it('Vercel default: x-vercel-forwarded-for wins over x-forwarded-for, which a proxy in front may overwrite', () => { + expect( + resolve( + { 'x-vercel-forwarded-for': '203.0.113.9', 'x-real-ip': '203.0.113.9', 'x-forwarded-for': '198.51.100.1' }, + { VERCEL: '1' }, + ), + ).toBe('203.0.113.9'); + }); + + it('Vercel default: falls through to x-real-ip', () => { + expect(resolve({ 'x-real-ip': '203.0.113.12' }, { VERCEL: '1' })).toBe('203.0.113.12'); + }); + + it('Cloudflare: cf-connecting-ip wins over a spoofable x-forwarded-for', () => { + const env = { AUTH_IP_ADDRESS_HEADERS: 'cf-connecting-ip' }; + expect(resolve({ 'cf-connecting-ip': '203.0.113.7', 'x-forwarded-for': '198.51.100.1' }, env)).toBe( + '203.0.113.7', + ); + }); + + it('Azure Front Door: x-azure-clientip resolves where x-forwarded-for carries ip:port', () => { + const headers = { 'x-azure-clientip': '203.0.113.8', 'x-forwarded-for': '203.0.113.8:51234' }; + // Unconfigured, the ip:port value is not a valid IP (so it is unresolved). + expect(getIPFromHeader(headers['x-forwarded-for'])).toBeNull(); + expect(resolve(headers, { AUTH_IP_ADDRESS_HEADERS: 'x-azure-clientip' })).toBe('203.0.113.8'); + }); + + it('reverse proxy appending to x-forwarded-for: trusted proxies are skipped from the right', () => { + const chain = '198.51.100.99, 203.0.113.10, 10.0.0.5'; + // Unconfigured, a multi-value chain is unresolved (shared bucket). + expect(getIPFromHeader(chain)).toBeNull(); + // Configured: the spoofable leftmost value is ignored; the first untrusted hop is the client. + expect(getIPFromHeader(chain, parseIpAddressOptions({ AUTH_TRUSTED_PROXIES: '10.0.0.0/24' }))).toBe( + '203.0.113.10', + ); + }); +}); + +describe('rate limiting keys on the configured client IP', () => { + const ORIGIN = 'http://localhost:3000'; + + function buildInstance(env: Record) { + return betterAuth({ + secret: 'x'.repeat(40), + baseURL: ORIGIN, + rateLimit: { enabled: true }, + advanced: { ipAddress: parseIpAddressOptions(env) }, + logger: { disabled: true }, + }); + } + + const signIn = (instance: ReturnType, ip: string) => + instance.handler( + new Request(`${ORIGIN}/api/auth/sign-in/social`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Origin: ORIGIN, 'x-vercel-forwarded-for': ip }, + body: JSON.stringify({ provider: 'none' }), + }), + ); + + it('negative control: unconfigured off Vercel, three requests from one client lock out a different client', async () => { + const instance = buildInstance({}); + for (let i = 0; i < 3; i++) expect((await signIn(instance, '203.0.113.20')).status).not.toBe(429); + expect((await signIn(instance, '203.0.113.21')).status).toBe(429); + }); + + it('on Vercel: each client gets its own bucket', async () => { + const instance = buildInstance({ VERCEL: '1' }); + for (let i = 0; i < 3; i++) expect((await signIn(instance, '203.0.113.30')).status).not.toBe(429); + expect((await signIn(instance, '203.0.113.30')).status).toBe(429); + expect((await signIn(instance, '203.0.113.31')).status).not.toBe(429); + }); +}); diff --git a/src/auth.secret-guard.test.ts b/src/auth.secret-guard.test.ts new file mode 100644 index 0000000..5fca4c4 --- /dev/null +++ b/src/auth.secret-guard.test.ts @@ -0,0 +1,203 @@ +// @vitest-environment node +import { readFileSync } from 'node:fs'; +import path from 'node:path'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; + +/** + * Guard for `assertAuthEnvironment` in src/lib/auth.ts (review item + * security-auth-secret-fallback-and-test-flag). + * + * With no secret, better-auth 1.7.6 signs sessions with its PUBLIC default + * secret and only refuses that when NODE_ENV is "production"; a truthy `TEST` + * env var skips its validation entirely. In stateless mode a known secret + * means anyone can mint a session for any userGuid. These tests prove that + * IMPORTING `@/lib/auth` throws for each bad configuration — removing the + * module-level call (or any single check) turns them red. + * + * `fetch` is stubbed to throw so nothing can leave the process (this fork's + * genericOAuth config fetches discovery when the instance initialises; the + * blocked fetch just leaves the provider unregistered, which these tests don't + * exercise). + */ + +// Every test re-imports `@/lib/auth` from a reset module graph, which is slow +// under coverage instrumentation on a loaded runner; give it headroom rather +// than let a cold import trip the 5 s default. +vi.setConfig({ testTimeout: 30_000 }); + +const GOOD_SECRET = 'a-perfectly-fine-test-secret-0123456789'; +const KEYS = [ + 'VITEST', 'BETTER_AUTH_SECRET', 'NEXTAUTH_SECRET', 'BETTER_AUTH_SECRETS', 'NODE_ENV', 'TEST', + 'BETTER_AUTH_URL', 'NEXTAUTH_URL', 'MINISTRY_PLATFORM_BASE_URL', +] as const; +// test-setup.ts stubs an http://localhost BETTER_AUTH_URL, which src/lib/env.ts +// refuses in production; the production cases below use this one instead. +const PROD_URL = 'https://app.example.org'; +let saved: Record; + +beforeEach(() => { + saved = Object.fromEntries(KEYS.map((k) => [k, process.env[k]])); + vi.resetModules(); + vi.stubGlobal('fetch', vi.fn(async (input: unknown) => { + throw new Error(`Blocked unexpected fetch in test: ${String(input)}`); + })); + vi.spyOn(console, 'error').mockImplementation(() => {}); + vi.spyOn(console, 'warn').mockImplementation(() => {}); +}); + +afterEach(() => { + setEnv(saved); + vi.unstubAllGlobals(); + vi.restoreAllMocks(); +}); + +/** Sets the env for one import; `undefined` deletes the variable. */ +function setEnv(values: Partial>) { + for (const [k, v] of Object.entries(values)) { + if (v === undefined) delete process.env[k]; + else process.env[k] = v; + } +} + +/** Import `@/lib/auth` fresh with the Vitest exemption switched OFF. */ +async function importAuthAsIfNotVitest() { + setEnv({ VITEST: undefined }); + return import('@/lib/auth'); +} + +describe('assertAuthEnvironment at module load', () => { + it('control: a valid secret imports cleanly outside the Vitest exemption', async () => { + setEnv({ BETTER_AUTH_SECRET: GOOD_SECRET, NEXTAUTH_SECRET: undefined, BETTER_AUTH_SECRETS: undefined, TEST: undefined }); + const mod = await importAuthAsIfNotVitest(); + expect(mod.auth).toBeDefined(); + }); + + it('control: NEXTAUTH_SECRET is still accepted as the fallback', async () => { + setEnv({ BETTER_AUTH_SECRET: undefined, NEXTAUTH_SECRET: GOOD_SECRET, BETTER_AUTH_SECRETS: undefined, TEST: undefined }); + await expect(importAuthAsIfNotVitest()).resolves.toHaveProperty('auth'); + }); + + it('refuses to import with no secret at all (development, NODE_ENV unset)', async () => { + setEnv({ BETTER_AUTH_SECRET: undefined, NEXTAUTH_SECRET: undefined, NODE_ENV: undefined, TEST: undefined }); + await expect(importAuthAsIfNotVitest()).rejects.toThrow(/BETTER_AUTH_SECRET is not set/); + }); + + it('refuses to import with an empty secret', async () => { + setEnv({ BETTER_AUTH_SECRET: '', NEXTAUTH_SECRET: undefined, NODE_ENV: 'development' }); + await expect(importAuthAsIfNotVitest()).rejects.toThrow(/BETTER_AUTH_SECRET is not set/); + }); + + it('refuses to import with better-auth\'s public default secret, even outside production', async () => { + setEnv({ + BETTER_AUTH_SECRET: 'better-auth-secret-12345678901234567890', + NODE_ENV: 'development', + }); + await expect(importAuthAsIfNotVitest()).rejects.toThrow(/public default secret/); + }); + + it('refuses to import with a secret shorter than 32 characters', async () => { + setEnv({ BETTER_AUTH_SECRET: 'changeme', NODE_ENV: 'development' }); + await expect(importAuthAsIfNotVitest()).rejects.toThrow(/at least 32 characters/); + }); + + it('refuses to import when BETTER_AUTH_SECRETS would silently override the validated secret', async () => { + setEnv({ BETTER_AUTH_SECRET: GOOD_SECRET, BETTER_AUTH_SECRETS: '1:some-other-secret-value-0123456789' }); + await expect(importAuthAsIfNotVitest()).rejects.toThrow(/BETTER_AUTH_SECRETS is set/); + }); + + it.each(['1', 'true', 'yes'])('refuses to import with TEST=%s on a production process', async (flag) => { + setEnv({ BETTER_AUTH_SECRET: GOOD_SECRET, BETTER_AUTH_SECRETS: undefined, NODE_ENV: 'production', TEST: flag, BETTER_AUTH_URL: PROD_URL }); + await expect(importAuthAsIfNotVitest()).rejects.toThrow(/TEST is set on a production process/); + }); + + it('allows TEST=false on a production process (better-auth reads it as false too)', async () => { + setEnv({ BETTER_AUTH_SECRET: GOOD_SECRET, BETTER_AUTH_SECRETS: undefined, NODE_ENV: 'production', TEST: 'false', BETTER_AUTH_URL: PROD_URL }); + await expect(importAuthAsIfNotVitest()).resolves.toHaveProperty('auth'); + }); + + it('never puts the secret in the error message', async () => { + const shortSecret = 'short-but-secret'; + setEnv({ BETTER_AUTH_SECRET: shortSecret, NODE_ENV: 'development' }); + const error = await importAuthAsIfNotVitest().catch((e: unknown) => e); + expect(error).toBeInstanceOf(Error); + expect((error as Error).message).not.toContain(shortSecret); + }); + + it('is skipped under Vitest (the only exemption), so tests can build instances freely', async () => { + setEnv({ VITEST: 'true', BETTER_AUTH_SECRET: GOOD_SECRET, NODE_ENV: 'production', TEST: '1', BETTER_AUTH_URL: PROD_URL }); + await expect(import('@/lib/auth')).resolves.toHaveProperty('auth'); + }); +}); + +/** + * Review item security-auth-url-env-not-validated: the two auth-critical URLs are + * validated at module load (src/lib/env.ts has the per-value cases). Unlike the + * secret guard these run under Vitest too, so no exemption is switched off. + */ +describe('auth-critical URLs at module load', () => { + it.each([ + ['BETTER_AUTH_URL', { BETTER_AUTH_URL: undefined, NEXTAUTH_URL: undefined }, /BETTER_AUTH_URL is not set/], + ['MINISTRY_PLATFORM_BASE_URL', { MINISTRY_PLATFORM_BASE_URL: undefined }, /MINISTRY_PLATFORM_BASE_URL is not set/], + ])('refuses to import with %s unset (no Host-header fallback, no undefined/oauth)', async (_n, env, message) => { + setEnv(env); + await expect(import('@/lib/auth')).rejects.toThrow(message); + }); + + it('refuses an http:// BETTER_AUTH_URL on a production process', async () => { + setEnv({ BETTER_AUTH_SECRET: GOOD_SECRET, NODE_ENV: 'production', TEST: undefined, BETTER_AUTH_URL: 'http://app.example.org' }); + await expect(importAuthAsIfNotVitest()).rejects.toThrow(/BETTER_AUTH_URL must use https:\/\//); + }); + + it('accepts a loopback http BETTER_AUTH_URL on a production process (local / CI next build)', async () => { + setEnv({ BETTER_AUTH_SECRET: GOOD_SECRET, NODE_ENV: 'production', TEST: undefined, BETTER_AUTH_URL: 'http://localhost:3000' }); + const { auth } = await importAuthAsIfNotVitest(); + expect(auth.options.baseURL).toBe('http://localhost:3000'); + }); + + it('refuses an http:// MINISTRY_PLATFORM_BASE_URL in any environment', async () => { + setEnv({ MINISTRY_PLATFORM_BASE_URL: 'http://mp.example.org' }); + await expect(import('@/lib/auth')).rejects.toThrow(/MINISTRY_PLATFORM_BASE_URL must use https:\/\//); + }); + + it('uses the normalized values: origin-only baseURL, no //oauth', async () => { + setEnv({ BETTER_AUTH_URL: 'https://app.example.org/', MINISTRY_PLATFORM_BASE_URL: 'https://mp.example.org/api/' }); + const { auth, ministryPlatformProviderConfig } = await import('@/lib/auth'); + expect(auth.options.baseURL).toBe('https://app.example.org'); + expect(ministryPlatformProviderConfig.discoveryUrl).toBe( + 'https://mp.example.org/api/oauth/.well-known/openid-configuration', + ); + }); +}); + +describe('assertAuthEnvironment (pure function)', () => { + it('accepts a valid configuration', async () => { + const { assertAuthEnvironment } = await import('@/lib/auth'); + expect(() => assertAuthEnvironment({ BETTER_AUTH_SECRET: GOOD_SECRET, NODE_ENV: 'production' })).not.toThrow(); + }); + + it('prefers BETTER_AUTH_SECRET over NEXTAUTH_SECRET, like the auth options do', async () => { + const { assertAuthEnvironment } = await import('@/lib/auth'); + expect(() => + assertAuthEnvironment({ BETTER_AUTH_SECRET: 'short', NEXTAUTH_SECRET: GOOD_SECRET }), + ).toThrow(/at least 32 characters/); + }); + + it('pins the same default secret the installed better-auth ships (drift guard)', async () => { + const { BETTER_AUTH_DEFAULT_SECRET } = await import('@/lib/auth'); + // DEFAULT_SECRET is not exported by better-auth; read its source instead. + const constants = readFileSync( + path.join(process.cwd(), 'node_modules/better-auth/dist/utils/constants.mjs'), + 'utf8', + ); + expect(constants).toContain(`DEFAULT_SECRET = "${BETTER_AUTH_DEFAULT_SECRET}"`); + }); +}); + +describe('advanced.disableOriginCheck', () => { + it('is pinned to false so a TEST env var cannot switch the origin check off', async () => { + const { auth } = await import('@/lib/auth'); + expect(auth.options.advanced?.disableOriginCheck).toBe(false); + const context = await auth.$context; + expect(context.skipOriginCheck).toBe(false); + }); +}); diff --git a/src/auth.session-config.test.ts b/src/auth.session-config.test.ts new file mode 100644 index 0000000..811cddd --- /dev/null +++ b/src/auth.session-config.test.ts @@ -0,0 +1,165 @@ +// @vitest-environment node +// (node, not jsdom: better-auth verifies the id_token with `jose` over WebCrypto, +// which rejects jsdom-realm typed arrays.) +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { betterAuth } from 'better-auth'; +import { symmetricDecodeJWT } from 'better-auth/crypto'; + +/** + * Pins what the session cookies the real `auth` instance sets actually carry + * (security Step 3, playbook Phase 3), so a better-auth upgrade that changes + * stateless defaults, or a config edit, cannot ship unnoticed: + * - exactly `session_token` + `session_data`, no `account_data` cookie + * (`storeAccountCookie: false`), with the pinned Max-Age values; + * - `session_data` is a JWE: opaque without the secret, and its DECRYPTED + * payload carries no MP OAuth token; + * - `/get-session` withholds `token`, `ipAddress` and `userAgent`. + * + * `fetch` THROWS for any URL the mock OIDC server does not serve. + */ + +const oidc = await vi.hoisted(async () => + (await import('@/test-utils/mock-oidc')).installMockOidc(), +); + +import { + auth, + enrichSession, + SESSION_COOKIE_CACHE_MAX_AGE_SECONDS, + SESSION_EXPIRES_IN_SECONDS, + WITHHELD_SESSION_FIELDS, +} from '@/lib/auth'; +import { codeFlow, getSession, MOCK_ACCESS_TOKEN, MOCK_REFRESH_TOKEN } from '@/test-utils/mock-oidc'; + +const ORIGIN = 'http://localhost:3000'; +const T0 = new Date('2026-09-30T08:00:00Z').getTime(); + +/** Set-Cookie lines keyed by cookie name, attributes lower-cased for matching. */ +function setCookies(response: Response): Map { + const out = new Map(); + for (const line of response.headers.getSetCookie()) { + const [pair, ...attrs] = line.split(';').map((s) => s.trim()); + const eq = pair.indexOf('='); + out.set(pair.slice(0, eq), { value: pair.slice(eq + 1), attrs: attrs.map((a) => a.toLowerCase()) }); + } + return out; +} + +beforeEach(() => { + oidc.reset(); + vi.useFakeTimers({ toFake: ['Date'] }); + vi.setSystemTime(T0); + vi.spyOn(console, 'error').mockImplementation(() => {}); + vi.spyOn(console, 'warn').mockImplementation(() => {}); +}); + +afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); +}); + +describe('cookies set at sign-in', () => { + it('sets exactly session_token and session_data — no account_data cookie', async () => { + const { response } = await codeFlow(auth, ORIGIN); + const cookies = setCookies(response); + + const names = [...cookies.keys()].filter((n) => /session|account/.test(n)).sort(); + expect(names).toEqual(['better-auth.session_data', 'better-auth.session_token']); + + expect(cookies.get('better-auth.session_token')!.attrs).toEqual( + expect.arrayContaining(['httponly', 'samesite=lax', 'path=/', `max-age=${SESSION_EXPIRES_IN_SECONDS}`]), + ); + expect(cookies.get('better-auth.session_data')!.attrs).toEqual( + expect.arrayContaining(['httponly', 'samesite=lax', 'path=/', `max-age=${SESSION_COOKIE_CACHE_MAX_AGE_SECONDS}`]), + ); + }); + + it('no cookie value carries the MP access, refresh or id token', async () => { + const { response } = await codeFlow(auth, ORIGIN); + const idToken = oidc.lastIdToken!; + expect(idToken).toBeTruthy(); + for (const { value } of setCookies(response).values()) { + expect(value).not.toContain(MOCK_ACCESS_TOKEN); + expect(value).not.toContain(MOCK_REFRESH_TOKEN); + expect(value).not.toContain(idToken); + } + }); + + it('negative control: with storeAccountCookie left at the stateless default, an account_data cookie appears', async () => { + const leaky = betterAuth({ ...auth.options, account: { ...auth.options.account, storeAccountCookie: true } }); + const { response } = await codeFlow(leaky, ORIGIN); + expect([...setCookies(response).keys()].some((n) => n.endsWith('account_data'))).toBe(true); + }); +}); + +describe('what the session_data cookie contains', () => { + it('is a JWE (five segments), not a readable JWT', async () => { + const { jar } = await codeFlow(auth, ORIGIN); + const value = decodeURIComponent(jar.get('session_data')!); + expect(value.split('.')).toHaveLength(5); + // A JWT's payload segment would base64-decode to JSON naming the user. + expect(Buffer.from(value, 'base64url').toString('utf8')).not.toContain(oidc.sub); + }); + + it('decrypts to better-auth session + user only, with no OAuth token anywhere', async () => { + const { jar } = await codeFlow(auth, ORIGIN); + const context = await auth.$context; + + const payload = (await symmetricDecodeJWT( + decodeURIComponent(jar.get('session_data')!), + context.secretConfig, + 'better-auth-session', + )) as { session: Record; user: Record } | null; + + expect(payload).not.toBeNull(); + const { user, session } = payload!; + expect(user.userGuid).toBe(oidc.sub); + expect(session.userId).toBe(user.id); + expect(new Date(String(session.expiresAt)).getTime()).toBe(T0 + SESSION_EXPIRES_IN_SECONDS * 1000); + + const serialized = JSON.stringify(payload); + expect(serialized).not.toContain(MOCK_ACCESS_TOKEN); + expect(serialized).not.toContain(MOCK_REFRESH_TOKEN); + expect(serialized).not.toContain(oidc.lastIdToken!); + expect(serialized).not.toMatch(/accessToken|refreshToken|idToken/); + }); +}); + +describe('/get-session withholds token, ipAddress and userAgent', () => { + it('the HTTP response carries none of them, and still the user', async () => { + const { jar } = await codeFlow(auth, ORIGIN); + const body = await getSession(auth, ORIGIN, jar); + + expect(body?.user.userGuid).toBe(oidc.sub); + expect(body?.user.firstName).toBe('Code'); + expect(body?.user.lastName).toBe('Flow'); + for (const field of WITHHELD_SESSION_FIELDS) expect(body?.session).not.toHaveProperty(field); + expect(body?.session).toHaveProperty('expiresAt'); + }); + + it('server-side auth.api.getSession carries none of them either', async () => { + const { jar } = await codeFlow(auth, ORIGIN); + const session = await auth.api.getSession({ headers: new Headers({ Cookie: jar.header() }) }); + expect(session?.user.userGuid).toBe(oidc.sub); + for (const field of WITHHELD_SESSION_FIELDS) expect(session?.session).not.toHaveProperty(field); + }); +}); + +describe('enrichSession', () => { + const session = { id: 's1', userId: 'u1', expiresAt: new Date(T0), token: 't', ipAddress: '1.2.3.4', userAgent: 'ua' }; + + it('strips exactly the withheld fields and does not mutate its input', () => { + const result = enrichSession({ name: 'Ada Lovelace' }, session); + expect(result.session).toEqual({ id: 's1', userId: 'u1', expiresAt: new Date(T0) }); + expect(session.token).toBe('t'); + }); + + it('splits the display name', () => { + expect(enrichSession({ name: 'Mary Jane Watson' }, session).user).toMatchObject({ + firstName: 'Mary', + lastName: 'Jane Watson', + }); + expect(enrichSession({ name: 'Madonna' }, session).user).toMatchObject({ firstName: 'Madonna', lastName: '' }); + expect(enrichSession({ name: null }, session).user).toMatchObject({ firstName: '', lastName: '' }); + }); +}); diff --git a/src/auth.session-lifetime.test.ts b/src/auth.session-lifetime.test.ts new file mode 100644 index 0000000..b219eb4 --- /dev/null +++ b/src/auth.session-lifetime.test.ts @@ -0,0 +1,191 @@ +// @vitest-environment node +// (node, not jsdom: better-auth verifies the id_token with `jose` over WebCrypto, +// which rejects jsdom-realm typed arrays.) +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { betterAuth } from 'better-auth'; + +/** + * Clock-walk guard for the session lifetime settings in src/lib/auth.ts + * (security Step 3, playbook Phase 3). Ported from upstream MPNext. + * + * Signs in through the REAL `auth` instance with the full authorization-code + * flow against a mock MP OIDC provider (discovery, JWKS, token, userinfo — the + * id_token genuinely RS256-signed), then steps a fake clock and replays the + * cookies to `GET /api/auth/get-session` exactly as a browser (or an attacker + * holding a copied cookie pair) would. `fetch` THROWS for any URL the mock + * does not serve, so nothing here can reach a real Ministry Platform. + * + * Pinned (better-auth 1.7.6): + * - Hard ceiling: no session is valid after sign-in + 12h, on the cookie-cache + * path or the in-memory-adapter path, however often it is used. + * - A cookie pair not backed by a live in-memory row (copied before sign-out, + * or on an instance that never saw the sign-in) dies within 1h of being + * minted. + * The negative control rebuilds the instance with the pre-fix session config + * and shows the same walk survives ~7 days — so deleting `expiresIn` or + * `refreshCache: false` turns this suite red. + */ + +const oidc = await vi.hoisted(async () => + (await import('@/test-utils/mock-oidc')).installMockOidc(), +); + +import { auth, SESSION_EXPIRES_IN_SECONDS, SESSION_COOKIE_CACHE_MAX_AGE_SECONDS } from '@/lib/auth'; +import { codeFlow, CookieJar, getSession, signOut, type AuthLike } from '@/test-utils/mock-oidc'; + +const ORIGIN = 'http://localhost:3000'; +const MINUTE = 60 * 1000; +const HOUR = 60 * MINUTE; +const DAY = 24 * HOUR; +const T0 = new Date('2026-09-30T08:00:00Z').getTime(); + +async function signIn(instance: AuthLike): Promise { + const { jar, location } = await codeFlow(instance, ORIGIN); + expect(location).not.toMatch(/error/); + expect(jar.get('session_token')).toBeTruthy(); + expect(jar.get('session_data')).toBeTruthy(); + return jar; +} + +async function userGuid(instance: AuthLike, jar: CookieJar): Promise { + const body = await getSession(instance, ORIGIN, jar); + return (body?.user?.userGuid as string | undefined) ?? null; +} + +/** A copy of the jar without the cookies whose names match. */ +function without(jar: CookieJar, pattern: RegExp): CookieJar { + const copy = new CookieJar(jar); + for (const name of [...copy.cookies.keys()]) if (pattern.test(name)) copy.cookies.delete(name); + return copy; +} + +/** + * Steps the clock by `step` from T0 and asks for the session each time, for up + * to `limit`. Returns the last instant (ms after T0) the session was still + * valid and the first instant it was not. + */ +async function walk(instance: AuthLike, jar: CookieJar, step: number, limit: number) { + let lastValid = 0; + for (let t = step; t <= limit; t += step) { + vi.setSystemTime(T0 + t); + const guid = await userGuid(instance, jar); + if (guid === null) return { lastValid, firstInvalid: t }; + expect(guid).toBe(oidc.sub); + lastValid = t; + } + return { lastValid, firstInvalid: null }; +} + +beforeEach(() => { + vi.useFakeTimers({ toFake: ['Date'] }); + vi.setSystemTime(T0); + vi.spyOn(console, 'error').mockImplementation(() => {}); + vi.spyOn(console, 'warn').mockImplementation(() => {}); +}); + +afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); +}); + +describe('session lifetime settings', () => { + it('pins the configured values (12h absolute, no sliding, encrypted, no cookie-only re-mint)', () => { + expect(SESSION_EXPIRES_IN_SECONDS).toBe(12 * 60 * 60); + expect(SESSION_COOKIE_CACHE_MAX_AGE_SECONDS).toBe(60 * 60); + expect(auth.options.session).toMatchObject({ + expiresIn: SESSION_EXPIRES_IN_SECONDS, + disableSessionRefresh: true, + cookieCache: { + enabled: true, + maxAge: SESSION_COOKIE_CACHE_MAX_AGE_SECONDS, + strategy: 'jwe', + refreshCache: false, + }, + }); + }); + + it('resolves refreshCache to off in the live context (the stateless default did not win)', async () => { + const context = await auth.$context; + expect(context.sessionConfig.cookieRefreshCache).toBe(false); + expect(context.sessionConfig.expiresIn).toBe(SESSION_EXPIRES_IN_SECONDS); + }); +}); + +describe('clock walk through the real auth instance', () => { + it('an actively used session (cookie cache + in-memory row) never survives sign-in + 12h', async () => { + const jar = await signIn(auth); + // Poll every 10 minutes, as a busy tab would. + const result = await walk(auth, jar, 10 * MINUTE, 3 * DAY); + + // better-auth's check is `expiresAt < now`, so the last valid instant is + // exactly sign-in + 12h and the next step is refused. + expect(result.lastValid).toBe(12 * HOUR); + expect(result.firstInvalid).toBe(12 * HOUR + 10 * MINUTE); + }); + + // `disableSessionRefresh` is defence in depth here: better-auth only slides + // `expiresAt` once per `updateAge` (1 day), longer than the 12h `expiresIn`. + // Its removal is caught by the config pin above; it matters the day someone + // raises `expiresIn` past `updateAge`. + it('the in-memory-adapter path alone does not slide expiresAt (no session_data cookie)', async () => { + const jar = without(await signIn(auth), /session_data/); + const result = await walk(auth, jar, 30 * MINUTE, 3 * DAY); + + expect(result.lastValid).toBe(12 * HOUR); + expect(result.firstInvalid).toBe(12 * HOUR + 30 * MINUTE); + }); + + it('a cookie pair copied before sign-out dies within 1h of being minted', async () => { + const victim = await signIn(auth); + const copied = new CookieJar(victim); + + vi.setSystemTime(T0 + 5 * MINUTE); + await signOut(auth, ORIGIN, victim); + expect(await userGuid(auth, victim)).toBeNull(); + + // The attacker replays the copy every 5 minutes. + const result = await walk(auth, copied, 5 * MINUTE, 3 * DAY); + + expect(result.firstInvalid).not.toBeNull(); + expect(result.firstInvalid).toBeLessThanOrEqual(SESSION_COOKIE_CACHE_MAX_AGE_SECONDS * 1000 + 5 * MINUTE); + }); + + it('a cookie pair on an instance with no in-memory row (serverless) dies within 1h', async () => { + const jar = await signIn(auth); + // Same config and secret, but it never saw the sign-in — like a different + // serverless container. + const otherInstance = betterAuth({ ...auth.options }); + const maxAgeMs = SESSION_COOKIE_CACHE_MAX_AGE_SECONDS * 1000; + + vi.setSystemTime(T0 + maxAgeMs); + expect(await userGuid(otherInstance, jar)).toBe(oidc.sub); + vi.setSystemTime(T0 + maxAgeMs + 1000); + expect(await userGuid(otherInstance, jar)).toBeNull(); + }); +}); + +/** + * Negative control: the pre-fix session block (1h JWT cookie cache and nothing + * else). Proves the walks above can detect a regression — this is the 7-day + * window the TODO describes. + */ +describe('negative control: pre-fix session config', () => { + const preFix = betterAuth({ + ...auth.options, + session: { cookieCache: { enabled: true, maxAge: 60 * 60, strategy: 'jwt' } }, + }); + + it('a copied cookie pair survives sign-out and silently re-mints until ~7 days', async () => { + const victim = await signIn(preFix); + const copied = new CookieJar(victim); + vi.setSystemTime(T0 + 5 * MINUTE); + await signOut(preFix, ORIGIN, victim); + + const result = await walk(preFix, copied, 50 * MINUTE, 8 * DAY); + + // 50-minute steps: last valid at 201 x 50 min (6.98 days), refused at the + // next step (7.01 days) — the default 7-day expiresAt, re-minted all along. + expect(result.lastValid).toBe(201 * 50 * MINUTE); + expect(result.firstInvalid).toBe(202 * 50 * MINUTE); + }); +}); diff --git a/src/auth.user-oauth-tokens.test.ts b/src/auth.user-oauth-tokens.test.ts new file mode 100644 index 0000000..b5ae40a --- /dev/null +++ b/src/auth.user-oauth-tokens.test.ts @@ -0,0 +1,136 @@ +// @vitest-environment node +// (node, not jsdom: better-auth verifies the id_token with `jose` over WebCrypto, +// which rejects jsdom-realm typed arrays.) +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { betterAuth } from 'better-auth'; + +/** + * The user's own MP OAuth tokens are never used (all MP data access is the + * client-credentials service account), so the app must not ask for a refresh + * token, must not put the tokens in the browser (`account_data` cookie — see + * src/auth.session-config.test.ts), and must not keep the access/refresh + * tokens in the in-memory adapter. Security Step 3, playbook Phase 3. + * + * Signs in through the REAL `auth` instance with the full authorization-code + * flow against a mock MP OIDC provider. `fetch` THROWS for any URL the mock + * does not serve, so nothing here can reach a real Ministry Platform. + */ + +const oidc = await vi.hoisted(async () => + (await import('@/test-utils/mock-oidc')).installMockOidc(), +); + +import { auth, ministryPlatformProviderConfig, stripUserOAuthTokens } from '@/lib/auth'; +import { codeFlow, type AuthLike } from '@/test-utils/mock-oidc'; + +const ORIGIN = 'http://localhost:3000'; + +/** The two members `signIn` needs, typed loosely so a rebuilt instance fits. */ +type Instance = AuthLike & { + api: { getSession: (a: { headers: Headers }) => Promise<{ user: { id: string; userGuid?: unknown } } | null> }; + $context: Promise<{ internalAdapter: { findAccounts: (userId: string) => Promise>> } }>; +}; + +async function signIn(instance: Instance) { + const { jar, authorizeUrl, location } = await codeFlow(instance, ORIGIN); + expect(location).not.toMatch(/error/); + const session = await instance.api.getSession({ headers: new Headers({ Cookie: jar.header() }) }); + expect(session?.user.userGuid).toBe(oidc.sub); + const context = await instance.$context; + const accounts = await context.internalAdapter.findAccounts(session!.user.id); + return { authorizeUrl, jar, accounts }; +} + +beforeEach(() => { + oidc.reset(); + vi.spyOn(console, 'error').mockImplementation(() => {}); + vi.spyOn(console, 'warn').mockImplementation(() => {}); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe('user MP OAuth tokens are not requested or retained', () => { + it('does not request offline_access (no refresh token is ever needed)', async () => { + expect(ministryPlatformProviderConfig.scopes).not.toContain('offline_access'); + const { authorizeUrl } = await signIn(auth as unknown as Instance); + const scopes = authorizeUrl.searchParams.get('scope')!.split(' '); + expect(scopes).toContain('openid'); + expect(scopes).not.toContain('offline_access'); + }); + + it('keeps no access/refresh token in the in-memory account row, on first and repeat sign-in', async () => { + for (let i = 0; i < 2; i++) { + const { accounts } = await signIn(auth as unknown as Instance); + expect(accounts).toHaveLength(1); + const [account] = accounts; + expect(account.accessToken ?? null).toBeNull(); + expect(account.refreshToken ?? null).toBeNull(); + expect(account.accessTokenExpiresAt ?? null).toBeNull(); + expect(account.refreshTokenExpiresAt ?? null).toBeNull(); + // Kept deliberately: not an API bearer; needed for a future + // id_token_hint on MP logout (security Step 4). It lives ONLY in this + // process's memory adapter — there is no cookie copy any more. + expect(account.idToken).toBe(oidc.lastIdToken); + } + }); + + it('negative control: without the databaseHooks, the memory adapter keeps both tokens', async () => { + const unhooked = betterAuth({ ...auth.options, databaseHooks: {} }); + const { accounts } = await signIn(unhooked as unknown as Instance); + expect(accounts[0].accessToken).toBe('mock-access-token'); + expect(accounts[0].refreshToken).toBe('mock-refresh-token'); + }); +}); + +describe('the retained id_token is still reachable for logout (security Step 4)', () => { + // Not used by the app yet: src/components/user-menu/actions.ts hand-builds + // the endsession URL. This pins that dropping the account cookie did NOT + // remove the id_token_hint source — on the instance that handled sign-in, + // better-auth's own sign-out returns it from the in-memory row. + it('sign-out on the same instance returns MP endsession with id_token_hint', async () => { + const { jar } = await signIn(auth as unknown as Instance); + const result = (await auth.api.signOut({ + headers: new Headers({ Cookie: jar.header() }), + body: { disableRedirect: true }, + } as never)) as { url?: string }; + + const url = new URL(result.url!); + expect(`${url.origin}${url.pathname}`).toBe('https://test-mp.example.com/oauth/connect/endsession'); + expect(url.searchParams.get('id_token_hint')).toBe(oidc.lastIdToken); + }); +}); + +describe('stripUserOAuthTokens', () => { + it('blanks the tokens and expiries and leaves everything else alone', () => { + const input = { + accountId: 'sub', + providerId: 'ministryplatform', + idToken: 'id', + accessToken: 'a', + refreshToken: 'r', + accessTokenExpiresAt: new Date(), + refreshTokenExpiresAt: new Date(), + }; + expect(stripUserOAuthTokens(input)).toEqual({ + accountId: 'sub', + providerId: 'ministryplatform', + idToken: 'id', + accessToken: null, + refreshToken: null, + accessTokenExpiresAt: null, + refreshTokenExpiresAt: null, + }); + expect(input.accessToken).toBe('a'); + }); + + it('is wired as both the account create and update hook', async () => { + const hooks = auth.options.databaseHooks!.account!; + const row = { accessToken: 'a', refreshToken: 'r', idToken: 'i' }; + for (const hook of [hooks.create!.before!, hooks.update!.before!]) { + const result = (await (hook as (a: object) => Promise<{ data: Record }>)(row)).data; + expect(result).toMatchObject({ accessToken: null, refreshToken: null, idToken: 'i' }); + } + }); +}); diff --git a/src/components/user-menu/actions.test.ts b/src/components/user-menu/actions.test.ts index c0c45c7..b0edafe 100644 --- a/src/components/user-menu/actions.test.ts +++ b/src/components/user-menu/actions.test.ts @@ -64,7 +64,7 @@ describe('handleSignOut', () => { delete process.env.MINISTRY_PLATFORM_BASE_URL; mockSignOut.mockResolvedValueOnce(undefined); - await expect(handleSignOut()).rejects.toThrow('MINISTRY_PLATFORM_BASE_URL is not configured'); + await expect(handleSignOut()).rejects.toThrow('MINISTRY_PLATFORM_BASE_URL is not set'); }); it('should throw when neither BETTER_AUTH_URL nor NEXTAUTH_URL is set', async () => { @@ -72,7 +72,7 @@ describe('handleSignOut', () => { delete process.env.NEXTAUTH_URL; mockSignOut.mockResolvedValueOnce(undefined); - await expect(handleSignOut()).rejects.toThrow('BETTER_AUTH_URL (or NEXTAUTH_URL) is not configured'); + await expect(handleSignOut()).rejects.toThrow('BETTER_AUTH_URL is not set (NEXTAUTH_URL is accepted as a fallback)'); expect(mockRedirect).not.toHaveBeenCalled(); }); @@ -87,4 +87,24 @@ describe('handleSignOut', () => { expect.stringContaining('post_logout_redirect_uri=https%3A%2F%2Ffallback.example.com') ); }); + + it('refuses a non-https MP URL rather than redirecting the browser to it', async () => { + process.env.MINISTRY_PLATFORM_BASE_URL = 'http://mp.example.com'; + mockSignOut.mockResolvedValueOnce(undefined); + + await expect(handleSignOut()).rejects.toThrow('MINISTRY_PLATFORM_BASE_URL must use https://'); + expect(mockRedirect).not.toHaveBeenCalled(); + }); + + it('normalizes a trailing slash, so the URL never contains //oauth', async () => { + process.env.MINISTRY_PLATFORM_BASE_URL = 'https://mp.example.com/api/'; + process.env.BETTER_AUTH_URL = 'https://myapp.example.com/'; + mockSignOut.mockResolvedValueOnce(undefined); + + await handleSignOut(); + + expect(mockRedirect).toHaveBeenCalledWith( + 'https://mp.example.com/api/oauth/connect/endsession?post_logout_redirect_uri=https%3A%2F%2Fmyapp.example.com' + ); + }); }); diff --git a/src/components/user-menu/actions.ts b/src/components/user-menu/actions.ts index 5597f13..55a9eed 100644 --- a/src/components/user-menu/actions.ts +++ b/src/components/user-menu/actions.ts @@ -1,6 +1,7 @@ 'use server'; import { auth } from "@/lib/auth"; +import { getAuthBaseUrl, getMpBaseUrl } from "@/lib/env"; import { headers } from "next/headers"; import { redirect } from "next/navigation"; @@ -10,19 +11,12 @@ export async function handleSignOut() { headers: await headers(), }); - const baseUrl = process.env.MINISTRY_PLATFORM_BASE_URL; - if (!baseUrl) { - throw new Error('MINISTRY_PLATFORM_BASE_URL is not configured'); - } - - const postLogoutRedirectUri = process.env.BETTER_AUTH_URL || process.env.NEXTAUTH_URL; - if (!postLogoutRedirectUri) { - throw new Error('BETTER_AUTH_URL (or NEXTAUTH_URL) is not configured'); - } - - const endSessionUrl = `${baseUrl}/oauth/connect/endsession`; + // Both validated and normalized (https, no credentials/query/fragment, no + // trailing slash; the app URL origin-only) — see src/lib/env.ts. They throw + // on a bad value without echoing it. + const endSessionUrl = `${getMpBaseUrl()}/oauth/connect/endsession`; const params = new URLSearchParams({ - post_logout_redirect_uri: postLogoutRedirectUri, + post_logout_redirect_uri: getAuthBaseUrl(), }); redirect(`${endSessionUrl}?${params.toString()}`); diff --git a/src/lib/auth.ts b/src/lib/auth.ts index 95a3a7c..7542a21 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -7,9 +7,176 @@ import { } from "better-auth/plugins"; import { customSession } from "better-auth/plugins"; import { nextCookies } from "better-auth/next-js"; +import { isIP } from "node:net"; +import { getAuthBaseUrl, getMpBaseUrl } from "@/lib/env"; import { validateGuid } from "@/lib/validation"; -const mpBaseUrl = process.env.MINISTRY_PLATFORM_BASE_URL!; +/** + * better-auth's built-in fallback secret (`DEFAULT_SECRET`, + * node_modules/better-auth/dist/utils/constants.mjs — not exported, so it is + * pinned here; `src/auth.secret-guard.test.ts` reads the library file to catch + * drift). It is public, so a session signed with it can be forged by anyone. + */ +export const BETTER_AUTH_DEFAULT_SECRET = "better-auth-secret-12345678901234567890"; +export const MIN_AUTH_SECRET_LENGTH = 32; + +/** Mirrors better-auth's `toBoolean` (@better-auth/core env-impl), which `isTest()` uses on `TEST`. */ +function isTruthyEnvFlag(value: string | undefined): boolean { + return value ? value !== "false" : false; +} + +/** + * Refuses to boot on an auth configuration that would make sessions forgeable + * or silently switch off better-auth's own safety checks. Throws; never puts + * the secret in the message. + * + * Why this exists instead of relying on better-auth's `validateSecret` + * (node_modules/better-auth/dist/context/create-context.mjs, 1.7.6): + * - With no secret set, better-auth falls back to its PUBLIC default secret and + * only throws for it when `NODE_ENV === "production"`. On a dev/demo box + * (which in the MP world usually talks to the production MP) it boots + * silently. In stateless mode the signed cookie is the only authority, so a + * known secret lets anyone mint a session for any `userGuid`. + * - A secret shorter than 32 characters only produces a warning. + * - `isTest()` is `NODE_ENV === "test" || toBoolean(env.TEST)`. Any truthy + * `TEST` on a production process skips secret validation entirely and (were + * `advanced.disableOriginCheck` not pinned below) the Origin/callbackURL + * checks too. + * - `BETTER_AUTH_SECRETS` (versioned secrets) silently takes precedence over + * the `secret` option, so a guard on `BETTER_AUTH_SECRET` would be checking + * a key that is not the one in use. This app does not use versioned + * secrets; refuse the variable rather than half-validate it. + * + * Exported and pure (takes the env as an argument) so it can be tested without + * re-importing the module; the module-level call below is what enforces it. + */ +export function assertAuthEnvironment( + env: Readonly>, +): void { + const secret = env.BETTER_AUTH_SECRET || env.NEXTAUTH_SECRET; + if (!secret) { + throw new Error( + "[auth] BETTER_AUTH_SECRET is not set (NEXTAUTH_SECRET is accepted as a fallback). Refusing to start: better-auth would sign sessions with its public default secret. Generate one with `openssl rand -base64 32`.", + ); + } + if (secret === BETTER_AUTH_DEFAULT_SECRET) { + throw new Error( + "[auth] BETTER_AUTH_SECRET is better-auth's public default secret. Refusing to start: anyone could forge a session. Generate one with `openssl rand -base64 32`.", + ); + } + if (secret.length < MIN_AUTH_SECRET_LENGTH) { + throw new Error( + `[auth] BETTER_AUTH_SECRET must be at least ${MIN_AUTH_SECRET_LENGTH} characters. Refusing to start. Generate one with \`openssl rand -base64 32\`.`, + ); + } + if (env.BETTER_AUTH_SECRETS) { + throw new Error( + "[auth] BETTER_AUTH_SECRETS is set, but this app signs with BETTER_AUTH_SECRET and does not support versioned secrets. Refusing to start: better-auth would silently prefer BETTER_AUTH_SECRETS over the validated secret. Unset it.", + ); + } + if (env.NODE_ENV === "production" && isTruthyEnvFlag(env.TEST)) { + throw new Error( + "[auth] TEST is set on a production process. Refusing to start: better-auth treats a truthy TEST as a test run and skips its secret validation. Unset TEST.", + ); + } +} + +// Enforced at module load in every environment (development, production, or +// NODE_ENV unset — including `next build`, which imports this module while +// collecting page data). Vitest is the only exemption, detected by the +// `VITEST` env var Vitest itself sets, so individual tests can stub the +// environment and build auth instances; `src/auth.secret-guard.test.ts` +// clears `VITEST` to prove this call really runs on import. +if (!process.env.VITEST) { + assertAuthEnvironment(process.env); +} + +// The two auth-critical URLs, validated once at module load (see +// src/lib/env.ts): https (loopback http outside production only for MP), no +// credentials, query or fragment, no trailing slash, and BETTER_AUTH_URL an +// origin. Unlike the secret guard these run under Vitest too; `test-setup.ts` +// stubs valid values. An unset BETTER_AUTH_URL is refused rather than left to +// better-auth, which would otherwise derive the base URL — and so the OAuth +// redirect_uri and the trusted origins — from the request's Host header. +const mpBaseUrl = getMpBaseUrl(); +const authBaseUrl = getAuthBaseUrl(); + +/** + * Default client-IP headers, used only when the process runs on Vercel + * (`VERCEL` is set) and `AUTH_IP_ADDRESS_HEADERS` is not. + * + * Vercel's edge sets and overwrites `x-forwarded-for`, `x-vercel-forwarded-for` + * and `x-real-ip` with the single public client IP and does not forward a + * client-supplied value (https://vercel.com/docs/headers/request-headers, + * checked 2026-09-30). `x-vercel-forwarded-for` comes first because it is the + * one that survives a proxy placed IN FRONT of Vercel, which may overwrite + * `x-forwarded-for`. + * + * Off Vercel these are just request headers any client can send, so they are + * NOT a safe default there: with the variable unset on another host, + * better-auth's own default (a single-value `x-forwarded-for`) applies. + */ +export const VERCEL_IP_ADDRESS_HEADERS = ["x-vercel-forwarded-for", "x-real-ip"]; + +/** + * Client-IP resolution for better-auth's rate limiter (`/sign-in*` is limited + * per IP in production). better-auth's default trusts only a single, valid IP + * in `x-forwarded-for`; anything else — no header, an appended chain, Azure's + * `ip:port` — drops every client into ONE shared bucket, so a handful of + * requests blocks sign-in for everyone. Which header is trustworthy depends on + * the host, so it is configuration, not code: + * + * - `AUTH_IP_ADDRESS_HEADERS` — comma-separated header names, tried in order + * (e.g. `cf-connecting-ip` behind Cloudflare). Only name a header your edge + * always OVERWRITES; a header clients can set themselves lets them rotate + * past the limit or lock a victim's IP out. Unset: `VERCEL_IP_ADDRESS_HEADERS` + * on Vercel, better-auth's default elsewhere. + * - `AUTH_TRUSTED_PROXIES` — comma-separated proxy IPs/CIDRs. The forwarded + * chain is walked right to left past these, and the first untrusted hop is + * the client (for proxies that append to `x-forwarded-for`). + * + * Invalid entries refuse startup: better-auth itself only warns and ignores a + * bad trusted-proxy entry, which would silently fall back to the shared + * bucket. Per-host guidance is in `.env.example`. Exported for tests. + */ +export function parseIpAddressOptions( + env: Readonly>, +): { ipAddressHeaders?: string[]; trustedProxies?: string[] } { + const list = (value: string | undefined) => + (value ?? "").split(",").map((entry) => entry.trim()).filter(Boolean); + + const configured = list(env.AUTH_IP_ADDRESS_HEADERS).map((h) => h.toLowerCase()); + const badHeaders = configured.filter((h) => !/^[a-z0-9-]+$/.test(h)); + if (badHeaders.length > 0) { + throw new Error( + `[auth] AUTH_IP_ADDRESS_HEADERS has invalid header names: ${badHeaders.join(", ")}. Use comma-separated names like "cf-connecting-ip".`, + ); + } + const ipAddressHeaders = + configured.length > 0 ? configured : env.VERCEL ? [...VERCEL_IP_ADDRESS_HEADERS] : []; + + const trustedProxies = list(env.AUTH_TRUSTED_PROXIES); + const badProxies = trustedProxies.filter((entry) => !isIpOrCidr(entry)); + if (badProxies.length > 0) { + throw new Error( + `[auth] AUTH_TRUSTED_PROXIES has entries that are not an IP address or CIDR range: ${badProxies.join(", ")}.`, + ); + } + + return { + ...(ipAddressHeaders.length > 0 && { ipAddressHeaders }), + ...(trustedProxies.length > 0 && { trustedProxies }), + }; +} + +function isIpOrCidr(entry: string): boolean { + const slash = entry.indexOf("/"); + const family = isIP(slash === -1 ? entry : entry.slice(0, slash)); + if (family === 0) return false; + if (slash === -1) return true; + const prefix = entry.slice(slash + 1); + return /^\d{1,3}$/.test(prefix) && Number(prefix) <= (family === 4 ? 32 : 128); +} /** * Reserved TLD (RFC 2606) used to build a synthetic, guaranteed-unique email @@ -242,9 +409,14 @@ export const ministryPlatformProviderConfig: GenericOAuthConfig = { discoveryUrl: `${mpBaseUrl}/oauth/.well-known/openid-configuration`, clientId: process.env.MINISTRY_PLATFORM_CLIENT_ID!, clientSecret: process.env.MINISTRY_PLATFORM_CLIENT_SECRET!, + /** + * No `offline_access`: this app never uses the user's own MP tokens (all MP + * data access goes through the client-credentials service account), so it + * has no business asking MP for a long-lived, full-scope refresh token for + * every signed-in user. `src/auth.user-oauth-tokens.test.ts` guards it. + */ scopes: [ "openid", - "offline_access", "http://www.thinkministry.com/dataplatform/scopes/all", ], /** @@ -416,9 +588,119 @@ export const ministryPlatformProviderConfig: GenericOAuthConfig = { }, }; +/** + * Session lifetime. The app is stateless (no database, no `secondaryStorage`): + * the signed `session_token` + encrypted `session_data` cookies are the + * session, backed only by better-auth's per-process in-memory adapter. That + * rules out real server-side revocation, so these settings instead put a HARD + * ceiling on how long any session — including a copied or forged cookie pair — + * can live. Verified against better-auth 1.7.6 source; + * `src/auth.session-lifetime.test.ts` walks the clock through the real `auth` + * instance to pin it. + * + * - `expiresIn: 12h` — `session.expiresAt` is set once, at sign-in, to + * sign-in + 12h. Both `/get-session` paths refuse a session past it (the + * cookie-cache path checks the cached `expiresAt`, the memory-adapter path + * the stored row). It also sets the `session_token` cookie's Max-Age. + * Default was 7 days. + * + * - `disableSessionRefresh: true` — without it the memory-adapter path slides + * `expiresAt` forward another `expiresIn` once per `updateAge` (1 day), so a + * long-running process kept a session alive indefinitely. + * + * - `cookieCache.refreshCache: false` — MUST be explicit. With no database, + * better-auth defu-merges `refreshCache: true` UNDER this config + * (context/create-context.mjs), so leaving it out silently turns it on. With + * `true`, `/get-session` re-signs `session_data` from the cookie itself in + * the last 20% of `maxAge` with no store lookup at all, so a copied cookie + * pair survived the victim's sign-out until `expiresAt` (7 days, before this + * change). With `false`, a cookie NOT backed by a live in-memory row — a + * pair copied before sign-out, or one forged from a leaked secret — dies + * `maxAge` (1h) after it was minted: the request then falls through to the + * memory adapter, which re-mints the cache only if the row still exists. + * Trade-off: on serverless, a request that lands on an instance without the + * row after the hour gets no session and goes back through MP sign-in — + * which doubles as an hourly re-check against MP that a disabled login fails. + * + * Emergency "sign everyone out": bump `cookieCache.version` and redeploy, or + * rotate BETTER_AUTH_SECRET (invalidates every signed cookie, including a + * forged one). See .claude/references/auth/README.md. + */ +export const SESSION_EXPIRES_IN_SECONDS = 12 * 60 * 60; +export const SESSION_COOKIE_CACHE_MAX_AGE_SECONDS = 60 * 60; + +/** + * Blanks the user's MP access and refresh tokens (and their expiries) on an + * account row before better-auth stores it in the in-memory adapter — where + * they would otherwise sit in plaintext until restart, and a heap dump would + * expose every signed-in user's full-scope MP API rights. `getUserInfo` has + * already used the access token by the time the row is written. + * + * The `idToken` is KEPT: it is not an API bearer, and an RP-initiated logout + * `id_token_hint` (security Step 4) needs it. Exported for tests. + */ +export function stripUserOAuthTokens(account: T): T { + return { + ...account, + accessToken: null, + refreshToken: null, + accessTokenExpiresAt: null, + refreshTokenExpiresAt: null, + }; +} + +/** + * Session-row fields withheld from the `/get-session` response (and from + * server-side `auth.api.getSession`). Nothing in `src/` reads them, and each is + * more than the page needs: + * - `token` — the raw session token: a bearer credential the day a `bearer` + * plugin is added, which would make the cookie's HttpOnly flag moot. + * - `ipAddress`, `userAgent` — request metadata better-auth records at + * sign-in; not the page's business. + * better-auth still keeps them on the in-memory row and inside the encrypted + * `session_data` cookie; this only stops them being handed to page JS. + */ +export const WITHHELD_SESSION_FIELDS = ["token", "ipAddress", "userAgent"] as const; + +type WithheldSessionField = (typeof WITHHELD_SESSION_FIELDS)[number]; + +/** + * The `customSession` callback, extracted so it can be unit tested (the plugin + * closes over its callback and never exposes it). + * + * No API calls here — profile loading is handled by UserProvider via + * getCurrentUserProfile(). This keeps getSession() fast and avoids hitting the + * MP API on every request. It only splits the display name and strips + * `WITHHELD_SESSION_FIELDS` from the session. + */ +export function enrichSession< + U extends { name?: string | null }, + S extends object, +>(user: U, session: S) { + const copy = { ...session } as Record; + for (const field of WITHHELD_SESSION_FIELDS) delete copy[field]; + return { + user: { + ...user, + firstName: user.name?.split(" ")[0] || "", + lastName: user.name?.split(" ").slice(1).join(" ") || "", + }, + session: copy as Omit, + }; +} + const options = { - baseURL: process.env.BETTER_AUTH_URL || process.env.NEXTAUTH_URL, + baseURL: authBaseUrl, secret: process.env.BETTER_AUTH_SECRET || process.env.NEXTAUTH_SECRET, + advanced: { + // Pinned explicitly so an env var cannot flip it: left undefined, + // better-auth sets `skipOriginCheck = isTest()`, i.e. a truthy `TEST` env + // var would disable the Origin/callbackURL checks + // (context/create-context.mjs). See `assertAuthEnvironment` above. + disableOriginCheck: false, + // See `parseIpAddressOptions` above. + ipAddress: parseIpAddressOptions(process.env), + }, disabledPaths: disabledAuthPaths, // User-level hooks. customSession and nextCookies register their own hooks // on the plugin objects; these run alongside them, not instead. If another @@ -441,16 +723,31 @@ const options = { onAPIError: { errorURL: "/auth-error", }, + // See SESSION_EXPIRES_IN_SECONDS above for why each of these is set. session: { + expiresIn: SESSION_EXPIRES_IN_SECONDS, + disableSessionRefresh: true, cookieCache: { enabled: true, - maxAge: 60 * 60, // 1 hour cache - strategy: "jwt" as const, + maxAge: SESSION_COOKIE_CACHE_MAX_AGE_SECONDS, + // Encrypted (JWE keyed from BETTER_AUTH_SECRET), not just signed. With + // "jwt" the payload — name, real MP email, userGuid, IP, user agent, the + // raw session token — was readable by anything that sees Cookie + // headers: proxy/APM logs, HAR files, cookie-reading extensions. + // Changing strategy invalidates every existing `session_data` cookie + // once. `src/auth.session-config.test.ts` guards it. + strategy: "jwe" as const, + refreshCache: false, }, }, account: { storeStateStrategy: "cookie" as const, - storeAccountCookie: true, + // The user's own MP tokens are never used: all MP data access goes + // through the client-credentials service account. better-auth defaults + // this to `true` when there is no database, which put the user's MP + // access/refresh/id tokens into an `account_data` cookie. Keep them out of + // the browser entirely. See `stripUserOAuthTokens` for the in-memory copy. + storeAccountCookie: false, /** * Never merge a new provider account onto an existing user record. * @@ -466,6 +763,14 @@ const options = { user: { additionalFields: userAdditionalFields, }, + // Applied by the in-memory adapter better-auth uses when there is no + // database. See `stripUserOAuthTokens` above. + databaseHooks: { + account: { + create: { before: async (account) => ({ data: stripUserOAuthTokens(account) }) }, + update: { before: async (account) => ({ data: stripUserOAuthTokens(account) }) }, + }, + }, plugins: [ genericOAuth({ config: [ministryPlatformProviderConfig], @@ -477,22 +782,7 @@ export const auth = betterAuth({ ...options, plugins: [ ...(options.plugins ?? []), - customSession( - async ({ user, session }) => { - // No API calls here — profile loading is handled by UserProvider - // on the client side via getCurrentUserProfile(). This keeps - // getSession() fast and avoids hitting the MP API on every request. - return { - user: { - ...user, - firstName: user.name?.split(" ")[0] || "", - lastName: user.name?.split(" ").slice(1).join(" ") || "", - }, - session, - }; - }, - options, - ), + customSession(async ({ user, session }) => enrichSession(user, session), options), nextCookies(), ], }); diff --git a/src/lib/env.test.ts b/src/lib/env.test.ts new file mode 100644 index 0000000..65741d2 --- /dev/null +++ b/src/lib/env.test.ts @@ -0,0 +1,119 @@ +import { describe, it, expect } from 'vitest'; +import { getAuthBaseUrl, getMpBaseUrl } from '@/lib/env'; + +const dev = (extra: Record) => ({ NODE_ENV: 'development', ...extra }); +const prod = (extra: Record) => ({ NODE_ENV: 'production', ...extra }); + +describe('getMpBaseUrl', () => { + const mp = (value: string | undefined, env = dev) => getMpBaseUrl(env({ MINISTRY_PLATFORM_BASE_URL: value })); + + it.each([ + ['https://mp.example.org', 'https://mp.example.org'], + ['https://mp.example.org/', 'https://mp.example.org'], + ['https://mp.example.org/ministryplatformapi', 'https://mp.example.org/ministryplatformapi'], + ['https://mp.example.org/ministryplatformapi//', 'https://mp.example.org/ministryplatformapi'], + [' https://MP.Example.org:8443/api ', 'https://mp.example.org:8443/api'], + ])('normalizes %j to %j', (input, expected) => { + expect(mp(input)).toBe(expected); + }); + + it.each([ + [undefined, 'is not set'], + ['', 'is not set'], + [' ', 'is not set'], + ['mp.example.org', 'is not a valid absolute URL'], + ['http://mp.example.org', 'must use https://'], + ['ftp://mp.example.org', 'must use https://'], + ['https://u:p@mp.example.org', 'must not contain credentials'], + ['https://u@mp.example.org', 'must not contain credentials'], + ['https://mp.example.org?a', 'must not contain a query string or fragment'], + ['https://mp.example.org?', 'must not contain a query string or fragment'], + ['https://mp.example.org/api#frag', 'must not contain a query string or fragment'], + ])('refuses %j (%s)', (input, message) => { + expect(() => mp(input)).toThrow(`MINISTRY_PLATFORM_BASE_URL ${message}`); + }); + + it('never echoes the value in the error', () => { + expect(() => mp('https://user:hunter2@mp.example.org')).toThrow( + expect.objectContaining({ message: expect.not.stringContaining('hunter2') }), + ); + expect(() => mp('http://secret-host.example.org')).toThrow( + expect.objectContaining({ message: expect.not.stringContaining('secret-host') }), + ); + }); + + it.each(['http://localhost:5000', 'http://127.0.0.1:5000/api', 'http://[::1]:5000'])( + 'allows loopback http outside production: %s', + (input) => { + expect(mp(input)).toBe(input.replace(/\/$/, '')); + expect(getMpBaseUrl({ MINISTRY_PLATFORM_BASE_URL: input })).toBe(input); + }, + ); + + it('refuses loopback http in production', () => { + expect(() => mp('http://localhost:5000', prod)).toThrow( + 'MINISTRY_PLATFORM_BASE_URL must use https:// in production', + ); + }); + + it('reads process.env by default', () => { + expect(getMpBaseUrl()).toBe('https://test-mp.example.com'); + }); +}); + +describe('getAuthBaseUrl', () => { + it.each([ + ['https://app.example.org', 'https://app.example.org'], + ['https://app.example.org/', 'https://app.example.org'], + ['https://APP.example.org:8443', 'https://app.example.org:8443'], + ])('normalizes %j to %j', (input, expected) => { + expect(getAuthBaseUrl(prod({ BETTER_AUTH_URL: input }))).toBe(expected); + }); + + it('falls back to NEXTAUTH_URL, and prefers BETTER_AUTH_URL', () => { + expect(getAuthBaseUrl(prod({ NEXTAUTH_URL: 'https://legacy.example.org' }))).toBe('https://legacy.example.org'); + expect(getAuthBaseUrl(prod({ BETTER_AUTH_URL: ' ', NEXTAUTH_URL: 'https://legacy.example.org' }))).toBe( + 'https://legacy.example.org', + ); + expect( + getAuthBaseUrl(prod({ BETTER_AUTH_URL: 'https://app.example.org', NEXTAUTH_URL: 'https://legacy.example.org' })), + ).toBe('https://app.example.org'); + }); + + it('names NEXTAUTH_URL in the error when that is the value in use', () => { + expect(() => getAuthBaseUrl(prod({ NEXTAUTH_URL: 'http://legacy.example.org' }))).toThrow( + 'NEXTAUTH_URL must use https://', + ); + }); + + it.each([ + [undefined, 'BETTER_AUTH_URL is not set'], + ['', 'BETTER_AUTH_URL is not set'], + ['app.example.org', 'BETTER_AUTH_URL is not a valid absolute URL'], + ['http://app.example.org', 'BETTER_AUTH_URL must use https://'], + ['https://u:p@app.example.org', 'BETTER_AUTH_URL must not contain credentials'], + ['https://app.example.org?a', 'BETTER_AUTH_URL must not contain a query string or fragment'], + ['https://app.example.org#x', 'BETTER_AUTH_URL must not contain a query string or fragment'], + ['https://app.example.org/app', 'BETTER_AUTH_URL must be an origin only'], + ['https://app.example.org/api/auth', 'BETTER_AUTH_URL must be an origin only'], + ])('refuses %j', (input, message) => { + expect(() => getAuthBaseUrl(dev({ BETTER_AUTH_URL: input }))).toThrow(message); + }); + + it('allows loopback http in every environment (local / CI next build), but no other http host', () => { + expect(getAuthBaseUrl(dev({ BETTER_AUTH_URL: 'http://localhost:3000' }))).toBe('http://localhost:3000'); + expect(getAuthBaseUrl({ BETTER_AUTH_URL: 'http://127.0.0.1:3000/' })).toBe('http://127.0.0.1:3000'); + expect(getAuthBaseUrl(prod({ BETTER_AUTH_URL: 'http://localhost:3000' }))).toBe('http://localhost:3000'); + expect(getAuthBaseUrl(prod({ BETTER_AUTH_URL: 'http://[::1]:3000' }))).toBe('http://[::1]:3000'); + expect(() => getAuthBaseUrl(prod({ BETTER_AUTH_URL: 'http://app.example.org' }))).toThrow( + 'BETTER_AUTH_URL must use https:// (http:// is allowed only for localhost / 127.0.0.1).', + ); + expect(() => getAuthBaseUrl(prod({ BETTER_AUTH_URL: 'http://localhost.example.org' }))).toThrow( + 'BETTER_AUTH_URL must use https://', + ); + }); + + it('reads process.env by default', () => { + expect(getAuthBaseUrl()).toBe('http://localhost:3000'); + }); +}); diff --git a/src/lib/env.ts b/src/lib/env.ts new file mode 100644 index 0000000..dbd0850 --- /dev/null +++ b/src/lib/env.ts @@ -0,0 +1,109 @@ +/** + * Validated readers for the two auth-critical URLs. + * + * `MINISTRY_PLATFORM_BASE_URL` is the trust anchor for everything the app says + * to Ministry Platform: the client-secret POST, every bearer API call, the + * userinfo call carrying the user's access token, OIDC discovery (which names + * the JWKS that id_tokens are verified against) and the end-session redirect. + * `BETTER_AUTH_URL` is better-auth's `baseURL`: the OAuth `redirect_uri`, the + * trusted origin for `callbackURL`, and (via its scheme) whether cookies are + * `Secure`. Unvalidated, an `http://` value sent secrets in cleartext, a + * trailing slash produced `//oauth`, an unset MP URL silently became + * `undefined/oauth/...`, and an unset `BETTER_AUTH_URL` made better-auth derive + * the base URL from the request's Host header (redirect_uri poisoning). + * + * Both readers throw on a bad value and never echo it: a URL can carry + * credentials (`https://user:pass@host`), and error messages end up in logs. + * They take the env as an argument (defaulting to `process.env`) so they can be + * unit tested without re-importing anything. + */ + +type Env = Readonly>; + +/** Loopback hosts allowed over plain http (see `loopbackHttpInProduction`). */ +const LOOPBACK_HOSTNAMES = new Set(["localhost", "127.0.0.1", "[::1]"]); + +function isProduction(env: Env): boolean { + return env.NODE_ENV === "production"; +} + +/** + * Parses `raw` and applies the checks both URLs share. `name` is the variable + * name used in error messages; the value itself is never included. + */ +function parseAuthCriticalUrl( + name: string, + raw: string | undefined, + env: Env, + { loopbackHttpInProduction = false }: { loopbackHttpInProduction?: boolean } = {}, +): URL { + const value = raw?.trim(); + if (!value) { + throw new Error(`[env] ${name} is not set.`); + } + let url: URL; + try { + url = new URL(value); + } catch { + throw new Error(`[env] ${name} is not a valid absolute URL.`); + } + const loopbackAllowed = loopbackHttpInProduction || !isProduction(env); + const loopbackHttp = + url.protocol === "http:" && LOOPBACK_HOSTNAMES.has(url.hostname) && loopbackAllowed; + if (url.protocol !== "https:" && !loopbackHttp) { + throw new Error( + loopbackAllowed + ? `[env] ${name} must use https:// (http:// is allowed only for localhost / 127.0.0.1${loopbackHttpInProduction ? "" : " outside production"}).` + : `[env] ${name} must use https:// in production.`, + ); + } + if (url.username || url.password) { + throw new Error(`[env] ${name} must not contain credentials.`); + } + // `new URL("https://x?")` parses to an empty `search`, so check the raw + // string too: a stray `?` or `#` means the value is not what was intended. + if (url.search || url.hash || value.includes("?") || value.includes("#")) { + throw new Error(`[env] ${name} must not contain a query string or fragment.`); + } + return url; +} + +/** + * The Ministry Platform API base URL, e.g. + * `https://my.church.org/ministryplatformapi`, with no trailing slash, so + * callers can append `/oauth/...` or `/tables/...` directly. + * + * A path IS allowed here (MP's API lives under `/ministryplatformapi`), unlike + * `getAuthBaseUrl`. `https:` is required; `http://localhost` / `127.0.0.1` + * are accepted only when `NODE_ENV !== "production"`, for a local mock. + */ +export function getMpBaseUrl(env: Env = process.env): string { + const url = parseAuthCriticalUrl("MINISTRY_PLATFORM_BASE_URL", env.MINISTRY_PLATFORM_BASE_URL, env); + return `${url.origin}${url.pathname.replace(/\/+$/, "")}`; +} + +/** + * The app's own origin (`BETTER_AUTH_URL`, falling back to `NEXTAUTH_URL`), + * e.g. `https://app.church.org`. Required: without it better-auth trusts the + * request's Host header. Origin only — better-auth appends `/api/auth` itself, + * and the value is also the exact `post_logout_redirect_uri` registered in MP, + * so a path is refused rather than silently dropped (a single trailing `/` is + * tolerated). `https:` is required for every real host, in every environment. + * `http://localhost` / `127.0.0.1` / `[::1]` is accepted even when + * `NODE_ENV=production`, so `next build` / `next start` work locally and in CI + * with the default `.env.local`: a loopback origin never leaves the machine, + * and better-auth leaves cookies non-`Secure` only for such an http origin. + * (The MP URL stays strict — it carries the client secret.) + */ +export function getAuthBaseUrl(env: Env = process.env): string { + const name = env.BETTER_AUTH_URL?.trim() ? "BETTER_AUTH_URL" : "NEXTAUTH_URL"; + const raw = env.BETTER_AUTH_URL?.trim() ? env.BETTER_AUTH_URL : env.NEXTAUTH_URL; + if (!raw?.trim()) { + throw new Error("[env] BETTER_AUTH_URL is not set (NEXTAUTH_URL is accepted as a fallback)."); + } + const url = parseAuthCriticalUrl(name, raw, env, { loopbackHttpInProduction: true }); + if (url.pathname !== "/") { + throw new Error(`[env] ${name} must be an origin only (no path), e.g. https://app.example.org.`); + } + return url.origin; +} diff --git a/src/lib/providers/ministry-platform/auth/client-credentials.ts b/src/lib/providers/ministry-platform/auth/client-credentials.ts index 3d79cf8..2471751 100644 --- a/src/lib/providers/ministry-platform/auth/client-credentials.ts +++ b/src/lib/providers/ministry-platform/auth/client-credentials.ts @@ -1,7 +1,11 @@ +import { getMpBaseUrl } from "@/lib/env"; + export type CredentialProfile = 'default' | 'dev'; export async function getClientCredentialsToken(profile: CredentialProfile = 'default') { - const mpBaseUrl = process.env.MINISTRY_PLATFORM_BASE_URL!; + // Validated (https, no credentials/query, no trailing slash): this request + // carries the service-account client secret. See src/lib/env.ts. + const mpBaseUrl = getMpBaseUrl(); const mpOauthUrl = `${mpBaseUrl}/oauth`; const { clientId, clientSecret } = resolveCredentials(profile); diff --git a/src/lib/providers/ministry-platform/client.ts b/src/lib/providers/ministry-platform/client.ts index 72adfbf..ebf9dd7 100644 --- a/src/lib/providers/ministry-platform/client.ts +++ b/src/lib/providers/ministry-platform/client.ts @@ -1,6 +1,7 @@ import { getClientCredentialsToken } from "./auth/client-credentials"; import { HttpClient } from "./utils/http-client"; import { logger } from "./utils/logger"; +import { getMpBaseUrl } from "@/lib/env"; // Token refresh interval - refresh 5 minutes before actual expiration for safety const TOKEN_LIFE = 5 * 60 * 1000; // 5 minutes @@ -38,7 +39,8 @@ export class MinistryPlatformClient { * Initializes both the default and dev HTTP clients and sets up token management */ constructor() { - this.baseUrl = process.env.MINISTRY_PLATFORM_BASE_URL!; + // Validated and normalized (see src/lib/env.ts); throws on a bad value. + this.baseUrl = getMpBaseUrl(); this.httpClient = new HttpClient(this.baseUrl, () => this.token); this.devHttpClient = new HttpClient(this.baseUrl, () => this.devToken); } diff --git a/src/test-setup.ts b/src/test-setup.ts index 06753d4..58deb2e 100644 --- a/src/test-setup.ts +++ b/src/test-setup.ts @@ -19,6 +19,7 @@ vi.stubEnv('MINISTRY_PLATFORM_CLIENT_ID', 'test-client-id'); vi.stubEnv('MINISTRY_PLATFORM_CLIENT_SECRET', 'test-client-secret'); vi.stubEnv('MINISTRY_PLATFORM_DEV_CLIENT_ID', 'test-dev-client-id'); vi.stubEnv('MINISTRY_PLATFORM_DEV_CLIENT_SECRET', 'test-dev-client-secret'); -vi.stubEnv('NEXTAUTH_SECRET', 'test-secret-key-for-testing'); +// >= 32 chars, so it would also pass assertAuthEnvironment (src/lib/auth.ts). +vi.stubEnv('NEXTAUTH_SECRET', 'test-secret-key-for-testing-0123456789'); vi.stubEnv('NEXTAUTH_URL', 'http://localhost:3000'); vi.stubEnv('NODE_ENV', 'test'); diff --git a/src/test-utils/mock-oidc.ts b/src/test-utils/mock-oidc.ts new file mode 100644 index 0000000..0504e7d --- /dev/null +++ b/src/test-utils/mock-oidc.ts @@ -0,0 +1,219 @@ +/** + * Test-only mock of Ministry Platform's OIDC provider, for driving the REAL + * `auth.handler` from src/lib/auth.ts through the full authorization-code flow + * (discovery, JWKS, token, userinfo), with id_tokens genuinely RS256-signed by + * a key the mock JWKS publishes. Adapted from upstream MPNext's + * `src/test-utils/mock-oidc.ts` for this fork (provider id `ministryplatform`, + * boot-time `discoveryUrl`). + * + * `installMockOidc()` replaces `globalThis.fetch` with a stub that THROWS for + * any URL it does not serve, so nothing that uses it can reach a real Ministry + * Platform. This fork's genericOAuth config fetches discovery when the `auth` + * instance initialises, so run it before `@/lib/auth` is imported, i.e. inside + * `vi.hoisted`: + * + * const oidc = await vi.hoisted(async () => + * (await import('@/test-utils/mock-oidc')).installMockOidc()); + * + * Use it from `// @vitest-environment node` suites: better-auth verifies the + * id_token with `jose` over WebCrypto, which rejects jsdom-realm typed arrays. + * + * Not a test file itself; it is imported only by `src/auth.*.test.ts`. + */ +import { generateKeyPairSync, sign } from 'node:crypto'; +import { expect } from 'vitest'; + +export const MOCK_MP_BASE = 'https://test-mp.example.com'; +export const MOCK_ISSUER = `${MOCK_MP_BASE}/oauth`; +export const MOCK_CLIENT_ID = 'test-client-id'; +export const MOCK_SUB = 'dddddddd-dddd-4ddd-8ddd-dddddddddddd'; +export const MOCK_ACCESS_TOKEN = 'mock-access-token'; +export const MOCK_REFRESH_TOKEN = 'mock-refresh-token'; +export const PROVIDER_ID = 'ministryplatform'; + +export interface RecordedCall { + url: string; + method: string; + body: string; +} + +export interface MockOidc { + sub: string; + /** Every request the mock served, in order. */ + calls: RecordedCall[]; + /** The id_token the token endpoint returned most recently. */ + lastIdToken: string | null; + reset(): void; +} + +const b64 = (v: unknown) => Buffer.from(JSON.stringify(v)).toString('base64url'); + +const json = (body: unknown) => + new Response(JSON.stringify(body), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + +export function installMockOidc(): MockOidc { + const { publicKey, privateKey } = generateKeyPairSync('rsa', { modulusLength: 2048 }); + const jwk = { ...publicKey.export({ format: 'jwk' }), kid: 'test-key-1', alg: 'RS256', use: 'sig' }; + + const mock: MockOidc = { + sub: MOCK_SUB, + calls: [], + lastIdToken: null, + reset() { + mock.calls.length = 0; + mock.lastIdToken = null; + }, + }; + + function signIdToken(): string { + const now = Math.floor(Date.now() / 1000); + const input = `${b64({ alg: 'RS256', kid: 'test-key-1', typ: 'JWT' })}.${b64({ + iss: MOCK_ISSUER, + aud: MOCK_CLIENT_ID, + sub: MOCK_SUB, + iat: now, + exp: now + 300, + })}`; + return `${input}.${sign('RSA-SHA256', Buffer.from(input), privateKey).toString('base64url')}`; + } + + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const request = new Request(input, init); + const { url, method } = request; + mock.calls.push({ url, method, body: await request.clone().text() }); + + if (url === `${MOCK_ISSUER}/.well-known/openid-configuration`) { + return json({ + issuer: MOCK_ISSUER, + authorization_endpoint: `${MOCK_ISSUER}/connect/authorize`, + token_endpoint: `${MOCK_ISSUER}/connect/token`, + userinfo_endpoint: `${MOCK_ISSUER}/connect/userinfo`, + end_session_endpoint: `${MOCK_ISSUER}/connect/endsession`, + jwks_uri: `${MOCK_ISSUER}/.well-known/openid-configuration/jwks`, + id_token_signing_alg_values_supported: ['RS256'], + }); + } + if (url === `${MOCK_ISSUER}/.well-known/openid-configuration/jwks`) { + return json({ keys: [jwk] }); + } + if (url === `${MOCK_ISSUER}/connect/token` && method === 'POST') { + mock.lastIdToken = signIdToken(); + // MP returns a refresh token when offline_access is granted; hand one + // out regardless so the tests prove the app never stores it. + return json({ + access_token: MOCK_ACCESS_TOKEN, + refresh_token: MOCK_REFRESH_TOKEN, + id_token: mock.lastIdToken, + token_type: 'Bearer', + expires_in: 3600, + }); + } + if (url === `${MOCK_ISSUER}/connect/userinfo`) { + return json({ + sub: MOCK_SUB, + given_name: 'Code', + family_name: 'Flow', + email: 'code-flow-member@example.test', + }); + } + throw new Error(`Blocked unexpected fetch in test: ${method} ${url}`); + }) as typeof fetch; + + return mock; +} + +/** A minimal browser cookie jar: applies Set-Cookie (including deletions). */ +export class CookieJar { + readonly cookies = new Map(); + + constructor(from?: CookieJar) { + if (from) for (const [k, v] of from.cookies) this.cookies.set(k, v); + } + + apply(response: Response): this { + for (const line of response.headers.getSetCookie()) { + const [pair, ...attrs] = line.split(';'); + const eq = pair.indexOf('='); + const name = pair.slice(0, eq).trim(); + const value = pair.slice(eq + 1).trim(); + const expired = attrs.some((a) => /^\s*max-age=(0|-)/i.test(a)); + if (value === '' || expired) this.cookies.delete(name); + else this.cookies.set(name, value); + } + return this; + } + + /** The value of the one cookie whose name ends with `suffix`. */ + get(suffix: string): string | undefined { + return [...this.cookies].find(([name]) => name.endsWith(suffix))?.[1]; + } + + header(): string { + return [...this.cookies].map(([k, v]) => `${k}=${v}`).join('; '); + } +} + +export interface AuthLike { + handler: (request: Request) => Promise; +} + +/** Starts sign-in; returns the jar (state cookie) and the authorize URL. */ +export async function startSignIn(instance: AuthLike, origin: string) { + const response = await instance.handler( + new Request(`${origin}/api/auth/sign-in/social`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Origin: origin }, + body: JSON.stringify({ provider: PROVIDER_ID, callbackURL: '/' }), + }), + ); + expect(response.status).toBe(200); + const jar = new CookieJar().apply(response); + const { url } = (await response.json()) as { url: string }; + return { jar, authorizeUrl: new URL(url) }; +} + +/** The full authorization-code flow; returns the callback response and the updated jar. */ +export async function codeFlow(instance: AuthLike, origin: string) { + const { jar, authorizeUrl } = await startSignIn(instance, origin); + const state = String(authorizeUrl.searchParams.get('state')); + const response = await instance.handler( + new Request( + `${origin}/api/auth/callback/${PROVIDER_ID}?${new URLSearchParams({ code: 'test-code', state })}`, + { headers: { Cookie: jar.header() } }, + ), + ); + expect(response.status).toBe(302); + jar.apply(response); + return { response, jar, authorizeUrl, location: String(response.headers.get('location')) }; +} + +/** `GET /get-session`; returns the parsed body (null when signed out) and the response. */ +export async function getSession( + instance: AuthLike, + origin: string, + jar: CookieJar, +): Promise<{ user: Record; session: Record } | null> { + const response = await instance.handler( + new Request(`${origin}/api/auth/get-session`, { headers: { Cookie: jar.header() } }), + ); + expect(response.status).toBe(200); + jar.apply(response); + return (await response.json()) as { user: Record; session: Record } | null; +} + +/** `POST /sign-out` with the jar's cookies; applies the deletions. */ +export async function signOut(instance: AuthLike, origin: string, jar: CookieJar) { + const response = await instance.handler( + new Request(`${origin}/api/auth/sign-out`, { + method: 'POST', + headers: { Cookie: jar.header(), Origin: origin, 'Content-Type': 'application/json' }, + body: '{}', + }), + ); + expect(response.status).toBe(200); + jar.apply(response); + return response; +} diff --git a/vitest.config.mts b/vitest.config.mts index 833d073..2d3b647 100644 --- a/vitest.config.mts +++ b/vitest.config.mts @@ -50,6 +50,7 @@ export default defineConfig({ 'src/test-setup.ts', '**/*.d.ts', '**/*.{test,spec}.{ts,tsx}', + 'src/test-utils/**', // Test-only helpers (mock OIDC server), imported only by tests 'src/lib/providers/ministry-platform/models/**', // Auto-generated files 'src/lib/providers/ministry-platform/scripts/**', // Build-time CLI scripts 'src/components/ui/**', // Vendored shadcn/ui primitives From 6f52180b62ce37948789f4b80f2a47d242232d35 Mon Sep 17 00:00:00 2001 From: Chris Kehayias Date: Wed, 30 Sep 2026 08:08:02 -0400 Subject: [PATCH 06/24] =?UTF-8?q?fix(auth):=20Step=204=20=E2=80=94=20OIDC?= =?UTF-8?q?=20lazy=20discovery,=20id=5Ftoken=20sub=20binding,=20dedicated?= =?UTF-8?q?=20OIDC=20client,=20sign-out=20hint=20(#38)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What Security **Step 4** (`.claude/TODO/2026-09-30-step4-oidc-lazy-discovery-and-sub-binding.md`), ported from MPNext `port-oidc-lazy-discovery.md` (Phases 2–7) and `port-security-review-2026-09-28.md` Phases 4–5. The repo was in **state B**: `discoveryUrl` set, no rebuild facade. - **Fixes the boot-time discovery outage (MPNext #101).** The provider now has explicit `authorizationUrl` / `tokenUrl` / `endSessionEndpoint` and no `discoveryUrl`. Building the auth instance makes **no MP call**, so `next build`, cold starts and session checks never contact MP, and the provider always registers. - **The app still verifies the id_token itself**, in `getUserInfo`. RS256 is pinned, and `iss` and `aud` (the OIDC client id) are checked. The issuer and `jwks_uri` load lazily at the first callback (`lazyIdTokenVerifier`): 5 s timeout, `redirect:"error"`, single-flight, cached on success, **never cached on failure**, and it fails closed on a partial document. - **`getUserInfo` rewritten.** Steps, in order: refuse a missing id_token → verify → check `sub`, `exp` and `azp` → fetch userinfo (10 s timeout, `redirect:"error"`, never throws) → `extractUserGuid` → **case-insensitive `sub` binding** (`auth.userinfo.sub_mismatch`). This is F12's third layer. Kept unchanged: the synthetic email, `mpEmail`, `emailVerified` taken from the claim, `accountLinking:false`, `disabledPaths` and the `refuseIdTokenSignIn` hook. - **Removed:** `requireIdTokenVerification`, which genericOAuth throws for when there is no discovery, and `disableIdTokenNonceBinding`, which is now a no-op. `accountSubject` uses the `typeof` form. `providerId` stays `"ministryplatform"` (it is the registered redirect URI). - **Dedicated OIDC client (owner decision).** Sign-in now uses `MP_OIDC_CLIENT_ID` / `MP_OIDC_CLIENT_SECRET` (`getOidcClient()` in `src/lib/env.ts`) as both the OAuth client and the id_token audience. If they are unset it falls back to `MINISTRY_PLATFORM_CLIENT_ID/SECRET` and logs a one-time `auth.oidc.shared_client` warning per process (not during `next build`). Setting only one of the pair refuses to start. A follow-up TODO makes them required. - **One auth instance per process** (`sharedInstance`). Next loads `auth.ts` once per bundle layer, so without it the sign-out action never saw the callback's in-memory rows. - **Sign-out.** `auth.api.signOut({ body: { disableRedirect: true } })` runs first. `id_token_hint` is taken only from an MP-origin URL. The URL is rebuilt with the exact `post_logout_redirect_uri` and **always** includes `client_id`. `user-menu.tsx` now uses try/catch with `unstable_rethrow` first, then shows an alert; before, it swallowed failures. - **`/auth-error`.** `unable_to_get_user_info` now tells the user to retry, since that is what an MP blip at the callback looks like. `oauth_provider_not_found` now reads as a configuration fault. `nonce_binding_missing` is removed. ## Discovery document (mpi, read-only GET, 2026-09-30) | key | value | |---|---| | issuer | `https://mpi.ministryplatform.com/ministryplatformapi/oauth` | | authorize / token / userinfo / endsession | `…/oauth/connect/{authorize,token,userinfo,endsession}` (match the hardcoded paths) | | `jwks_uri` | `…/oauth/.well-known/jwks` (**not** hardcoded) | | `id_token_signing_alg_values_supported` | `["RS256"]` | | `authorization_response_iss_parameter_supported` | absent, so nothing is lost by skipping better-auth's RFC 9207 check | | `response_types_supported` | includes implicit/hybrid | | `code_challenge_methods_supported` | `["plain","S256"]` (still not honoured; PKCE stays off) | ## Log events - **Added:** `auth.oidc.discovery_failed` (`http_status`/`request_failed`/`invalid_json`/`invalid_document`+`field`), `auth.userinfo.id_token_unverified` (`verifier_unavailable` | `verification_failed` + jose `code`/`claim`/`errName`), `auth.userinfo.id_token_claims_invalid`, `auth.userinfo.sub_mismatch`, `auth.oidc.shared_client` (warn). - **Changed:** `auth.userinfo.fetch_failed` and `auth.userinfo.invalid_sub` are now single JSON lines. - All events carry identifiers only. **What an MP outage looks like now:** `/signin` always starts. A callback during the outage lands on `/auth-error?error=unable_to_get_user_info`, and the next sign-in recovers on the same process. `PROVIDER_NOT_FOUND` now means misconfiguration only. ## Tests 1986 tests pass (127 files). Coverage is 98.9 % statements and 99.72 % lines; the thresholds are unchanged. - New `src/auth.oidc-discovery.test.ts` (28 tests): - MP `reject`/`http_500`/`hang` at boot → sign-in starts and sessions answer with **0** MP calls - discovery or JWKS failure at the callback → refused, then the same instance recovers - discovery timeout and `redirect:"error"`; a hung discovery is given up - single-flight across 5 concurrent callbacks - a partial document fails closed - nonce negative control with `discoveryUrl` re-added - `lazyIdTokenVerifier` unit tests - New `src/auth.user-info.test.ts` (43 tests), using genuinely signed tokens: - alg confusion: HS256 keyed with either client secret, and `alg:none` → `ERR_JOSE_ALG_NOT_ALLOWED` - foreign key, wrong iss/aud, the service-account id as aud, expired or non-numeric exp - missing_exp, azp - sub binding: match, case-only difference, mismatch, missing/empty/non-string sub, missing id_token - userinfo robustness, emailVerified, mpEmail - New `src/auth.shared-instance.test.ts`, with a negative control. - `src/auth.id-token-sign-in.test.ts`: the **"KNOWN GAP until Step 4" test now expects refusal via the sub binding** (hook removed and `discoveryUrl` re-added: 401, `reason:"mismatch"`). A matched pair still signs in as a control. With only the hook removed, better-auth returns `404 ID_TOKEN_NOT_SUPPORTED`. - `src/auth.test.ts`: the old discovery/nonce/`requireIdTokenVerification` pins are replaced with explicit-endpoint pins and a check that the live provider has `idToken` undefined and `requiresIdTokenNonce:false`. The getUserInfo tests moved to the node-env suite. - `src/test-utils/mock-oidc.ts` gains per-endpoint failure modes, `omitDiscovery`/`claims`/`foreignKey`/`userinfo` state, and `signIdToken`. ## Mutation checks (each turned the suite red, then reverted) - `discoveryUrl` restored, run with `--testTimeout=3000`: 14/28 discovery tests failed (35 across `src/auth*`), and the hang cases failed by timeout. - The sub comparison forced true: the F12 substitution test and the user-info mismatch test failed. - Each of these also failed at least one test: - removing the algorithms pin - removing userinfo or discovery `redirect:"error"` - removing the missing_exp or azp check - caching a failure - setting the audience to the service account - accepting a missing id_token - removing the shared-client warning - removing the `sharedInstance` cache - removing `endSessionEndpoint` - removing `accountSubject` ## Verification - `npm run lint`, `npm run typecheck`, `npm run test:coverage` and `npm run build` all pass. - `MINISTRY_PLATFORM_BASE_URL=https://mp.invalid npm run build` logs no discovery error. - `next start` replay with process-env overrides (`.env.local` untouched), against three MP targets: unresolvable, **hanging** (a TCP listener that never answers) and mpi. - `/api/auth/get-session`, `/sign-in/social` (200, authorize URL with `client_id` = OIDC client, no nonce/PKCE) and `/signin` all answer in ≤ 35 ms. - The hanging MP received 0 connections. - Against mpi, `lazyIdTokenVerifier` loads the discovery doc and JWKS and resolves an RS256 key (read-only GETs). - **Not done:** the real-MP sign-in smoke test. It needs a person and a real login: sign in, check `userGuid`, confirm no discovery/unverified events, and confirm sign-out ends the MP session. - `jose ^6.2.12` is now a direct dependency. The lockfile root lists it (a one-line diff); this repo has no lockfile script to fix. ## Deploy note - **New optional env vars `MP_OIDC_CLIENT_ID` / `MP_OIDC_CLIENT_SECRET`.** Nothing breaks without them: sign-in falls back to the service-account client and logs `auth.oidc.shared_client`. - **Owner action:** register a dedicated MP OIDC client for this app in each environment, then set both vars in Vercel (Preview and Production) and `.env.local`. Register on it: - redirect URI `/api/auth/callback/ministryplatform` - post-logout redirect URI exactly `` - Follow-up TODO: `.claude/TODO/2026-09-30-require-dedicated-oidc-client.md`. - **Sign-out limitation:** the `id_token_hint` exists only on the process that handled the callback. On another serverless instance MP gets `client_id` + `post_logout_redirect_uri` and may show its "log out?" prompt once. - Any alerts should key on the new events above. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 (1M context) --- ...026-09-30-require-dedicated-oidc-client.md | 39 ++ ...ep4-oidc-lazy-discovery-and-sub-binding.md | 47 +- .claude/TODO/INDEX.md | 16 +- .claude/references/GLOSSARY.md | 6 +- .claude/references/auth/README.md | 17 +- .claude/references/auth/oauth-flow.md | 252 +++---- .claude/references/auth/sessions.md | 8 +- .claude/references/security/README.md | 97 ++- .env.example | 23 +- CLAUDE.md | 3 +- package-lock.json | 1 + package.json | 1 + scripts/setup.ts | 61 ++ src/app/auth-error/page.test.tsx | 20 +- src/app/auth-error/page.tsx | 33 +- src/auth.id-token-sign-in.test.ts | 141 ++-- src/auth.oidc-discovery.test.ts | 404 +++++++++++ src/auth.secret-guard.test.ts | 94 ++- src/auth.shared-instance.test.ts | 124 ++++ src/auth.test.ts | 337 ++-------- src/auth.user-info.test.ts | 379 +++++++++++ src/auth.user-oauth-tokens.test.ts | 14 +- src/components/user-menu/actions.test.ts | 100 ++- src/components/user-menu/actions.ts | 74 +- src/components/user-menu/user-menu.test.tsx | 59 +- src/components/user-menu/user-menu.tsx | 13 +- src/lib/auth.ts | 630 ++++++++++++++---- src/lib/env.test.ts | 53 +- src/lib/env.ts | 67 +- src/test-setup.ts | 7 +- src/test-utils/mock-oidc.ts | 178 ++++- 31 files changed, 2506 insertions(+), 792 deletions(-) create mode 100644 .claude/TODO/2026-09-30-require-dedicated-oidc-client.md create mode 100644 src/auth.oidc-discovery.test.ts create mode 100644 src/auth.shared-instance.test.ts create mode 100644 src/auth.user-info.test.ts diff --git a/.claude/TODO/2026-09-30-require-dedicated-oidc-client.md b/.claude/TODO/2026-09-30-require-dedicated-oidc-client.md new file mode 100644 index 0000000..413ead9 --- /dev/null +++ b/.claude/TODO/2026-09-30-require-dedicated-oidc-client.md @@ -0,0 +1,39 @@ +--- +title: "Make MP_OIDC_CLIENT_ID / MP_OIDC_CLIENT_SECRET required (remove the service-account fallback)" +severity: medium +tags: [security, doc] +area: auth +files: [src/lib/env.ts, src/lib/auth.ts, src/lib/env.test.ts, src/auth.secret-guard.test.ts, src/components/user-menu/actions.test.ts, .env.example, scripts/setup.ts, .claude/references/auth/oauth-flow.md] +discovered: 2026-09-30 +discovered_by: step4-oidc-lazy-discovery (owner decision: the OIDC client must be for this app only) +status: open +--- + +## Problem +Security Step 4 made user sign-in run as a dedicated MP OIDC client (`MP_OIDC_CLIENT_ID` / +`MP_OIDC_CLIENT_SECRET`), but so existing deploys keep signing in, `getOidcClient()` still falls back +to the client-credentials service account (`MINISTRY_PLATFORM_CLIENT_ID` / `_SECRET`) when the pair is +unset, logging `auth.oidc.shared_client`. While the fallback exists, one leaked secret is both the app's +full MP data access and its user sign-in, and the id_token audience check accepts tokens minted for the +service account's client. + +## Evidence +- `src/lib/env.ts` `getOidcClient()` — `shared: "fallback"` branch. +- `src/lib/auth.ts` `warnIfSharedOidcClient()` — the `auth.oidc.shared_client` warning. +- `next start` / Vercel runtime logs show `{"event":"auth.oidc.shared_client","source":"fallback"}` on + every environment that has not been split. + +## Proposed fix +Once every environment (local, Preview, Production, and any customer-deployed clone) has a dedicated MP +client — redirect URI `/api/auth/callback/ministryplatform`, post-logout redirect URI +`` — and `MP_OIDC_CLIENT_ID` / `MP_OIDC_CLIENT_SECRET` set: +1. `getOidcClient()`: throw when the pair is unset (keep the "both or neither" and + `same_as_service_account` checks — make the latter a refusal too). +2. Delete `warnIfSharedOidcClient` and its tests; flip the fallback tests in `src/lib/env.test.ts`, + `src/auth.secret-guard.test.ts` and `src/components/user-menu/actions.test.ts` to expect refusal. +3. `scripts/setup.ts`: mark both `required: true`; `.env.example` and the auth references: drop + "recommended / fallback" wording. + +## Impact if not fixed +Environments that never set the pair keep sharing one client between the service account and user +sign-in indefinitely; the warning is easy to ignore. diff --git a/.claude/TODO/2026-09-30-step4-oidc-lazy-discovery-and-sub-binding.md b/.claude/TODO/2026-09-30-step4-oidc-lazy-discovery-and-sub-binding.md index 56ec43d..c62e4d4 100644 --- a/.claude/TODO/2026-09-30-step4-oidc-lazy-discovery-and-sub-binding.md +++ b/.claude/TODO/2026-09-30-step4-oidc-lazy-discovery-and-sub-binding.md @@ -3,10 +3,10 @@ title: "Step 4 — OIDC lazy discovery, id_token sub binding, dedicated OIDC cli severity: high tags: [security, missing-test, doc] area: auth -files: [src/lib/auth.ts, src/components/user-menu/actions.ts, src/components/user-menu/user-menu.tsx, src/app/auth-error/page.tsx, src/auth.test.ts, package.json, .claude/references/auth/oauth-flow.md, .claude/references/security/README.md] +files: [src/lib/auth.ts, src/lib/env.ts, src/components/user-menu/actions.ts, src/components/user-menu/user-menu.tsx, src/app/auth-error/page.tsx, src/auth.test.ts, src/test-utils/mock-oidc.ts, package.json, .env.example, scripts/setup.ts, .claude/references/auth/oauth-flow.md, .claude/references/security/README.md] discovered: 2026-09-30 discovered_by: playbook-audit (port-oidc-lazy-discovery.md, port-security-review-2026-09-28.md P4/P5) -status: open +status: resolved --- ## Problem @@ -44,3 +44,46 @@ Follow `S:\MP\MPNext\.claude\playbooks\port-oidc-lazy-discovery.md` Phases 2–7 ## Impact if not fixed Any MP outage at cold start breaks sign-in until redeploy; F12's defence-in-depth layer stays missing; a single client compromise exposes both the service account and user sign-in. + +## Resolution (2026-09-30) + +Branch `fix/step4-oidc-lazy-discovery`. State was **B** (`discoveryUrl`, no facade); better-auth 1.7.6 +(genericOAuth fetches nothing in `init` without `discoveryUrl`). + +1. **Phase 2 (mpi, read-only)** — `issuer` `…/ministryplatformapi/oauth`; `connect/{authorize,token,userinfo, + endsession}` as expected; `jwks_uri` `…/oauth/.well-known/jwks` (not hardcoded); + `id_token_signing_alg_values_supported: ["RS256"]`; no `authorization_response_iss_parameter_supported`; + implicit/hybrid `response_types` advertised; `code_challenge_methods_supported: ["plain","S256"]` (still unusable). +2. **`jose ^6.2.12`** direct dependency; lockfile root lists it (one-line diff, plain `npm install`). +3. **`src/lib/auth.ts`** — explicit `authorizationUrl`/`tokenUrl`/`endSessionEndpoint`; `discoveryUrl`, + `requireIdTokenVerification`, `disableIdTokenNonceBinding` removed; `accountSubject` typeof form; + `providerId` kept `"ministryplatform"`. `lazyIdTokenVerifier` (5 s, `redirect:"error"`, single-flight, + success-cached, failure never cached, fail-closed) + `verifyMpIdToken` (RS256 pinned, iss, aud = OIDC + client). `getUserInfo`: missing id_token → verify → sub/exp/azp → userinfo (10 s, `redirect:"error"`, never + throws) → `extractUserGuid` → case-insensitive sub binding (`auth.userinfo.sub_mismatch`). Synthetic email, + `mpEmail`, claim-derived `emailVerified`, `accountLinking:false`, `disabledPaths`, `refuseIdTokenSignIn` + unchanged. `sharedInstance` (one instance per process across Next bundle layers — required for the + sign-out hint and session-row deletion). +4. **Dedicated OIDC client** — `getOidcClient()` in `src/lib/env.ts`: `MP_OIDC_CLIENT_ID`/`_SECRET`, both or + neither; fallback to `MINISTRY_PLATFORM_CLIENT_ID`/`_SECRET` with `auth.oidc.shared_client` (once per + process, silent during `next build`). Documented in `.env.example`, `scripts/setup.ts`, auth refs. + Follow-up: `2026-09-30-require-dedicated-oidc-client.md`. +5. **Sign-out** — `auth.api.signOut({ body: { disableRedirect: true } })` first; `id_token_hint` taken only from an + MP-origin URL; URL rebuilt with exact `post_logout_redirect_uri` + `client_id` always. Limitation (no hint on + another serverless instance) documented. `user-menu.tsx`: try/catch, `unstable_rethrow` first, then alert. +6. **`/auth-error`** — retry copy for `unable_to_get_user_info`; `oauth_provider_not_found` = configuration; + `nonce_binding_missing` removed. +7. **Tests** — new `src/auth.oidc-discovery.test.ts`, `src/auth.user-info.test.ts`, + `src/auth.shared-instance.test.ts`; F12 "KNOWN GAP" flipped to refusal via the sub binding (hook removed + + `discoveryUrl` re-added), plus `ID_TOKEN_NOT_SUPPORTED` with only the hook removed; config pins replace the + old `auth.test.ts` discovery/nonce pins; mock-oidc gains per-endpoint `reject`/`http_500`/`hang`, + `omitDiscovery`, `claims`, `foreignKey`, `signIdToken`. +8. **Mutation checks (all red)** — `discoveryUrl` restored with `--testTimeout=3000` (14/28 discovery tests, + 35 across `src/auth*`, hang cases by timeout); sub comparison forced true; algorithms pin; userinfo and + discovery `redirect:"error"`; missing_exp; azp; failure cached; audience = service account; missing + id_token accepted; shared-client warning; `sharedInstance` cache; `endSessionEndpoint`; `accountSubject`. +9. **Phase 6** — `MINISTRY_PLATFORM_BASE_URL=https://mp.invalid npm run build`: no discovery error. `next start` + replay: MP unresolvable and MP hanging (a TCP listener that never answers) → `/api/auth/get-session`, + `/sign-in/social` (200, authorize URL, no nonce/PKCE), `/signin` answer in ≤ 35 ms with **0** MP + connections; against mpi the same, and `lazyIdTokenVerifier` loads mpi's discovery + JWKS (RS256 key found). +10. **Not done** — real-MP sign-in smoke test (needs a person and a real login). diff --git a/.claude/TODO/INDEX.md b/.claude/TODO/INDEX.md index 62932fe..73ab135 100644 --- a/.claude/TODO/INDEX.md +++ b/.claude/TODO/INDEX.md @@ -12,6 +12,7 @@ last_updated: 2026-09-30 + @@ -51,16 +52,16 @@ Total: **15 open TODOs**. ### Critical (0) _none open_ -### High (3) +### High (2) | Area | Tags | Title | File | |---|---|---|---| -| auth | security, missing-test, doc | Step 4 — OIDC lazy discovery, sub binding, dedicated OIDC client, sign-out | [→](2026-09-30-step4-oidc-lazy-discovery-and-sub-binding.md) | | auth | security, doc | Ops — Rotate BETTER_AUTH_SECRET; advise downstream clones | [→](2026-09-30-ops-rotate-better-auth-secret.md) | | auth | security, doc | Ops — Set MP_SECURITY_ROLES=* in every environment | [→](2026-09-30-ops-set-mp-security-roles-env.md) | -### Medium (7) +### Medium (8) | Area | Tags | Title | File | |---|---|---|---| +| auth | security, doc | Make MP_OIDC_CLIENT_ID / _SECRET required (remove the service-account fallback) | [→](2026-09-30-require-dedicated-oidc-client.md) | | mp-provider | security, missing-test | Step 5 — MP HTTP client and provider hardening | [→](2026-09-30-step5-mp-http-client-and-provider-hardening.md) | | routing | security, drift | Step 6 — Headers, next.config, server-only, prerender + build in CI | [→](2026-09-30-step6-headers-csp-build-guards.md) | | services | drift, missing-test | Step 7 — Write-audit gaps and datetime drift cleanup | [→](2026-09-30-step7-write-audit-and-datetime-cleanup.md) | @@ -83,7 +84,7 @@ _none open_ ## By tag ### security (10) -- all open 2026-09-30 step/ops/held items except step7 (step1, step2, step3 resolved) +- all open 2026-09-30 step/ops/held items except step7 (step1–step4 resolved), plus require-dedicated-oidc-client ### bug (3) - components-template-editor-no-mp-persistence — medium @@ -103,8 +104,8 @@ _none open_ ### testing (0) _none open_ -### doc (0) -_none open_ +### doc (1) +- require-dedicated-oidc-client — medium ### perf (0) _none open_ @@ -118,7 +119,7 @@ _none open_ | components | 4 | | testing | 0 | | services | 0 | -| auth | 0 | +| auth | 3 | | mp-provider | 0 | | utils | 0 | | routing | 0 | @@ -133,6 +134,7 @@ _none open_ | Date | Title | File | |---|---|---| +| 2026-09-30 | Step 4 — OIDC lazy discovery (no MP call at boot), app-side id_token verification, sub binding, dedicated OIDC client, sign-out id_token_hint | [→](2026-09-30-step4-oidc-lazy-discovery-and-sub-binding.md) | | 2026-09-30 | Step 3 — 12 h session ceiling, JWE cookie, no user OAuth tokens, boot guards + env.ts, secrets hygiene, client-IP header | [→](2026-09-30-step3-session-lifetime-and-secrets-hygiene.md) | | 2026-09-30 | Step 2 — Fail-closed MP_SECURITY_ROLES, group-wizard mass assignment, page-metadata injection, F11, page self-gating, filter/error hygiene | [→](2026-09-30-step2-authorization-and-input-validation.md) | | 2026-09-30 | Step 1 — F12 id_token sign-in refused, F3b callbackUrl bypass closed, /sign-in/social boundary + no-store | [→](2026-09-30-step1-close-live-auth-holes-f12-f3b.md) | diff --git a/.claude/references/GLOSSARY.md b/.claude/references/GLOSSARY.md index 32e765d..0ace4d3 100644 --- a/.claude/references/GLOSSARY.md +++ b/.claude/references/GLOSSARY.md @@ -124,10 +124,10 @@ Hand-written TypeScript interface/type shaping data between layers (e.g. `LabelD ## endsession (aliases: RP-initiated logout, OIDC endsession) -MP OAuth endpoint at `/oauth/connect/endsession` that ends the SSO session and redirects to a pre-registered `post_logout_redirect_uri`. +MP OAuth endpoint at `/oauth/connect/endsession` that ends the SSO session and redirects to a pre-registered `post_logout_redirect_uri`. The app always sends `client_id` (the OIDC sign-in client) and adds `id_token_hint` when the process that handled sign-in still holds the id_token. **Not to be confused with:** Better Auth's local `signOut` (only clears the BA cookie). -**Defined in:** `src/components/user-menu/actions.ts:18` +**Defined in:** `src/components/user-menu/actions.ts` (`handleSignOut`) **See:** `components/user-menu.md` ## escapeFilterString (aliases: MP filter quote escape) @@ -142,7 +142,7 @@ Helper that escapes `'` -> `''`, `%` -> `[%]`, `_` -> `[_]` before interpolating ## genericOAuth (aliases: generic OAuth plugin) -Better Auth plugin wiring arbitrary OIDC providers; configured with `providerId: "ministryplatform"`, OIDC discovery URL, `openid` + MP all scope (no `offline_access`), `pkce: false` (MP advertises `S256` in discovery but rejects the token exchange with `invalid_grant`), `disableIdTokenNonceBinding: true` (MP omits the claim), and `realm=realm` authorization param. As of Better Auth 1.7 it registers providers as first-class **social** providers, so the callback URL is the core `/api/auth/callback/{providerId}` — not the former `/api/auth/oauth2/callback/{providerId}`. +Better Auth plugin wiring arbitrary OIDC providers; configured with `providerId: "ministryplatform"`, explicit authorize/token/endsession endpoints and **no** `discoveryUrl` (since security Step 4: no MP call at boot; the id_token is verified by the app in `getUserInfo` against lazily loaded discovery + JWKS), the dedicated OIDC client (`MP_OIDC_CLIENT_ID`), `openid` + MP all scope (no `offline_access`), `pkce: false` (MP advertises `S256` in discovery but rejects the token exchange with `invalid_grant`), no nonce (MP omits the claim), and `realm=realm` authorization param. As of Better Auth 1.7 it registers providers as first-class **social** providers, so the callback URL is the core `/api/auth/callback/{providerId}` — not the former `/api/auth/oauth2/callback/{providerId}`. **Not to be confused with:** `client credentials flow` (server-to-server, no user). **Defined in:** `src/lib/auth.ts:32` diff --git a/.claude/references/auth/README.md b/.claude/references/auth/README.md index f60e708..727f23f 100644 --- a/.claude/references/auth/README.md +++ b/.claude/references/auth/README.md @@ -5,12 +5,12 @@ domain: auth --- ## What's in this domain -Better Auth (`^1.5.5`) wired to Ministry Platform OIDC via the `genericOAuth` plugin, with stateless sessions (12 h ceiling, encrypted `session_data` cookie, no user OAuth tokens), boot-time secret/URL refusals, a `customSession` name-splitter, and dual-layer route protection (proxy + `AuthWrapper`). +Better Auth (`1.7.x`) wired to Ministry Platform OIDC via the `genericOAuth` plugin — explicit endpoints (no boot-time discovery), the id_token verified by the app against lazily loaded discovery + JWKS, a dedicated OIDC client (`MP_OIDC_CLIENT_ID`, falling back to the service account with a warning), and one auth instance per process — with stateless sessions (12 h ceiling, encrypted `session_data` cookie, no user OAuth tokens), boot-time secret/URL refusals, a `customSession` name-splitter, and dual-layer route protection (proxy + `AuthWrapper`). ## File map | File | Purpose | When to read | |------|---------|--------------| -| `oauth-flow.md` | `genericOAuth` config, callback URL, `getUserInfo`, `mapProfileToUser`, OIDC `endsession` logout | Wiring the MP OAuth client, debugging callbacks or logout | +| `oauth-flow.md` | `genericOAuth` config, lazy id_token verification, `getUserInfo` + sub binding, dedicated OIDC client, log events, `endsession` logout | Wiring the MP OAuth client, debugging callbacks, outages or logout | | `sessions.md` | Session lifetime, JWE `cookieCache`, no OAuth tokens, boot refusals (`assertAuthEnvironment`, `src/lib/env.ts`), client-IP headers, `customSession`, session shape | Reading/modifying session data, any `getSession()` call | | `route-protection.md` | `src/proxy.ts` + `AuthWrapper` dual-layer pattern, `x-pathname` forwarding, `callbackUrl` preservation | Touching route guards or sign-in redirect behavior | | `user-identity.md` | `session.user.id` (Better Auth internal) vs `session.user.userGuid` (MP `User_GUID`) — gotcha | Before any MP API lookup keyed by the signed-in user | @@ -19,15 +19,20 @@ Better Auth (`^1.5.5`) wired to Ministry Platform OIDC via the `genericOAuth` pl | Path | Role | |------|------| | `src/lib/auth.ts` | Server Better Auth config (plugins: `genericOAuth`, `customSession`, `nextCookies`) | -| `src/lib/auth-client.ts` | Client `authClient` (`genericOAuthClient`, `customSessionClient`) | +| `src/lib/auth-client.ts` | Client `authClient` (`customSessionClient`) | +| `src/lib/env.ts` | `getMpBaseUrl()`, `getAuthBaseUrl()`, `getOidcClient()` — validated env readers | | `src/app/api/auth/[...all]/route.ts` | Route handler (`toNextJsHandler(auth)`) | -| `src/app/signin/page.tsx` | Sign-in page — auto-redirects via `authClient.signIn.oauth2()` | +| `src/app/signin/page.tsx` | Sign-in page — auto-redirects via `authClient.signIn.social({ provider: "ministryplatform" })` | | `src/proxy.ts` | Route protection (session-cookie presence check + `x-pathname` forwarding) | | `src/components/layout/auth-wrapper.tsx` | Server-component guard — redirects to `/signin` with `callbackUrl` | -| `src/components/user-menu/actions.ts` | `handleSignOut()` — OIDC `endsession` redirect | +| `src/components/user-menu/actions.ts` | `handleSignOut()` — clears the session, then `endsession` with `client_id` (+ `id_token_hint` when this process has it) | | `src/contexts/user-context.tsx` | `UserProvider` — loads MP profile client-side from `userGuid` | | `src/contexts/session-context.tsx` | `useAppSession()` — thin wrapper around `authClient.useSession()` | -| `src/auth.test.ts` | Unit tests for `customSession`, `getUserInfo`, `mapProfileToUser` logic | +| `src/auth.test.ts` | Config pins, `customSession`, `mapProfileToUser` | +| `src/auth.user-info.test.ts` | `getUserInfo` with genuinely signed id_tokens (verification, alg confusion, sub binding) | +| `src/auth.oidc-discovery.test.ts` | MP down/hanging at boot and at the callback; lazy verifier units | +| `src/auth.id-token-sign-in.test.ts` / `src/auth.shared-instance.test.ts` | F12 layers / one instance per process | +| `src/test-utils/mock-oidc.ts` | Mock MP OIDC provider (per-endpoint failure modes, `signIdToken`) | | `src/proxy.test.ts` | Proxy route-protection tests | | `src/components/layout/auth-wrapper.test.tsx` | `AuthWrapper` redirect tests | | `src/components/user-menu/actions.test.ts` | `handleSignOut` tests | diff --git a/.claude/references/auth/oauth-flow.md b/.claude/references/auth/oauth-flow.md index f040078..8533d71 100644 --- a/.claude/references/auth/oauth-flow.md +++ b/.claude/references/auth/oauth-flow.md @@ -1,198 +1,112 @@ --- -title: OAuth Flow (genericOAuth + OIDC endsession) +title: OAuth Flow (genericOAuth, lazy id_token verification, OIDC endsession) domain: auth type: reference -applies_to: [src/lib/auth.ts, src/app/signin/page.tsx, src/app/api/auth/[...all]/route.ts, src/components/user-menu/actions.ts] -symbols: [auth, handleSignOut] -related: [sessions.md, user-identity.md, route-protection.md] -last_verified: 2026-04-17 +applies_to: [src/lib/auth.ts, src/lib/env.ts, src/app/signin/page.tsx, src/app/api/auth/[...all]/route.ts, src/components/user-menu/actions.ts, src/components/user-menu/user-menu.tsx] +symbols: [auth, ministryPlatformProviderConfig, lazyIdTokenVerifier, sharedInstance, getOidcClient, handleSignOut] +related: [sessions.md, user-identity.md, route-protection.md, ../security/README.md] +last_verified: 2026-09-30 --- ## Purpose -End-to-end OAuth2/OIDC flow against Ministry Platform: sign-in, token exchange, profile mapping, and OIDC-style logout via `endsession`. +End-to-end OAuth2/OIDC flow against Ministry Platform: sign-in, token exchange, +id_token verification, profile mapping, and RP-initiated logout via `endsession`. ## Files -- `src/lib/auth.ts` — `genericOAuth` provider config, exported as `ministryPlatformProviderConfig` (`providerId: "ministryplatform"`) -- `src/app/signin/page.tsx` — client page that calls `authClient.signIn.oauth2(...)` -- `src/app/api/auth/[...all]/route.ts` — Better Auth route handler via `toNextJsHandler(auth)` -- `src/components/user-menu/actions.ts` — `handleSignOut()` server action for OIDC logout -- `src/auth.test.ts` — unit tests for `getUserInfo`, `mapProfileToUser` -- `src/components/user-menu/actions.test.ts` — tests for `handleSignOut` +- `src/lib/auth.ts` — provider config (`ministryPlatformProviderConfig`, `providerId: "ministryplatform"`), `lazyIdTokenVerifier`, `sharedInstance` +- `src/lib/env.ts` — `getMpBaseUrl()`, `getAuthBaseUrl()`, `getOidcClient()` (validated env readers) +- `src/app/signin/sign-in-content.tsx` — calls `authClient.signIn.social({ provider: "ministryplatform", callbackURL })` +- `src/app/api/auth/[...all]/route.ts` — deny-by-default allowlist in front of `toNextJsHandler(auth)` +- `src/components/user-menu/actions.ts` — `handleSignOut()` server action +- Tests: `src/auth.oidc-discovery.test.ts` (outages, lazy discovery), `src/auth.user-info.test.ts` (`getUserInfo` with signed tokens), `src/auth.id-token-sign-in.test.ts` (F12), `src/auth.shared-instance.test.ts`, `src/auth.test.ts` (config pins), `src/components/user-menu/actions.test.ts`; mock MP in `src/test-utils/mock-oidc.ts` ## Key concepts -- Provider is registered under **`providerId: "ministryplatform"`** (exported as `ministryPlatformProviderConfig` in `src/lib/auth.ts`). This string is the key for the OAuth callback URL, the deny-by-default allowlist in `src/app/api/auth/[...all]/route.ts`, and `signIn.social({ provider })`. All three must agree; `src/auth.test.ts` pins it. -- **OIDC discovery** is used — no hand-wired endpoints. `discoveryUrl` points at MP's well-known config. -- **PKCE is DISABLED** (`pkce: false`) — and this is a permanent constraint, not a TODO. **MP's discovery document advertises `code_challenge_methods_supported: ["plain", "S256"]`, but MP does not honour the verifier at the token endpoint.** With PKCE on, the authorize leg succeeds and returns a code, then the exchange fails with `invalid_grant` (400) and the user lands on `/auth-error?error=invalid_code`. Verified against a live tenant on 2026-09-13. Do not re-enable this on the strength of the discovery document; `src/auth.test.ts` pins it off. -- **id_token nonce binding is DISABLED** (`disableIdTokenNonceBinding: true`). As of Better Auth 1.7 any provider with a `discoveryUrl` publishing a JWKS binds the id_token to the authorization request by default; **MP does not echo the `nonce` claim**, so leaving it on fails every sign-in with `unable_to_get_user_info`. See `../security/README.md`. -- MP requires a **`realm=realm`** authorization URL param (`authorizationUrlParams`, `src/lib/auth.ts:45-47`). -- **`getUserInfo`** fetches `${MP_BASE_URL}/oauth/connect/userinfo` with the access token and returns `{ sub, email: , mpEmail, name, image: undefined, emailVerified: }`. It returns `null` (never throws) on a bad response or an unusable `sub`. **The key must be `sub`, not `id`** — Better Auth 1.7 derives the provider account key from `accountSubject`, and the 1.6 `id` shape fails with `OAUTH_ACCOUNT_SUBJECT_INVALID` after a successful token exchange. -- **`accountSubject`** is declared explicitly as `({ profile }) => String(profile.sub ?? "")`, so the account key never depends on the boot-time discovery fetch inferring `isOidc`. -- **`mapProfileToUser`** persists the OIDC `sub` as `userGuid`, and sets `email` to a **synthetic** `@mp.invalid` address so Better Auth cannot key two MP users onto one record via a shared household email. The real address is kept as `mpEmail`. See `user-identity.md` and `../security/README.md#identity`. -- **OAuth callback URL:** `${APP_URL}/api/auth/callback/ministryplatform` — the **core** `/api/auth/callback/{providerId}` pattern. Must be registered on the MP OAuth client. **This path changed in Better Auth 1.7**: genericOAuth no longer mounts its own `/api/auth/oauth2/callback/{providerId}` endpoint. -- **Sign-out is a two-step flow:** clear local Better Auth session via `auth.api.signOut(...)`, then `redirect(...)` the browser to MP's `/oauth/connect/endsession?post_logout_redirect_uri=...`. -- `id_token_hint` is **not** passed to `endsession` (optional in OIDC). `post_logout_redirect_uri` must be pre-registered on the MP OAuth client. - -## genericOAuth configuration (from `src/lib/auth.ts`) - -| Setting | Value | Source | -|---|---|---| -| `providerId` | `"ministryplatform"` | `ministryPlatformProviderConfig` | -| `discoveryUrl` | `${MP_BASE_URL}/oauth/.well-known/openid-configuration` | `auth.ts:36` | -| `clientId` | `process.env.MINISTRY_PLATFORM_CLIENT_ID!` | `auth.ts:37` | -| `clientSecret` | `process.env.MINISTRY_PLATFORM_CLIENT_SECRET!` | `auth.ts:38` | -| `scopes` | `["openid", "http://www.thinkministry.com/dataplatform/scopes/all"]` — no `offline_access` since 2026-09-30 (the user's own tokens are never used) | `ministryPlatformProviderConfig` | -| `pkce` | `false` (MP advertises S256 but rejects the exchange) | `ministryPlatformProviderConfig` | -| `disableIdTokenNonceBinding` | `true` (MP omits the claim) | `ministryPlatformProviderConfig` | -| `authorizationUrlParams` | `{ realm: "realm" }` | `auth.ts:45-47` | -| `getUserInfo` | custom `fetch(${MP_BASE_URL}/oauth/connect/userinfo)` | `auth.ts:48-76` | -| `mapProfileToUser` | `{ userGuid, email: synthetic, mpEmail }` | `ministryPlatformProviderConfig` | - -## `getUserInfo` implementation (verbatim) - -```typescript -// src/lib/auth.ts:48-76 -getUserInfo: async (tokens) => { - // Fetch the OIDC profile to get the sub (User_GUID) - const response = await fetch( - `${mpBaseUrl}/oauth/connect/userinfo`, - { - headers: { - Authorization: `Bearer ${tokens.accessToken}`, - }, - }, - ); - - if (!response.ok) { - console.error( - "getUserInfo - Failed to fetch user info:", - response.status, - ); - return null; - } - - const profile = await response.json(); - - return { - id: profile.sub, - email: profile.email, - name: `${profile.given_name} ${profile.family_name}`, - image: undefined, - emailVerified: true, - }; -}, -``` - -Notes: -- `emailVerified: true` is **hardcoded** — MP is trusted to have validated the email. -- The returned `id` (= `sub`) becomes the **`accountId`** in the account table, not `user.id`. See `user-identity.md`. - -## `mapProfileToUser` implementation (verbatim) - -```typescript -// src/lib/auth.ts:82-86 -mapProfileToUser: (profile) => { - return { - userGuid: profile.id, - } as Record; -}, -``` - -- The `Record` cast is required because genericOAuth's TS type for `mapProfileToUser` doesn't surface `additionalFields`; the runtime does pass them to `createOAuthUser`. +- **No boot-time discovery (security Step 4, upstream MPNext issue #101).** The provider has explicit `authorizationUrl`, `tokenUrl` and `endSessionEndpoint` and **no `discoveryUrl`**, so building the auth instance — `next build`, cold starts, every page's session check — makes **no MP call** and the provider always registers. With `discoveryUrl`, better-auth 1.7 fetched discovery once at boot with no timeout or retry: one MP blip left `/sign-in/social` answering `404 PROVIDER_NOT_FOUND` until a restart, and a hung MP stalled every request ~300 s. +- **The id_token is still verified — by the app.** `getUserInfo` calls `verifyMpIdToken`: RS256 only (`ID_TOKEN_ALGORITHMS`, pinned), signature against MP's JWKS, `iss` = the discovered issuer, `aud` = the OIDC client id, `exp`/`nbf` when present. The issuer and `jwks_uri` come from MP's discovery document, loaded **lazily at the first callback** by `lazyIdTokenVerifier`: 5 s timeout (`OIDC_DISCOVERY_TIMEOUT_MS`), `redirect: "error"`, single-flight, cached on success, **never cached on failure**, fails closed on a document missing `issuer`/`jwks_uri`. The JWKS URL is **not** pinned (MP serves `/oauth/.well-known/jwks`, not the IdentityServer default). +- **`getUserInfo` order** (every refusal returns `null`, never throws — better-auth's callback does not catch): missing id_token → verify → `sub` present → `exp` present / `azp` = us when `aud` is a list → userinfo fetch (10 s `USERINFO_TIMEOUT_MS`, `redirect: "error"`) → `extractUserGuid` → **userinfo `sub` must equal the id_token `sub`** (case-insensitive). The binding is F12's defence-in-depth layer. Returns `{ sub, email: @mp.invalid, mpEmail, name, image: undefined, emailVerified: }`. +- **Dedicated OIDC client (owner decision).** Sign-in runs as `MP_OIDC_CLIENT_ID` / `MP_OIDC_CLIENT_SECRET`, not the service account (`MINISTRY_PLATFORM_CLIENT_ID`). Unset, `getOidcClient()` falls back to the service account and `auth.ts` logs `auth.oidc.shared_client` once per process; half a pair refuses to start. The fallback is slated for removal (`.claude/TODO/2026-09-30-require-dedicated-oidc-client.md`). +- **PKCE is DISABLED** (`pkce: false`) — permanent. MP advertises `code_challenge_methods_supported: ["plain", "S256"]` but rejects the exchange with `invalid_grant` (verified 2026-09-13). **No nonce either**: with no id_token config better-auth neither sends nor requires one (`requiresIdTokenNonce: false`). `requireIdTokenVerification` and `disableIdTokenNonceBinding` are gone (the first throws without discovery; the second is a no-op). Accepted risk F8 — see `../security/README.md`. +- MP requires a **`realm=realm`** authorization URL param (`authorizationUrlParams`). +- **`accountSubject`** is `({ profile }) => typeof profile.sub === "string" ? profile.sub : ""`. Required: without discovery the provider is not recognised as OIDC and the default resolver would read `profile.id` (never returned), keying every account on `""`. +- **`mapProfileToUser`** persists `sub` as `userGuid` and sets `email` to the synthetic `@mp.invalid`; the real address is `mpEmail`. See `user-identity.md`. +- **Callback URL:** `${APP_URL}/api/auth/callback/ministryplatform` (core `/callback/{providerId}` since better-auth 1.7). `providerId` must stay `"ministryplatform"` — it is the registered redirect URI and the stable half of every account key. +- **One auth instance per process** (`sharedInstance`, `Symbol.for("mpnext-tools.auth")`). Next loads `auth.ts` once per bundle layer; without the cache the sign-out server action ran against a different in-memory store than the callback, so it found no id_token and could not delete the session row. ## Sign-in flow ``` -1. Browser hits protected path -2. src/proxy.ts → no session cookie → 302 /signin?callbackUrl= -3. /signin (src/app/signin/page.tsx): - - authClient.getSession() — if already signed in, redirect to callbackUrl - - Else authClient.signIn.social({ provider: "ministryplatform", callbackURL }) -4. Browser → MP /oauth/connect/authorize?... (with realm=realm, scopes) -5. User authenticates at MP -6. MP → ${APP_URL}/api/auth/callback/ministryplatform?code=... -7. Better Auth (via toNextJsHandler): - a. Exchanges code for tokens - b. getUserInfo(tokens) → { sub, email: @mp.invalid, mpEmail, name, emailVerified: } - b2. accountSubject({ profile }) → sub (the provider account key) - c. mapProfileToUser(profile) → { userGuid: sub, email: @mp.invalid, mpEmail } - d. Creates user (id=generated, userGuid=sub, email, name) - e. Creates account (accountId=sub) in the in-memory adapter — access/refresh tokens - nulled by databaseHooks (idToken kept); storeAccountCookie: false (no account_data cookie) - f. Creates session (expiresAt = now + 12h) → sets session_token + encrypted (JWE) session_data -8. Browser lands on callbackURL (app page) -9. Client-side UserProvider reads session.user.userGuid (to decide whether to load) - → getCurrentUserProfile() [server action re-derives the GUID from the session] +1. Browser hits protected path → src/proxy.ts → 302 /signin?callbackUrl= +2. /signin → authClient.signIn.social({ provider: "ministryplatform", callbackURL }) + (POST /api/auth/sign-in/social — no MP call; the authorize URL is built locally) +3. Browser → MP /oauth/connect/authorize?client_id=&realm=realm&... +4. MP → ${APP_URL}/api/auth/callback/ministryplatform?code=...&state=... +5. better-auth: state cookie check → token exchange (client secret) → getUserInfo(tokens): + a. first callback on this process: fetch discovery (issuer, jwks_uri), then JWKS + b. verify id_token; sub / exp / azp checks + c. userinfo; sub binding + → accountSubject → mapProfileToUser → user + account rows (access/refresh tokens + nulled by databaseHooks; idToken kept in memory only) → session cookies +6. Browser lands on callbackURL ``` -## Sign-in entry (verbatim from `src/app/signin/page.tsx`) - -```typescript -// src/app/signin/sign-in-content.tsx -authClient.signIn.social({ - provider: "ministryplatform", - callbackURL: sanitizeCallbackUrl(searchParams?.get("callbackUrl")), -}); -``` - -Better Auth 1.7 removed `signIn.oauth2` along with the `genericOAuthClient` -plugin; generic providers now go through core `signIn.social`, and the field is -`provider`, not `providerId`. The page body lives in `sign-in-content.tsx` -because `page.tsx` must stay a server component to opt out of prerendering — -see `../security/README.md#nonces-force-dynamic-rendering`. - -## Sign-out flow (verbatim from `src/components/user-menu/actions.ts`) - -```typescript -// src/components/user-menu/actions.ts -export async function handleSignOut() { - // Clear the Better Auth session - await auth.api.signOut({ - headers: await headers(), - }); - - const baseUrl = process.env.MINISTRY_PLATFORM_BASE_URL; - if (!baseUrl) { - throw new Error('MINISTRY_PLATFORM_BASE_URL is not configured'); - } - - const endSessionUrl = `${baseUrl}/oauth/connect/endsession`; - const params = new URLSearchParams({ - post_logout_redirect_uri: process.env.BETTER_AUTH_URL || process.env.NEXTAUTH_URL || 'http://localhost:3000', - }); - - redirect(`${endSessionUrl}?${params.toString()}`); -} -``` +## Failure modes and log events (identifiers only) + +| Event | Meaning | +|---|---| +| `auth.oidc.discovery_failed` | Discovery unavailable at a callback. `reason`: `http_status`+`status`, `request_failed`+`errName`, `invalid_json`+`errName`, `invalid_document`+`field` (`body`/`issuer`/`jwks_uri`). Retried on the next sign-in | +| `auth.userinfo.id_token_unverified` | `reason: "verifier_unavailable"` (discovery failed) or `"verification_failed"` + jose `code` (e.g. `ERR_JWT_EXPIRED`, `ERR_JWS_SIGNATURE_VERIFICATION_FAILED`, `ERR_JOSE_ALG_NOT_ALLOWED`, `ERR_JWKS_TIMEOUT`) / `claim` / `errName` | +| `auth.userinfo.id_token_claims_invalid` | `missing_exp` or `azp_mismatch` | +| `auth.userinfo.sub_mismatch` | `missing_id_token`, `missing_sub`, or `mismatch` (token substitution) | +| `auth.userinfo.fetch_failed` | userinfo `http_status`, `request_failed`, `invalid_json`, `not_an_object` | +| `auth.userinfo.invalid_sub` | userinfo `sub` missing or not a GUID | +| `auth.oidc.shared_client` (warn) | sign-in is using the service-account client (`source`: `fallback` / `same_as_service_account`) | + +**What an MP outage looks like now:** `/signin` always starts. A callback during +the outage lands on `/auth-error?error=unable_to_get_user_info` (retry copy), +and the next sign-in recovers on the same process. `PROVIDER_NOT_FOUND` / +`oauth_provider_not_found` now means misconfiguration only. + +## Sign-out flow (`src/components/user-menu/actions.ts`) + +1. `auth.api.signOut({ headers, body: { disableRedirect: true } })` — clears the + app session **first**, so a broken env below still signs the user out, and + returns better-auth's provider logout URL. +2. The `id_token_hint` is taken from that URL **only if its origin is the MP + origin**. +3. The end-session URL is rebuilt: + `${MP}/oauth/connect/endsession?post_logout_redirect_uri=&client_id=[&id_token_hint=…]` + — `post_logout_redirect_uri` exactly as registered (better-auth would add a + trailing `/` and drop `client_id` when it has a hint). +4. `redirect()` — a `NEXT_REDIRECT` throw. `user-menu.tsx` wraps the call in + try/catch with `unstable_rethrow(err)` **first**, then alerts on a genuine + failure (it used to fail silently). + +**Limitation:** the id_token lives only in the in-memory account row of the +process that handled the callback. A sign-out on another serverless instance +(or after a restart) has no hint; MP then relies on `client_id` + +`post_logout_redirect_uri` and may show its "log out?" prompt once. The app +session is cleared either way. ## Environment variables | Variable | Required | Purpose | |---|---|---| -| `MINISTRY_PLATFORM_BASE_URL` | yes | OIDC discovery root + userinfo + endsession. Validated at boot by `getMpBaseUrl()` (https; loopback http outside production) | -| `MINISTRY_PLATFORM_CLIENT_ID` | yes | OAuth + API client ID registered on MP | -| `MINISTRY_PLATFORM_CLIENT_SECRET` | yes | OAuth + API client secret | -| `BETTER_AUTH_URL` | yes (fallback `NEXTAUTH_URL`) | App base URL for callbacks + `post_logout_redirect_uri`. Validated at boot by `getAuthBaseUrl()` (origin only; https except loopback) | -| `BETTER_AUTH_SECRET` | yes (fallback `NEXTAUTH_SECRET`) | Session signing/encryption secret. Boot refuses unset, better-auth's default, or < 32 chars | -| `AUTH_IP_ADDRESS_HEADERS` | no | Client-IP header(s) for rate limiting. Default on Vercel: `x-vercel-forwarded-for,x-real-ip` | -| `AUTH_TRUSTED_PROXIES` | no | Proxy IPs/CIDRs to skip in an appended `x-forwarded-for` chain | - -`NEXTAUTH_*` fallbacks exist for gradual migration from NextAuth (`src/lib/auth.ts`, `src/lib/env.ts`). - -## MP OAuth client setup +| `MINISTRY_PLATFORM_BASE_URL` | yes | Root of the OIDC endpoints (authorize, token, userinfo, endsession, lazy discovery). Validated by `getMpBaseUrl()` | +| `MP_OIDC_CLIENT_ID` / `MP_OIDC_CLIENT_SECRET` | recommended (both or neither) | Dedicated sign-in client: OAuth client, id_token audience, sign-out `client_id` | +| `MINISTRY_PLATFORM_CLIENT_ID` / `_SECRET` | yes | Service account for MP data; deprecated sign-in fallback | +| `BETTER_AUTH_URL` | yes (fallback `NEXTAUTH_URL`) | App origin: callbacks + `post_logout_redirect_uri`. Validated by `getAuthBaseUrl()` | +| `BETTER_AUTH_SECRET` | yes (fallback `NEXTAUTH_SECRET`) | Session signing/encryption secret | -Register these URLs on the MP OAuth client: +## MP OAuth client setup (the dedicated sign-in client) - **Redirect URI:** `${APP_URL}/api/auth/callback/ministryplatform` -- **Post-logout redirect URI:** value of `BETTER_AUTH_URL` (or `NEXTAUTH_URL`) +- **Post-logout redirect URI:** exactly `BETTER_AUTH_URL` (no trailing slash) ## Gotchas -- `user.id` ≠ `userGuid`. See `user-identity.md`. -- `emailVerified` now reports the provider's actual `email_verified` claim (it used to be hardcoded `true`, which unconditionally satisfied Better Auth's implicit account-linking condition). -- `session.user.email` is a synthetic `@mp.invalid` address, NOT the user's real one. Read `mpEmail`, or `MPUserProfile.Email_Address` from `UserService`. -- PKCE disabled by design — `pkce: false` at `src/lib/auth.ts:44`. MP's OIDC provider does not support PKCE and requires the `realm=realm` workaround instead (see Key concepts above). -- No `id_token_hint` on `endsession` — `post_logout_redirect_uri` **must** be pre-registered on MP. -- `handleSignOut` falls back to `http://localhost:3000` if neither `BETTER_AUTH_URL` nor `NEXTAUTH_URL` is set (`src/components/user-menu/actions.ts:20`). Deploying without the env var will 302 to localhost. +- Re-adding `discoveryUrl` brings back the boot-time outage **and** re-opens `/sign-in/social`'s id_token mode (F12); `src/auth.oidc-discovery.test.ts` and `src/auth.test.ts` go red. +- `user.id` ≠ `userGuid`; `session.user.email` is synthetic — read `mpEmail`. +- Under `next dev`, auth option edits need a server restart (the instance is cached on `globalThis`). ## Related docs -- `sessions.md` — what gets stored in the JWT cookie after callback +- `sessions.md` — cookies, lifetime, what sign-out can and cannot revoke - `user-identity.md` — `sub` → `userGuid` mapping and consumer patterns - `route-protection.md` — `callbackUrl` preservation through the OAuth round-trip diff --git a/.claude/references/auth/sessions.md b/.claude/references/auth/sessions.md index 4772c0b..18a7678 100644 --- a/.claude/references/auth/sessions.md +++ b/.claude/references/auth/sessions.md @@ -81,9 +81,11 @@ advanced: { hooks null the access/refresh tokens in memory. The **`idToken` is kept** in the in-memory row: on the instance that handled sign-in, better-auth's own `auth.api.signOut({ body: { disableRedirect: true } })` returns MP's - endsession URL with `id_token_hint` (pinned in `auth.user-oauth-tokens.test.ts`). - The sign-out action does not use it yet (security Step 4); on a different - serverless instance there is no row and so no hint. + endsession URL with `id_token_hint` (pinned in `auth.user-oauth-tokens.test.ts`), + and `handleSignOut` forwards that hint (`sharedInstance` makes every Next + bundle layer in the process share the row). On a different serverless + instance there is no row and so no hint: sign-out still sends `client_id`, + and MP may show its "log out?" prompt once. See `oauth-flow.md`. - **`customSession` (`enrichSession`)** runs on every `getSession()`. It must stay cheap: no MP API calls. It adds `firstName`/`lastName` and strips `WITHHELD_SESSION_FIELDS` (`token`, `ipAddress`, `userAgent`) from the diff --git a/.claude/references/security/README.md b/.claude/references/security/README.md index c8af96e..af70416 100644 --- a/.claude/references/security/README.md +++ b/.claude/references/security/README.md @@ -267,8 +267,9 @@ installed version, not assumed. Better Auth 1.7 stopped deriving the provider account id from `profile.id` (the user-info type now declares `id?: never`) and derives it from `accountSubject(...)` instead. genericOAuth's default is -`isOidc ? profile.sub : profile.id`, where `isOidc` is inferred **at boot** from -whether the discovery fetch returned `id_token_signing_alg_values_supported`. +`isOidc ? profile.sub : profile.id`, where `isOidc` comes from a discovery +document — and since security Step 4 the provider has **no** `discoveryUrl`, so +the default would read `profile.id` and key every account on `""`. Two consequences this app handles explicitly: @@ -277,34 +278,50 @@ Two consequences this app handles explicitly: `OAUTH_ACCOUNT_SUBJECT_INVALID` **after a successful token exchange** — surfacing as `/auth-error?error=unable_to_get_user_info`, which reads like a userinfo fetch failure rather than an identity-mapping one. -2. **`accountSubject` is declared explicitly**, so the account key does not - depend on a boot-time network fetch succeeding. A transient discovery failure - would otherwise silently switch which field identifies the user. +2. **`accountSubject` is declared explicitly** (`typeof profile.sub === + "string" ? profile.sub : ""`). Required, not stylistic, without discovery. `mapProfileToUser` receives the **raw** object `getUserInfo` returned, so it reads `sub` for the same reason. `src/auth.test.ts` pins all three, and the guard is verified to fail against the 1.6 shape. -### `disableIdTokenNonceBinding` — required for Ministry Platform - -As of 1.7, any provider configured with a `discoveryUrl` that publishes a JWKS -binds the `id_token` to the authorization request **by default**: Better Auth -sends a server-generated `nonce` and rejects a callback whose `id_token` does -not echo it (OIDC Core 1.0 §3.1.3.7). **MP omits the claim**, so every sign-in -would fail with `unable_to_get_user_info`. - -The failure is inverted from the obvious reading, which is what makes it cost -hours: sign-in succeeds **only when the boot-time discovery fetch failed**, -because that leaves the id_token config undefined and skips verification -entirely. A *working* discovery means a *broken* sign-in. - -What this gives up is binding the id_token to that particular authorization -request. Signature, issuer and audience are still verified against MP's JWKS, -and residual replay risk is mitigated by the `state` cookie check, by this being -a confidential client exchanging the code with a client secret, and by PKCE. - -`src/auth.test.ts` pins this flag and `pkce`, because both fail as a broken -sign-in for every user rather than as a type error. +### OIDC: lazy discovery and id_token verification + +Security Step 4 (2026-09-30), ported from upstream MPNext issue #101. Full +detail in [`../auth/oauth-flow.md`](../auth/oauth-flow.md). + +- **No `discoveryUrl`.** Explicit `authorizationUrl` / `tokenUrl` / + `endSessionEndpoint`, so building the auth instance makes no MP call. With + `discoveryUrl`, better-auth 1.7 fetched discovery once at boot with no + timeout: a failed fetch dropped the provider until a restart (an uptime check + stayed green), a hung one stalled every request. +- **The id_token is verified by the app** in `getUserInfo` (`verifyMpIdToken`): + RS256 pinned (an HS256 token keyed with a client secret, or `alg: none`, is + `ERR_JOSE_ALG_NOT_ALLOWED`), signature against MP's JWKS, `iss`, `aud` = the + OIDC client id; then `sub` present, `exp` present, `azp` when `aud` is a + list; then the userinfo `sub` must equal the id_token `sub`. Every refusal + returns `null` and logs identifiers only. +- **Issuer + `jwks_uri` load lazily** at the first callback + (`lazyIdTokenVerifier`): 5 s timeout, `redirect: "error"`, single-flight, + cached on success, never cached on failure, fail closed on a partial + document. The JWKS URL is not pinned. +- **`requireIdTokenVerification` / `disableIdTokenNonceBinding` removed.** The + first throws without discovery; the second is a no-op without an id_token + config (better-auth then neither sends nor requires a nonce). Re-adding + `discoveryUrl` would also turn nonce binding back on, which MP cannot satisfy + — `src/auth.oidc-discovery.test.ts` keeps that negative control. +- **Dedicated OIDC client** (owner decision): `MP_OIDC_CLIENT_ID` / + `MP_OIDC_CLIENT_SECRET`, separate from the service account. Falls back to the + service account with `auth.oidc.shared_client` until every environment is + split (`.claude/TODO/2026-09-30-require-dedicated-oidc-client.md`). +- **One auth instance per process** (`sharedInstance`), so sign-out finds the + id_token (`id_token_hint`) and deletes the session row. Sign-out always sends + `client_id`; on another serverless instance there is no hint and MP may + prompt once. + +`src/auth.test.ts` pins `pkce: false` and the absence of `discoveryUrl`, +because both fail as a broken sign-in (or a reopened hole) rather than as a +type error. ### `userGuid` must stay `input: true` @@ -338,7 +355,8 @@ points (security Step 3, 2026-09-30): withholds `token`, `ipAddress` and `userAgent`. - **No user MP OAuth tokens**: no `offline_access`, no `account_data` cookie, access/refresh tokens nulled in the in-memory account row. The `idToken` is - kept in memory for a future `id_token_hint` (Step 4). + kept in memory: it is the sign-out `id_token_hint` (on the process that + handled the callback only). - **Boot refusals**: missing, default or < 32-char `BETTER_AUTH_SECRET`, `BETTER_AUTH_SECRETS`, `TEST` in production, or a malformed/non-https `BETTER_AUTH_URL` / `MINISTRY_PLATFORM_BASE_URL` all stop the process @@ -447,10 +465,11 @@ Closed in this repo, from the upstream MPNext hardening playbook | F9 | Medium | No CSP, no HSTS, no anti-framing, no Referrer-Policy | | F3 | Medium | Open redirect via `?callbackUrl=` on `/signin` | | F3b | Low–Medium | F3 bypass: `?callbackUrl=/%09/evil` — the URL parser strips tab/LF/CR after string checks. Closed 2026-09-30 (sanitizer mirrors `isSafeRelativeURL`, returns the raw value) | -| F12 | Low–Medium | `/sign-in/social` id_token branch + caller-supplied access token = sign in as another user. Closed 2026-09-30 by `hooks.before` (`ID_TOKEN_SIGN_IN_DISABLED`) + route body filter + `/link-social` disabled. MP's client allows implicit/hybrid, hence Low–Medium. The `sub` binding in `getUserInfo` is **still open** (Step 4) | +| F12 | Low–Medium | `/sign-in/social` id_token branch + caller-supplied access token = sign in as another user. Closed 2026-09-30 by `hooks.before` (`ID_TOKEN_SIGN_IN_DISABLED`) + route body filter + `/link-social` disabled. MP's client allows implicit/hybrid, hence Low–Medium. Since Step 4 (2026-09-30) better-auth also refuses the mode itself (no id_token config without `discoveryUrl` — a side effect, not a control) and `getUserInfo` binds the verified id_token `sub` to the userinfo `sub` (`auth.userinfo.sub_mismatch`) | | F7 | Low | ~30 Better Auth endpoints publicly mounted; OAuth errors on a third-party page | | P2/P3/P6 (2026-09-28 review) | Medium | Sessions up to 7 days via default `refreshCache`; readable JWT cookie; user MP refresh token in an `account_data` cookie; no boot-time secret/URL guards; rate-limit IP unconfigured; `.env*` not ignored; `settings.local.json` tracked. Closed 2026-09-30 (security Step 3) — see [Sessions and secrets](#sessions-and-secrets) | -| F8 | Low | **Resolved as WONTFIX.** PKCE stays `false`: MP advertises `S256` in discovery but rejects the token exchange with `invalid_grant`. See below. | +| MPNext #101 | Medium (availability) | Boot-time OIDC discovery: one MP blip left sign-in 404ing until a restart; a hang stalled every request ~300 s. Closed 2026-09-30 (Step 4): explicit endpoints, id_token verified by the app against lazily loaded discovery — see [OIDC: lazy discovery](#oidc-lazy-discovery-and-id_token-verification) | +| F8 | Low | **Resolved as WONTFIX / accepted risk.** PKCE stays `false`: MP advertises `S256` in discovery but rejects the token exchange with `invalid_grant`; MP does not echo a nonce either. See below. | **Not applicable to this repo:** F4 (no `Made_By` / contact-log feature, and attribution was already server-authoritative — no server action ever accepted a @@ -474,8 +493,13 @@ accepting the `code_challenge` on the authorize URL — look like confirmation. The flow only breaks on the last hop. `src/auth.test.ts` pins `pkce: false` with this reasoning so it is not re-opened from the discovery document alone. -Without PKCE the code rests on the client secret and the `state` cookie check, -which is a confidential client's normal posture. +Stated plainly: with neither PKCE nor a nonce, **nothing binds an +authorization code to the browser that started the flow** — the `state` check +does not stop an attacker injecting a stolen code into their own flow, and the +client secret does not help because the app redeems the injected code itself +(RFC 9700 §4.5). What remains keeps codes out of reach: a dedicated MP OIDC +client with exact redirect URIs (`MP_OIDC_CLIENT_ID`, Step 4), `Referrer-Policy`, +and no code-bearing URLs in logs. ### Still open — needs a human @@ -500,7 +524,12 @@ which is a confidential client's normal posture. push protection and Dependabot security updates were all **disabled** on 2026-09-30 (public repo). `SECURITY.md` points reporters at private vulnerability reporting, so it needs enabling. -- A transient **discovery failure at boot disables the OAuth provider for the - life of the process**, with no retry (inherited upstream issue). It also - inverts the sign-in failure mode: a *working* discovery is what turns on - id_token verification. +- **Register a dedicated MP OIDC client** and set `MP_OIDC_CLIENT_ID` / + `MP_OIDC_CLIENT_SECRET` in every environment (redirect URI + `/api/auth/callback/ministryplatform`, post-logout redirect + URI ``). Until then sign-in shares the service-account + client and logs `auth.oidc.shared_client`. +- **Real-MP smoke test of Step 4 not yet done** (a real sign-in): sign in, + confirm `/api/auth/get-session` has a `userGuid`, no + `auth.oidc.discovery_failed` / `auth.userinfo.id_token_unverified` in the + log, and sign-out ends the MP session without a "log out?" prompt. diff --git a/.env.example b/.env.example index 4620df4..147b7fd 100644 --- a/.env.example +++ b/.env.example @@ -26,7 +26,8 @@ BETTER_AUTH_URL=http://localhost:3000 # MinistryPlatform API Configuration # ============================================================================= # This API Client / Secret will be used for server-side data access and will be -# the user for audit log data when no userId is provided. +# the user for audit log data when no userId is provided. User sign-in should +# use its own client (MP_OIDC_CLIENT_ID below). MINISTRY_PLATFORM_CLIENT_ID=MPNext MINISTRY_PLATFORM_CLIENT_SECRET= @@ -34,6 +35,26 @@ MINISTRY_PLATFORM_CLIENT_SECRET= # no credentials, query or fragment. A trailing slash is stripped. MINISTRY_PLATFORM_BASE_URL=https://mpi.ministryplatform.com/ministryplatformapi +# ----------------------------------------------------------------------------- +# User sign-in (OIDC) client — RECOMMENDED, soon required +# ----------------------------------------------------------------------------- +# A MinistryPlatform API Client registered for THIS app's user sign-in only, +# separate from the service-account client above. Its id is the id_token +# audience and the sign-out client_id. Register it in MP with: +# - redirect URI: /api/auth/callback/ministryplatform +# - post-logout redirect URI: (exact, no trailing slash) +# +# Why separate: sharing one client means one leaked secret is both the app's +# full MP data access and its user sign-in, and the id_token audience check +# accepts tokens minted for the service account's client. +# +# Set BOTH or NEITHER (one without the other refuses to start). Left unset, +# sign-in falls back to MINISTRY_PLATFORM_CLIENT_ID / _SECRET and logs one +# `auth.oidc.shared_client` warning per process. This fallback will be removed +# once every environment has a dedicated client. +MP_OIDC_CLIENT_ID= +MP_OIDC_CLIENT_SECRET= + # ----------------------------------------------------------------------------- # Development-only MinistryPlatform API credentials # ----------------------------------------------------------------------------- diff --git a/CLAUDE.md b/CLAUDE.md index 4c4f1ac..0282359 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -54,8 +54,9 @@ to keep them from diverging. - **Auth**: Better Auth with Ministry Platform OAuth via genericOAuth plugin — see **[Auth Reference](.claude/references/auth/README.md)** for full details - **Key files**: `src/lib/auth.ts` (server config), `src/lib/auth-client.ts` (client), `src/proxy.ts` (route protection) - **Critical**: `session.user.id` is Better Auth's internal ID, NOT the MP User_GUID. Use `session.user.userGuid` for all MP API lookups. + - **OIDC**: explicit endpoints, **no `discoveryUrl`** (it would restore the boot-time MP dependency and F12's id_token mode); the id_token is verified by the app in `getUserInfo` (RS256, lazily discovered issuer/JWKS, `sub` bound to userinfo). Sign-in uses the dedicated `MP_OIDC_CLIENT_ID`/`_SECRET` (falls back to the service account with an `auth.oidc.shared_client` warning). See [OAuth flow](.claude/references/auth/oauth-flow.md) - **Stateless Sessions**: no database; hard 12 h lifetime, encrypted (JWE) cookie cache with `refreshCache: false`, no user OAuth tokens in cookies or memory (`storeAccountCookie: false`, no `offline_access`); `customSession` does name splitting and withholds `token`/`ipAddress`/`userAgent` (no API calls). See [sessions](.claude/references/auth/sessions.md) - - **Required Environment Variables**: `MINISTRY_PLATFORM_BASE_URL`, `BETTER_AUTH_URL` (or `NEXTAUTH_URL` fallback), `BETTER_AUTH_SECRET` (or `NEXTAUTH_SECRET` fallback). **The app refuses to boot** (including `next build`) on a missing/default/<32-char secret, `BETTER_AUTH_SECRETS`, `TEST` in production, or a non-https/malformed URL — read URLs through `src/lib/env.ts` (`getMpBaseUrl()`, `getAuthBaseUrl()`), never raw `process.env` + - **Required Environment Variables**: `MINISTRY_PLATFORM_BASE_URL`, `MINISTRY_PLATFORM_CLIENT_ID`/`_SECRET` (service account), `BETTER_AUTH_URL` (or `NEXTAUTH_URL` fallback), `BETTER_AUTH_SECRET` (or `NEXTAUTH_SECRET` fallback); `MP_OIDC_CLIENT_ID`/`_SECRET` recommended (both or neither), slated to become required. **The app refuses to boot** (including `next build`) on a missing/default/<32-char secret, `BETTER_AUTH_SECRETS`, `TEST` in production, or a non-https/malformed URL — read URLs and the OIDC client through `src/lib/env.ts` (`getMpBaseUrl()`, `getAuthBaseUrl()`, `getOidcClient()`), never raw `process.env` - **Optional**: `AUTH_IP_ADDRESS_HEADERS` / `AUTH_TRUSTED_PROXIES` — client-IP source for rate limiting (default on Vercel: `x-vercel-forwarded-for,x-real-ip`; see `.env.example`) - **Git hooks**: `npm install` runs `prepare`, which sets `core.hooksPath=.githooks`; `.githooks/pre-commit` refuses staged `.env*` files other than `.env.example` - **`MP_SECURITY_ROLES` fails closed**: `*` = any MP security role, a comma list = only those roles, **unset/blank/`","` = nobody** (every tool redirects to `/no-access`). Set it in every environment. diff --git a/package-lock.json b/package-lock.json index 385ee03..ee5ec79 100644 --- a/package-lock.json +++ b/package-lock.json @@ -37,6 +37,7 @@ "dotenv": "^18.0.4", "grapesjs": "^0.22.16", "grapesjs-mjml": "^1.0.8", + "jose": "^6.2.12", "lucide-react": "^1.49.0", "mjml": "^5.4.1", "next": "^16.3.7", diff --git a/package.json b/package.json index 9bf3c10..7307ddc 100644 --- a/package.json +++ b/package.json @@ -52,6 +52,7 @@ "dotenv": "^18.0.4", "grapesjs": "^0.22.16", "grapesjs-mjml": "^1.0.8", + "jose": "^6.2.12", "lucide-react": "^1.49.0", "mjml": "^5.4.1", "next": "^16.3.7", diff --git a/scripts/setup.ts b/scripts/setup.ts index cebea0a..15c7283 100644 --- a/scripts/setup.ts +++ b/scripts/setup.ts @@ -130,6 +130,22 @@ const ENV_VARS: EnvVar[] = [ defaultValue: '*', }, // Optional variables + { + // Recommended, and planned to become required: the dedicated OIDC client + // for user sign-in. Unset, sign-in falls back to the service-account client + // above and logs `auth.oidc.shared_client` (see getOidcClient in + // src/lib/env.ts). Set both or neither. + name: 'MP_OIDC_CLIENT_ID', + required: false, + sensitive: false, + description: 'Dedicated MP OIDC client for user sign-in (recommended; separate from the API client)', + }, + { + name: 'MP_OIDC_CLIENT_SECRET', + required: false, + sensitive: true, + description: 'Secret for MP_OIDC_CLIENT_ID', + }, { name: 'MINISTRY_PLATFORM_DEV_CLIENT_ID', required: false, @@ -811,6 +827,15 @@ function runCheckMode(): number { const issues = [...missing, ...empty].map((v) => v.name); console.log(chalk.red(`✗ Missing: ${issues.join(', ')}`)); } + // Advisory only (not a failure yet): the dedicated sign-in client. + const envForOidc = parseEnvFile(ENV_LOCAL_PATH); + const hasOidcId = Boolean(envForOidc.get('MP_OIDC_CLIENT_ID')); + const hasOidcSecret = Boolean(envForOidc.get('MP_OIDC_CLIENT_SECRET')); + if (hasOidcId !== hasOidcSecret) { + console.log(chalk.red(' ✗ MP_OIDC_CLIENT_ID and MP_OIDC_CLIENT_SECRET must be set together (the app refuses to start)')); + } else if (!hasOidcId) { + console.log(chalk.yellow(' ! MP_OIDC_CLIENT_ID not set: sign-in reuses the API client (recommended: a dedicated client)')); + } // Step 6: Dependencies process.stdout.write(chalk.cyan('[6/8] Dependencies... ')); @@ -1068,11 +1093,47 @@ async function runInteractiveSetup(options: SetupOptions): Promise { console.log(chalk.green(` ✓ MINISTRY_PLATFORM_CLIENT_SECRET = ********`)); } + // The dedicated sign-in (OIDC) client. Optional for now: blank keeps the + // deprecated fallback to the API client above (logged as + // `auth.oidc.shared_client` at runtime). The app refuses to start with only + // one of the pair set, so the secret is asked for only when an id is given. + console.log(chalk.yellow('\n User Sign-in (OIDC) Client')); + console.log( + chalk.gray( + ' Recommended: an MP API client used ONLY for user sign-in, separate from the data client.\n' + + ' Register redirect URI /api/auth/callback/ministryplatform and\n' + + ' post-logout redirect URI . Leave blank to reuse the data client (not recommended).' + ) + ); + const oidcClientId = await input({ + message: 'Enter MP_OIDC_CLIENT_ID (blank to skip):', + default: currentEnv.get('MP_OIDC_CLIENT_ID') || undefined, + }); + if (oidcClientId) { + updates.set('MP_OIDC_CLIENT_ID', oidcClientId); + console.log(chalk.green(` ✓ MP_OIDC_CLIENT_ID = ${oidcClientId}`)); + const oidcClientSecret = await password({ + message: `Enter MP_OIDC_CLIENT_SECRET (${oidcClientId}):`, + }); + if (oidcClientSecret) { + updates.set('MP_OIDC_CLIENT_SECRET', oidcClientSecret); + console.log(chalk.green(` ✓ MP_OIDC_CLIENT_SECRET = ********`)); + } else if (!currentEnv.get('MP_OIDC_CLIENT_SECRET')) { + console.log( + chalk.red(' ✗ MP_OIDC_CLIENT_SECRET is required with MP_OIDC_CLIENT_ID; the app will refuse to start until it is set.') + ); + } + } else { + console.log(chalk.gray(' Skipped: sign-in will reuse MINISTRY_PLATFORM_CLIENT_ID (auth.oidc.shared_client warning).')); + } + // Variables handled specially (skip in regular loop) const speciallyHandledVars = [ ...mpDerivedVars, 'MINISTRY_PLATFORM_CLIENT_ID', 'MINISTRY_PLATFORM_CLIENT_SECRET', + 'MP_OIDC_CLIENT_ID', + 'MP_OIDC_CLIENT_SECRET', ]; // Now check for other missing/empty required variables diff --git a/src/app/auth-error/page.test.tsx b/src/app/auth-error/page.test.tsx index b7fda5c..fd49f26 100644 --- a/src/app/auth-error/page.test.tsx +++ b/src/app/auth-error/page.test.tsx @@ -30,19 +30,25 @@ describe('AuthErrorPage', () => { expect(screen.getByText(/response from Ministry Platform was incomplete/i)).toBeInTheDocument(); }); - it('explains nonce_binding_missing as a configuration fault, not a user fault', async () => { - // MP never echoes the id_token nonce. Seeing this code in production means - // `disableIdTokenNonceBinding` regressed in src/lib/auth.ts, and it breaks - // sign-in for EVERY user — so it must not read as "try again". + it('no longer maps nonce_binding_missing (no nonce is sent since Step 4), so it falls back', async () => { await renderPage({ error: 'nonce_binding_missing' }); - expect(screen.getByText(/isn't configured correctly/i)).toBeInTheDocument(); + expect(screen.getByText(/couldn't complete sign-in/i)).toBeInTheDocument(); }); - it('explains oauth_provider_not_found, the boot-time discovery failure', async () => { + it('explains oauth_provider_not_found as a configuration fault, not an MP outage', async () => { + // Building the auth instance makes no MP call any more, so an MP outage + // can no longer unregister the provider. await renderPage({ error: 'oauth_provider_not_found' }); - expect(screen.getByText(/couldn't reach Ministry Platform when it started/i)).toBeInTheDocument(); + expect(screen.getByText(/isn't configured correctly/i)).toBeInTheDocument(); + expect(document.body.textContent).not.toMatch(/when it started/i); + }); + + it('invites a retry for unable_to_get_user_info, which is what an MP blip at the callback looks like', async () => { + await renderPage({ error: 'unable_to_get_user_info' }); + + expect(screen.getByText(/please try again in a moment/i)).toBeInTheDocument(); }); it('falls back for an unknown code', async () => { diff --git a/src/app/auth-error/page.tsx b/src/app/auth-error/page.tsx index e5118c7..566ca3b 100644 --- a/src/app/auth-error/page.tsx +++ b/src/app/auth-error/page.tsx @@ -53,27 +53,32 @@ const ERROR_MESSAGES = new Map(Object.entries({ state_security_mismatch: "The sign-in request couldn't be verified — this usually means it was left open too long, cookies are blocked, or another sign-in tab is open. Close other tabs and try again.", - /* - * Emitted when Better Auth sent a `nonce` and the id_token did not echo it. - * MP never echoes it, so seeing this means `disableIdTokenNonceBinding` has - * regressed in `src/lib/auth.ts` — it is a configuration bug, not a user - * problem, and it affects EVERY user. - */ - nonce_binding_missing: - "Sign-in isn't configured correctly for Ministry Platform. Please contact your administrator.", - - // The id_token didn't match the provider's discovery document. + // Better Auth's RFC 9207 callback `iss` check. The MP provider has no + // discovered issuer since security Step 4, so these should not fire for it; + // kept so a future provider change still gets an explanation. issuer_missing: "Ministry Platform returned a sign-in token this app can't trust. Please contact your administrator.", issuer_mismatch: "Ministry Platform returned a sign-in token this app can't trust. Please contact your administrator.", + /* + * The provider is not registered. Since security Step 4 building the auth + * instance makes no Ministry Platform call, so this is never an MP outage — + * it means the app's own sign-in configuration is wrong. + */ oauth_provider_not_found: - "Ministry Platform sign-in isn't available right now. This can happen if the app couldn't reach Ministry Platform when it started — please contact your administrator.", + "Ministry Platform sign-in isn't configured correctly for this app. Please contact your administrator.", - // The userinfo endpoint gave us nothing usable. Includes the case where MP - // returned no `sub` (see `auth.userinfo.invalid_sub` in src/lib/auth.ts). + /* + * `getUserInfo` in src/lib/auth.ts returned null. Since security Step 4 + * this is also what an MP blip at the callback looks like (discovery, JWKS + * or userinfo unreachable — `auth.oidc.discovery_failed`, + * `auth.userinfo.id_token_unverified`, `auth.userinfo.fetch_failed`), and + * the next attempt recovers on its own, so invite a retry. It also covers a + * refused token (`auth.userinfo.sub_mismatch`, `..._claims_invalid`, + * `..._invalid_sub`); the server log says which. + */ unable_to_get_user_info: - "We signed you in with Ministry Platform, but couldn't read your user profile back from it. This usually clears up on a retry.", + "We couldn't read your Ministry Platform account. Please try again in a moment.", email_not_found: "Ministry Platform didn't return enough profile information to complete sign-in. Please contact your administrator.", email_not_verified: diff --git a/src/auth.id-token-sign-in.test.ts b/src/auth.id-token-sign-in.test.ts index 58e451a..b5d068f 100644 --- a/src/auth.id-token-sign-in.test.ts +++ b/src/auth.id-token-sign-in.test.ts @@ -11,33 +11,35 @@ import { genericOAuth, type GenericOAuthConfig, type GenericOAuthOptions } from * * better-auth 1.7's `/sign-in/social` has an id_token mode: given * `idToken: { token, accessToken }` it verifies the id_token (signature, iss, - * aud) and then calls our `getUserInfo` with the CALLER'S access token. This - * fork sets `discoveryUrl`, so the mode is live for the "ministryplatform" - * provider, and nothing binds the verified id_token's `sub` to the access + * aud) and then calls our `getUserInfo` with the CALLER'S access token. While + * this fork set `discoveryUrl` the mode was live for the "ministryplatform" + * provider, and nothing bound the verified id_token's `sub` to the access * token's userinfo `sub`: an attacker's own valid id_token plus a victim's - * access token mints a session as the victim. + * access token minted a session as the victim. * * These tests drive the REAL `auth` instance from src/lib/auth.ts against a * mock MP OIDC provider (discovery, JWKS and userinfo), with id_tokens really - * RS256-signed by a key the mock JWKS publishes — so better-auth's own - * verification genuinely passes and the only thing standing between the - * attacker and the victim's session is this app's code. `fetch` is replaced - * with a stub that THROWS for any URL the mock doesn't serve: nothing here can - * reach a real Ministry Platform. + * RS256-signed by a key the mock JWKS publishes — so every signature check + * genuinely passes and the only thing standing between the attacker and the + * victim's session is this app's code. `fetch` is replaced with a stub that + * THROWS for any URL the mock doesn't serve: nothing here can reach a real + * Ministry Platform. * * Layers, each proven with the others out of the way: - * - `refuseIdTokenSignIn` (`hooks.before`) — here, driving `auth.handler` - * directly, i.e. with the route filter bypassed. + * - `refuseIdTokenSignIn` (`hooks.before`) — the primary control; here, + * driving `auth.handler` directly, i.e. with the route filter bypassed. * - The route's body filter — src/app/api/auth/[...all]/route.test.ts, with * Better Auth (and so the hook) mocked out. * - `/link-social` in `disabledAuthPaths` — here, bypassing the route. - * - `requireIdTokenVerification` — here, on an instance whose discovery - * document lacks `jwks_uri`. - * - * NOT YET: the `id_token.sub` <-> userinfo `sub` binding inside `getUserInfo` - * lands with the OIDC lazy-discovery rewrite (security Step 4). The "KNOWN GAP" - * test below pins today's behaviour with the hook removed, and must be flipped - * to a refusal when that binding lands. + * - With the hook removed, better-auth refuses the mode itself + * (`ID_TOKEN_NOT_SUPPORTED`), because since security Step 4 the provider + * has no `discoveryUrl` and so no id_token config. A side effect of + * configuration, not a control. + * - The `id_token.sub` <-> userinfo `sub` binding in `getUserInfo` + * (Step 4) — here, with the hook removed AND `discoveryUrl` re-added, i.e. + * the exact configuration that made the attack work. The substitution is + * refused, while the victim's own matched pair still signs in (proving the + * mode really is live and the refusal is the binding). */ const mockOidc = await vi.hoisted(async () => { @@ -91,12 +93,6 @@ const mockOidc = await vi.hoisted(async () => { if (url === `${issuer}/.well-known/openid-configuration`) { return json(discovery); } - // A discovery document with no jwks_uri, for requireIdTokenVerification. - if (url === `${base}/no-jwks/.well-known/openid-configuration`) { - const { jwks_uri: _omit, ...rest } = discovery; - void _omit; - return json(rest); - } if (url === `${issuer}/.well-known/jwks`) { return json({ keys: [jwk] }); } @@ -160,6 +156,17 @@ function pluginsWith(patch: Partial) { }); } +/** The structured (JSON) events written to console.error. */ +function loggedEvents(): Array> { + return vi.mocked(console.error).mock.calls.flatMap(([line]) => { + try { + return [JSON.parse(String(line)) as Record]; + } catch { + return []; + } + }); +} + beforeEach(() => { mockOidc.userinfoCalls.length = 0; vi.spyOn(console, 'error').mockImplementation(() => {}); @@ -236,10 +243,57 @@ describe('refuseIdTokenSignIn (hooks.before) — primary control, route filter b }); }); -describe('with the hook removed — the mode is live on this config (discoveryUrl is set)', () => { +describe('with the hook removed, better-auth refuses the mode itself (no discoveryUrl since Step 4)', () => { // Same config, same plugins, same getUserInfo — only the user hook is gone. const authWithoutHook = betterAuth({ ...auth.options, hooks: {} }); + it.each([ + ['an attacker id_token paired with a victim access token', attackBody], + [ + 'even a matched id_token + access token pair (the mode is off, not just the attack)', + () => ({ + provider: PROVIDER, + idToken: { token: mockOidc.signIdToken(mockOidc.subs.victim), accessToken: 'access-token-victim' }, + }), + ], + ])('refuses %s with 404 ID_TOKEN_NOT_SUPPORTED', async (_label, body) => { + const response = await signInSocial(authWithoutHook, body()); + + expect(response.status).toBe(404); + expect(await response.json()).toMatchObject({ code: 'ID_TOKEN_NOT_SUPPORTED' }); + expect(sessionCookies(response)).toEqual([]); + expect(mockOidc.userinfoCalls).toEqual([]); + }); +}); + +describe('getUserInfo sub binding — with the hook removed AND discoveryUrl re-added (the pre-Step-4 config)', () => { + // The configuration that made the mode live: `discoveryUrl` gives the + // provider an id_token config, and with it nonce binding and a mandatory + // verifier, which the old config also had to set. + const authWithoutHook = betterAuth({ + ...auth.options, + plugins: pluginsWith({ + discoveryUrl: `${mockOidc.base}/oauth/.well-known/openid-configuration`, + requireIdTokenVerification: true, + disableIdTokenNonceBinding: true, + }), + hooks: {}, + }); + + it('REFUSES the substituted token (formerly the "KNOWN GAP until Step 4")', async () => { + const response = await signInSocial(authWithoutHook, attackBody()); + + // better-auth maps getUserInfo's null to FAILED_TO_GET_USER_INFO (401). + expect(response.status).toBe(401); + expect(sessionCookies(response)).toEqual([]); + // The victim's token WAS used (the mode is live without the hook)... + expect(mockOidc.userinfoCalls).toEqual(['access-token-victim']); + // ...and the binding is what refused it. + expect(loggedEvents()).toContainEqual( + expect.objectContaining({ event: 'auth.userinfo.sub_mismatch', reason: 'mismatch' }), + ); + }); + it('CONTROL: a matched id_token + access token pair signs in, so the fixture really exercises the mode', async () => { const response = await signInSocial(authWithoutHook, { provider: PROVIDER, @@ -251,18 +305,8 @@ describe('with the hook removed — the mode is live on this config (discoveryUr expect(response.status).toBe(200); expect(sessionCookies(response)).not.toEqual([]); - }); - - it('KNOWN GAP until Step 4: without the hook, the substituted token signs in AS THE VICTIM', async () => { - // This is exactly why the hook (and the route filter) must stay. When the - // `id_token.sub` <-> userinfo `sub` binding lands in `getUserInfo` - // (Step 4), this test must be flipped to expect a refusal. - const response = await signInSocial(authWithoutHook, attackBody()); - - expect(response.status).toBe(200); const body = (await response.json()) as { user: { userGuid: string } }; expect(body.user.userGuid).toBe(mockOidc.subs.victim); - expect(mockOidc.userinfoCalls).toEqual(['access-token-victim']); }); }); @@ -287,30 +331,3 @@ describe('/link-social is disabled at the router (its own id_token branch)', () expect(response.status).not.toBe(404); }); }); - -describe('requireIdTokenVerification — refuse to run with an unverified id_token', () => { - const noJwksDiscovery = `${mockOidc.base}/no-jwks/.well-known/openid-configuration`; - - it('skips the provider when discovery yields no jwks_uri, so sign-in 404s instead of downgrading', async () => { - const instance = betterAuth({ - ...auth.options, - plugins: pluginsWith({ discoveryUrl: noJwksDiscovery }), - }); - - const response = await signInSocial(instance, { provider: PROVIDER, callbackURL: '/' }); - - expect(response.status).toBe(404); - expect(await response.json()).toMatchObject({ code: 'PROVIDER_NOT_FOUND' }); - }); - - it('CONTROL: without the flag, the same discovery document registers the provider with NO id_token verification', async () => { - const instance = betterAuth({ - ...auth.options, - plugins: pluginsWith({ discoveryUrl: noJwksDiscovery, requireIdTokenVerification: false }), - }); - - const response = await signInSocial(instance, { provider: PROVIDER, callbackURL: '/' }); - - expect(response.status).toBe(200); - }); -}); diff --git a/src/auth.oidc-discovery.test.ts b/src/auth.oidc-discovery.test.ts new file mode 100644 index 0000000..65fb4f8 --- /dev/null +++ b/src/auth.oidc-discovery.test.ts @@ -0,0 +1,404 @@ +// @vitest-environment node +// (node, not jsdom: jose verifies id_tokens over WebCrypto, which rejects +// jsdom-realm typed arrays.) +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { betterAuth } from 'better-auth'; +import { genericOAuth, type GenericOAuthConfig, type GenericOAuthOptions } from 'better-auth/plugins'; +import { SignJWT, exportJWK, generateKeyPair } from 'jose'; + +/** + * Security Step 4 (upstream MPNext issue #101): an MP blip during OIDC + * discovery must not take sign-in down for longer than the blip itself. + * + * Before, genericOAuth fetched MP's discovery document ONCE, inside the auth + * context's `init`, with no timeout and no retry, and every auth request (and + * every page's session check) awaited that `init`. A failed fetch left + * `/sign-in/social` answering `404 PROVIDER_NOT_FOUND` until a restart; a hung + * one stalled every request for undici's ~300 s headers timeout. + * + * Now the provider is configured with explicit endpoints, so building the + * instance makes no MP call at all, and the id_token verifier (issuer + JWKS) + * is loaded from discovery lazily by `lazyIdTokenVerifier`: at the first + * callback, with a timeout, cached on success, never on failure. + * + * The integration tests drive the REAL `auth` export against + * src/test-utils/mock-oidc.ts; the unit tests stub `fetch` themselves. Either + * way `fetch` THROWS for any URL not served, so nothing here can reach a real + * Ministry Platform. + * + * Mutation check: re-adding `discoveryUrl` to the provider config turns the + * boot, recovery and single-flight cases red (the `hang` case by timeout — + * run with `--testTimeout=3000`). + */ + +const oidc = await vi.hoisted(async () => + (await import('@/test-utils/mock-oidc')).installMockOidc(), +); + +import { + CookieJar, + MOCK_DISCOVERY_URL, + MOCK_USERINFO_URL, + PROVIDER_ID, + callback, + codeFlow, + getSession, + postSignInSocial, + startSignIn, +} from '@/test-utils/mock-oidc'; + +type AuthModule = typeof import('@/lib/auth'); + +const ORIGIN = 'http://localhost:3000'; + +/** A fresh module: a fresh auth instance and a fresh (empty) verifier cache. */ +async function freshAuth(): Promise { + vi.resetModules(); + return import('@/lib/auth'); +} + +const discoveryFetches = () => oidc.calls.filter((c) => c.url === MOCK_DISCOVERY_URL).length; +const userinfoCalls = () => oidc.calls.filter((c) => c.url === MOCK_USERINFO_URL); + +/** The structured (JSON) events written to console.error. */ +function loggedEvents(): Array> { + return vi.mocked(console.error).mock.calls.flatMap(([line]) => { + try { + return [JSON.parse(String(line)) as Record]; + } catch { + return []; + } + }); +} + +function expectRefused(location: string) { + const url = new URL(location, ORIGIN); + expect(url.pathname).toBe('/auth-error'); + expect(url.searchParams.get('error')).toBe('unable_to_get_user_info'); +} + +beforeEach(() => { + oidc.reset(); + vi.spyOn(console, 'error').mockImplementation(() => {}); + vi.spyOn(console, 'warn').mockImplementation(() => {}); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe('MP down or hanging while the app starts', () => { + it.each(['reject', 'http_500', 'hang'] as const)( + 'MP %s: the instance builds, sign-in starts and session checks answer, with no MP call', + async (mode) => { + oidc.state.mp = mode; + const { auth } = await freshAuth(); + + // In `hang` mode any MP call here would never settle and time the test out. + await auth.$context; + const start = await postSignInSocial(auth, ORIGIN, { provider: PROVIDER_ID, callbackURL: '/' }); + expect(start.status).toBe(200); + const { url } = (await start.json()) as { url: string }; + expect(url).toMatch(/^https:\/\/test-mp\.example\.com\/oauth\/connect\/authorize\?/); + expect(await getSession(auth, ORIGIN, new CookieJar())).toBeNull(); + expect(await auth.api.getSession({ headers: new Headers() })).toBeNull(); + + expect(oidc.calls).toEqual([]); + }, + ); + + it('the ministryplatform provider is always registered', async () => { + oidc.state.mp = 'reject'; + const { auth } = await freshAuth(); + const ctx = await auth.$context; + expect(ctx.socialProviders.map((p) => p.id)).toContain(PROVIDER_ID); + expect(oidc.calls).toEqual([]); + }); + + it('the authorize URL carries no nonce and no PKCE challenge (MP supports neither)', async () => { + const { auth } = await freshAuth(); + const { authorizeUrl } = await startSignIn(auth, ORIGIN); + expect(authorizeUrl.searchParams.has('nonce')).toBe(false); + expect(authorizeUrl.searchParams.has('code_challenge')).toBe(false); + expect(authorizeUrl.searchParams.get('client_id')).toBe(process.env.MP_OIDC_CLIENT_ID); + }); +}); + +describe('discovery failing at the callback: that sign-in is refused, the next one works', () => { + it.each([ + ['rejects (a network error)', 'reject', { reason: 'request_failed', errName: 'TypeError' }], + ['answers 500', 'http_500', { reason: 'http_status', status: 500 }], + ] as const)( + 'discovery %s → refused and logged; after MP recovers, the SAME instance signs in (no restart)', + async (_label, mode, logged) => { + const { auth } = await freshAuth(); + oidc.state.discovery = mode; + + const failed = await codeFlow(auth, ORIGIN); + + expectRefused(failed.location); + expect(failed.jar.get('session_token')).toBeUndefined(); + expect(userinfoCalls()).toEqual([]); + expect(loggedEvents()).toContainEqual( + expect.objectContaining({ event: 'auth.oidc.discovery_failed', ...logged }), + ); + expect(loggedEvents()).toContainEqual( + expect.objectContaining({ event: 'auth.userinfo.id_token_unverified', reason: 'verifier_unavailable' }), + ); + // Identifiers only: the discovery URL never reaches the log. + expect(vi.mocked(console.error).mock.calls.flat().join(' ')).not.toContain('test-mp.example.com'); + + oidc.state.discovery = 'ok'; + const { jar, location } = await codeFlow(auth, ORIGIN); + + expect(location).toBe('/'); + expect((await getSession(auth, ORIGIN, jar))?.user).toMatchObject({ userGuid: oidc.sub }); + // The failure was not cached: discovery was simply asked again. + expect(discoveryFetches()).toBe(2); + }, + ); + + it('a hung discovery request is given up after OIDC_DISCOVERY_TIMEOUT_MS; the callback is refused, not held', async () => { + const mod = await freshAuth(); + oidc.state.discovery = 'hang'; + // Shrink the 5 s timeout for this test only (the spy passes a smaller + // value through to the real AbortSignal.timeout). + const realTimeout = AbortSignal.timeout.bind(AbortSignal); + vi.spyOn(AbortSignal, 'timeout').mockImplementation((ms) => + realTimeout(ms === mod.OIDC_DISCOVERY_TIMEOUT_MS ? 20 : ms), + ); + + const failed = await codeFlow(mod.auth, ORIGIN); + + expectRefused(failed.location); + expect(loggedEvents()).toContainEqual( + expect.objectContaining({ event: 'auth.oidc.discovery_failed', reason: 'request_failed', errName: 'TimeoutError' }), + ); + }); + + it.each([ + ['rejects (a network error)', 'reject', { errName: 'TypeError' }], + ['answers 500', 'http_500', { code: 'ERR_JOSE_GENERIC' }], + ] as const)('the JWKS %s → refused; after MP recovers, the SAME instance signs in', async (_label, mode, logged) => { + const { auth } = await freshAuth(); + oidc.state.jwks = mode; + + const failed = await codeFlow(auth, ORIGIN); + + expectRefused(failed.location); + expect(userinfoCalls()).toEqual([]); + expect(loggedEvents()).toContainEqual( + expect.objectContaining({ event: 'auth.userinfo.id_token_unverified', reason: 'verification_failed', ...logged }), + ); + + oidc.state.jwks = 'ok'; + const { location } = await codeFlow(auth, ORIGIN); + + expect(location).toBe('/'); + // Discovery itself succeeded the first time and stayed cached. + expect(discoveryFetches()).toBe(1); + }); + + it.each([['jwks_uri'], ['issuer']])( + 'a discovery document without %s: sign-in still STARTS, the callback refuses and says which field', + async (missing) => { + oidc.state.omitDiscovery = [missing]; + const { auth } = await freshAuth(); + + const { location, jar } = await codeFlow(auth, ORIGIN); + + expectRefused(location); + expect(jar.get('session_token')).toBeUndefined(); + expect(loggedEvents()).toContainEqual( + expect.objectContaining({ event: 'auth.oidc.discovery_failed', reason: 'invalid_document', field: missing }), + ); + }, + ); + + it('the discovery request carries OIDC_DISCOVERY_TIMEOUT_MS and refuses redirects', async () => { + const mod = await freshAuth(); + const mpFetch = globalThis.fetch; + const discoveryInits: RequestInit[] = []; + vi.spyOn(globalThis, 'fetch').mockImplementation((input, init) => { + if ((input instanceof Request ? input.url : String(input)) === MOCK_DISCOVERY_URL) discoveryInits.push(init ?? {}); + return mpFetch(input, init); + }); + const timeoutSpy = vi.spyOn(AbortSignal, 'timeout'); + + const { location } = await codeFlow(mod.auth, ORIGIN); + + expect(location).toBe('/'); + expect(mod.OIDC_DISCOVERY_TIMEOUT_MS).toBe(5_000); + expect(discoveryInits).toHaveLength(1); + expect(discoveryInits[0].redirect).toBe('error'); + const call = timeoutSpy.mock.calls.findIndex(([ms]) => ms === mod.OIDC_DISCOVERY_TIMEOUT_MS); + expect(call).toBeGreaterThanOrEqual(0); + expect(discoveryInits[0].signal).toBe(timeoutSpy.mock.results[call].value); + }); + + it('concurrent callbacks share one discovery fetch, and later sign-ins reuse it', async () => { + const { auth } = await freshAuth(); + const starts = await Promise.all(Array.from({ length: 5 }, () => startSignIn(auth, ORIGIN))); + + const responses = await Promise.all( + starts.map(({ jar, authorizeUrl }) => + callback(auth, ORIGIN, { code: 'test-code', state: String(authorizeUrl.searchParams.get('state')) }, jar), + ), + ); + + expect(responses.map((r) => [r.status, r.headers.get('location')])).toEqual(Array(5).fill([302, '/'])); + expect(discoveryFetches()).toBe(1); + + await codeFlow(auth, ORIGIN); + expect(discoveryFetches()).toBe(1); + }); +}); + +/** + * Negative control: WITH `discoveryUrl` (the pre-Step-4 shape, minus the + * nonce opt-out), better-auth binds the id_token to the request with a + * `nonce` MP never echoes — so an MP-shaped token cannot sign in. This is why + * the old config needed `disableIdTokenNonceBinding`, and why that option is a + * no-op now. + */ +describe('negative control: discoveryUrl re-added', () => { + it('the authorize request carries a nonce and the MP-shaped (nonce-less) token cannot sign in', async () => { + const { auth } = await freshAuth(); + const plugins = (auth.options.plugins ?? []).map((plugin) => { + if (plugin.id !== 'generic-oauth') return plugin; + const [config] = (plugin as unknown as { options: GenericOAuthOptions }).options.config; + const withDiscovery: GenericOAuthConfig = { ...config, discoveryUrl: MOCK_DISCOVERY_URL }; + return genericOAuth({ config: [withDiscovery] }); + }); + const withDiscovery = betterAuth({ ...auth.options, plugins }); + + const { authorizeUrl } = await startSignIn(withDiscovery, ORIGIN); + expect(authorizeUrl.searchParams.get('nonce')).toBeTruthy(); + // ...and building that instance DID contact MP. + expect(discoveryFetches()).toBeGreaterThan(0); + + const { location } = await codeFlow(withDiscovery, ORIGIN); + expect(location).toMatch(/error=/); + }); +}); + +describe('lazyIdTokenVerifier (unit)', () => { + const BASE = 'https://mp.test/ministryplatformapi/oauth'; + const DISCOVERY = `${BASE}/.well-known/openid-configuration`; + // MP's own JWKS path (not the IdentityServer default the mock uses), given + // as a path-absolute reference, so it must be resolved, not pinned. + const JWKS = 'https://mp.test/ministryplatformapi/oauth/.well-known/jwks'; + + interface Stub { + discovery: () => Response | Promise; + jwks?: () => Response | Promise; + } + + /** Stubs `fetch` for one test; returns the URLs requested. */ + function stubFetch(stub: Stub) { + const calls: string[] = []; + vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => { + const url = input instanceof Request ? input.url : String(input); + calls.push(url); + if (url === DISCOVERY) return stub.discovery(); + if (url === JWKS && stub.jwks) return stub.jwks(); + throw new Error(`Blocked unexpected fetch in test: ${url}`); + }); + return calls; + } + + const json = (body: unknown) => + new Response(JSON.stringify(body), { status: 200, headers: { 'Content-Type': 'application/json' } }); + const document = (overrides: Record = {}) => + json({ issuer: BASE, jwks_uri: '/ministryplatformapi/oauth/.well-known/jwks', ...overrides }); + + it('fetches nothing until first used, then resolves jwks_uri and verifies against it', async () => { + const { lazyIdTokenVerifier } = await freshAuth(); + const { publicKey, privateKey } = await generateKeyPair('RS256'); + const jwk = { ...(await exportJWK(publicKey)), kid: 'k1', alg: 'RS256' }; + const calls = stubFetch({ discovery: () => document(), jwks: () => json({ keys: [jwk] }) }); + + const load = lazyIdTokenVerifier(DISCOVERY); + expect(calls).toEqual([]); + + const verifier = await load(); + expect(verifier.issuer).toBe(BASE); + const token = await new SignJWT({ sub: 'x' }) + .setProtectedHeader({ alg: 'RS256', kid: 'k1' }) + .setIssuer(BASE) + .setExpirationTime('5m') + .sign(privateKey); + const { jwtVerify } = await import('jose'); + await expect(jwtVerify(token, verifier.jwks, { issuer: verifier.issuer })).resolves.toBeDefined(); + expect(calls).toEqual([DISCOVERY, JWKS]); + }); + + it('caches a success: later calls, concurrent or not, share one fetch', async () => { + const { lazyIdTokenVerifier } = await freshAuth(); + const calls = stubFetch({ discovery: () => document() }); + const load = lazyIdTokenVerifier(DISCOVERY); + + const [a, b] = await Promise.all([load(), load()]); + const c = await load(); + + expect(a).toBe(b); + expect(c).toBe(a); + expect(calls).toHaveLength(1); + }); + + it('never caches a failure: the next call fetches again', async () => { + const { lazyIdTokenVerifier } = await freshAuth(); + let down = true; + const calls = stubFetch({ + discovery: () => (down ? Promise.reject(new TypeError('fetch failed')) : document()), + }); + const load = lazyIdTokenVerifier(DISCOVERY); + + await expect(load()).rejects.toThrow(/OIDC discovery failed: request_failed/); + down = false; + await expect(load()).resolves.toMatchObject({ issuer: BASE }); + expect(calls).toHaveLength(2); + }); + + it('gives up on a hung discovery request after timeoutMs, and logs TimeoutError', async () => { + const { lazyIdTokenVerifier } = await freshAuth(); + // Accepts the request and never answers; settles only when the caller aborts. + vi.spyOn(globalThis, 'fetch').mockImplementation( + (_input, init) => + new Promise((_, reject) => { + init?.signal?.addEventListener('abort', () => reject(init.signal!.reason), { once: true }); + }), + ); + const started = Date.now(); + + await expect(lazyIdTokenVerifier(DISCOVERY, { timeoutMs: 20 })()).rejects.toThrow(/request_failed/); + + expect(Date.now() - started).toBeLessThan(2_000); + expect(loggedEvents()).toContainEqual( + expect.objectContaining({ event: 'auth.oidc.discovery_failed', reason: 'request_failed', errName: 'TimeoutError' }), + ); + }); + + it.each<[string, () => Response, Record]>([ + ['a 404', () => new Response('nope', { status: 404 }), { reason: 'http_status', status: 404 }], + ['a non-JSON 200', () => new Response('proxy', { status: 200 }), { reason: 'invalid_json', errName: 'SyntaxError' }], + ['a JSON array', () => json([]), { reason: 'invalid_document', field: 'body' }], + ['JSON null', () => json(null), { reason: 'invalid_document', field: 'body' }], + ['no issuer', () => document({ issuer: undefined }), { reason: 'invalid_document', field: 'issuer' }], + ['an issuer that is not a URL', () => document({ issuer: 'mp' }), { reason: 'invalid_document', field: 'issuer' }], + ['no jwks_uri', () => document({ jwks_uri: undefined }), { reason: 'invalid_document', field: 'jwks_uri' }], + ['a non-string jwks_uri', () => document({ jwks_uri: 42 }), { reason: 'invalid_document', field: 'jwks_uri' }], + ['an unparseable jwks_uri', () => document({ jwks_uri: 'https://[' }), { reason: 'invalid_document', field: 'jwks_uri' }], + ])('fails closed on %s, logging identifiers only', async (_label, discovery, logged) => { + const { lazyIdTokenVerifier } = await freshAuth(); + stubFetch({ discovery }); + + await expect(lazyIdTokenVerifier(DISCOVERY)()).rejects.toThrow(/OIDC discovery failed/); + + const events = loggedEvents().filter((e) => e.event === 'auth.oidc.discovery_failed'); + expect(events).toEqual([expect.objectContaining(logged)]); + const text = vi.mocked(console.error).mock.calls.flat().join(' '); + expect(text).not.toContain('mp.test'); + expect(text).not.toContain('proxy'); + }); +}); diff --git a/src/auth.secret-guard.test.ts b/src/auth.secret-guard.test.ts index 5fca4c4..e3b77e5 100644 --- a/src/auth.secret-guard.test.ts +++ b/src/auth.secret-guard.test.ts @@ -14,10 +14,13 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; * IMPORTING `@/lib/auth` throws for each bad configuration — removing the * module-level call (or any single check) turns them red. * - * `fetch` is stubbed to throw so nothing can leave the process (this fork's - * genericOAuth config fetches discovery when the instance initialises; the - * blocked fetch just leaves the provider unregistered, which these tests don't - * exercise). + * `fetch` is stubbed to throw so nothing can leave the process (building the + * auth instance makes no MP call; discovery is loaded lazily at the first + * sign-in callback, which these tests never reach). + * + * Imports with `VITEST` cleared go through `sharedInstance`, which caches the + * instance on globalThis; the cache is cleared around every test so each + * import really builds a new instance from the env it was given. */ // Every test re-imports `@/lib/auth` from a reset module graph, which is slow @@ -29,13 +32,20 @@ const GOOD_SECRET = 'a-perfectly-fine-test-secret-0123456789'; const KEYS = [ 'VITEST', 'BETTER_AUTH_SECRET', 'NEXTAUTH_SECRET', 'BETTER_AUTH_SECRETS', 'NODE_ENV', 'TEST', 'BETTER_AUTH_URL', 'NEXTAUTH_URL', 'MINISTRY_PLATFORM_BASE_URL', + 'MP_OIDC_CLIENT_ID', 'MP_OIDC_CLIENT_SECRET', 'MINISTRY_PLATFORM_CLIENT_ID', 'MINISTRY_PLATFORM_CLIENT_SECRET', ] as const; // test-setup.ts stubs an http://localhost BETTER_AUTH_URL, which src/lib/env.ts // refuses in production; the production cases below use this one instead. const PROD_URL = 'https://app.example.org'; let saved: Record; +const SHARED_AUTH_KEY = Symbol.for('mpnext-tools.auth'); +const clearSharedAuth = () => { + delete (globalThis as Record)[SHARED_AUTH_KEY]; +}; + beforeEach(() => { + clearSharedAuth(); saved = Object.fromEntries(KEYS.map((k) => [k, process.env[k]])); vi.resetModules(); vi.stubGlobal('fetch', vi.fn(async (input: unknown) => { @@ -46,6 +56,7 @@ beforeEach(() => { }); afterEach(() => { + clearSharedAuth(); setEnv(saved); vi.unstubAllGlobals(); vi.restoreAllMocks(); @@ -163,9 +174,80 @@ describe('auth-critical URLs at module load', () => { setEnv({ BETTER_AUTH_URL: 'https://app.example.org/', MINISTRY_PLATFORM_BASE_URL: 'https://mp.example.org/api/' }); const { auth, ministryPlatformProviderConfig } = await import('@/lib/auth'); expect(auth.options.baseURL).toBe('https://app.example.org'); - expect(ministryPlatformProviderConfig.discoveryUrl).toBe( - 'https://mp.example.org/api/oauth/.well-known/openid-configuration', + expect(ministryPlatformProviderConfig.authorizationUrl).toBe( + 'https://mp.example.org/api/oauth/connect/authorize', + ); + expect(ministryPlatformProviderConfig.tokenUrl).toBe('https://mp.example.org/api/oauth/connect/token'); + expect(ministryPlatformProviderConfig.endSessionEndpoint).toBe( + 'https://mp.example.org/api/oauth/connect/endsession', ); + // Building the instance (and its context) contacted nothing. + await auth.$context; + expect(fetch).not.toHaveBeenCalled(); + }); +}); + +describe('the OIDC sign-in client at module load (owner decision: dedicated client)', () => { + const sharedClientWarnings = () => + vi.mocked(console.warn).mock.calls.flatMap(([line]) => { + try { + const event = JSON.parse(String(line)) as Record; + return event.event === 'auth.oidc.shared_client' ? [event] : []; + } catch { + return []; + } + }); + + it('uses the dedicated MP_OIDC_CLIENT_ID / _SECRET, not the service account, and does not warn', async () => { + setEnv({ MP_OIDC_CLIENT_ID: 'oidc-app', MP_OIDC_CLIENT_SECRET: 'oidc-secret', MINISTRY_PLATFORM_CLIENT_ID: 'svc' }); + const { ministryPlatformProviderConfig } = await importAuthAsIfNotVitest(); + expect(ministryPlatformProviderConfig.clientId).toBe('oidc-app'); + expect(ministryPlatformProviderConfig.clientSecret).toBe('oidc-secret'); + expect(sharedClientWarnings()).toEqual([]); + }); + + it('falls back to the service-account client with ONE auth.oidc.shared_client warning per process', async () => { + setEnv({ + MP_OIDC_CLIENT_ID: undefined, + MP_OIDC_CLIENT_SECRET: undefined, + MINISTRY_PLATFORM_CLIENT_ID: 'svc', + MINISTRY_PLATFORM_CLIENT_SECRET: 'svc-secret', + }); + const first = await importAuthAsIfNotVitest(); + vi.resetModules(); + const second = await importAuthAsIfNotVitest(); // another Next bundle layer + + expect(second.auth).toBe(first.auth); + expect(first.ministryPlatformProviderConfig.clientId).toBe('svc'); + expect(sharedClientWarnings()).toEqual([ + { event: 'auth.oidc.shared_client', message: expect.any(String), source: 'fallback' }, + ]); + // Identifiers only: never the client id or secret. + const logged = vi.mocked(console.warn).mock.calls.flat().join(' '); + expect(logged).not.toContain('svc-secret'); + expect(logged).not.toMatch(/"svc"/); + }); + + it('warns when the "dedicated" client is the service-account id', async () => { + setEnv({ MP_OIDC_CLIENT_ID: 'svc', MP_OIDC_CLIENT_SECRET: 'x', MINISTRY_PLATFORM_CLIENT_ID: 'svc' }); + await importAuthAsIfNotVitest(); + expect(sharedClientWarnings()).toEqual([expect.objectContaining({ source: 'same_as_service_account' })]); + }); + + it('stays silent during next build (NEXT_PHASE), whose workers are separate processes', async () => { + const { warnIfSharedOidcClient } = await import('@/lib/auth'); + vi.mocked(console.warn).mockClear(); + warnIfSharedOidcClient({ shared: 'fallback' }, { NEXT_PHASE: 'phase-production-build' }); + expect(sharedClientWarnings()).toEqual([]); + warnIfSharedOidcClient({ shared: 'fallback' }, { NEXT_PHASE: 'phase-production-server' }); + expect(sharedClientWarnings()).toHaveLength(1); + warnIfSharedOidcClient({ shared: null }, {}); + expect(sharedClientWarnings()).toHaveLength(1); + }); + + it('refuses to import with only half of the dedicated pair set', async () => { + setEnv({ MP_OIDC_CLIENT_ID: 'oidc-app', MP_OIDC_CLIENT_SECRET: undefined }); + await expect(importAuthAsIfNotVitest()).rejects.toThrow(/MP_OIDC_CLIENT_ID and MP_OIDC_CLIENT_SECRET must be set together/); }); }); diff --git a/src/auth.shared-instance.test.ts b/src/auth.shared-instance.test.ts new file mode 100644 index 0000000..93883dc --- /dev/null +++ b/src/auth.shared-instance.test.ts @@ -0,0 +1,124 @@ +// @vitest-environment node +// (node, not jsdom: `getUserInfo` verifies the id_token with `jose` over +// WebCrypto, which rejects jsdom-realm typed arrays.) +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; + +/** + * Regression guard for the one-`auth`-per-process fix (`sharedInstance` in + * src/lib/auth.ts, security Step 4). Next loads src/lib/auth.ts once per + * bundle layer, so the `/api/auth` route handler (OAuth callback, + * `/get-session`) and the sign-out server action ran in different module + * copies with different in-memory stores: the logout URL never carried + * `id_token_hint`, and sign-out could not delete the session row + * `/get-session` is served from. + * + * `vi.resetModules()` + a second `import()` reproduces "a second layer": two + * module copies in one process. `VITEST` is cleared so the real (non-test) + * path runs. `fetch` THROWS for any URL the mock does not serve, so nothing + * here can reach a real Ministry Platform. + */ + +const oidc = await vi.hoisted(async () => + (await import('@/test-utils/mock-oidc')).installMockOidc(), +); + +import { codeFlow, getSession, type CookieJar } from '@/test-utils/mock-oidc'; + +type AuthModule = typeof import('@/lib/auth'); + +const ORIGIN = 'http://localhost:3000'; +const MINUTE = 60 * 1000; +const T0 = new Date('2026-09-30T08:00:00Z').getTime(); +const KEY = Symbol.for('mpnext-tools.auth'); +const savedVitest = process.env.VITEST; + +/** Imports src/lib/auth as a fresh module copy — what a separate Next bundle layer gets. */ +async function loadLayer(): Promise { + vi.resetModules(); + return import('@/lib/auth'); +} + +async function sessionGuid({ auth }: AuthModule, jar: CookieJar) { + return ((await getSession(auth, ORIGIN, jar))?.user.userGuid as string | undefined) ?? null; +} + +beforeEach(() => { + oidc.reset(); + delete (globalThis as Record)[KEY]; + vi.useFakeTimers({ toFake: ['Date'] }); + vi.setSystemTime(T0); + vi.spyOn(console, 'error').mockImplementation(() => {}); + vi.spyOn(console, 'warn').mockImplementation(() => {}); +}); + +afterEach(() => { + process.env.VITEST = savedVitest; + delete (globalThis as Record)[KEY]; + vi.useRealTimers(); + vi.restoreAllMocks(); +}); + +describe('sharedInstance', () => { + it('returns one instance per process outside Vitest', async () => { + const { sharedInstance } = await loadLayer(); + const key = Symbol('test-shared'); + const create = vi.fn(() => ({})); + const a = sharedInstance(key, create, {}); + const b = sharedInstance(key, create, {}); + expect(a).toBe(b); + expect(create).toHaveBeenCalledTimes(1); + delete (globalThis as Record)[key]; + }); + + it('builds a fresh instance under Vitest', async () => { + const { sharedInstance } = await loadLayer(); + const key = Symbol('test-vitest'); + const a = sharedInstance(key, () => ({}), { VITEST: 'true' }); + const b = sharedInstance(key, () => ({}), { VITEST: 'true' }); + expect(a).not.toBe(b); + expect((globalThis as Record)[key]).toBeUndefined(); + }); + + it('two module copies (two Next layers) get the same auth instance', async () => { + delete process.env.VITEST; + const routeLayer = await loadLayer(); + const actionLayer = await loadLayer(); + expect(routeLayer).not.toBe(actionLayer); // really two module copies + expect(actionLayer.auth).toBe(routeLayer.auth); + }); +}); + +describe('sign-in in the route layer, sign-out in the server-action layer', () => { + async function run() { + const routeLayer = await loadLayer(); + const actionLayer = await loadLayer(); + const { jar, location } = await codeFlow(routeLayer.auth, ORIGIN); + expect(location).toBe('/'); + expect(await sessionGuid(routeLayer, jar)).toBe(oidc.sub); + + const result = (await actionLayer.auth.api.signOut({ + headers: new Headers({ Cookie: jar.header() }), + body: { disableRedirect: true }, + } as never)) as { url?: string }; + + // A copied cookie pair, replayed after the 1 h cookie cache, against the route layer. + vi.setSystemTime(T0 + 61 * MINUTE); + const replayed = await sessionGuid(routeLayer, jar); + return { url: result.url, replayed }; + } + + it('shared: the logout URL carries id_token_hint and the replayed cookie is dead', async () => { + delete process.env.VITEST; + const { url, replayed } = await run(); + expect(new URL(url!).searchParams.get('id_token_hint')).toBe(oidc.lastIdToken); + expect(replayed).toBeNull(); + }); + + it('negative control: separate copies (the pre-fix behaviour) lose both', async () => { + process.env.VITEST = 'true'; + const { url, replayed } = await run(); + // No account row with an id_token in this copy: at best a hint-less URL. + expect(url ? new URL(url).searchParams.get('id_token_hint') : null).toBeNull(); + expect(replayed).toBe(oidc.sub); + }); +}); diff --git a/src/auth.test.ts b/src/auth.test.ts index 8f71e54..1327002 100644 --- a/src/auth.test.ts +++ b/src/auth.test.ts @@ -17,9 +17,13 @@ import { * * Tests for the Better Auth configuration in src/lib/auth.ts. * - customSession: lightweight name splitting only (no API calls) - * - getUserInfo: fetches OIDC profile and returns id=sub (used as accountId) + * - the Ministry Platform provider config pins (explicit endpoints, no + * discovery at boot, PKCE off, account subject) * - mapProfileToUser: stores the OAuth sub claim as userGuid (additionalField) * - User profile loading is handled client-side by UserProvider + * + * `getUserInfo` itself verifies a real RS256 id_token with jose, which needs + * the node environment; its tests live in src/auth.user-info.test.ts. */ /** @@ -154,60 +158,20 @@ describe('Auth - Custom Session Enrichment Logic', () => { }); describe('Auth - OAuth Configuration', () => { - it('should configure Ministry Platform as generic OAuth provider', () => { - const config = { - providerId: 'ministryplatform', - scopes: ['openid', 'offline_access', 'http://www.thinkministry.com/dataplatform/scopes/all'], - pkce: false, - }; + it('configures Ministry Platform as a generic OAuth provider with the real settings', () => { + const config = ministryPlatformProviderConfig; expect(config.providerId).toBe('ministryplatform'); - expect(config.scopes).toContain('openid'); - expect(config.scopes).toContain('offline_access'); + expect(config.scopes).toEqual(['openid', 'http://www.thinkministry.com/dataplatform/scopes/all']); expect(config.pkce).toBe(false); + expect(config.authorizationUrlParams).toEqual({ realm: 'realm' }); }); - it('should map getUserInfo profile to user info with sub as id', () => { - // Simulate the getUserInfo callback — returns id=sub for the accountId - const profile = { - sub: 'ab12cd34-ef56-7890-abcd-ef1234567890', - given_name: 'John', - family_name: 'Doe', - email: 'john@example.com', - }; - - const userInfo = { - id: profile.sub, - email: profile.email, - name: `${profile.given_name} ${profile.family_name}`, - image: undefined, - emailVerified: true, - }; - - expect(userInfo.id).toBe('ab12cd34-ef56-7890-abcd-ef1234567890'); - expect(userInfo.name).toBe('John Doe'); - expect(userInfo.email).toBe('john@example.com'); - expect(userInfo.image).toBeUndefined(); - expect(userInfo.emailVerified).toBe(true); - }); - - it('should map profile to user with userGuid via mapProfileToUser', () => { - // Simulate the mapProfileToUser callback - // It receives the getUserInfo result and extracts the sub as userGuid - const getUserInfoResult = { - id: 'ab12cd34-ef56-7890-abcd-ef1234567890', - email: 'john@example.com', - name: 'John Doe', - image: undefined, - emailVerified: true, - }; - - // mapProfileToUser extracts profile.id (the sub) as userGuid - const mappedFields = { - userGuid: getUserInfoResult.id, - }; - - expect(mappedFields.userGuid).toBe('ab12cd34-ef56-7890-abcd-ef1234567890'); + it('signs in as the dedicated OIDC client, not the service account', () => { + // test-setup.ts stubs the two as different clients. + expect(ministryPlatformProviderConfig.clientId).toBe(process.env.MP_OIDC_CLIENT_ID); + expect(ministryPlatformProviderConfig.clientSecret).toBe(process.env.MP_OIDC_CLIENT_SECRET); + expect(ministryPlatformProviderConfig.clientId).not.toBe(process.env.MINISTRY_PLATFORM_CLIENT_ID); }); /** @@ -451,24 +415,45 @@ describe('Auth - buildDisplayName', () => { }); /** - * Better Auth 1.7 provider-config guards. + * Provider-config pins (security Step 4 / MPNext issue #101). * - * Both flags below fail SILENTLY-ish: sign-in breaks for every user with a - * generic `unable_to_get_user_info`, with nothing in the app's own code to - * point at. They are pinned here because a future upgrade that resets them - * should fail a test run, not a production sign-in. + * Each of these fails SILENTLY in production — a sign-in outage, or a closed + * hole quietly reopened — rather than as a type error, so they are pinned + * here. Behaviour is proven end to end in src/auth.oidc-discovery.test.ts, + * src/auth.user-info.test.ts and src/auth.id-token-sign-in.test.ts. */ describe('Auth - Ministry Platform provider config (better-auth 1.7)', () => { - it('disables id_token nonce binding, because MP does not echo the claim', () => { - // As of 1.7, any provider with a `discoveryUrl` publishing a JWKS binds the - // id_token to the authorization request BY DEFAULT — Better Auth sends a - // server-generated nonce and rejects a callback whose id_token does not - // echo it. MP omits the claim entirely. - // - // The failure looks intermittent but is inverted from the obvious reading: - // sign-in works only when the boot-time discovery fetch FAILED, because - // that leaves the id_token config undefined and skips verification. - expect(ministryPlatformProviderConfig.disableIdTokenNonceBinding).toBe(true); + const oauth = `${process.env.MINISTRY_PLATFORM_BASE_URL}/oauth`; + + it('configures explicit MP endpoints and NO discoveryUrl (no MP call at boot)', () => { + // With `discoveryUrl`, genericOAuth fetched discovery once at boot with no + // timeout or retry: one MP blip left sign-in 404ing (or every request + // hanging ~300 s) until a restart. It would also give the provider an + // id_token config, which re-opens /sign-in/social's id_token mode (F12). + expect(ministryPlatformProviderConfig).not.toHaveProperty('discoveryUrl'); + expect(ministryPlatformProviderConfig.authorizationUrl).toBe(`${oauth}/connect/authorize`); + expect(ministryPlatformProviderConfig.tokenUrl).toBe(`${oauth}/connect/token`); + // Without it, sign-out loses the MP end-session URL and its id_token_hint. + expect(ministryPlatformProviderConfig.endSessionEndpoint).toBe(`${oauth}/connect/endsession`); + }); + + it('carries neither requireIdTokenVerification nor disableIdTokenNonceBinding', () => { + // genericOAuth THROWS at init for the first without discoveryUrl; the + // second is a no-op with no id_token config. The id_token is verified by + // the app (`verifyMpIdToken`) instead. + expect(ministryPlatformProviderConfig).not.toHaveProperty('requireIdTokenVerification'); + expect(ministryPlatformProviderConfig).not.toHaveProperty('disableIdTokenNonceBinding'); + }); + + it('the live provider has no id_token config and requires no nonce', async () => { + const ctx = await auth.$context; + const provider = ctx.socialProviders.find((p) => p.id === 'ministryplatform') as + | { idToken?: unknown; requiresIdTokenNonce?: boolean } + | undefined; + + expect(provider).toBeDefined(); + expect(provider!.idToken).toBeUndefined(); + expect(provider!.requiresIdTokenNonce).toBe(false); }); it('keeps PKCE OFF — MP advertises it but does not honour it', () => { @@ -478,34 +463,18 @@ describe('Auth - Ministry Platform provider config (better-auth 1.7)', () => { // authorize leg succeeds and returns a code, then the token exchange fails // with `invalid_grant` and the user lands on // /auth-error?error=invalid_code. - // - // This test exists to stop someone re-enabling it on the strength of the - // discovery document alone. expect(ministryPlatformProviderConfig.pkce).toBe(false); }); - it('requires id_token verification, so a discovery document without issuer/jwks_uri is refused', () => { - // Without it, a document missing either field silently leaves the normal - // flow's id_token UNVERIFIED. Behaviour is proven end to end in - // src/auth.id-token-sign-in.test.ts. - expect(ministryPlatformProviderConfig.requireIdTokenVerification).toBe(true); - }); - it('wires refuseIdTokenSignIn as the hooks.before (F12)', () => { expect(auth.options.hooks?.before).toBe(refuseIdTokenSignIn); expect(disabledAuthPaths).toContain('/link-social'); }); - it('uses discovery rather than hardcoded endpoints', () => { - expect(ministryPlatformProviderConfig.discoveryUrl).toContain( - '/oauth/.well-known/openid-configuration', - ); - }); - - it('keeps the providerId the allowlist and the client both reference', () => { + it('keeps the providerId the allowlist, the client and the MP redirect URI all reference', () => { // `src/app/api/auth/[...all]/route.ts` allowlists - // `GET /callback/ministryplatform`, and the sign-in page calls - // `signIn.social({ provider: "ministryplatform" })`. All three must agree. + // `GET /callback/ministryplatform` (the redirect URI registered in MP), and + // the sign-in page calls `signIn.social({ provider: "ministryplatform" })`. expect(ministryPlatformProviderConfig.providerId).toBe('ministryplatform'); }); }); @@ -524,7 +493,6 @@ describe('Auth - Ministry Platform provider config (better-auth 1.7)', () => { * userinfo fetch problem, not an identity-mapping one. */ describe('Auth - provider account key (better-auth 1.7)', () => { - const GUID = 'AB12CD34-EF56-7890-ABCD-EF1234567890'; const normalized = 'ab12cd34-ef56-7890-abcd-ef1234567890'; function callAccountSubject(profile: Record) { @@ -534,98 +502,24 @@ describe('Auth - provider account key (better-auth 1.7)', () => { } it('declares accountSubject explicitly rather than relying on the default', () => { - // The default is `isOidc ? profile.sub : profile.id`, where `isOidc` is - // inferred at boot from the discovery fetch. That would make the account - // key depend on a network call succeeding at startup. + // Required, not a style choice: without discovery the provider is not + // recognised as OIDC, so the default resolver reads `profile.id` — which + // getUserInfo never returns — and every account would key on "". expect(typeof ministryPlatformProviderConfig.accountSubject).toBe('function'); }); - it('derives the account key from sub', () => { - expect(callAccountSubject({ sub: normalized })).toBe(normalized); + it('derives the account key from sub, never from id', () => { + expect(callAccountSubject({ sub: normalized, id: 'not-this' })).toBe(normalized); + expect(callAccountSubject({ id: 'not-this' })).toBe(''); }); - it('returns empty (not "undefined") when sub is absent, so Better Auth refuses cleanly', () => { + it('returns empty (not "undefined") when sub is absent or not a string, so Better Auth refuses cleanly', () => { // `resolveOAuthAccountKey` rejects "", "undefined" and "null" alike, but // returning the literal string "undefined" would be a latent bug if that // guard ever narrowed. expect(callAccountSubject({})).toBe(''); expect(callAccountSubject({ sub: null })).toBe(''); - }); - - it('getUserInfo returns `sub`, NOT `id` — the 1.6 shape breaks the account key', async () => { - const fetchMock = vi - .spyOn(globalThis, 'fetch') - .mockResolvedValue( - new Response( - JSON.stringify({ - sub: GUID, - email: 'jane@example.org', - email_verified: true, - given_name: 'Jane', - family_name: 'Doe', - }), - { status: 200, headers: { 'Content-Type': 'application/json' } }, - ), - ); - - try { - const getUserInfo = ministryPlatformProviderConfig.getUserInfo!; - const info = (await getUserInfo({ accessToken: 'tok' } as never)) as Record< - string, - unknown - >; - - expect(info.sub).toBe(normalized); - expect(info).not.toHaveProperty('id'); - // And the account key resolves off it. - expect(callAccountSubject(info)).toBe(normalized); - } finally { - fetchMock.mockRestore(); - } - }); - - it('getUserInfo still applies the synthetic email and keeps the real one as mpEmail', async () => { - const fetchMock = vi - .spyOn(globalThis, 'fetch') - .mockResolvedValue( - new Response( - JSON.stringify({ sub: GUID, email: 'shared@household.org', email_verified: false }), - { status: 200, headers: { 'Content-Type': 'application/json' } }, - ), - ); - - try { - const info = (await ministryPlatformProviderConfig.getUserInfo!({ - accessToken: 'tok', - } as never)) as Record; - - expect(info.email).toBe(`${normalized}@mp.invalid`); - expect(info.mpEmail).toBe('shared@household.org'); - expect(info.emailVerified).toBe(false); - } finally { - fetchMock.mockRestore(); - } - }); - - it('getUserInfo returns null (never throws) when MP sends no usable sub', async () => { - const fetchMock = vi - .spyOn(globalThis, 'fetch') - .mockResolvedValue( - new Response(JSON.stringify({ email: 'x@y.org' }), { - status: 200, - headers: { 'Content-Type': 'application/json' }, - }), - ); - const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); - - try { - await expect( - ministryPlatformProviderConfig.getUserInfo!({ accessToken: 'tok' } as never), - ).resolves.toBeNull(); - } finally { - fetchMock.mockRestore(); - errSpy.mockRestore(); - } + expect(callAccountSubject({ sub: 12345 })).toBe(''); }); it('mapProfileToUser reads sub from the raw profile', () => { @@ -642,115 +536,6 @@ describe('Auth - provider account key (better-auth 1.7)', () => { }); }); -/** - * `getUserInfo` failure paths. - * - * Better Auth does NOT wrap `provider.getUserInfo` in a try/catch inside its - * callback route, so this function must return `null` rather than throw on - * every failure — a throw surfaces as an unhandled error instead of a clean - * `unable_to_get_user_info` redirect. - * - * These paths also carry a logging contract (CLAUDE.md rule 14): the userinfo - * body can echo profile content, so only status and shape may be logged. - */ -describe('Auth - getUserInfo failure handling', () => { - const GUID = 'ab12cd34-ef56-7890-abcd-ef1234567890'; - - function callGetUserInfo() { - const fn = ministryPlatformProviderConfig.getUserInfo; - if (!fn) throw new Error('getUserInfo must be declared'); - return fn({ accessToken: 'token-abc' } as never); - } - - beforeEach(() => { - vi.spyOn(console, 'error').mockImplementation(() => {}); - }); - - afterEach(() => { - vi.restoreAllMocks(); - }); - - it('returns null — never throws — when the userinfo endpoint errors', async () => { - vi.stubGlobal( - 'fetch', - vi.fn().mockResolvedValue({ ok: false, status: 502, json: vi.fn() }), - ); - - await expect(callGetUserInfo()).resolves.toBeNull(); - }); - - it('logs only the HTTP status on a failed fetch, never the body', async () => { - const json = vi.fn().mockResolvedValue({ secret: 'profile-content' }); - vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false, status: 403, json })); - - await callGetUserInfo(); - - expect(console.error).toHaveBeenCalledWith('auth.userinfo.fetch_failed', { status: 403 }); - expect(json).not.toHaveBeenCalled(); - }); - - it('sends the access token as a bearer credential', async () => { - const fetchMock = vi.fn().mockResolvedValue({ ok: false, status: 500, json: vi.fn() }); - vi.stubGlobal('fetch', fetchMock); - - await callGetUserInfo(); - - const [, init] = fetchMock.mock.calls[0]; - expect(init.headers.Authorization).toBe('Bearer token-abc'); - }); - - it('returns null when the profile carries no usable sub', async () => { - vi.stubGlobal( - 'fetch', - vi.fn().mockResolvedValue({ - ok: true, - status: 200, - json: vi.fn().mockResolvedValue({ email: 'someone@example.com' }), - }), - ); - - await expect(callGetUserInfo()).resolves.toBeNull(); - expect(console.error).toHaveBeenCalledWith( - 'auth.userinfo.invalid_sub', - expect.objectContaining({ hasSub: false }), - ); - }); - - it('returns the normalized sub and a trimmed mpEmail on success', async () => { - vi.stubGlobal( - 'fetch', - vi.fn().mockResolvedValue({ - ok: true, - status: 200, - json: vi.fn().mockResolvedValue({ - sub: GUID.toUpperCase(), - email: ' person@church.org ', - }), - }), - ); - - const result = (await callGetUserInfo()) as Record; - - expect(result.sub).toBe(GUID); - expect(result.mpEmail).toBe('person@church.org'); - }); - - it('nulls mpEmail when the profile email is blank', async () => { - vi.stubGlobal( - 'fetch', - vi.fn().mockResolvedValue({ - ok: true, - status: 200, - json: vi.fn().mockResolvedValue({ sub: GUID, email: ' ' }), - }), - ); - - const result = (await callGetUserInfo()) as Record; - - expect(result.mpEmail).toBeNull(); - }); -}); - /** * `mapProfileToUser` must refuse to build a user record it cannot tie back to * an MP `dp_Users` row. `getUserInfo` has already validated `sub`, so reaching diff --git a/src/auth.user-info.test.ts b/src/auth.user-info.test.ts new file mode 100644 index 0000000..a901abf --- /dev/null +++ b/src/auth.user-info.test.ts @@ -0,0 +1,379 @@ +// @vitest-environment node +// (node, not jsdom: `getUserInfo` verifies the id_token with `jose` over +// WebCrypto, which rejects jsdom-realm typed arrays.) +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { createHmac } from 'node:crypto'; +import type { OAuth2Tokens } from 'better-auth/oauth2'; + +/** + * `getUserInfo` of the Ministry Platform provider (src/lib/auth.ts), called + * directly with GENUINELY signed id_tokens from the mock MP + * (src/test-utils/mock-oidc.ts). Since security Step 4 it verifies the + * id_token itself — RS256 against MP's JWKS, `iss`, `aud` — then checks + * `sub` / `exp` / `azp`, then fetches userinfo and binds its `sub` to the + * id_token's. Every refusal returns null (better-auth's callback route does + * not catch a throw) and logs one structured line carrying identifiers only + * (CLAUDE.md rule 14): never a token, a GUID, a body or `err.message`. + * + * `stubUserinfo` replaces ONLY the userinfo response; discovery and the JWKS + * still come from the mock MP, whose `fetch` throws for any URL it does not + * serve, so nothing here can reach a real Ministry Platform. + */ + +const oidc = await vi.hoisted(async () => + (await import('@/test-utils/mock-oidc')).installMockOidc(), +); + +import { ministryPlatformProviderConfig, USERINFO_TIMEOUT_MS } from '@/lib/auth'; +import { MOCK_USERINFO_URL } from '@/test-utils/mock-oidc'; + +// Captured before any test spies on `fetch`, so `stubUserinfo` can always +// hand everything but userinfo to the mock MP (and never recurse). +const mockMpFetch = globalThis.fetch; +const CLIENT_ID = process.env.MP_OIDC_CLIENT_ID!; + +/** + * Replaces ONLY the userinfo response. Returns a mock of just the userinfo + * calls, as `(url, init)` — assert "userinfo was never called" on it, not on + * `fetch`, because discovery and the JWKS legitimately go through `fetch`. + */ +function stubUserinfo(respond: () => Response | Promise) { + const userinfo = vi.fn<(url: string, init?: RequestInit) => Promise>(async () => respond()); + vi.spyOn(globalThis, 'fetch').mockImplementation((input, init) => { + const url = input instanceof Request ? input.url : String(input); + return url === MOCK_USERINFO_URL ? userinfo(url, init) : mockMpFetch(input, init); + }); + return userinfo; +} + +const jsonResponse = (body: unknown) => + new Response(JSON.stringify(body), { status: 200, headers: { 'Content-Type': 'application/json' } }); + +function getUserInfo(tokens: Partial) { + const fn = ministryPlatformProviderConfig.getUserInfo; + if (!fn) throw new Error('getUserInfo must be declared'); + return fn(tokens as OAuth2Tokens) as Promise | null>; +} + +/** The structured (JSON) events written to console.error. */ +function loggedEvents(): Array> { + return vi.mocked(console.error).mock.calls.flatMap(([line]) => { + try { + return [JSON.parse(String(line)) as Record]; + } catch { + return []; + } + }); +} + +const loggedText = () => vi.mocked(console.error).mock.calls.flat().join(' '); + +beforeEach(() => { + oidc.reset(); + vi.spyOn(console, 'error').mockImplementation(() => {}); + vi.spyOn(console, 'warn').mockImplementation(() => {}); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe('a verified, matching id_token + userinfo pair', () => { + const GUID = 'ab12cd34-ef56-7890-abcd-ef1234567890'; + + it('returns `sub` (NOT `id` — the 1.6 shape breaks the account key) and resolves the account subject off it', async () => { + stubUserinfo(() => + jsonResponse({ sub: GUID.toUpperCase(), given_name: 'Jane', family_name: 'Doe', email: 'jane@example.org' }), + ); + + const info = await getUserInfo({ accessToken: 'tok', idToken: oidc.signIdToken({ sub: GUID }) }); + + expect(info).toMatchObject({ sub: GUID, name: 'Jane Doe' }); + expect(info).not.toHaveProperty('id'); + expect( + ministryPlatformProviderConfig.accountSubject!({ tokens: {} as never, profile: info as never }), + ).toBe(GUID); + }); + + it('applies the synthetic email and keeps the real one, trimmed, as mpEmail', async () => { + stubUserinfo(() => jsonResponse({ sub: GUID, email: ' shared@household.org ' })); + + const info = await getUserInfo({ accessToken: 'tok', idToken: oidc.signIdToken({ sub: GUID }) }); + + expect(info?.email).toBe(`${GUID}@mp.invalid`); + expect(info?.mpEmail).toBe('shared@household.org'); + }); + + it('nulls mpEmail when the profile email is blank or missing', async () => { + stubUserinfo(() => jsonResponse({ sub: GUID, email: ' ' })); + expect((await getUserInfo({ accessToken: 't', idToken: oidc.signIdToken({ sub: GUID }) }))?.mpEmail).toBeNull(); + }); + + it.each([ + ['omitted', {}, false], + ['false', { email_verified: false }, false], + ['a truthy non-boolean', { email_verified: 'true' }, false], + ['true', { email_verified: true }, true], + ])('reports emailVerified only as MP claimed it (%s)', async (_label, claim, expected) => { + stubUserinfo(() => jsonResponse({ sub: GUID, ...claim })); + + const info = await getUserInfo({ accessToken: 't', idToken: oidc.signIdToken({ sub: GUID }) }); + + expect(info?.emailVerified).toBe(expected); + }); + + it('sends the access token as a bearer, with a 10 s timeout, refusing redirects', async () => { + expect(USERINFO_TIMEOUT_MS).toBe(10_000); + const timeoutSpy = vi.spyOn(AbortSignal, 'timeout'); + const userinfo = stubUserinfo(() => jsonResponse({ sub: GUID })); + + await getUserInfo({ accessToken: 'token-abc', idToken: oidc.signIdToken({ sub: GUID }) }); + + expect(userinfo).toHaveBeenCalledTimes(1); + const [url, init] = userinfo.mock.calls[0]; + expect(url).toBe(`${process.env.MINISTRY_PLATFORM_BASE_URL}/oauth/connect/userinfo`); + expect((init!.headers as Record).Authorization).toBe('Bearer token-abc'); + expect(init!.redirect).toBe('error'); + // (The discovery and JWKS requests set their own, shorter, timeouts.) + const call = timeoutSpy.mock.calls.findIndex(([ms]) => ms === USERINFO_TIMEOUT_MS); + expect(call).toBeGreaterThanOrEqual(0); + expect(init!.signal).toBe(timeoutSpy.mock.results[call].value); + }); +}); + +describe('the userinfo request never throws', () => { + const GUID = 'ab12cd34-ef56-7890-abcd-ef1234596001'; + const tokens = () => ({ accessToken: 'secret-access-token', idToken: oidc.signIdToken({ sub: GUID }) }); + + function onlyEvent() { + const events = loggedEvents(); + expect(events).toHaveLength(1); + expect(loggedText()).not.toContain('secret-access-token'); + return events[0]; + } + + it('returns null on a non-2xx, logging only the status and never reading the body', async () => { + const body = vi.fn(); + stubUserinfo(() => { + const response = new Response('{"secret":"profile-content"}', { status: 403 }); + response.json = body; + return response; + }); + + await expect(getUserInfo(tokens())).resolves.toBeNull(); + + expect(onlyEvent()).toEqual({ + event: 'auth.userinfo.fetch_failed', + message: expect.any(String), + reason: 'http_status', + status: 403, + }); + expect(body).not.toHaveBeenCalled(); + }); + + it('returns null when fetch rejects (network error / timeout)', async () => { + stubUserinfo(() => Promise.reject(new DOMException('The operation timed out', 'TimeoutError'))); + + await expect(getUserInfo(tokens())).resolves.toBeNull(); + expect(onlyEvent()).toMatchObject({ reason: 'request_failed', errName: 'TimeoutError' }); + }); + + it('returns null when fetch rejects with a non-Error value', async () => { + stubUserinfo(() => Promise.reject('boom')); + + await expect(getUserInfo(tokens())).resolves.toBeNull(); + expect(onlyEvent()).toMatchObject({ reason: 'request_failed', errName: 'string' }); + }); + + it('returns null for a non-JSON 200 (a proxy HTML error page), without logging the body', async () => { + stubUserinfo(() => new Response('Gateway says hi to member@example.com', { status: 200 })); + + await expect(getUserInfo(tokens())).resolves.toBeNull(); + expect(onlyEvent()).toMatchObject({ event: 'auth.userinfo.fetch_failed', reason: 'invalid_json', errName: 'SyntaxError' }); + expect(loggedText()).not.toContain('member@example.com'); + }); + + it.each([ + ['null', 'null'], + ['an array', '[]'], + ['a string', '"sub"'], + ])('returns null when the JSON body is %s (not an object)', async (_label, raw) => { + stubUserinfo(() => new Response(raw, { status: 200 })); + + await expect(getUserInfo(tokens())).resolves.toBeNull(); + expect(onlyEvent()).toMatchObject({ reason: 'not_an_object' }); + }); + + it.each([ + ['missing', {}, false], + ['not a GUID', { sub: 'not-a-guid' }, true], + ['a number', { sub: 12345 }, false], + ])('returns null when the userinfo sub is %s', async (_label, profile, hasSub) => { + stubUserinfo(() => jsonResponse({ email: 'x@y.org', ...profile })); + + await expect(getUserInfo(tokens())).resolves.toBeNull(); + expect(onlyEvent()).toEqual({ event: 'auth.userinfo.invalid_sub', message: expect.any(String), hasSub }); + }); +}); + +/** + * The verification itself. Each refusal happens BEFORE userinfo is called, + * so a refused token never spends (or leaks) the access token. + */ +describe('id_token verification (verifyMpIdToken) and claim checks', () => { + const GUID = 'ab12cd34-ef56-7890-abcd-ef1234595001'; + const userinfoFor = () => stubUserinfo(() => jsonResponse({ sub: GUID, given_name: 'Pat' })); + + async function expectRefusedBeforeUserinfo(idToken: string, logged: Record) { + const userinfo = userinfoFor(); + await expect(getUserInfo({ accessToken: 'access-token', idToken })).resolves.toBeNull(); + expect(userinfo).not.toHaveBeenCalled(); + expect(loggedEvents()).toContainEqual(expect.objectContaining(logged)); + // Identifiers only: the token never reaches the log. + expect(loggedText()).not.toContain(idToken); + } + + it.each([ + ['an exp in the past', { exp: Math.floor(Date.now() / 1000) - 60 }, { code: 'ERR_JWT_EXPIRED' }], + ['a non-numeric exp', { exp: 'tomorrow' }, { code: 'ERR_JWT_CLAIM_VALIDATION_FAILED', claim: 'exp' }], + ['a foreign issuer', { iss: 'https://evil.example/oauth' }, { code: 'ERR_JWT_CLAIM_VALIDATION_FAILED', claim: 'iss' }], + ['another client as the audience', { aud: 'some-other-client' }, { code: 'ERR_JWT_CLAIM_VALIDATION_FAILED', claim: 'aud' }], + // The service account's client id is NOT this app's sign-in audience. + ['the service-account client as the audience', { aud: process.env.MINISTRY_PLATFORM_CLIENT_ID }, { code: 'ERR_JWT_CLAIM_VALIDATION_FAILED', claim: 'aud' }], + ])('jose refuses an id_token with %s', async (_label, claims, code) => { + await expectRefusedBeforeUserinfo(oidc.signIdToken({ sub: GUID, ...claims }), { + event: 'auth.userinfo.id_token_unverified', + reason: 'verification_failed', + ...code, + }); + }); + + it('refuses an id_token signed by a key the JWKS does not publish', async () => { + oidc.state.foreignKey = true; + await expectRefusedBeforeUserinfo(oidc.signIdToken({ sub: GUID }), { + event: 'auth.userinfo.id_token_unverified', + reason: 'verification_failed', + code: 'ERR_JWS_SIGNATURE_VERIFICATION_FAILED', + }); + }); + + /** An HS256 token (HMAC of `payload` under `secret`) naming the mock JWKS key's `kid`. */ + const hs256 = (payload: Record, secret: string) => { + const input = `${Buffer.from(JSON.stringify({ alg: 'HS256', kid: 'test-key-1', typ: 'JWT' })).toString('base64url')}.${Buffer.from(JSON.stringify(payload)).toString('base64url')}`; + return `${input}.${createHmac('sha256', secret).update(input).digest('base64url')}`; + }; + const unsigned = (claims: Record) => + `${Buffer.from(JSON.stringify({ alg: 'none', typ: 'JWT' })).toString('base64url')}.${Buffer.from(JSON.stringify(claims)).toString('base64url')}.`; + const validClaims = () => ({ iss: oidc.issuer, aud: CLIENT_ID, sub: GUID, exp: 4102444800 }); + + it.each([ + ['not three segments', () => 'only.two', 'ERR_JWS_INVALID'], + ['a five-segment JWE', () => 'a.b.c.d.e', 'ERR_JWS_INVALID'], + ['a header that is not JSON', () => `x.${Buffer.from('{}').toString('base64url')}.y`, 'ERR_JWS_INVALID'], + // Algorithm confusion: neither client secret is a verification key here. + ['HS256 signed with the OIDC client secret', () => hs256(validClaims(), process.env.MP_OIDC_CLIENT_SECRET!), 'ERR_JOSE_ALG_NOT_ALLOWED'], + ['HS256 signed with the service-account secret', () => hs256(validClaims(), process.env.MINISTRY_PLATFORM_CLIENT_SECRET!), 'ERR_JOSE_ALG_NOT_ALLOWED'], + ['unsigned (alg: none)', () => unsigned(validClaims()), 'ERR_JOSE_ALG_NOT_ALLOWED'], + ])('refuses a malformed or unverifiable id_token (%s)', async (_label, token, code) => { + await expectRefusedBeforeUserinfo(token(), { + event: 'auth.userinfo.id_token_unverified', + reason: 'verification_failed', + code, + }); + }); + + it.each([ + ['no exp', { exp: undefined }, 'missing_exp'], + ['aud: [other, ours] with azp: other', { aud: ['other-client', CLIENT_ID], azp: 'other-client' }, 'azp_mismatch'], + ['aud: [other, ours] with no azp', { aud: ['other-client', CLIENT_ID] }, 'azp_mismatch'], + ])('refuses a verified id_token with %s (the app’s own claim checks)', async (_label, claims, reason) => { + await expectRefusedBeforeUserinfo(oidc.signIdToken({ sub: GUID, ...claims }), { + event: 'auth.userinfo.id_token_claims_invalid', + reason, + }); + }); + + it.each([ + ['a single string aud', { aud: CLIENT_ID }], + ['aud: [other, ours] with azp: ours', { aud: ['other-client', CLIENT_ID], azp: CLIENT_ID }], + ])('accepts %s', async (_label, claims) => { + userinfoFor(); + await expect( + getUserInfo({ accessToken: 'access-token', idToken: oidc.signIdToken({ sub: GUID, ...claims }) }), + ).resolves.toMatchObject({ sub: GUID }); + }); +}); + +/** + * F12's defence-in-depth layer: the userinfo `sub` (what the ACCESS token + * says) must equal the verified id_token `sub`. `/sign-in/social`'s id_token + * mode called `getUserInfo` with a CALLER-SUPPLIED access token, so without + * this an attacker's own id_token plus a victim's access token signed in as + * the victim. End to end in src/auth.id-token-sign-in.test.ts. + */ +describe('id_token sub binding (auth.userinfo.sub_mismatch)', () => { + const userinfoSub = 'ab12cd34-ef56-7890-abcd-ef1234597001'; + const attackerSub = 'ab12cd34-ef56-7890-abcd-ef1234597002'; + + it('accepts a matching sub', async () => { + stubUserinfo(() => jsonResponse({ sub: userinfoSub })); + await expect( + getUserInfo({ accessToken: 'a', idToken: oidc.signIdToken({ sub: userinfoSub }) }), + ).resolves.toMatchObject({ sub: userinfoSub }); + }); + + it('accepts a case-only difference (GUID case carries no meaning)', async () => { + stubUserinfo(() => jsonResponse({ sub: userinfoSub })); + await expect( + getUserInfo({ accessToken: 'a', idToken: oidc.signIdToken({ sub: userinfoSub.toUpperCase() }) }), + ).resolves.toMatchObject({ sub: userinfoSub }); + }); + + it('refuses a userinfo sub that differs from the id_token sub (token substitution)', async () => { + const userinfo = stubUserinfo(() => jsonResponse({ sub: userinfoSub })); + + await expect( + getUserInfo({ accessToken: 'victim-access-token', idToken: oidc.signIdToken({ sub: attackerSub }) }), + ).resolves.toBeNull(); + + expect(userinfo).toHaveBeenCalledTimes(1); + expect(loggedEvents()).toEqual([ + { event: 'auth.userinfo.sub_mismatch', message: expect.any(String), reason: 'mismatch' }, + ]); + // Identifiers only: neither GUID nor any token content reaches the log. + expect(loggedText()).not.toContain(attackerSub); + expect(loggedText()).not.toContain(userinfoSub); + expect(loggedText()).not.toContain('victim-access-token'); + }); + + // Signed and verified, so the refusal is the app's check: jose type-checks + // `sub` only when asked to match a given subject. + it.each([ + ['missing', { sub: undefined }], + ['empty', { sub: '' }], + ['non-string', { sub: 12345 }], + ])('refuses an id_token whose sub is %s, before calling userinfo', async (_label, claims) => { + const userinfo = stubUserinfo(() => jsonResponse({ sub: userinfoSub })); + + await expect(getUserInfo({ accessToken: 'a', idToken: oidc.signIdToken(claims) })).resolves.toBeNull(); + + expect(userinfo).not.toHaveBeenCalled(); + expect(loggedEvents()).toContainEqual(expect.objectContaining({ event: 'auth.userinfo.sub_mismatch', reason: 'missing_sub' })); + }); + + it.each([ + ['absent', undefined], + ['empty', ''], + ])('refuses when the id_token is %s, before any MP call', async (_label, idToken) => { + const userinfo = stubUserinfo(() => jsonResponse({ sub: userinfoSub })); + const fetchesBefore = oidc.calls.length; + + await expect(getUserInfo({ accessToken: 'a', idToken })).resolves.toBeNull(); + + expect(userinfo).not.toHaveBeenCalled(); + expect(oidc.calls.length).toBe(fetchesBefore); + expect(loggedEvents()).toEqual([ + { event: 'auth.userinfo.sub_mismatch', message: expect.any(String), reason: 'missing_id_token' }, + ]); + }); +}); diff --git a/src/auth.user-oauth-tokens.test.ts b/src/auth.user-oauth-tokens.test.ts index b5ae40a..83d1872 100644 --- a/src/auth.user-oauth-tokens.test.ts +++ b/src/auth.user-oauth-tokens.test.ts @@ -69,8 +69,8 @@ describe('user MP OAuth tokens are not requested or retained', () => { expect(account.refreshToken ?? null).toBeNull(); expect(account.accessTokenExpiresAt ?? null).toBeNull(); expect(account.refreshTokenExpiresAt ?? null).toBeNull(); - // Kept deliberately: not an API bearer; needed for a future - // id_token_hint on MP logout (security Step 4). It lives ONLY in this + // Kept deliberately: not an API bearer; it is the id_token_hint on MP + // logout (src/components/user-menu/actions.ts). It lives ONLY in this // process's memory adapter — there is no cookie copy any more. expect(account.idToken).toBe(oidc.lastIdToken); } @@ -84,11 +84,11 @@ describe('user MP OAuth tokens are not requested or retained', () => { }); }); -describe('the retained id_token is still reachable for logout (security Step 4)', () => { - // Not used by the app yet: src/components/user-menu/actions.ts hand-builds - // the endsession URL. This pins that dropping the account cookie did NOT - // remove the id_token_hint source — on the instance that handled sign-in, - // better-auth's own sign-out returns it from the in-memory row. +describe('the retained id_token is still reachable for logout', () => { + // src/components/user-menu/actions.ts takes the hint from exactly this + // URL. This pins that dropping the account cookie did NOT remove the + // id_token_hint source — on the instance that handled sign-in, better-auth's + // own sign-out returns it from the in-memory row. it('sign-out on the same instance returns MP endsession with id_token_hint', async () => { const { jar } = await signIn(auth as unknown as Instance); const result = (await auth.api.signOut({ diff --git a/src/components/user-menu/actions.test.ts b/src/components/user-menu/actions.test.ts index b0edafe..39183f6 100644 --- a/src/components/user-menu/actions.test.ts +++ b/src/components/user-menu/actions.test.ts @@ -23,74 +23,129 @@ vi.mock('next/navigation', () => ({ import { handleSignOut } from './actions'; +/** + * `handleSignOut` clears the app session, then sends the browser to MP's + * end-session endpoint with `post_logout_redirect_uri` (exactly the registered + * app origin), `client_id` (the OIDC sign-in client, always), and + * `id_token_hint` when better-auth — on the process that still holds the + * user's id_token — returned one on an MP-origin logout URL. + */ describe('handleSignOut', () => { const originalEnv = process.env; + /** The URL handleSignOut redirected to. */ + function redirectedTo(): URL { + expect(mockRedirect).toHaveBeenCalledTimes(1); + return new URL(mockRedirect.mock.calls[0][0] as string); + } + beforeEach(() => { vi.clearAllMocks(); process.env = { ...originalEnv }; process.env.MINISTRY_PLATFORM_BASE_URL = 'https://mp.example.com'; process.env.BETTER_AUTH_URL = 'https://myapp.example.com'; + process.env.MP_OIDC_CLIENT_ID = 'app-oidc-client'; + process.env.MP_OIDC_CLIENT_SECRET = 'app-oidc-secret'; + mockSignOut.mockResolvedValue({ success: true }); }); afterAll(() => { process.env = originalEnv; }); - it('should call auth.api.signOut', async () => { - mockSignOut.mockResolvedValueOnce(undefined); - + it('clears the Better Auth session first, asking for the provider logout URL in the body', async () => { await handleSignOut(); expect(mockSignOut).toHaveBeenCalledWith({ headers: expect.any(Headers), + body: { disableRedirect: true }, }); + expect(mockSignOut.mock.invocationCallOrder[0]).toBeLessThan(mockRedirect.mock.invocationCallOrder[0]); }); - it('should redirect to MP end session URL', async () => { - mockSignOut.mockResolvedValueOnce(undefined); + it('redirects to MP end-session with post_logout_redirect_uri and the OIDC client_id', async () => { + await handleSignOut(); + + const url = redirectedTo(); + expect(`${url.origin}${url.pathname}`).toBe('https://mp.example.com/oauth/connect/endsession'); + expect(url.searchParams.get('post_logout_redirect_uri')).toBe('https://myapp.example.com'); + expect(url.searchParams.get('client_id')).toBe('app-oidc-client'); + expect(url.searchParams.has('id_token_hint')).toBe(false); + }); + + it('adds the id_token_hint better-auth returned on this instance', async () => { + mockSignOut.mockResolvedValueOnce({ + success: true, + url: 'https://mp.example.com/oauth/connect/endsession?id_token_hint=the.id.token', + }); await handleSignOut(); - expect(mockRedirect).toHaveBeenCalledWith( - expect.stringContaining('https://mp.example.com/oauth/connect/endsession') - ); - expect(mockRedirect).toHaveBeenCalledWith( - expect.stringContaining('post_logout_redirect_uri=https%3A%2F%2Fmyapp.example.com') - ); + const url = redirectedTo(); + expect(url.searchParams.get('id_token_hint')).toBe('the.id.token'); + // Still rebuilt by us: exact registered redirect URI and client_id. + expect(url.searchParams.get('post_logout_redirect_uri')).toBe('https://myapp.example.com'); + expect(url.searchParams.get('client_id')).toBe('app-oidc-client'); + }); + + it.each([ + ['a different origin', 'https://evil.example/oauth/connect/endsession?id_token_hint=x'], + ['an unparseable URL', 'not a url'], + ['a non-string', 42], + ['an empty hint', 'https://mp.example.com/oauth/connect/endsession?id_token_hint='], + ])('ignores a logout URL on %s', async (_label, url) => { + mockSignOut.mockResolvedValueOnce({ success: true, url }); + + await handleSignOut(); + + expect(redirectedTo().searchParams.has('id_token_hint')).toBe(false); + }); + + it('uses the service-account client id as client_id when no dedicated OIDC client is set (fallback)', async () => { + delete process.env.MP_OIDC_CLIENT_ID; + delete process.env.MP_OIDC_CLIENT_SECRET; + process.env.MINISTRY_PLATFORM_CLIENT_ID = 'svc-client'; + process.env.MINISTRY_PLATFORM_CLIENT_SECRET = 'svc-secret'; + + await handleSignOut(); + + expect(redirectedTo().searchParams.get('client_id')).toBe('svc-client'); }); - it('should throw when MINISTRY_PLATFORM_BASE_URL is missing', async () => { + it('still clears the session, then throws, when MINISTRY_PLATFORM_BASE_URL is missing', async () => { delete process.env.MINISTRY_PLATFORM_BASE_URL; - mockSignOut.mockResolvedValueOnce(undefined); await expect(handleSignOut()).rejects.toThrow('MINISTRY_PLATFORM_BASE_URL is not set'); + expect(mockSignOut).toHaveBeenCalledTimes(1); + expect(mockRedirect).not.toHaveBeenCalled(); }); - it('should throw when neither BETTER_AUTH_URL nor NEXTAUTH_URL is set', async () => { + it('throws when neither BETTER_AUTH_URL nor NEXTAUTH_URL is set', async () => { delete process.env.BETTER_AUTH_URL; delete process.env.NEXTAUTH_URL; - mockSignOut.mockResolvedValueOnce(undefined); await expect(handleSignOut()).rejects.toThrow('BETTER_AUTH_URL is not set (NEXTAUTH_URL is accepted as a fallback)'); expect(mockRedirect).not.toHaveBeenCalled(); }); - it('should fall back to NEXTAUTH_URL when BETTER_AUTH_URL is not set', async () => { + it('throws on half a dedicated OIDC client rather than send the wrong client_id', async () => { + delete process.env.MP_OIDC_CLIENT_SECRET; + + await expect(handleSignOut()).rejects.toThrow(/must be set together/); + expect(mockRedirect).not.toHaveBeenCalled(); + }); + + it('falls back to NEXTAUTH_URL when BETTER_AUTH_URL is not set', async () => { delete process.env.BETTER_AUTH_URL; process.env.NEXTAUTH_URL = 'https://fallback.example.com'; - mockSignOut.mockResolvedValueOnce(undefined); await handleSignOut(); - expect(mockRedirect).toHaveBeenCalledWith( - expect.stringContaining('post_logout_redirect_uri=https%3A%2F%2Ffallback.example.com') - ); + expect(redirectedTo().searchParams.get('post_logout_redirect_uri')).toBe('https://fallback.example.com'); }); it('refuses a non-https MP URL rather than redirecting the browser to it', async () => { process.env.MINISTRY_PLATFORM_BASE_URL = 'http://mp.example.com'; - mockSignOut.mockResolvedValueOnce(undefined); await expect(handleSignOut()).rejects.toThrow('MINISTRY_PLATFORM_BASE_URL must use https://'); expect(mockRedirect).not.toHaveBeenCalled(); @@ -99,12 +154,11 @@ describe('handleSignOut', () => { it('normalizes a trailing slash, so the URL never contains //oauth', async () => { process.env.MINISTRY_PLATFORM_BASE_URL = 'https://mp.example.com/api/'; process.env.BETTER_AUTH_URL = 'https://myapp.example.com/'; - mockSignOut.mockResolvedValueOnce(undefined); await handleSignOut(); expect(mockRedirect).toHaveBeenCalledWith( - 'https://mp.example.com/api/oauth/connect/endsession?post_logout_redirect_uri=https%3A%2F%2Fmyapp.example.com' + 'https://mp.example.com/api/oauth/connect/endsession?post_logout_redirect_uri=https%3A%2F%2Fmyapp.example.com&client_id=app-oidc-client', ); }); }); diff --git a/src/components/user-menu/actions.ts b/src/components/user-menu/actions.ts index 55a9eed..2eb4712 100644 --- a/src/components/user-menu/actions.ts +++ b/src/components/user-menu/actions.ts @@ -1,23 +1,79 @@ 'use server'; import { auth } from "@/lib/auth"; -import { getAuthBaseUrl, getMpBaseUrl } from "@/lib/env"; +import { getAuthBaseUrl, getMpBaseUrl, getOidcClient } from "@/lib/env"; import { headers } from "next/headers"; import { redirect } from "next/navigation"; +/** + * Pulls the `id_token_hint` out of the provider logout URL better-auth returns + * from `signOut`. better-auth can only build that URL with a hint in the + * process whose in-memory account row still holds the user's id_token (it is + * in no cookie — `storeAccountCookie` is off), so this is null on any other + * serverless instance. Only a URL on the MP origin is trusted. + */ +function idTokenHintFrom(providerLogoutUrl: unknown, mpOrigin: string): string | null { + if (typeof providerLogoutUrl !== "string") return null; + try { + const url = new URL(providerLogoutUrl); + if (url.origin !== mpOrigin) return null; + return url.searchParams.get("id_token_hint") || null; + } catch { + return null; + } +} + +/** + * Signs the user out of this app, then sends the browser to MP's end-session + * endpoint so MP ends its own SSO session too (RP-initiated logout). + * + * Why the URL is rebuilt here rather than taken from better-auth: + * `post_logout_redirect_uri` must be EXACTLY the value registered on the MP + * client (`getAuthBaseUrl()`, an origin with no trailing slash), while + * better-auth would send a `/`-suffixed URL, and would omit `client_id` + * whenever it has an id_token. + * + * Without `id_token_hint` or `client_id`, IdentityServer-style providers (MP) + * cannot tell which client's registered post-logout URIs to check, so they + * prompt "log out?" and do not redirect — leaving the MP SSO session alive on + * a shared PC if the tab is closed there. So `client_id` (the OIDC sign-in + * client) is ALWAYS sent, and `id_token_hint` whenever this process still has + * the id_token. + * + * KNOWN LIMITATION: the id_token lives only in the in-memory account row of + * the process that handled the sign-in callback (`sharedInstance` makes every + * Next bundle layer in that process share it). When sign-out lands on a + * different serverless instance, or after a restart, there is no hint; MP then + * relies on `client_id` + `post_logout_redirect_uri` and may show its + * "log out?" confirmation once. The app session is cleared either way. + */ export async function handleSignOut() { - // Clear the Better Auth session - await auth.api.signOut({ + // Clear the Better Auth session FIRST, so a misconfigured environment below + // still signs the user out of this app. `disableRedirect` asks better-auth + // for the provider logout URL in the body instead of a redirect; only its + // id_token is used. + const result = await auth.api.signOut({ headers: await headers(), + body: { disableRedirect: true }, }); - // Both validated and normalized (https, no credentials/query/fragment, no - // trailing slash; the app URL origin-only) — see src/lib/env.ts. They throw - // on a bad value without echoing it. - const endSessionUrl = `${getMpBaseUrl()}/oauth/connect/endsession`; + // Each throws on an unset or unusable value without echoing it (see + // src/lib/env.ts). No localhost fallback: a post-logout redirect to the + // wrong origin either fails MP's registered-URI check or sends the user + // somewhere unexpected. + const mpBaseUrl = getMpBaseUrl(); + const appUrl = getAuthBaseUrl(); + const { clientId } = getOidcClient(); + const params = new URLSearchParams({ - post_logout_redirect_uri: getAuthBaseUrl(), + post_logout_redirect_uri: appUrl, + client_id: clientId, }); + const idTokenHint = idTokenHintFrom( + (result as { url?: unknown } | undefined)?.url, + new URL(mpBaseUrl).origin, + ); + if (idTokenHint) params.set("id_token_hint", idTokenHint); - redirect(`${endSessionUrl}?${params.toString()}`); + redirect(`${mpBaseUrl}/oauth/connect/endsession?${params.toString()}`); } diff --git a/src/components/user-menu/user-menu.test.tsx b/src/components/user-menu/user-menu.test.tsx index 35941e2..78f7d7c 100644 --- a/src/components/user-menu/user-menu.test.tsx +++ b/src/components/user-menu/user-menu.test.tsx @@ -10,6 +10,7 @@ import type { MPUserProfile } from "@/lib/providers/ministry-platform/types"; * 2. Email_Address rendering including null-safe render. * 3. `handleItemClick` wiring: `onClose?.()` runs before `handleSignOut()`. * 4. Sign-out still works when `onClose` is omitted. + * 5. A failed sign-out is surfaced; Next's redirect signal is re-thrown. * * The Radix DropdownMenu primitives rely on a Portal plus pointer-event * gating that does not play nicely with jsdom, so we stub the UI barrel @@ -17,8 +18,11 @@ import type { MPUserProfile } from "@/lib/providers/ministry-platform/types"; * logic rather than Radix internals. */ -const { mockHandleSignOut } = vi.hoisted(() => ({ +const { mockHandleSignOut, clicks } = vi.hoisted(() => ({ mockHandleSignOut: vi.fn(), + // What each menu item's onClick returned (the handler's promise), so a test + // can await a rejection instead of leaving it unhandled. + clicks: [] as unknown[], })); vi.mock("./actions", () => ({ @@ -44,7 +48,17 @@ vi.mock("@/components/ui/dropdown-menu", () => { DropdownMenuItem: function Item({ children, onClick, className }: any) { return React.createElement( "button", - { type: "button", onClick, className }, + { + type: "button", + onClick: (e: unknown) => + clicks.push( + Promise.resolve(onClick?.(e)).then( + () => ({ rejected: false }), + (error: unknown) => ({ rejected: true, error }), + ), + ), + className, + }, children ); }, @@ -164,4 +178,45 @@ describe("UserMenu", () => { expect(mockHandleSignOut).toHaveBeenCalledTimes(1); }); + + it("re-throws Next's NEXT_REDIRECT signal instead of treating a successful sign-out as a failure", async () => { + const redirectSignal = Object.assign(new Error("NEXT_REDIRECT"), { + digest: "NEXT_REDIRECT;replace;https://mp.example.com/oauth/connect/endsession;307;", + }); + mockHandleSignOut.mockRejectedValueOnce(redirectSignal); + const alertSpy = vi.spyOn(window, "alert").mockImplementation(() => {}); + clicks.length = 0; + + render( + + + + ); + await act(async () => { + screen.getByRole("button", { name: /sign out/i }).click(); + }); + + await expect(clicks[0]).resolves.toEqual({ rejected: true, error: redirectSignal }); + expect(alertSpy).not.toHaveBeenCalled(); + }); + + it("tells the user when sign-out genuinely fails, instead of swallowing it", async () => { + mockHandleSignOut.mockRejectedValueOnce(new Error("[env] MP_OIDC_CLIENT_ID and MP_OIDC_CLIENT_SECRET must be set together")); + const alertSpy = vi.spyOn(window, "alert").mockImplementation(() => {}); + clicks.length = 0; + + render( + + + + ); + await act(async () => { + screen.getByRole("button", { name: /sign out/i }).click(); + }); + + await expect(clicks[0]).resolves.toEqual({ rejected: false }); + expect(alertSpy).toHaveBeenCalledWith(expect.stringMatching(/sign out did not complete/i)); + // The raw error (which can name configuration) is not shown. + expect(alertSpy).not.toHaveBeenCalledWith(expect.stringContaining("MP_OIDC_CLIENT_ID")); + }); }); diff --git a/src/components/user-menu/user-menu.tsx b/src/components/user-menu/user-menu.tsx index 9f3359b..bc43011 100644 --- a/src/components/user-menu/user-menu.tsx +++ b/src/components/user-menu/user-menu.tsx @@ -1,6 +1,7 @@ "use client"; import { ArrowRightOnRectangleIcon } from "@heroicons/react/24/outline"; +import { unstable_rethrow } from "next/navigation"; import { MPUserProfile } from "@/lib/providers/ministry-platform/types"; import { DropdownMenu, @@ -32,7 +33,17 @@ export function UserMenu({ onClose, userProfile, children }: UserMenuProps) { onClose(); } if (action === "signout") { - await handleSignOut(); + try { + await handleSignOut(); + } catch (err) { + // MUST stay first: a successful sign-out ends in `redirect()`, a + // NEXT_REDIRECT control-flow throw Next has to see, or the navigation + // to MP's logout would be swallowed here as an "error". + unstable_rethrow(err); + // A genuine failure. The app session is cleared before anything in + // `handleSignOut` can throw, so say so rather than fail silently. + alert("Sign out did not complete. Please close this browser window to finish signing out."); + } } }; diff --git a/src/lib/auth.ts b/src/lib/auth.ts index 7542a21..f7ee1a0 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -8,7 +8,8 @@ import { import { customSession } from "better-auth/plugins"; import { nextCookies } from "better-auth/next-js"; import { isIP } from "node:net"; -import { getAuthBaseUrl, getMpBaseUrl } from "@/lib/env"; +import { createRemoteJWKSet, jwtVerify, type JWTPayload, type JWTVerifyGetKey } from "jose"; +import { getAuthBaseUrl, getMpBaseUrl, getOidcClient, type OidcClient } from "@/lib/env"; import { validateGuid } from "@/lib/validation"; /** @@ -101,6 +102,11 @@ if (!process.env.VITEST) { const mpBaseUrl = getMpBaseUrl(); const authBaseUrl = getAuthBaseUrl(); +// The OIDC client user sign-in runs as — dedicated (`MP_OIDC_CLIENT_ID` / +// `_SECRET`), or the service-account client as a deprecated fallback. See +// `getOidcClient` in src/lib/env.ts and `warnIfSharedOidcClient` below. +const oidcClient = getOidcClient(); + /** * Default client-IP headers, used only when the process runs on Vercel * (`VERCEL` is set) and `AUTH_IP_ADDRESS_HEADERS` is not. @@ -279,14 +285,20 @@ export const ID_TOKEN_SIGN_IN_DISABLED = "ID_TOKEN_SIGN_IN_DISABLED"; * aud) and then calls our `getUserInfo` with the CALLER-SUPPLIED * `accessToken` (`node_modules/better-auth/dist/api/routes/sign-in.mjs`). * - * Because `ministryPlatformProviderConfig` sets `discoveryUrl`, genericOAuth - * builds an id_token config for the provider, which switches that mode ON + * While `ministryPlatformProviderConfig` set `discoveryUrl`, genericOAuth + * built an id_token config for the provider, which switched that mode ON * (`supportsIdTokenSignIn`) — and genericOAuth has no option to turn it off. - * Nothing binds the verified id_token's `sub` to the access token's userinfo - * `sub`, so an attacker's own valid id_token plus ANY other user's MP access - * token (from any MP client `/connect/userinfo` accepts) would mint a session - * as that other user: their roles on every authorization check, their - * `User_ID` on every write. + * Nothing in better-auth binds the verified id_token's `sub` to the access + * token's userinfo `sub`, so an attacker's own valid id_token plus ANY other + * user's MP access token (from any MP client `/connect/userinfo` accepts) + * minted a session as that other user: their roles on every authorization + * check, their `User_ID` on every write. + * + * Since security Step 4 the provider has no `discoveryUrl` (see `mpOidc` + * below), so it has no id_token config and better-auth refuses the mode + * itself (`404 ID_TOKEN_NOT_SUPPORTED`). That is a side effect of + * configuration, not a control — re-adding `discoveryUrl` switches the mode + * straight back on — so this hook stays the PRIMARY control and answers first. * * `hooks.before` runs for HTTP requests AND in-process `auth.api.*` calls, so * this covers callers the route filter in `src/app/api/auth/[...all]/route.ts` @@ -298,11 +310,9 @@ export const ID_TOKEN_SIGN_IN_DISABLED = "ID_TOKEN_SIGN_IN_DISABLED"; * `ID_TOKEN_NOT_SUPPORTED` (also 404). * * Defence in depth: the route filter refuses any body key but `provider` and - * `callbackURL`. The remaining layer — binding `id_token.sub` to userinfo - * `sub` inside `getUserInfo` — is NOT here yet; it lands with the OIDC - * lazy-discovery rewrite (security Step 4). Until then this hook and the - * route filter are what close the path. `src/auth.id-token-sign-in.test.ts` - * proves each independently. + * `callbackURL`, and `getUserInfo` refuses a profile whose userinfo `sub` does + * not match the verified id_token `sub` (`auth.userinfo.sub_mismatch`). Keep + * all three; `src/auth.id-token-sign-in.test.ts` proves each independently. */ export const refuseIdTokenSignIn = createAuthMiddleware(async (ctx) => { if (ctx.path !== "/sign-in/social") return; @@ -397,18 +407,350 @@ export function buildDisplayName(profile: unknown, fallbackEmail: string | null) return "Ministry Platform User"; } +/** + * Timeout for the lazy discovery request behind `lazyIdTokenVerifier`, and for + * the JWKS request jose makes from it. Both run inside the OAuth callback, so a + * hung MP holds that one callback for at most this long — never the process. + */ +export const OIDC_DISCOVERY_TIMEOUT_MS = 5_000; + +/** + * Timeout for the userinfo request. It runs inside the OAuth callback, so a + * hung MP would otherwise hold the callback open until the platform's own + * request timeout. 10 s is well past MP's normal latency. + */ +export const USERINFO_TIMEOUT_MS = 10_000; + +/** + * The only id_token signing algorithm accepted. MP advertises exactly this + * (`id_token_signing_alg_values_supported: ["RS256"]`, checked against + * `mpi.ministryplatform.com` on 2026-09-30). Pinned rather than read from + * discovery or the token header, so nothing an attacker controls can widen + * what is accepted — an HS256 token "signed" with the client secret, or + * `alg: none`, fails as `ERR_JOSE_ALG_NOT_ALLOWED`. If MP ever signs with + * another algorithm, sign-in fails loudly (`auth.userinfo.id_token_unverified` + * with that code) and it has to be added here — asymmetric algorithms only. + */ +export const ID_TOKEN_ALGORITHMS = ["RS256"]; + +/** What `getUserInfo` verifies MP's id_tokens against. */ +export interface IdTokenVerifier { + issuer: string; + jwks: JWTVerifyGetKey; +} + +type DiscoveryFailure = + | { reason: "http_status"; status: number } + | { reason: "request_failed" | "invalid_json"; errName: string } + | { reason: "invalid_document"; field: "body" | "issuer" | "jwks_uri" }; + +/** `err.name` (e.g. "TimeoutError", "TypeError"), or the value's type. */ +function errorName(err: unknown): string { + // Duck-typed rather than `instanceof Error`: a DOMException (what an + // `AbortSignal.timeout` abort rejects with) is not an Error in every realm. + const name = (err as { name?: unknown } | null)?.name; + return typeof name === "string" ? name : typeof err; +} + +/** One structured line on console.error. Identifiers only (CLAUDE.md rule 14). */ +function logAuthEvent(event: string, message: string, detail: Record) { + console.error(JSON.stringify({ event, message, ...detail })); +} + +/** Never the body or the URL: the reason, a status or an error NAME. */ +function logDiscoveryFailure(detail: DiscoveryFailure) { + logAuthEvent( + "auth.oidc.discovery_failed", + "MP OIDC discovery document unavailable; sign-in fails until it loads (retried on the next sign-in)", + detail, + ); +} + +async function fetchIdTokenVerifier( + discoveryUrl: string, + timeoutMs: number, +): Promise { + const fail = (detail: DiscoveryFailure): never => { + logDiscoveryFailure(detail); + throw new Error(`OIDC discovery failed: ${detail.reason}`); + }; + let response: Response; + try { + // `redirect: "error"`: MP serves discovery directly; a redirect is either + // a misconfiguration or someone steering us to a different key set. + response = await fetch(discoveryUrl, { + signal: AbortSignal.timeout(timeoutMs), + redirect: "error", + }); + } catch (err) { + return fail({ reason: "request_failed", errName: errorName(err) }); + } + if (!response.ok) return fail({ reason: "http_status", status: response.status }); + let doc: unknown; + try { + doc = await response.json(); + } catch (err) { + return fail({ reason: "invalid_json", errName: errorName(err) }); + } + if (typeof doc !== "object" || doc === null || Array.isArray(doc)) { + return fail({ reason: "invalid_document", field: "body" }); + } + const { issuer, jwks_uri: jwksUri } = doc as Record; + if (typeof issuer !== "string" || !URL.canParse(issuer)) { + return fail({ reason: "invalid_document", field: "issuer" }); + } + if (typeof jwksUri !== "string" || !URL.canParse(jwksUri, discoveryUrl)) { + return fail({ reason: "invalid_document", field: "jwks_uri" }); + } + return { + issuer, + // jose caches the key set, re-fetches it (at most once per 30 s) when a + // token names an unknown `kid` (key rotation), refuses a redirected or + // non-200 response, and does not cache a failed fetch. + jwks: createRemoteJWKSet(new URL(jwksUri, discoveryUrl), { timeoutDuration: timeoutMs }), + }; +} + +/** + * Returns a loader for the id_token verifier (issuer + JWKS) described by the + * OIDC discovery document at `discoveryUrl`, fetched on first use rather than + * when the auth instance is built (MPNext issue #101). + * + * - Lazy: nothing is fetched until the first OAuth callback needs to verify an + * id_token, so building the auth instance, `next build`, cold starts and + * every session check make no MP call at all. + * - Cached on success for the life of the process (the trust model genericOAuth + * had when it fetched discovery at boot). Key rotation is jose's job. + * - Single-flight: concurrent callbacks share one in-flight fetch. + * - Never caches a failure: a failed fetch is logged + * (`auth.oidc.discovery_failed`), that sign-in is refused, and the next + * callback tries again. A blip lasts exactly as long as MP is down. + * - Fails closed: a document without a usable `issuer` or `jwks_uri` is a + * failure, never a verifier that skips a check. + * + * Exported so tests can build an isolated loader; the app uses the one + * module-level instance `loadMpIdTokenVerifier`. + */ +export function lazyIdTokenVerifier( + discoveryUrl: string, + { timeoutMs = OIDC_DISCOVERY_TIMEOUT_MS }: { timeoutMs?: number } = {}, +): () => Promise { + let pending: Promise | null = null; + return () => + (pending ??= fetchIdTokenVerifier(discoveryUrl, timeoutMs).catch((err: unknown) => { + pending = null; + throw err; + })); +} + +/** + * Why `getUserInfo` refused an id_token it could not verify. + * `verifier_unavailable`: discovery did not load (its own + * `auth.oidc.discovery_failed` line says why). `verification_failed`: jose + * refused the token or could not fetch the key set; `code` is jose's error + * code (e.g. `ERR_JWS_SIGNATURE_VERIFICATION_FAILED`, `ERR_JWT_EXPIRED`, + * `ERR_JOSE_ALG_NOT_ALLOWED`, `ERR_JWKS_TIMEOUT`) and `claim` the claim name + * for a claim failure. Never the token, its claims, or `err.message`. + */ +function logIdTokenUnverified( + detail: + | { reason: "verifier_unavailable" } + | { reason: "verification_failed"; code?: string; claim?: string; errName: string }, +) { + logAuthEvent( + "auth.userinfo.id_token_unverified", + "MP id_token could not be verified; refusing sign-in", + detail, + ); +} + +/** Never the GUIDs themselves or any token content — the reason only. */ +function logSubBindingFailure(reason: "missing_id_token" | "missing_sub" | "mismatch") { + logAuthEvent( + "auth.userinfo.sub_mismatch", + "MP userinfo sub could not be bound to the id_token sub; refusing sign-in", + { reason }, + ); +} + +function logIdTokenClaimFailure(reason: "missing_exp" | "azp_mismatch") { + logAuthEvent( + "auth.userinfo.id_token_claims_invalid", + "MP id_token failed the app's claim checks; refusing sign-in", + { reason }, + ); +} + +/** + * Why the userinfo request produced no usable profile. The HTTP status or the + * error's NAME only: never the response body (member PII), the access token, + * or `err.message` (which can echo the URL or body). + */ +function logUserinfoFetchFailure( + detail: + | { reason: "http_status"; status: number } + | { reason: "request_failed" | "invalid_json"; errName: string } + | { reason: "not_an_object" }, +) { + logAuthEvent( + "auth.userinfo.fetch_failed", + "MP userinfo request produced no usable profile; refusing sign-in", + detail, + ); +} + +/** + * OIDC Core §3.1.3.7 claim checks jose's `jwtVerify` does not make itself: it + * checks `exp` only when PRESENT (an expired or non-numeric one is already + * refused there), so a token with no `exp` would pass; and nothing checks + * `azp` when `aud` lists several audiences. Returns the refusal reason, or + * null. Runs on an already-verified payload, so it narrows what a genuinely + * MP-signed token may look like; it is not the signature check. + */ +function idTokenClaimFailure( + claims: JWTPayload, + clientId: string, +): "missing_exp" | "azp_mismatch" | null { + const { exp, aud, azp } = claims as Record; + if (typeof exp !== "number") return "missing_exp"; + if (Array.isArray(aud) && azp !== clientId) return "azp_mismatch"; + return null; +} + +/** + * Ministry Platform's OIDC endpoints, configured explicitly instead of read + * from the discovery document at boot (MPNext issue #101). + * + * With `discoveryUrl` set, genericOAuth fetched discovery ONCE, inside the auth + * context's `init`, with no timeout and no retry, and every auth request — and + * every in-process `auth.api.*` call, i.e. every page's session check — awaits + * that `init`. A failed fetch dropped the provider for the life of the + * instance (`/sign-in/social` 404'd `PROVIDER_NOT_FOUND` until a restart); a + * hung one stalled every request for undici's ~300 s headers timeout. With + * these set and no `discoveryUrl`, building the auth instance makes no network + * call, so the provider always registers. + * + * These are IdentityServer's fixed `connect/*` paths under MP's `/oauth` + * (checked against `mpi.ministryplatform.com`'s discovery document on + * 2026-09-30). The JWKS URL is deliberately NOT pinned: it is not the same on + * every IdentityServer (MP serves `/oauth/.well-known/jwks`; the IdentityServer + * default is `/oauth/.well-known/openid-configuration/jwks`), so it and the + * issuer still come from discovery — fetched lazily, when the first id_token + * needs verifying (`loadMpIdTokenVerifier`). + */ +const mpOidc = { + discovery: `${mpBaseUrl}/oauth/.well-known/openid-configuration`, + authorization: `${mpBaseUrl}/oauth/connect/authorize`, + token: `${mpBaseUrl}/oauth/connect/token`, + userinfo: `${mpBaseUrl}/oauth/connect/userinfo`, + endSession: `${mpBaseUrl}/oauth/connect/endsession`, +}; + +const loadMpIdTokenVerifier = lazyIdTokenVerifier(mpOidc.discovery); + +/** + * Verifies an MP id_token — RS256 signature against MP's JWKS, `iss` against + * the discovered issuer, `aud` against the OIDC client id, plus `exp`/`nbf` + * when present — and returns its claims, or null (logged) when it cannot be + * verified. + * + * This is the check genericOAuth made itself while discovery ran at boot (the + * same jose `jwtVerify` call), moved here because without `discoveryUrl` the + * provider has no id_token config and better-auth verifies nothing. + * `getUserInfo` is the one place every sign-in path reaches with the token + * response in hand. Neither a nonce nor PKCE is checked: MP supports neither + * (accepted risk F8, see `pkce` below). + */ +async function verifyMpIdToken(idToken: string): Promise { + let verifier: IdTokenVerifier; + try { + verifier = await loadMpIdTokenVerifier(); + } catch { + logIdTokenUnverified({ reason: "verifier_unavailable" }); + return null; + } + try { + const { payload } = await jwtVerify(idToken, verifier.jwks, { + issuer: verifier.issuer, + audience: oidcClient.clientId, + algorithms: ID_TOKEN_ALGORITHMS, + }); + return payload; + } catch (err) { + const { code, claim } = (err ?? {}) as { code?: unknown; claim?: unknown }; + logIdTokenUnverified({ + reason: "verification_failed", + ...(typeof code === "string" && { code }), + ...(typeof claim === "string" && { claim }), + errName: errorName(err), + }); + return null; + } +} + +/** + * Logs `auth.oidc.shared_client` (once per auth instance, i.e. once per + * process outside Vitest — see `sharedInstance`) when sign-in is not running + * as a dedicated OIDC client. A warning, not a refusal, until every + * environment has `MP_OIDC_CLIENT_ID` / `MP_OIDC_CLIENT_SECRET`; see + * `.claude/TODO/2026-09-30-require-dedicated-oidc-client.md`. + * + * Silent during `next build` (`NEXT_PHASE`), whose page-data workers are + * separate processes and would each print it; the running server logs it. + * Exported for tests. + */ +export function warnIfSharedOidcClient( + client: Pick, + env: Readonly> = process.env, +): void { + if (!client.shared || env.NEXT_PHASE === "phase-production-build") return; + console.warn( + JSON.stringify({ + event: "auth.oidc.shared_client", + message: + "User sign-in is using the MP service-account client; register a dedicated MP OIDC client and set MP_OIDC_CLIENT_ID / MP_OIDC_CLIENT_SECRET", + source: client.shared, + }), + ); +} + /** * Ministry Platform OAuth provider configuration. * - * Exported so `src/auth.test.ts` can pin the flags whose failure mode is a - * SILENT broken sign-in rather than a type error — `pkce` and - * `disableIdTokenNonceBinding` in particular. + * Exported so the tests can pin the settings whose failure mode is a SILENT + * broken sign-in (or a silently reopened hole) rather than a type error: + * no `discoveryUrl`, the explicit endpoints, `pkce: false`, `accountSubject`. */ export const ministryPlatformProviderConfig: GenericOAuthConfig = { + // Must stay "ministryplatform": it is the registered MP redirect URI + // (`/api/auth/callback/ministryplatform`), the route allowlist entry, and + // the stable half of every stored account key. providerId: "ministryplatform", - discoveryUrl: `${mpBaseUrl}/oauth/.well-known/openid-configuration`, - clientId: process.env.MINISTRY_PLATFORM_CLIENT_ID!, - clientSecret: process.env.MINISTRY_PLATFORM_CLIENT_SECRET!, + // NO `discoveryUrl`, deliberately (MPNext issue #101): see `mpOidc` above. + // Setting it would bring back the boot-time fetch that took sign-in down on + // an MP blip, AND give the provider an id_token config, which re-opens + // `/sign-in/social`'s id_token mode (see `refuseIdTokenSignIn`). The id_token + // is verified in `getUserInfo` instead (`verifyMpIdToken`). + // `src/auth.oidc-discovery.test.ts` fails if it comes back. + // + // Without discovery the provider has no `issuer`, so better-auth's RFC 9207 + // callback `iss` check is skipped — moot for MP, whose discovery document + // does not advertise `authorization_response_iss_parameter_supported`, and a + // single-provider app has no mix-up to defend. + // + // `requireIdTokenVerification` and `disableIdTokenNonceBinding` are gone + // with it: genericOAuth throws for the first without `discoveryUrl`, and the + // second is a no-op when the provider has no id_token config (better-auth + // then neither sends nor requires a nonce). + authorizationUrl: mpOidc.authorization, + tokenUrl: mpOidc.token, + // Required for RP-initiated logout: without it better-auth builds no MP + // end-session URL, and sign-out loses the `id_token_hint` that lets MP end + // its own session without a "log out?" prompt. (`userInfoUrl` is not set: + // the custom `getUserInfo` below fetches `mpOidc.userinfo` itself.) + endSessionEndpoint: mpOidc.endSession, + clientId: oidcClient.clientId, + clientSecret: oidcClient.clientSecret, /** * No `offline_access`: this app never uses the user's own MP tokens (all MP * data access goes through the client-credentials service account), so it @@ -428,102 +770,121 @@ export const ministryPlatformProviderConfig: GenericOAuthConfig = { * verifier at the token endpoint: with `pkce: true` the authorize leg * succeeds and returns a code, then the exchange fails with `invalid_grant` * (HTTP 400) and the user lands on `/auth-error?error=invalid_code`. + * Verified against a live MP tenant on 2026-09-13. * - * That failure shape is what makes this trap expensive — the advertised - * support and the accepted `code_challenge` on the authorize URL both look - * like confirmation, and the flow only breaks on the last hop. Verified - * against a live MP tenant on 2026-09-13. + * No nonce either: MP does not echo `nonce` in the id_token. With no + * `discoveryUrl` the provider has no id_token config, so better-auth neither + * sends a nonce nor requires one, and `verifyMpIdToken` checks none. * - * Without PKCE the authorization code rests on the client secret and the - * OAuth `state` cookie check. This is a confidential client, so that is the - * posture this app has always had. + * ACCEPTED RISK (F8), stated plainly: with MP supporting neither PKCE nor + * the nonce, NOTHING binds an authorization code to the browser that started + * the flow. The `state` cookie check does not help (an attacker who obtains + * a victim's code starts their own flow, with their own valid state, and + * injects the victim's code into it), and being a confidential client does + * not help either (the app redeems the injected code with its own secret) — + * authorization-code injection, RFC 9700 §4.5. What remains keeps codes out + * of an attacker's reach: a dedicated MP OIDC client with exact redirect + * URIs (`MP_OIDC_CLIENT_ID`), `Referrer-Policy`, and no code-bearing URLs in + * logs. What IS still checked: the id_token's signature against MP's JWKS, + * its issuer and audience (`verifyMpIdToken`). */ pkce: false, - /** - * Ministry Platform does not echo the `nonce` claim in the - * authorization-code flow. - * - * As of Better Auth 1.7, any provider configured with `discoveryUrl` - * that publishes a JWKS binds the id_token to the authorization - * request BY DEFAULT: it sends a server-generated `nonce` and rejects - * a callback whose id_token does not echo it (OIDC Core 1.0 - * §3.1.3.7). MP omits the claim, so every sign-in would fail with - * `unable_to_get_user_info`. - * - * What this gives up is binding the id_token to this particular - * authorization request. Signature, issuer and audience are still - * verified against MP's JWKS, and the residual replay risk is - * mitigated by the OAuth `state` cookie check, by this being a - * confidential client exchanging the code with a client secret, and - * by PKCE above. - * - * Watch out when debugging: this failure looks intermittent but is - * inverted from the obvious reading — sign-in succeeds only when the - * boot-time discovery fetch FAILED, because that leaves the id_token - * config undefined and skips verification entirely. A working - * discovery means a broken sign-in. - */ - disableIdTokenNonceBinding: true, - /** - * Refuse to register the provider if discovery yields no usable `issuer` + - * `jwks_uri`, instead of silently skipping id_token verification. - * - * Without this, a discovery document missing either field leaves the - * id_token config undefined and the normal flow's id_token goes UNVERIFIED - * — exactly the "working discovery breaks sign-in, broken discovery works" - * inversion described above, in its dangerous direction. With it, genericOAuth - * logs and skips the provider (sign-in 404s `PROVIDER_NOT_FOUND`): an outage, - * not a silent downgrade. MP's document publishes both (checked against - * `mpi.ministryplatform.com` on 2026-09-30). - */ - requireIdTokenVerification: true, authorizationUrlParams: { realm: "realm", }, /** - * The stable provider account key, new in Better Auth 1.7. - * - * Declared EXPLICITLY rather than relying on genericOAuth's default. The - * default is `isOidc ? profile.sub : profile.id`, where `isOidc` is inferred - * at boot from whether MP's discovery document returned - * `id_token_signing_alg_values_supported`. That makes the account key depend - * on a network fetch succeeding at startup — a transient discovery failure - * would silently switch which field identifies the user. - * - * `getUserInfo` has already run `extractUserGuid`, so `sub` is a validated, - * lower-cased MP `User_GUID` by the time this is called. + * The stable provider account key. Required, not a style choice: without + * discovery the provider is not recognised as OIDC, so genericOAuth's + * default resolver reads `profile.id` — which `getUserInfo` never returns — + * and every account would be keyed on "". `getUserInfo` has already run + * `extractUserGuid`, so `sub` is a validated, lower-cased MP `User_GUID`. */ - accountSubject: ({ profile }) => (profile.sub == null ? "" : String(profile.sub)), + accountSubject: ({ profile }) => (typeof profile.sub === "string" ? profile.sub : ""), getUserInfo: async (tokens) => { - // Fetch the OIDC profile to get the sub (User_GUID) - const response = await fetch( - `${mpBaseUrl}/oauth/connect/userinfo`, - { + // Verify the id_token (`verifyMpIdToken`: signature, iss, aud), then bind + // the access token to it: the userinfo `sub` must equal the verified + // id_token `sub`. The binding is defence in depth for any path that hands + // this function a CALLER-SUPPLIED access token — `/sign-in/social`'s + // id_token mode did (F12, see `refuseIdTokenSignIn`). + // + // Every refusal returns null and never throws: `provider.getUserInfo` is + // NOT wrapped in a try/catch in better-auth's callback route, so a throw + // surfaces as an unhandled error instead of a clean + // `/auth-error?error=unable_to_get_user_info` redirect with no session. + // + // A MISSING id_token fails closed too, deliberately. The code flow asks + // for `openid`, for which OIDC Core §3.1.3.3 requires an id_token in the + // token response, and MP sends one. An absent one means an unexpected code + // path handing us an access token with nothing to verify or bind to. + // Checked before any fetch, so a refused request never spends an MP call. + if (typeof tokens.idToken !== "string" || tokens.idToken === "") { + logSubBindingFailure("missing_id_token"); + return null; + } + const claims = await verifyMpIdToken(tokens.idToken); + if (!claims) return null; + // jose type-checks `sub` only when asked to match a given subject. + const idTokenSub = claims.sub; + if (typeof idTokenSub !== "string" || idTokenSub === "") { + logSubBindingFailure("missing_sub"); + return null; + } + const claimFailure = idTokenClaimFailure(claims, oidcClient.clientId); + if (claimFailure) { + logIdTokenClaimFailure(claimFailure); + return null; + } + + // `redirect: "error"`: userinfo never legitimately redirects, and a + // followed redirect would re-send the user's bearer token to wherever it + // pointed. A network error or timeout rejects `fetch`, and a non-JSON 200 + // (a proxy's HTML error page) throws from `.json()`: both return null. + let profile: Record; + try { + const response = await fetch(mpOidc.userinfo, { headers: { Authorization: `Bearer ${tokens.accessToken}`, }, - }, - ); - - if (!response.ok) { - // Status only — the body can echo profile content. - console.error("auth.userinfo.fetch_failed", { - status: response.status, + signal: AbortSignal.timeout(USERINFO_TIMEOUT_MS), + redirect: "error", }); + if (!response.ok) { + // Status only — the body can echo profile content. + logUserinfoFetchFailure({ reason: "http_status", status: response.status }); + return null; + } + let body: unknown; + try { + body = await response.json(); + } catch (err) { + logUserinfoFetchFailure({ reason: "invalid_json", errName: errorName(err) }); + return null; + } + if (typeof body !== "object" || body === null || Array.isArray(body)) { + logUserinfoFetchFailure({ reason: "not_an_object" }); + return null; + } + profile = body as Record; + } catch (err) { + logUserinfoFetchFailure({ reason: "request_failed", errName: errorName(err) }); return null; } - const profile = await response.json(); - const sub = extractUserGuid(profile); if (!sub) { - // Return null rather than throwing: `provider.getUserInfo` is NOT - // wrapped in a try/catch in Better Auth's callback route, so a - // throw surfaces as an unhandled error instead of a clean - // `unable_to_get_user_info` redirect with no session. - console.error("auth.userinfo.invalid_sub", { - hasSub: typeof (profile as { sub?: unknown })?.sub === "string", - }); + logAuthEvent( + "auth.userinfo.invalid_sub", + "MP userinfo returned no usable sub (User_GUID); refusing sign-in", + { hasSub: typeof profile.sub === "string" }, + ); + return null; + } + + // Case-insensitive: both are GUIDs, and GUID case carries no meaning, so a + // casing difference between MP's two endpoints must not lock a legitimate + // user out. (`sub` is already lower-cased by `extractUserGuid`.) + if (idTokenSub.toLowerCase() !== sub) { + logSubBindingFailure("mismatch"); return null; } @@ -534,18 +895,11 @@ export const ministryPlatformProviderConfig: GenericOAuthConfig = { return { /** - * The account subject. MUST be `sub`, not `id`. - * - * Better Auth 1.7 derives the stable provider account key from - * `accountSubject(...)` rather than from `profile.id` — the user-info - * type now literally declares `id?: never`. genericOAuth's default - * resolver reads `profile.sub` for an OIDC provider, and `accountSubject` - * below reads it explicitly. - * - * Returning `id` here (the 1.6 shape) leaves `sub` undefined, which - * `resolveOAuthAccountKey` rejects with `OAUTH_ACCOUNT_SUBJECT_INVALID` - * AFTER a successful token exchange — surfacing to the user as - * `/auth-error?error=unable_to_get_user_info`. + * The account subject. MUST be `sub`, not `id`: better-auth 1.7 derives + * the stable provider account key from `accountSubject(...)` above, + * which reads `profile.sub`. Returning `id` (the 1.6 shape) leaves `sub` + * undefined, which `resolveOAuthAccountKey` rejects with + * `OAUTH_ACCOUNT_SUBJECT_INVALID` AFTER a successful token exchange. */ sub, // What Better Auth stores and keys identity on. NOT the real @@ -637,7 +991,8 @@ export const SESSION_COOKIE_CACHE_MAX_AGE_SECONDS = 60 * 60; * already used the access token by the time the row is written. * * The `idToken` is KEPT: it is not an API bearer, and an RP-initiated logout - * `id_token_hint` (security Step 4) needs it. Exported for tests. + * `id_token_hint` needs it (`src/components/user-menu/actions.ts`). Exported + * for tests. */ export function stripUserOAuthTokens(account: T): T { return { @@ -778,13 +1133,54 @@ const options = { ], } satisfies BetterAuthOptions; -export const auth = betterAuth({ - ...options, - plugins: [ - ...(options.plugins ?? []), - customSession(async ({ user, session }) => enrichSession(user, session), options), - nextCookies(), - ], -}); +function createAuth() { + warnIfSharedOidcClient(oidcClient); + return betterAuth({ + ...options, + plugins: [ + ...(options.plugins ?? []), + customSession(async ({ user, session }) => enrichSession(user, session), options), + nextCookies(), + ], + }); +} + +/** globalThis key holding the process-wide auth instance. Exported for tests. */ +export const SHARED_AUTH_KEY = Symbol.for("mpnext-tools.auth"); + +/** + * Returns the one instance for this process, creating it on first use. + * + * Next loads this module once PER BUNDLE LAYER — the `/api/auth` route + * handler, server actions and server components each get their own copy + * (upstream measured 4 copies under `next dev`, 2 in a production build). Each + * copy would build its own `betterAuth()` with its own in-memory adapter, so + * the OAuth callback stored the account and session rows in one copy while + * the sign-out server action ran in another, where they did not exist: + * - sign-out could not delete the session row the route handler serves + * `/get-session` from, so a copied cookie pair outlived sign-out up to the + * 12 h cap instead of the 1 h cookie cache; + * - better-auth found no id_token, so the MP logout URL had no + * `id_token_hint` and MP stopped at a "log out?" prompt. + * Caching on globalThis makes every layer share one instance and one store. + * It is still per PROCESS: separate serverless instances share nothing (see + * `handleSignOut` for what that means for sign-out). + * + * Vitest is exempt (it re-imports the module to rebuild the instance under a + * different environment); `src/auth.shared-instance.test.ts` clears `VITEST` + * to exercise the real path. Under `next dev`, edits to the auth options take + * effect after a server restart, not on hot reload. + */ +export function sharedInstance( + key: symbol, + create: () => T, + env: Readonly> = process.env, +): T { + if (env.VITEST) return create(); + const store = globalThis as unknown as Record; + return (store[key] ??= create()); +} + +export const auth = sharedInstance(SHARED_AUTH_KEY, createAuth); export type Session = typeof auth.$Infer.Session; diff --git a/src/lib/env.test.ts b/src/lib/env.test.ts index 65741d2..b3770d8 100644 --- a/src/lib/env.test.ts +++ b/src/lib/env.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { getAuthBaseUrl, getMpBaseUrl } from '@/lib/env'; +import { getAuthBaseUrl, getMpBaseUrl, getOidcClient } from '@/lib/env'; const dev = (extra: Record) => ({ NODE_ENV: 'development', ...extra }); const prod = (extra: Record) => ({ NODE_ENV: 'production', ...extra }); @@ -117,3 +117,54 @@ describe('getAuthBaseUrl', () => { expect(getAuthBaseUrl()).toBe('http://localhost:3000'); }); }); + +describe('getOidcClient', () => { + const service = { MINISTRY_PLATFORM_CLIENT_ID: 'svc', MINISTRY_PLATFORM_CLIENT_SECRET: 'svc-secret' }; + + it('uses the dedicated pair when both are set', () => { + expect(getOidcClient({ ...service, MP_OIDC_CLIENT_ID: ' app ', MP_OIDC_CLIENT_SECRET: ' s ' })).toEqual({ + clientId: 'app', + clientSecret: 's', + shared: null, + }); + }); + + it('flags a dedicated pair whose id is the service account id', () => { + expect(getOidcClient({ ...service, MP_OIDC_CLIENT_ID: 'svc', MP_OIDC_CLIENT_SECRET: 's' }).shared).toBe( + 'same_as_service_account', + ); + }); + + it.each([ + ['unset', {}], + ['blank', { MP_OIDC_CLIENT_ID: ' ', MP_OIDC_CLIENT_SECRET: '' }], + ])('falls back to the service-account client when the pair is %s', (_label, extra) => { + expect(getOidcClient({ ...service, ...extra })).toEqual({ + clientId: 'svc', + clientSecret: 'svc-secret', + shared: 'fallback', + }); + }); + + it.each([ + ['only the id', { MP_OIDC_CLIENT_ID: 'app' }], + ['only the secret', { MP_OIDC_CLIENT_SECRET: 's' }], + ])('refuses %s of the dedicated pair, without echoing it', (_label, extra) => { + expect(() => getOidcClient({ ...service, ...extra })).toThrow(/must be set together/); + expect(() => getOidcClient({ ...service, ...extra })).toThrow( + expect.objectContaining({ message: expect.not.stringMatching(/app|: s/) }), + ); + }); + + it.each([ + ['no client at all', {}], + ['a fallback id without a secret', { MINISTRY_PLATFORM_CLIENT_ID: 'svc' }], + ])('refuses %s', (_label, env) => { + expect(() => getOidcClient(env)).toThrow(/No OIDC client for sign-in/); + }); + + it('reads process.env by default', () => { + // test-setup.ts stubs a dedicated pair distinct from the service account. + expect(getOidcClient()).toMatchObject({ clientId: 'test-client-id', shared: null }); + }); +}); diff --git a/src/lib/env.ts b/src/lib/env.ts index dbd0850..6355d82 100644 --- a/src/lib/env.ts +++ b/src/lib/env.ts @@ -1,10 +1,12 @@ /** - * Validated readers for the two auth-critical URLs. + * Validated readers for the two auth-critical URLs (and, at the end of the + * file, the OIDC sign-in client). * * `MINISTRY_PLATFORM_BASE_URL` is the trust anchor for everything the app says * to Ministry Platform: the client-secret POST, every bearer API call, the - * userinfo call carrying the user's access token, OIDC discovery (which names - * the JWKS that id_tokens are verified against) and the end-session redirect. + * userinfo call carrying the user's access token, OIDC discovery (loaded lazily + * at the first sign-in callback; it names the issuer and the JWKS that + * id_tokens are verified against) and the end-session redirect. * `BETTER_AUTH_URL` is better-auth's `baseURL`: the OAuth `redirect_uri`, the * trusted origin for `callbackURL`, and (via its scheme) whether cookies are * `Secure`. Unvalidated, an `http://` value sent secrets in cleartext, a @@ -107,3 +109,62 @@ export function getAuthBaseUrl(env: Env = process.env): string { } return url.origin; } + +/** The OIDC client user sign-in runs as. See `getOidcClient`. */ +export interface OidcClient { + clientId: string; + clientSecret: string; + /** + * `null` for a dedicated client. Otherwise why the client is (or may be) the + * one the service account also uses: + * - `"fallback"`: `MP_OIDC_CLIENT_ID` / `MP_OIDC_CLIENT_SECRET` are unset, + * so `MINISTRY_PLATFORM_CLIENT_ID` / `_SECRET` are used; + * - `"same_as_service_account"`: they are set, to the service account's id. + */ + shared: "fallback" | "same_as_service_account" | null; +} + +/** + * The MP OAuth client that user sign-in (OIDC) runs as, and whose id is the + * id_token audience and the sign-out `client_id`. + * + * It should be a client registered for THIS app's sign-in only + * (`MP_OIDC_CLIENT_ID` / `MP_OIDC_CLIENT_SECRET`), not the client-credentials + * service account every MP data call uses (`MINISTRY_PLATFORM_CLIENT_ID` / + * `_SECRET`). Sharing one client means one leaked secret is both the app's + * full MP data access and its user sign-in, the id_token audience check + * accepts tokens minted for the service account's client, and the redirect + * URIs registered on it serve two purposes. + * + * Until every environment has the dedicated pair, an unset pair falls back to + * the service-account client (`shared: "fallback"`) so existing deploys keep + * signing in; `src/lib/auth.ts` logs `auth.oidc.shared_client` once per + * process when `shared` is not null. Setting only ONE of the two is refused: + * half a dedicated client would pair one client's id with another's secret. + * + * Throws on an unusable configuration and never echoes a value. + */ +export function getOidcClient(env: Env = process.env): OidcClient { + const id = env.MP_OIDC_CLIENT_ID?.trim(); + const secret = env.MP_OIDC_CLIENT_SECRET?.trim(); + const serviceId = env.MINISTRY_PLATFORM_CLIENT_ID?.trim(); + if (id || secret) { + if (!id || !secret) { + throw new Error( + "[env] MP_OIDC_CLIENT_ID and MP_OIDC_CLIENT_SECRET must be set together (the dedicated sign-in client); one of them is empty.", + ); + } + return { + clientId: id, + clientSecret: secret, + shared: id === serviceId ? "same_as_service_account" : null, + }; + } + const serviceSecret = env.MINISTRY_PLATFORM_CLIENT_SECRET?.trim(); + if (!serviceId || !serviceSecret) { + throw new Error( + "[env] No OIDC client for sign-in: set MP_OIDC_CLIENT_ID and MP_OIDC_CLIENT_SECRET (or, as a deprecated fallback, MINISTRY_PLATFORM_CLIENT_ID and MINISTRY_PLATFORM_CLIENT_SECRET).", + ); + } + return { clientId: serviceId, clientSecret: serviceSecret, shared: "fallback" }; +} diff --git a/src/test-setup.ts b/src/test-setup.ts index 58deb2e..5b89f20 100644 --- a/src/test-setup.ts +++ b/src/test-setup.ts @@ -15,8 +15,13 @@ if (typeof globalThis.ResizeObserver === 'undefined') { // Mock environment variables for tests vi.stubEnv('MINISTRY_PLATFORM_BASE_URL', 'https://test-mp.example.com'); -vi.stubEnv('MINISTRY_PLATFORM_CLIENT_ID', 'test-client-id'); +// The service account and the OIDC sign-in client are DIFFERENT clients, as +// in production (see getOidcClient in src/lib/env.ts). `test-client-id` is the +// id_token audience the mock OIDC providers sign for. +vi.stubEnv('MINISTRY_PLATFORM_CLIENT_ID', 'test-service-client-id'); vi.stubEnv('MINISTRY_PLATFORM_CLIENT_SECRET', 'test-client-secret'); +vi.stubEnv('MP_OIDC_CLIENT_ID', 'test-client-id'); +vi.stubEnv('MP_OIDC_CLIENT_SECRET', 'test-oidc-client-secret'); vi.stubEnv('MINISTRY_PLATFORM_DEV_CLIENT_ID', 'test-dev-client-id'); vi.stubEnv('MINISTRY_PLATFORM_DEV_CLIENT_SECRET', 'test-dev-client-secret'); // >= 32 chars, so it would also pass assertAuthEnvironment (src/lib/auth.ts). diff --git a/src/test-utils/mock-oidc.ts b/src/test-utils/mock-oidc.ts index 0504e7d..55be648 100644 --- a/src/test-utils/mock-oidc.ts +++ b/src/test-utils/mock-oidc.ts @@ -1,26 +1,32 @@ /** * Test-only mock of Ministry Platform's OIDC provider, for driving the REAL * `auth.handler` from src/lib/auth.ts through the full authorization-code flow - * (discovery, JWKS, token, userinfo), with id_tokens genuinely RS256-signed by - * a key the mock JWKS publishes. Adapted from upstream MPNext's - * `src/test-utils/mock-oidc.ts` for this fork (provider id `ministryplatform`, - * boot-time `discoveryUrl`). + * (lazy discovery, JWKS, token, userinfo), with id_tokens genuinely + * RS256-signed by a key the mock JWKS publishes. Adapted from upstream + * MPNext's `src/test-utils/mock-oidc.ts` for this fork (provider id + * `ministryplatform`). * * `installMockOidc()` replaces `globalThis.fetch` with a stub that THROWS for * any URL it does not serve, so nothing that uses it can reach a real Ministry - * Platform. This fork's genericOAuth config fetches discovery when the `auth` - * instance initialises, so run it before `@/lib/auth` is imported, i.e. inside - * `vi.hoisted`: + * Platform. Building the auth instance makes no MP call (discovery is loaded + * lazily, at the first callback), but run it before `@/lib/auth` is imported + * anyway, i.e. inside `vi.hoisted`, so no module ever captures the real + * `fetch`: * * const oidc = await vi.hoisted(async () => * (await import('@/test-utils/mock-oidc')).installMockOidc()); * - * Use it from `// @vitest-environment node` suites: better-auth verifies the + * `state` lets a test simulate outages per endpoint (`reject`, `http_500`, + * `hang`) or for all of MP at once, strip discovery fields, override id_token + * claims, sign with a key the JWKS does not publish, or change the userinfo + * response. `reset()` restores everything. + * + * Use it from `// @vitest-environment node` suites: `getUserInfo` verifies the * id_token with `jose` over WebCrypto, which rejects jsdom-realm typed arrays. * * Not a test file itself; it is imported only by `src/auth.*.test.ts`. */ -import { generateKeyPairSync, sign } from 'node:crypto'; +import { generateKeyPairSync, sign, type KeyObject } from 'node:crypto'; import { expect } from 'vitest'; export const MOCK_MP_BASE = 'https://test-mp.example.com'; @@ -30,6 +36,34 @@ export const MOCK_SUB = 'dddddddd-dddd-4ddd-8ddd-dddddddddddd'; export const MOCK_ACCESS_TOKEN = 'mock-access-token'; export const MOCK_REFRESH_TOKEN = 'mock-refresh-token'; export const PROVIDER_ID = 'ministryplatform'; +export const MOCK_DISCOVERY_URL = `${MOCK_ISSUER}/.well-known/openid-configuration`; +export const MOCK_JWKS_URL = `${MOCK_ISSUER}/.well-known/openid-configuration/jwks`; +export const MOCK_USERINFO_URL = `${MOCK_ISSUER}/connect/userinfo`; + +/** + * How an MP endpoint behaves: `ok`; `reject` (a network error, like a refused + * connection); `http_500`; `hang` (accepts the request, never answers — it + * settles only when the caller's `AbortSignal` fires). + */ +export type MockEndpointMode = 'ok' | 'reject' | 'http_500' | 'hang'; + +/** Everything a test may change between requests. `reset()` restores it. */ +export interface MockOidcState { + /** How the discovery endpoint behaves. */ + discovery: MockEndpointMode; + /** How the JWKS endpoint behaves. */ + jwks: MockEndpointMode; + /** When not `ok`, EVERY mock MP endpoint behaves this way (MP down). */ + mp: MockEndpointMode; + /** Keys to drop from the discovery document. */ + omitDiscovery: string[]; + /** Claim overrides for the next id_tokens (an `undefined` value drops the claim). */ + claims: Record; + /** Sign the next id_tokens with a key the JWKS does NOT publish. */ + foreignKey: boolean; + /** Fields merged over the userinfo response. */ + userinfo: Record; +} export interface RecordedCall { url: string; @@ -39,10 +73,18 @@ export interface RecordedCall { export interface MockOidc { sub: string; + issuer: string; + state: MockOidcState; /** Every request the mock served, in order. */ calls: RecordedCall[]; /** The id_token the token endpoint returned most recently. */ lastIdToken: string | null; + /** + * An RS256 id_token signed by the key the JWKS publishes (or the foreign key + * when `state.foreignKey`), with the given claim overrides on top of + * `state.claims` (an `undefined` value drops the claim). + */ + signIdToken(claims?: Record): string; reset(): void; } @@ -54,49 +96,92 @@ const json = (body: unknown) => headers: { 'Content-Type': 'application/json' }, }); +function freshState(): MockOidcState { + return { + discovery: 'ok', + jwks: 'ok', + mp: 'ok', + omitDiscovery: [], + claims: {}, + foreignKey: false, + userinfo: {}, + }; +} + +/** A non-`ok` mode's response: rejects, 500s, or waits for the caller's abort. */ +function misbehave(mode: Exclude, signal: AbortSignal) { + if (mode === 'reject') return Promise.reject(new TypeError('fetch failed')); + if (mode === 'http_500') return Promise.resolve(new Response('error', { status: 500 })); + return new Promise((_, reject) => { + signal.addEventListener('abort', () => reject(signal.reason), { once: true }); + }); +} + export function installMockOidc(): MockOidc { - const { publicKey, privateKey } = generateKeyPairSync('rsa', { modulusLength: 2048 }); - const jwk = { ...publicKey.export({ format: 'jwk' }), kid: 'test-key-1', alg: 'RS256', use: 'sig' }; + const keyPair = () => generateKeyPairSync('rsa', { modulusLength: 2048 }); + const published = keyPair(); + const foreign = keyPair(); + const jwk = { ...published.publicKey.export({ format: 'jwk' }), kid: 'test-key-1', alg: 'RS256', use: 'sig' }; const mock: MockOidc = { sub: MOCK_SUB, + issuer: MOCK_ISSUER, + state: freshState(), calls: [], lastIdToken: null, + signIdToken: (claims) => signIdToken(claims), reset() { + mock.state = freshState(); mock.calls.length = 0; mock.lastIdToken = null; }, }; - function signIdToken(): string { + function signIdToken(overrides: Record = {}): string { const now = Math.floor(Date.now() / 1000); - const input = `${b64({ alg: 'RS256', kid: 'test-key-1', typ: 'JWT' })}.${b64({ - iss: MOCK_ISSUER, - aud: MOCK_CLIENT_ID, - sub: MOCK_SUB, - iat: now, - exp: now + 300, - })}`; - return `${input}.${sign('RSA-SHA256', Buffer.from(input), privateKey).toString('base64url')}`; + // The JSON round trip drops `undefined` values, i.e. removes the claim. + const claims = JSON.parse( + JSON.stringify({ + iss: MOCK_ISSUER, + aud: MOCK_CLIENT_ID, + sub: MOCK_SUB, + iat: now, + exp: now + 300, + ...mock.state.claims, + ...overrides, + }), + ) as Record; + const input = `${b64({ alg: 'RS256', kid: 'test-key-1', typ: 'JWT' })}.${b64(claims)}`; + const key: KeyObject = mock.state.foreignKey ? foreign.privateKey : published.privateKey; + return `${input}.${sign('RSA-SHA256', Buffer.from(input), key).toString('base64url')}`; } globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + // `request.signal`, not `init.signal`: it follows the caller's signal + // however it was passed (init, or a Request input). const request = new Request(input, init); const { url, method } = request; mock.calls.push({ url, method, body: await request.clone().text() }); - if (url === `${MOCK_ISSUER}/.well-known/openid-configuration`) { - return json({ + const servesUrl = url.startsWith(`${MOCK_ISSUER}/`); + if (servesUrl && mock.state.mp !== 'ok') return misbehave(mock.state.mp, request.signal); + + if (url === MOCK_DISCOVERY_URL) { + if (mock.state.discovery !== 'ok') return misbehave(mock.state.discovery, request.signal); + const doc: Record = { issuer: MOCK_ISSUER, authorization_endpoint: `${MOCK_ISSUER}/connect/authorize`, token_endpoint: `${MOCK_ISSUER}/connect/token`, - userinfo_endpoint: `${MOCK_ISSUER}/connect/userinfo`, + userinfo_endpoint: MOCK_USERINFO_URL, end_session_endpoint: `${MOCK_ISSUER}/connect/endsession`, - jwks_uri: `${MOCK_ISSUER}/.well-known/openid-configuration/jwks`, + jwks_uri: MOCK_JWKS_URL, id_token_signing_alg_values_supported: ['RS256'], - }); + }; + for (const key of mock.state.omitDiscovery) delete doc[key]; + return json(doc); } - if (url === `${MOCK_ISSUER}/.well-known/openid-configuration/jwks`) { + if (url === MOCK_JWKS_URL) { + if (mock.state.jwks !== 'ok') return misbehave(mock.state.jwks, request.signal); return json({ keys: [jwk] }); } if (url === `${MOCK_ISSUER}/connect/token` && method === 'POST') { @@ -111,12 +196,13 @@ export function installMockOidc(): MockOidc { expires_in: 3600, }); } - if (url === `${MOCK_ISSUER}/connect/userinfo`) { + if (url === MOCK_USERINFO_URL) { return json({ sub: MOCK_SUB, given_name: 'Code', family_name: 'Flow', email: 'code-flow-member@example.test', + ...mock.state.userinfo, }); } throw new Error(`Blocked unexpected fetch in test: ${method} ${url}`); @@ -160,31 +246,49 @@ export interface AuthLike { handler: (request: Request) => Promise; } -/** Starts sign-in; returns the jar (state cookie) and the authorize URL. */ -export async function startSignIn(instance: AuthLike, origin: string) { - const response = await instance.handler( +/** `POST /sign-in/social` as the sign-in page sends it. */ +export function postSignInSocial( + instance: AuthLike, + origin: string, + body: Record, +): Promise { + return instance.handler( new Request(`${origin}/api/auth/sign-in/social`, { method: 'POST', headers: { 'Content-Type': 'application/json', Origin: origin }, - body: JSON.stringify({ provider: PROVIDER_ID, callbackURL: '/' }), + body: JSON.stringify(body), }), ); +} + +/** Starts sign-in; returns the jar (state cookie) and the authorize URL. */ +export async function startSignIn(instance: AuthLike, origin: string) { + const response = await postSignInSocial(instance, origin, { provider: PROVIDER_ID, callbackURL: '/' }); expect(response.status).toBe(200); const jar = new CookieJar().apply(response); const { url } = (await response.json()) as { url: string }; return { jar, authorizeUrl: new URL(url) }; } +/** `GET /callback/ministryplatform` with the given query and cookies. */ +export function callback( + instance: AuthLike, + origin: string, + query: Record, + jar: CookieJar, +): Promise { + return instance.handler( + new Request(`${origin}/api/auth/callback/${PROVIDER_ID}?${new URLSearchParams(query)}`, { + headers: { Cookie: jar.header() }, + }), + ); +} + /** The full authorization-code flow; returns the callback response and the updated jar. */ export async function codeFlow(instance: AuthLike, origin: string) { const { jar, authorizeUrl } = await startSignIn(instance, origin); const state = String(authorizeUrl.searchParams.get('state')); - const response = await instance.handler( - new Request( - `${origin}/api/auth/callback/${PROVIDER_ID}?${new URLSearchParams({ code: 'test-code', state })}`, - { headers: { Cookie: jar.header() } }, - ), - ); + const response = await callback(instance, origin, { code: 'test-code', state }, jar); expect(response.status).toBe(302); jar.apply(response); return { response, jar, authorizeUrl, location: String(response.headers.get('location')) }; From 5844446ea31708fd969b3634116899a324b76c8b Mon Sep 17 00:00:00 2001 From: Chris Kehayias Date: Wed, 30 Sep 2026 08:27:34 -0400 Subject: [PATCH 07/24] =?UTF-8?q?fix(mp):=20Step=205=20=E2=80=94=20MP=20HT?= =?UTF-8?q?TP=20client=20and=20provider-service=20hardening=20(#39)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What / why Step 5 of the 2026-09-30 playbook-audit remediation (`port-security-review-2026-09-28.md` Phases 8–9). Every MP request goes out with the admin-level service-account bearer (`dataplatform/scopes/all`), so the client and provider services are hardened: **Phase 8 — HTTP client** (`utils/http-client.ts`, `client.ts`, `auth/client-credentials.ts`, `utils/logger.ts`) - `AbortSignal.timeout` on every MP fetch (20 s JSON, 60 s multipart, 10 s token; fresh per attempt) and `redirect: "error"` (a 307/308 would re-send member data or `client_secret`). - Path guard in `buildUrl`: refuses `..`, `?`/`#`/`\`, `%2e`/`%2f`/`%5c`, control chars, no leading `/`, and any resolved URL outside the API root. Query **keys** now encoded (`$`/`@` literal). - `readJsonResponse`: 204 / `Content-Length: 0` → `undefined`; JSON content-type required; a malformed body never leaks via `SyntaxError`. - Token response validated (non-empty `access_token`, `token_type` bearer). - Default and dev pipelines are one `TokenPipeline` each: single-flight refresh, 5–30 s jittered negative cache after a failure, 401 → invalidate-if-current → refresh → **one** retry. - Logs: `mp.request.failed` / `mp.token.*` events with method, GUID-redacted path, status or `errorName`. **Phase 9 — provider services** - `services/guards.ts` (`sanitizeIdentifier`, `sanitizeRecordId`/`sanitizeUniqueId` reusing `validatePositiveInt`/`validateGuid`, `tableEndpoint`). Table/File/Procedure services validate every path segment before any token work; `deleteTableRecords` refuses non-array `ids`. - `/files/{uniqueId}` download: own timeout + `redirect: "error"`; message says `{uniqueId}`. - `$ignorePermissions` removed from `GlobalFilterParams`; `getGlobalFilters` forwards only a validated `$userId`. - ~25 `logger.error(msg, error)` sites → `logger.error(event, { ids, error: errorName(e) })` (CLAUDE.md rule 14). **Out of scope (owner decision):** deny-all stored-procedure allowlist — still held in `.claude/TODO/2026-09-30-held-stored-procedure-allowlist.md`. Public `MPHelper` / `MinistryPlatformClient` APIs are unchanged; no `src/services/` changes needed. ## Behavior changes callers will notice - Table/procedure names that aren't plain identifiers, and non-positive-integer record/file IDs, now throw (`Invalid table name`, `Expected positive integer for record ID`, ...) before any request. - A 2xx without a JSON content-type throws `... unexpected content-type`. - After a failed token refresh, calls fail fast (`MP access token unavailable: recent refresh failed`) for 5–30 s. - A 401 the caller sees has already been retried once with a fresh token. ## Tests - Ported upstream `http-client`, `client-credentials`, `guards` tests; added client single-flight / negative-cache / 401 / secret-handling / dev-isolation tests and table/file/domain/procedure guard + log-shape tests. Provider folder at 100% coverage. - Local gates: `npm run lint` ✓, `npm run typecheck` ✓, `npm run test:coverage` ✓ (2178 tests; 98.96% stmts / 99.73% lines), `npm run build` ✓. - Live read-only check against non-prod `mpi.ministryplatform.com`: token validation, `/domain`, `/tables/Congregations`, `/domain/filters` all succeed; `../procs` table name refused. ## Mutation checks (break → red → revert) | Mutation | Failing tests | |---|---| | Remove `AbortSignal.timeout` (http-client) | 9 | | Remove token timeout | 2 | | `redirect: 'follow'` (http-client) | 8 | | `redirect: 'follow'` (token) | 2 | | Disable `assertSafeEndpoint` | 26 | | Disable 401 retry | 9 | | Disable token-response validation | 11 | | Disable negative cache | 3 | | Drop table-name guard | 9 | | Drop `recordId` guard (copy methods) | 9 | 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 (1M context) --- ...5-mp-http-client-and-provider-hardening.md | 40 +- .claude/TODO/INDEX.md | 11 +- .claude/references/GOTCHAS.md | 14 +- .claude/references/mp-provider/client.md | 296 ++-------- .../references/mp-provider/error-handling.md | 159 ++--- .../mp-provider/services/communication.md | 2 +- .../references/mp-provider/services/domain.md | 11 +- .../references/mp-provider/services/file.md | 5 +- .../mp-provider/services/metadata.md | 4 +- .../mp-provider/services/procedure.md | 8 +- .../references/mp-provider/services/table.md | 8 +- .../auth/client-credentials.test.ts | 364 ++++++++---- .../auth/client-credentials.ts | 72 ++- .../providers/ministry-platform/auth/index.ts | 2 +- .../ministry-platform/client.test.ts | 307 +++++++++- src/lib/providers/ministry-platform/client.ts | 152 ++--- src/lib/providers/ministry-platform/helper.ts | 1 - .../services/communication.service.ts | 6 +- .../services/domain.service.test.ts | 17 +- .../services/domain.service.ts | 20 +- .../services/file.service.test.ts | 130 +++- .../services/file.service.ts | 72 ++- .../ministry-platform/services/guards.test.ts | 102 ++++ .../ministry-platform/services/guards.ts | 63 ++ .../services/metadata.service.ts | 6 +- .../services/procedure.service.test.ts | 22 +- .../services/procedure.service.ts | 27 +- .../services/table.service.test.ts | 125 +++- .../services/table.service.ts | 96 +-- .../ministry-platform/types/provider.types.ts | 1 - .../utils/http-client.test.ts | 558 +++++++++++++++--- .../ministry-platform/utils/http-client.ts | 361 ++++++----- .../ministry-platform/utils/logger.ts | 22 + 33 files changed, 2174 insertions(+), 910 deletions(-) create mode 100644 src/lib/providers/ministry-platform/services/guards.test.ts create mode 100644 src/lib/providers/ministry-platform/services/guards.ts diff --git a/.claude/TODO/2026-09-30-step5-mp-http-client-and-provider-hardening.md b/.claude/TODO/2026-09-30-step5-mp-http-client-and-provider-hardening.md index 338ce00..a7208ea 100644 --- a/.claude/TODO/2026-09-30-step5-mp-http-client-and-provider-hardening.md +++ b/.claude/TODO/2026-09-30-step5-mp-http-client-and-provider-hardening.md @@ -6,7 +6,7 @@ area: mp-provider files: [src/lib/providers/ministry-platform/utils/http-client.ts, src/lib/providers/ministry-platform/client.ts, src/lib/providers/ministry-platform/services/table.service.ts, src/lib/providers/ministry-platform/types/provider.types.ts] discovered: 2026-09-30 discovered_by: playbook-audit (port-security-review-2026-09-28.md P8/P9) -status: open +status: resolved --- ## Problem @@ -28,3 +28,41 @@ remove `$ignorePermissions`; log identifiers and shape only. ## Impact if not fixed A hung MP request pins a function for its full timeout; a crafted identifier could reach unexpected API paths; raw error objects can leak response bodies into logs. + +## Resolution (2026-09-30) + +Branch `fix/step5-mp-http-client-hardening`, two commits (Phase 8, Phase 9). + +**Phase 8 — HTTP client** (`utils/http-client.ts`, `client.ts`, `auth/client-credentials.ts`, `utils/logger.ts`) +1. Every MP fetch has `AbortSignal.timeout` (20 s JSON, 60 s multipart, 10 s token; fresh per attempt) and + `redirect: "error"`. The unauthenticated `/files/{uniqueId}` download has its own 20 s timeout and + `redirect: "error"`. +2. `buildUrl` path guard: must start with `/`; refuses `..`, `?`, `#`, `\`, `%2e`/`%2f`/`%5c`, C0/DEL; the + resolved URL must stay under the API root. Query keys are encoded (`$` and `@` kept literal). +3. `readJsonResponse`: 204 / `Content-Length: 0` → `undefined`; JSON content-type required; parse failures + never leak a `SyntaxError` body fragment. +4. Token response validated (non-empty `access_token`, `token_type` bearer). +5. Both pipelines (`default`, `dev`) are a `TokenPipeline`: single-flight refresh, 5–30 s jittered negative + cache after a failure, and 401 → invalidate-if-current → refresh → one retry. +6. Logs: structured `mp.request.failed` / `mp.token.*` events with method, GUID-redacted endpoint, status or + `errorName`. `errorName` lives in `utils/logger.ts`. + +**Phase 9 — provider services** +1. `services/guards.ts`: `sanitizeIdentifier`, `sanitizeRecordId` / `sanitizeUniqueId` (reusing + `validatePositiveInt` / `validateGuid` from `src/lib/validation.ts`), `tableEndpoint`. +2. Table, File and Procedure services validate every path segment before any token work + (`table.service.ts` copy methods' `recordId`, `deleteTableRecords` ids incl. non-array refusal). +3. `$ignorePermissions` removed from `GlobalFilterParams` and helper docs; `getGlobalFilters` forwards an + allowlist (validated `$userId` only). +4. All ~25 `logger.error(msg, error)` sites replaced with `logger.error(event, { ids, error: errorName(e) })`. + +**Not done (owner decision 5):** the deny-all stored-procedure allowlist — still held in +`2026-09-30-held-stored-procedure-allowlist.md`. + +**Behaviour changes callers may notice:** table/procedure names that are not plain identifiers (spaces, +hyphens, dots) and non-integer record IDs now throw before any request; a successful response without a JSON +content-type now throws `unexpected content-type`; after a failed token refresh, calls fail fast for up to +30 s; the 401 error a caller sees has already been retried once with a fresh token. + +**Tests / mutation checks:** see the PR. Each of timeout, redirect, path guard, 401 retry, token validation, +negative cache, table-name guard and recordId guard was reverted in turn and turned the suite red. diff --git a/.claude/TODO/INDEX.md b/.claude/TODO/INDEX.md index 73ab135..01eea01 100644 --- a/.claude/TODO/INDEX.md +++ b/.claude/TODO/INDEX.md @@ -13,6 +13,7 @@ last_updated: 2026-09-30 + @@ -26,7 +27,7 @@ Severity tiers: - **medium**: doc drift, missing test, refactor with real cost - **low**: nits, minor doc fixes, stylistic improvements -Total: **15 open TODOs**. +Total: **14 open TODOs**. > **2026-09-30 — MPNext playbook audit.** The five `S:\MP\MPNext\.claude\playbooks` > port playbooks were audited against this fork. Remediation is split into nine @@ -58,11 +59,10 @@ _none open_ | auth | security, doc | Ops — Rotate BETTER_AUTH_SECRET; advise downstream clones | [→](2026-09-30-ops-rotate-better-auth-secret.md) | | auth | security, doc | Ops — Set MP_SECURITY_ROLES=* in every environment | [→](2026-09-30-ops-set-mp-security-roles-env.md) | -### Medium (8) +### Medium (7) | Area | Tags | Title | File | |---|---|---|---| | auth | security, doc | Make MP_OIDC_CLIENT_ID / _SECRET required (remove the service-account fallback) | [→](2026-09-30-require-dedicated-oidc-client.md) | -| mp-provider | security, missing-test | Step 5 — MP HTTP client and provider hardening | [→](2026-09-30-step5-mp-http-client-and-provider-hardening.md) | | routing | security, drift | Step 6 — Headers, next.config, server-only, prerender + build in CI | [→](2026-09-30-step6-headers-csp-build-guards.md) | | services | drift, missing-test | Step 7 — Write-audit gaps and datetime drift cleanup | [→](2026-09-30-step7-write-audit-and-datetime-cleanup.md) | | routing | security, doc | Held — CSP notes for customer-deployed clones | [→](2026-09-30-held-csp-customer-deployments.md) | @@ -83,8 +83,8 @@ _none open_ ## By tag -### security (10) -- all open 2026-09-30 step/ops/held items except step7 (step1–step4 resolved), plus require-dedicated-oidc-client +### security (9) +- all open 2026-09-30 step/ops/held items except step7 (step1–step5 resolved), plus require-dedicated-oidc-client ### bug (3) - components-template-editor-no-mp-persistence — medium @@ -134,6 +134,7 @@ _none open_ | Date | Title | File | |---|---|---| +| 2026-09-30 | Step 5 — MP HTTP client: timeouts, redirect:error, path guard, safe JSON, token validation, single-flight + negative cache, 401 retry; provider path guards, $ignorePermissions removed, identifier-only logging | [→](2026-09-30-step5-mp-http-client-and-provider-hardening.md) | | 2026-09-30 | Step 4 — OIDC lazy discovery (no MP call at boot), app-side id_token verification, sub binding, dedicated OIDC client, sign-out id_token_hint | [→](2026-09-30-step4-oidc-lazy-discovery-and-sub-binding.md) | | 2026-09-30 | Step 3 — 12 h session ceiling, JWE cookie, no user OAuth tokens, boot guards + env.ts, secrets hygiene, client-IP header | [→](2026-09-30-step3-session-lifetime-and-secrets-hygiene.md) | | 2026-09-30 | Step 2 — Fail-closed MP_SECURITY_ROLES, group-wizard mass assignment, page-metadata injection, F11, page self-gating, filter/error hygiene | [→](2026-09-30-step2-authorization-and-input-validation.md) | diff --git a/.claude/references/GOTCHAS.md b/.claude/references/GOTCHAS.md index 97cbe4b..0124a89 100644 --- a/.claude/references/GOTCHAS.md +++ b/.claude/references/GOTCHAS.md @@ -134,17 +134,17 @@ Known pitfalls for agents working on MPNext-Tools. Each entry has a symptom-firs ### GOTCHA-008: First API call after cold boot fails with empty `Authorization: Bearer` **Symptom:** Fresh server boot, first MP call returns 401 with an empty Bearer. -**Root cause:** Service method forgot to `await this.client.ensureValidToken()` before `getHttpClient().get()`. `HttpClient` has no auto-refresh hook — it just reads whatever `() => this.token` returns. +**Root cause:** Service method forgot to `await this.client.ensureValidToken()` before `getHttpClient().get()`. Since 2026-09-30 (Step 5) the `HttpClient` 401 hook refreshes and retries once, so this now costs an extra round trip instead of failing — but it is still a bug; do not rely on the hook for a cold client. **Fix:** Every MP-provider service method that does a network call must start with `await this.client.ensureValidToken()` (or `ensureValidDevToken()` for `api_dev_*` procs). -**Enforced where:** `src/lib/providers/ministry-platform/client.ts:49-68`. Seen correctly applied in e.g. `src/lib/providers/ministry-platform/services/file.service.ts:48`, `communication.service.ts:20,40`. +**Enforced where:** `src/lib/providers/ministry-platform/client.ts` (`TokenPipeline.ensureValid`). Seen correctly applied in e.g. `src/lib/providers/ministry-platform/services/file.service.ts:48`, `communication.service.ts:20,40`. **Related:** [mp-provider/](mp-provider/) -### GOTCHA-009: N concurrent MP calls on cold server trigger N OAuth token POSTs +### GOTCHA-009: N concurrent MP calls on cold server trigger N OAuth token POSTs (RESOLVED) **Symptom:** Log shows many simultaneous `POST /oauth/connect/token` calls on a cold boot under load. -**Root cause:** `ensureValidToken` has no in-flight promise de-duplication — each caller sees an expired token and each starts its own refresh. -**Fix:** Cache an in-flight `refreshPromise` inside `MinistryPlatformClient` so concurrent callers all await the same pending refresh. TODO: `.claude/TODO/2026-04-17-mp-provider-token-refresh-no-dedup.md`. -**Enforced where:** `src/lib/providers/ministry-platform/client.ts:49` (not currently enforced). +**Root cause (historical):** `ensureValidToken` had no in-flight promise de-duplication — each caller sees an expired token and each starts its own refresh. +**Fix:** Resolved. Each `TokenPipeline` in `client.ts` shares one in-flight refresh across concurrent callers, and after a failed refresh fails fast for 5–30 s (jittered negative cache) instead of stampeding the token endpoint. +**Enforced where:** `src/lib/providers/ministry-platform/client.ts` (`TokenPipeline`); pinned by `client.test.ts` "Single-flight refresh and negative cache". **Related:** [mp-provider/](mp-provider/) ### GOTCHA-010: Callers can't `e instanceof ZodError` after validation failure @@ -184,7 +184,7 @@ Known pitfalls for agents working on MPNext-Tools. Each entry has a symptom-firs **Symptom:** The deploy-tool flow writes rows into the production `dp_Tools` table. **Root cause:** Procedures prefixed with `api_dev_` are routed to the dev credentials pipeline by the provider. Without that prefix, `DeployTool` would run under production credentials. **Fix:** Keep the `api_dev_` prefix on any proc that should stay out of prod. Ensure `MINISTRY_PLATFORM_DEV_CLIENT_ID` / `MINISTRY_PLATFORM_DEV_CLIENT_SECRET` are set in `.env.local`. Keep the dev-panel localhost-only (see GOTCHA-033). Never rename these procs without checking the ProcedureService routing. -**Enforced where:** `src/services/toolService.ts:242-247`, `src/lib/providers/ministry-platform/client.ts:75-94` (dev pipeline). +**Enforced where:** `src/services/toolService.ts:242-247`, `src/lib/providers/ministry-platform/client.ts` (`dev` `TokenPipeline`). **Related:** [mp-provider/client.md](mp-provider/client.md) ### GOTCHA-015: File uploaded but no `$userId` audit trail diff --git a/.claude/references/mp-provider/client.md b/.claude/references/mp-provider/client.md index a2a4f38..9e4c6c6 100644 --- a/.claude/references/mp-provider/client.md +++ b/.claude/references/mp-provider/client.md @@ -10,281 +10,75 @@ applies_to: symbols: - MinistryPlatformClient - HttpClient + - readJsonResponse + - redactEndpoint - getClientCredentialsToken + - ClientCredentialsToken - CredentialProfile - logger + - errorName related: - architecture.md - error-handling.md - ../auth/README.md -last_verified: 2026-04-17 +last_verified: 2026-09-30 --- ## Purpose -`MinistryPlatformClient` manages the OAuth2 **client credentials** token lifecycle and owns the `HttpClient` that every service uses. This flow is the machine-to-machine pipeline used by the server; it is **separate** from the Better Auth / `genericOAuth` user login flow. +`MinistryPlatformClient` manages the OAuth2 **client credentials** token lifecycle and owns the `HttpClient` that every service uses. This is the machine-to-machine pipeline (scope `dataplatform/scopes/all` — admin level); it is **separate** from the Better Auth user login flow. Because every request carries an admin-level bearer, the client is hardened (Step 5, 2026-09-30): deadlines, no redirects, a path guard, safe JSON parsing, token validation, single-flight refresh with a negative cache, and a single 401 retry. ## Files -- `src/lib/providers/ministry-platform/client.ts` — `MinistryPlatformClient` (default + dev pipelines) -- `src/lib/providers/ministry-platform/auth/client-credentials.ts` — token-endpoint POST -- `src/lib/providers/ministry-platform/auth/client-credentials.test.ts` — token tests -- `src/lib/providers/ministry-platform/utils/http-client.ts` — `HttpClient` -- `src/lib/providers/ministry-platform/utils/http-client.test.ts` — HTTP tests -- `src/lib/providers/ministry-platform/utils/logger.ts` — `logger` (`[MP]` prefix) -- `src/lib/providers/ministry-platform/utils/logger.test.ts` — logger tests -- `src/lib/providers/ministry-platform/client.test.ts` — client tests (token lifecycle, dev pipeline) +- `client.ts` — `MinistryPlatformClient`; an internal `TokenPipeline` per credential profile (`default`, `dev`) +- `auth/client-credentials.ts` — token-endpoint POST + response validation +- `utils/http-client.ts` — `HttpClient`, `readJsonResponse`, `redactEndpoint`, `API_TIMEOUT_MS`, `UPLOAD_TIMEOUT_MS` +- `utils/logger.ts` — `logger` (`[MP]` prefix, `error` only) and `errorName` +- Tests: `client.test.ts`, `auth/client-credentials.test.ts`, `utils/http-client.test.ts`, `utils/logger.test.ts` -## Key concepts +## Token pipelines (`client.ts`) -- **OAuth2 client credentials** grant — scope `http://www.thinkministry.com/dataplatform/scopes/all` -- **5-minute internal token life** — client refreshes long before the server's typical `expires_in` (3600s) to avoid edge misses; `TOKEN_LIFE = 5 * 60 * 1000` in `client.ts:6` -- **Two isolated pipelines** on one client — default (prod) and `dev` (for `api_dev_*` stored procs only); each has its own token cache, its own `expiresAt`, and its own `HttpClient` -- **Token injection via closure** — `HttpClient` reads the current token through `() => this.token`; the getter re-captures the latest value on every request -- **Endpoint prefix:** all API paths are joined to `process.env.MINISTRY_PLATFORM_BASE_URL` (e.g. `/tables/Contacts`, `/tasks/generate-sequence`) — the base URL already includes the MP domain +| Behavior | Detail | +|---|---| +| Lifetime | Fixed `TOKEN_LIFE = 5 min` (ignores `expires_in`; MP issues 1 h tokens) | +| Single-flight | Concurrent `ensureValid*()` callers share one in-flight refresh | +| Negative cache | After a failed refresh, calls fail fast with `MP access token unavailable: recent refresh failed` for 5 s + up to 25 s jitter (`NEGATIVE_CACHE_MIN_MS`, `NEGATIVE_CACHE_JITTER_MS`) | +| 401 hook | Each pipeline's `HttpClient` gets `onUnauthorized(rejectedToken)`: invalidates only if the rejected token is still current (a burst of 401s → one refresh), then refreshes | +| Isolation | `default` and `dev` have separate token, expiry, negative cache, and `HttpClient`. `dev` is used only by `ProcedureService` for `api_dev_*` | +| Logging | `logger.error('mp.token.refresh_failed', { profile, error: errorName(e) })` — never the raw error | -## API / Interface +Public API is unchanged: `ensureValidToken()`, `ensureValidDevToken()`, `getHttpClient()`, `getDevHttpClient()`. -### `MinistryPlatformClient` +## Token request (`auth/client-credentials.ts`) -```typescript -// src/lib/providers/ministry-platform/client.ts:21-42 -export class MinistryPlatformClient { - private token: string = ""; - private expiresAt: Date = new Date(0); - private httpClient: HttpClient; +- `POST {MINISTRY_PLATFORM_BASE_URL}/oauth/connect/token`, form-encoded, `redirect: "error"` (a 307/308 would re-send `client_secret`), `AbortSignal.timeout(TOKEN_TIMEOUT_MS = 10 s)`. +- Network failure / timeout / refused redirect → `Failed to get client credentials token: `. +- Non-2xx → `Failed to get client credentials token: ` (never the body). +- 200 is parsed with `readJsonResponse` and must have a non-empty string `access_token` and `token_type` `bearer` (case-insensitive), else `...: invalid token response`. Returns `ClientCredentialsToken`. +- `'dev'` profile requires `MINISTRY_PLATFORM_DEV_CLIENT_ID` and `_SECRET`; missing → `Dev client credentials are not configured...` before any fetch. - private devToken: string = ""; - private devExpiresAt: Date = new Date(0); - private devHttpClient: HttpClient; +## `HttpClient` (`utils/http-client.ts`) - private baseUrl: string; +`new HttpClient(baseUrl, getToken, onUnauthorized?)`. Methods `get`, `post`, `postFormData`, `put`, `putFormData`, `delete`, `buildUrl` — all route through one private `request()`. - constructor() { - this.baseUrl = process.env.MINISTRY_PLATFORM_BASE_URL!; - this.httpClient = new HttpClient(this.baseUrl, () => this.token); - this.devHttpClient = new HttpClient(this.baseUrl, () => this.devToken); - } -``` +Every request: +1. **Path guard** (`buildUrl` → `assertSafeEndpoint`): endpoint must be a string starting with `/` and must not match `/\.\.|[?#\\]|%2e|%2f|%5c|[\u0000-\u001f\u007f]/i`; the resolved URL must stay under the base URL. Otherwise `Refusing unsafe MP API endpoint` (no fetch). +2. **Query string**: `undefined`/`null` dropped, arrays repeat the key, values `encodeURIComponent`ed, **keys encoded too** with `$` and `@` kept literal. +3. **Fetch** with `redirect: 'error'` and a fresh `AbortSignal.timeout` per attempt (`API_TIMEOUT_MS = 20 s`; `UPLOAD_TIMEOUT_MS = 60 s` for FormData). `Content-Type: application/json` only for JSON bodies. +4. **401** with an `onUnauthorized` hook → refresh → retry **once**. A failed refresh logs `mp.token.refresh_after_401_failed` and surfaces the original 401. +5. **Non-2xx** → logs `mp.request.failed { method, endpoint, status, statusText }`, throws `${METHOD} ${endpoint} failed: ${status} ${statusText}`. +6. **Network error / timeout / refused redirect** → logs `mp.request.failed { method, endpoint, error }`, throws `${METHOD} ${endpoint} failed: ${errorName}` (e.g. `TimeoutError`, `TypeError`). +7. **Success** → `readJsonResponse`: 204 or `Content-Length: 0` → `undefined`; content-type must be `application/json` or `application/*+json` (else `...: unexpected content-type`); a parse failure → `...: invalid JSON response` (the V8 `SyntaxError`, which quotes the body, never escapes). -Public methods: - -| Method | Purpose | -|--------|---------| -| `ensureValidToken()` | Refreshes default token if expired | -| `ensureValidDevToken()` | Refreshes dev token if expired (throws if dev env vars missing) | -| `getHttpClient()` | Returns default HttpClient (same instance every call) | -| `getDevHttpClient()` | Returns dev HttpClient (same instance every call) | - -### `getClientCredentialsToken(profile?)` - -```typescript -// src/lib/providers/ministry-platform/auth/client-credentials.ts:1-29 -export type CredentialProfile = 'default' | 'dev'; - -export async function getClientCredentialsToken(profile: CredentialProfile = 'default') { - const mpBaseUrl = process.env.MINISTRY_PLATFORM_BASE_URL!; - const mpOauthUrl = `${mpBaseUrl}/oauth`; - - const { clientId, clientSecret } = resolveCredentials(profile); - - const params = new URLSearchParams({ - grant_type: "client_credentials", - client_id: clientId, - client_secret: clientSecret, - scope: "http://www.thinkministry.com/dataplatform/scopes/all", - }); - - const response = await fetch(`${mpOauthUrl}/connect/token`, { - method: "POST", - headers: { "Content-Type": "application/x-www-form-urlencoded" }, - body: params.toString(), - }); - - if (!response.ok) { - throw new Error(`Failed to get client credentials token: ${response.statusText}`); - } - - return await response.json(); -} -``` - -Environment variables: - -| Var | Purpose | Pipeline | -|-----|---------|----------| -| `MINISTRY_PLATFORM_BASE_URL` | Base URL for all requests + OAuth | both | -| `MINISTRY_PLATFORM_CLIENT_ID` | OAuth client id | default | -| `MINISTRY_PLATFORM_CLIENT_SECRET` | OAuth client secret | default | -| `MINISTRY_PLATFORM_DEV_CLIENT_ID` | Dev OAuth client id | dev | -| `MINISTRY_PLATFORM_DEV_CLIENT_SECRET` | Dev OAuth client secret | dev | - -`resolveCredentials('dev')` throws `Dev client credentials are not configured...` if either dev var is missing (`client-credentials.ts:35-40`, verified by test `client-credentials.test.ts:139-156`). - -### `HttpClient` - -```typescript -// src/lib/providers/ministry-platform/utils/http-client.ts:4-11 -export class HttpClient { - private baseUrl: string; - private getToken: () => string; - - constructor(baseUrl: string, getToken: () => string) { - this.baseUrl = baseUrl; - this.getToken = getToken; - } -``` - -Methods: - -| Method | Sends | Notes | -|--------|-------|-------| -| `get(endpoint, queryParams?)` | GET | no body | -| `post(endpoint, body?, queryParams?)` | POST + JSON | `Content-Type: application/json` | -| `postFormData(endpoint, formData, queryParams?)` | POST + FormData | no `Content-Type` (browser sets it with boundary) | -| `put(endpoint, body, queryParams?)` | PUT + JSON | debug-logs request; error text included | -| `putFormData(endpoint, formData, queryParams?)` | PUT + FormData | no `Content-Type` | -| `delete(endpoint, queryParams?)` | DELETE | no body | -| `buildUrl(endpoint, queryParams?)` | (helper) | public, used by provider for `/tasks/generate-sequence` | - -All methods inject `Authorization: Bearer ${this.getToken()}` on every call (`http-client.ts:18-22, 43-47, 69-73, 96-100, 121-125, 143-147`). - -Query-string builder (`http-client.ts:164-174`): -- `undefined`/`null` values are dropped -- Arrays emit `key=v1&key=v2&key=v3` -- All values run through `encodeURIComponent` - -### `logger` - -```typescript -// src/lib/providers/ministry-platform/utils/logger.ts -const isDev = process.env.NODE_ENV !== 'production'; - -export const logger = { - debug: isDev - ? (...args: unknown[]) => console.log('[MP]', ...args) - : () => {}, - error: (...args: unknown[]) => console.error('[MP]', ...args), -}; -``` - -`debug` is a no-op in production; `error` always writes. Prefix `[MP]`. - -## How it works - -### Token refresh algorithm (default pipeline) - -```typescript -// src/lib/providers/ministry-platform/client.ts:49-68 -public async ensureValidToken(): Promise { - logger.debug("Checking token validity..."); - logger.debug("Expires at:", this.expiresAt); - logger.debug("Current time:", new Date()); - - if (this.expiresAt < new Date()) { - logger.debug("Token expired, refreshing..."); - try { - const creds = await getClientCredentialsToken(); - this.token = creds.access_token; - this.expiresAt = new Date(Date.now() + TOKEN_LIFE); // 5-min buffer - logger.debug("Token refreshed. Expires at:", this.expiresAt); - } catch (error) { - logger.error("Failed to refresh token:", error); - throw error; - } - } -} -``` - -Behavior verified by `client.test.ts`: -- Initial state `expiresAt = new Date(0)` → first call always fetches (`client.test.ts:47-60`) -- Within 5-minute window → no fetch (`client.test.ts:62-81`) -- After 5-minute window → refreshes (`client.test.ts:83-108`) -- Precise boundary: `4m59s` no refresh, `5m+` refreshes (`client.test.ts:151-183`) -- Errors from `getClientCredentialsToken` propagate (`client.test.ts:110-118`) - -### Dev token pipeline - -`ensureValidDevToken()` mirrors the default but passes `'dev'` to `getClientCredentialsToken` (`client.ts:75-94`). The two pipelines are fully independent: -- Separate token + `expiresAt` fields -- Separate `HttpClient` instance -- Dev-only callers: `ProcedureService` when the procedure name matches `api_dev_*` -- Verified independent by `client.test.ts:240-322` - -### Request path - -``` -Service.someMethod(args) - → client.ensureValidToken() - → (if expired) getClientCredentialsToken() → POST {baseUrl}/oauth/connect/token - → stores access_token in this.token + sets this.expiresAt = now + 5min - → client.getHttpClient().get/post/put/delete(endpoint, body?, queryParams?) - → fetch({baseUrl}{endpoint}?{query}, { Authorization: Bearer ${this.getToken()} }) - → if !response.ok: throw `${METHOD} ${endpoint} failed: ${status} ${statusText}[ - ${body}]` - → response.json() as T -``` - -### URL building - -```typescript -// src/lib/providers/ministry-platform/utils/http-client.ts:156-174 -public buildUrl(endpoint: string, queryParams?: QueryParams): string { - const url = `${this.baseUrl}${endpoint}`; - if (!queryParams) return url; - const queryString = this.buildQueryString(queryParams); - return queryString ? `${url}?${queryString}` : url; -} - -private buildQueryString(params: QueryParams): string { - return Object.entries(params) - .filter(([, value]) => value !== undefined && value !== null) - .map(([key, value]) => { - if (Array.isArray(value)) { - return value.map(v => `${key}=${encodeURIComponent(String(v))}`).join('&'); - } - return `${key}=${encodeURIComponent(String(value))}`; - }) - .join('&'); -} -``` - -## Usage - -### Consuming the client from a service (real example — pattern) - -```typescript -// Typical service pattern, mirrored in TableService / ProcedureService / etc. -async someMethod(table: string, params?: QueryParams): Promise { - await this.client.ensureValidToken(); - return this.client.getHttpClient().get(`/tables/${table}`, params); -} -``` - -### Direct HttpClient usage from provider (real example) - -```typescript -// src/lib/providers/ministry-platform/provider.ts:228-244 -public async generateSequence(pattern: RecurrencePattern): Promise { - await this.client.ensureValidToken(); - const queryParams: QueryParams = { - $type: pattern.Type, - $interval: pattern.Interval, - $startDate: pattern.StartDate, - }; - if (pattern.EndDate) queryParams.$endDate = pattern.EndDate; - // ... other optional fields - return this.client.getHttpClient().get('/tasks/generate-sequence', queryParams); -} -``` +`endpoint` in logs and messages is `redactEndpoint(endpoint)`: path only (never the query string, which carries `$filter`), GUIDs replaced with `{uniqueId}` (MP serves `/files/{uniqueId}` unauthenticated, so the ID is a download capability). ## Gotchas -- **No request-level token refresh.** `HttpClient` does not call `ensureValidToken` itself — every service method must call it first. A service that forgets to `await this.client.ensureValidToken()` will ship `Authorization: Bearer ` (empty) on a cold client; see `architecture.md` for call-site list. -- **5-minute buffer, not JWT `exp`.** The client ignores `expires_in` from the OAuth response and hard-codes 5 minutes via `TOKEN_LIFE`. If MP ever issues tokens shorter than 5 minutes, default pipeline breaks. -- **No concurrency control.** Two concurrent `ensureValidToken` calls on a cold client each trigger a fetch (see `client.test.ts:120-147`). Documented as acceptable current behavior, not a deduped singleton-in-flight. -- **`getHttpClient()` always returns the same instance.** Do not construct a new `HttpClient` — it would not share the token getter. -- **FormData error paths lose the response body.** `postFormData` and `putFormData` and `delete` throw without including `response.text()`, unlike `get`/`post`/`put`. -- **Logger leaks base URL and partial response bodies on error.** `logger.error` is always on; error logs may contain full URLs with query strings and raw response text. +- **Services must still call `ensureValidToken()` first.** The 401 hook covers revocation, not a cold client. +- **The path guard is defense in depth.** Provider services validate segments themselves (`services/guards.ts`); `encodeURIComponent('..') === '..'`, so the central guard is what catches a missed one. +- **An empty 200 without `Content-Length: 0` and without a JSON content-type throws** `unexpected content-type`. Previously `response.json()` threw a `SyntaxError` there anyway. +- **Negative cache applies to config errors too** — a missing dev credential fails fast for up to 30 s after the first failure. +- **`errorName` returns only a class-name-shaped `name`** (`^[A-Za-z]{1,64}$`), else `typeof`. Use it for every caught error you log. ## Related docs - `architecture.md` — layering overview -- `error-handling.md` — how errors wrap at each layer -- `../auth/README.md` — user-facing Better Auth OAuth (a different flow) +- `error-handling.md` — error messages at each layer +- `services/*.md` — per-service path guards diff --git a/.claude/references/mp-provider/error-handling.md b/.claude/references/mp-provider/error-handling.md index a57e33a..9e3af0c 100644 --- a/.claude/references/mp-provider/error-handling.md +++ b/.claude/references/mp-provider/error-handling.md @@ -4,142 +4,73 @@ domain: mp-provider type: reference applies_to: - src/lib/providers/ministry-platform/helper.ts - - src/lib/providers/ministry-platform/provider.ts - src/lib/providers/ministry-platform/client.ts - src/lib/providers/ministry-platform/auth/client-credentials.ts - src/lib/providers/ministry-platform/utils/http-client.ts + - src/lib/providers/ministry-platform/services/guards.ts symbols: - HttpClient - MinistryPlatformClient - MPHelper + - errorName related: - client.md - architecture.md -last_verified: 2026-04-17 +last_verified: 2026-09-30 --- ## Purpose -How Ministry Platform API errors (401/404/409/500), OAuth token-refresh errors, and Zod validation errors surface up the stack. No layer swallows errors; messages are wrapped/augmented but the original error chain is preserved. - -## Files -- `src/lib/providers/ministry-platform/utils/http-client.ts:24-35, 51-62, 78-82, 105-114, 131-134, 149-153` — where HTTP errors are thrown -- `src/lib/providers/ministry-platform/client.ts:57-67, 82-92` — token refresh error paths -- `src/lib/providers/ministry-platform/auth/client-credentials.ts:24-26` — OAuth response error -- `src/lib/providers/ministry-platform/helper.ts:186-196, 269-279` — Zod validation wrapping +How Ministry Platform API errors, token-refresh errors, input-guard refusals and Zod validation errors surface up the stack. No layer swallows errors. Every thrown message and log line carries **identifiers and shape only** (CLAUDE.md rule 14): method, redacted path, status, validated table/procedure name or record ID, and `errorName(error)` — never a response body, request body, `$filter`, full URL, file unique ID, or the raw error object. ## Key concepts -- **Every layer wraps but re-throws.** `HttpClient` builds a readable `Error`, `MPHelper` re-wraps Zod errors, `client.ts` just re-throws after logging -- **Error shape is `Error`, not a custom class.** There is no `MPApiError` type; consumers must parse `.message` to get status codes -- **401/404/409/500 are indistinguishable by type** at the TypeScript level — only the message string contains the status code -- **Token-refresh errors are eagerly logged** via `logger.error("Failed to refresh token:", error)` then re-thrown (`client.ts:63-66`) -- **FormData errors lose response body.** `postFormData`/`putFormData`/`delete` throw without calling `response.text()` +- **Every layer re-throws.** Services log a structured event (`logger.error('mp.._failed', { ...ids, error: errorName(e) })`) and re-throw the original error. +- **Error shape is `Error`, not a custom class.** Consumers must read `.message` for the status code. +- **Guards throw before any token or network work**, with a fixed message naming the field. +- **One automatic retry exists:** a 401 triggers a forced token refresh and a single retry. Nothing else is retried. ## Error taxonomy -| Layer | Trigger | Thrown at | Error message pattern | Caught at | -|-------|---------|-----------|----------------------|-----------| -| OAuth | Non-2xx from `/oauth/connect/token` | `auth/client-credentials.ts:25` | `Failed to get client credentials token: ${statusText}` | `client.ts:63-66` (logs, re-throws); ultimately bubbles to service/caller | -| OAuth | Dev env vars missing | `auth/client-credentials.ts:36` | `Dev client credentials are not configured. Set MINISTRY_PLATFORM_DEV_CLIENT_ID and MINISTRY_PLATFORM_DEV_CLIENT_SECRET to execute api_dev_* stored procedures.` | Service caller (or test) | -| OAuth | Network failure during token POST | `auth/client-credentials.ts:16` (fetch rejects) | Original `TypeError: fetch failed` etc. | `client.ts:63-66` logs + re-throws | -| HTTP | GET non-2xx | `utils/http-client.ts:32` | `GET ${endpoint} failed: ${status} ${statusText}[ - ${responseBody}]` | Service method (re-thrown up) | -| HTTP | POST non-2xx | `utils/http-client.ts:59` | `POST ${endpoint} failed: ${status} ${statusText}[ - ${responseBody}]` | Service method | -| HTTP | POST FormData non-2xx | `utils/http-client.ts:79` | `POST ${endpoint} failed: ${status} ${statusText}` (no body) | Service method | -| HTTP | PUT non-2xx | `utils/http-client.ts:112` | `PUT ${endpoint} failed: ${status} ${statusText}` (logs body but doesn't include) | Service method | -| HTTP | PUT FormData non-2xx | `utils/http-client.ts:132` | `PUT ${endpoint} failed: ${status} ${statusText}` (no body) | Service method | -| HTTP | DELETE non-2xx | `utils/http-client.ts:150` | `DELETE ${endpoint} failed: ${status} ${statusText}` (no body) | Service method | -| validation | Zod parse fail on create | `helper.ts:190` | `Validation failed for record ${index}: ${zodError.message}` | Caller (no API call made) | -| validation | Zod parse fail on update | `helper.ts:273` | `Validation failed for record ${index}: ${zodError.message}` | Caller (no API call made) | - -## Code: the wrapping pattern - -### HttpClient — GET/POST wrap status into message - -```typescript -// src/lib/providers/ministry-platform/utils/http-client.ts:24-35 -if (!response.ok) { - const responseText = await response.text().catch(() => ''); - logger.error("GET Request failed:", { - status: response.status, - statusText: response.statusText, - url, - responseBody: responseText - }); - throw new Error(`GET ${endpoint} failed: ${response.status} ${response.statusText}${responseText ? ` - ${responseText}` : ''}`); -} -``` - -POST uses the same pattern (`http-client.ts:51-62`). PUT logs the body but omits it from the thrown message (`http-client.ts:105-114`). - -### MinistryPlatformClient — log + re-throw - -```typescript -// src/lib/providers/ministry-platform/client.ts:57-67 -try { - const creds = await getClientCredentialsToken(); - this.token = creds.access_token; - this.expiresAt = new Date(Date.now() + TOKEN_LIFE); - logger.debug("Token refreshed. Expires at:", this.expiresAt); -} catch (error) { - logger.error("Failed to refresh token:", error); - throw error; -} -``` - -Re-throws the original error unchanged — test `client.test.ts:110-118` verifies OAuth errors bubble through with their original message. - -### MPHelper — Zod re-wrap - -```typescript -// src/lib/providers/ministry-platform/helper.ts:185-199 -validatedRecords = records.map((record, index) => { - try { - return schema.parse(record) as T; - } catch (validationError) { - throw new Error( - `Validation failed for record ${index}: ${ - validationError instanceof Error - ? validationError.message - : String(validationError) - }` - ); - } -}); -``` - -Rewraps `z.ZodError` into a plain `Error` with the index prefixed, which loses the structured issue list (only `.message` survives). Test `helper.test.ts:238-252` pins this behavior ("should include record index in validation error"). - -## HTTP status → expected behavior - -| Status | What it usually means in MP | Where it appears | -|--------|------------------------------|------------------| -| 400 Bad Request | Malformed filter, invalid body, validation error | All write methods | -| 401 Unauthorized | Token missing/expired/rejected | Any method with a stale token | -| 403 Forbidden | MP user permissions insufficient | Table CRUD on restricted tables | -| 404 Not Found | Unknown table, unknown record id, unknown procedure | Reads and updates | -| 409 Conflict | Concurrent update / primary-key conflict | Create and update | -| 413 Payload Too Large | Upload exceeds MP limit | `postFormData`, `putFormData` | -| 500 Internal Server Error | MP backend crash / misconfigured proc | Any | - -The provider does **not** retry any of these automatically — a 401 resulting from a race between MP revoking a token early and the 5-minute cache will propagate as a failure. - -## Known error-handling limitations (from tests) - -- `client.test.ts:120-147` — Concurrent `ensureValidToken` calls are not deduplicated; each triggers a separate fetch. Under a transient OAuth failure, they each see the same error. -- `client.test.ts:348-366` — After a failed refresh, state is not poisoned: a retry succeeds cleanly on the next call. -- `http-client.test.ts:140-176` — Tests pin the exact message shape for `404 Not Found`, `401 Unauthorized`, `500 Internal Server Error` on GET. -- `http-client.test.ts:240-251` — POST 400 → `POST ${endpoint} failed: 400 Bad Request`. -- `http-client.test.ts:281-292` — `postFormData` 413 → omits response body. -- `http-client.test.ts:319-331` — PUT 400 — test asserts the message does **not** include the response body (only logs it). -- `http-client.test.ts:397-408` — DELETE 403 → message has no body. +| Layer | Trigger | Message | +|-------|---------|---------| +| Guard | Bad table / procedure name | `Invalid table name` / `Invalid procedure name` | +| Guard | Bad record / file / user ID | `Expected positive integer for record ID` (`file ID`, `user ID`) | +| Guard | Non-array `ids` to `deleteTableRecords` | `Invalid record IDs` | +| Guard | Bad file unique ID | `Invalid GUID format` | +| HttpClient | Unsafe endpoint (traversal, `?#\`, encoded forms, control chars) | `Refusing unsafe MP API endpoint` | +| HttpClient | Non-2xx (after the one 401 retry) | `${METHOD} ${endpoint} failed: ${status} ${statusText}` | +| HttpClient | Network error / timeout / refused redirect | `${METHOD} ${endpoint} failed: ${errorName}` (e.g. `TimeoutError`) | +| HttpClient | 2xx with non-JSON content-type | `${METHOD} ${endpoint} failed: unexpected content-type` | +| HttpClient | 2xx with malformed JSON | `${METHOD} ${endpoint} failed: invalid JSON response` | +| Token | Network / timeout / redirect | `Failed to get client credentials token: ${errorName}` | +| Token | Non-2xx | `Failed to get client credentials token: ${status} ${statusText}` | +| Token | 200 without usable bearer | `Failed to get client credentials token: invalid token response` | +| Token | Within negative-cache window | `MP access token unavailable: recent refresh failed` | +| Token | Dev env vars missing | `Dev client credentials are not configured...` | +| FileService | Download non-2xx | `GET /files/{uniqueId} failed: ${status} ${statusText}` | +| MPHelper | Zod parse fail on create/update | `Validation failed for record ${index}: ...` (no API call made) | + +`${endpoint}` is the path only, with GUIDs replaced by `{uniqueId}`. + +## Log events + +| Event | Fields | +|---|---| +| `mp.request.failed` | `method, endpoint, status, statusText` or `method, endpoint, error` | +| `mp.token.refresh_after_401_failed` | `method, endpoint, error` | +| `mp.token.refresh_failed` | `profile, error` | +| `mp.table.*_failed` | `table`, optional `recordId` / `count`, `error` | +| `mp.procs.execute_failed` / `mp.procs.list_failed` | `procedure` (validated), `error` | +| `mp.files.*_failed`, `mp.domain.*`, `mp.metadata.*`, `mp.communication.*`, `mp.message.*` | `error` (plus `table` where validated) | + +All are written as `console.error('[MP]', event, fields)` via `logger.error`. ## Consumer guidance -- **Detecting 401/404:** `try { await mp.getTableRecords(...) } catch (e) { if (e.message.includes('401')) { /* auth */ } }`. There is no structured error shape. -- **Zod failures short-circuit the API call.** If validation fails in `createTableRecords`/`updateTableRecords`, the request is never sent. -- **Do not assume the original `ZodError` structure.** The re-wrapped `Error` only carries `.message`. -- **`logger.error` always fires** — response bodies may end up in production logs. Sensitive MP data could be logged on error. +- **Detecting 401/404:** `e.message.includes('404')`. There is no structured error type. +- **A 401 you see is after a retry** with a freshly issued token — it is not a stale-cache race. +- **Zod failures short-circuit the API call**, and the re-wrapped `Error` only carries `.message`. ## Related docs -- `client.md` — token-refresh flow and dev pipeline +- `client.md` — token pipelines, HttpClient request path - `architecture.md` — layering diagram diff --git a/.claude/references/mp-provider/services/communication.md b/.claude/references/mp-provider/services/communication.md index 657ab80..03054f0 100644 --- a/.claude/references/mp-provider/services/communication.md +++ b/.claude/references/mp-provider/services/communication.md @@ -5,7 +5,7 @@ type: reference applies_to: [src/lib/providers/ministry-platform/services/communication.service.ts] symbols: [CommunicationService, createCommunication, sendMessage] related: [../README.md, file.md] -last_verified: 2026-04-17 +last_verified: 2026-09-30 --- ## Purpose diff --git a/.claude/references/mp-provider/services/domain.md b/.claude/references/mp-provider/services/domain.md index a983451..ad66cf7 100644 --- a/.claude/references/mp-provider/services/domain.md +++ b/.claude/references/mp-provider/services/domain.md @@ -5,7 +5,7 @@ type: reference applies_to: [src/lib/providers/ministry-platform/services/domain.service.ts] symbols: [DomainService, getDomainInfo, getGlobalFilters] related: [../README.md, metadata.md] -last_verified: 2026-04-17 +last_verified: 2026-09-30 --- ## Purpose @@ -17,7 +17,7 @@ Read domain-level configuration (display name, timezone, culture, MFA settings) ## Key concepts - `getDomainInfo` hits `/domain` (no params). Authenticated; ties to the current access token's domain. -- `getGlobalFilters` hits `/domain/filters` with optional `$userId` and `$ignorePermissions` params. Returns `{ Key, Value }[]` where `Key: 0` represents "records with no filter assigned." +- `getGlobalFilters` hits `/domain/filters` forwarding **only** a validated `$userId` (an explicit allowlist; `$ignorePermissions` was removed 2026-09-30 and is dropped if smuggled in). Returns `{ Key, Value }[]` where `Key: 0` represents "records with no filter assigned." - The domain's global filter table name is in `DomainInfo.GlobalFilterTableName` (may be undefined on domains without global filters configured). ## API / Interface @@ -35,7 +35,7 @@ public async getGlobalFilters(params?: GlobalFilterParams): Promise( - `createTableRecords` / `updateTableRecords` pass `records` array as the body and `params` as query string. - `copyRecord` does NOT copy sub-pages or files; `copyRecordWithSubpages` can via `CopyParameters.SubpageIds` and `CopyParameters.CopyFiles`. Both create `dp_Sequences` entries for native MP series linkage (`table.service.ts:73-77`, `:100-105`). - `RecurrencePattern` and `CopyParameters` defined in `src/lib/providers/ministry-platform/types/provider.types.ts:221, :246`. -- Errors from `HttpClient` propagate unchanged after a `logger.error` call. +- Errors from `HttpClient` propagate unchanged after `logger.error('mp.table._failed', { table, recordId?, error: errorName(e) })`. ## Usage diff --git a/src/lib/providers/ministry-platform/auth/client-credentials.test.ts b/src/lib/providers/ministry-platform/auth/client-credentials.test.ts index 4c6b433..abb02fa 100644 --- a/src/lib/providers/ministry-platform/auth/client-credentials.test.ts +++ b/src/lib/providers/ministry-platform/auth/client-credentials.test.ts @@ -1,157 +1,321 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { getClientCredentialsToken } from './client-credentials'; +import { + getClientCredentialsToken, + TOKEN_TIMEOUT_MS, +} from '@/lib/providers/ministry-platform/auth/client-credentials'; + +/** + * getClientCredentialsToken Tests + * + * This function is the only thing standing between the app and every + * Ministry Platform API call - MinistryPlatformClient.ensureValidToken() calls it + * for every token refresh. + * + * What is worth pinning here: + * - the OAuth2 client_credentials grant is form-encoded, not JSON + * - the MP-specific scope string is sent verbatim (MP rejects a wrong scope) + * - a non-OK response throws rather than returning a token-shaped object with + * undefined fields, which would otherwise surface later as a confusing 401 + * - a 200 without a usable bearer token is rejected, not cached + * - the request has a deadline and refuses redirects (a 307 would re-send the + * client secret to whatever origin it names) + * - no error message ever carries the client secret or the response body + */ + +const SECRET = 'test-client-secret'; + +function mockHeaders(values: Record) { + const lower = Object.fromEntries( + Object.entries(values).map(([k, v]) => [k.toLowerCase(), v]) + ); + return { get: (name: string) => lower[name.toLowerCase()] ?? null }; +} + +function tokenResponse(body: unknown, contentType = 'application/json') { + return { + ok: true, + status: 200, + headers: mockHeaders({ 'Content-Type': contentType }), + json: vi.fn().mockResolvedValue(body), + }; +} describe('getClientCredentialsToken', () => { - const originalFetch = global.fetch; + let fetchMock: ReturnType; const originalEnv = { ...process.env }; - const mockFetch = vi.fn(); beforeEach(() => { vi.clearAllMocks(); - mockFetch.mockReset(); - global.fetch = mockFetch as unknown as typeof fetch; - process.env.MINISTRY_PLATFORM_BASE_URL = 'https://api.example.com'; + fetchMock = vi.fn(); + vi.stubGlobal('fetch', fetchMock); + + process.env.MINISTRY_PLATFORM_BASE_URL = 'https://mp.example.org/ministryplatformapi'; process.env.MINISTRY_PLATFORM_CLIENT_ID = 'test-client-id'; - process.env.MINISTRY_PLATFORM_CLIENT_SECRET = 'test-secret'; - process.env.MINISTRY_PLATFORM_DEV_CLIENT_ID = 'test-dev-client-id'; - process.env.MINISTRY_PLATFORM_DEV_CLIENT_SECRET = 'test-dev-secret'; + process.env.MINISTRY_PLATFORM_CLIENT_SECRET = SECRET; }); afterEach(() => { - global.fetch = originalFetch; - process.env = { ...originalEnv }; + vi.unstubAllGlobals(); vi.restoreAllMocks(); + process.env = { ...originalEnv }; }); - // Successful token fetch - it('should fetch token with correct URL, method, headers, and body', async () => { - const tokenResponse = { access_token: 'abc', expires_in: 3600 }; - mockFetch.mockResolvedValueOnce({ - ok: true, - json: async () => tokenResponse, - }); + it('should POST the client_credentials grant to the MP token endpoint', async () => { + fetchMock.mockResolvedValueOnce( + tokenResponse({ access_token: 'test-token', token_type: 'Bearer', expires_in: 3600 }) + ); const result = await getClientCredentialsToken(); - expect(mockFetch).toHaveBeenCalledTimes(1); - const [url, init] = mockFetch.mock.calls[0]; - expect(url).toBe('https://api.example.com/oauth/connect/token'); + expect(fetchMock).toHaveBeenCalledTimes(1); + const [url, init] = fetchMock.mock.calls[0]; + + expect(url).toBe('https://mp.example.org/ministryplatformapi/oauth/connect/token'); expect(init.method).toBe('POST'); - expect(init.headers).toEqual({ - 'Content-Type': 'application/x-www-form-urlencoded', + expect(init.headers['Content-Type']).toBe('application/x-www-form-urlencoded'); + + expect(result).toEqual({ + access_token: 'test-token', + token_type: 'Bearer', + expires_in: 3600, }); - expect(init.body).toContain('grant_type=client_credentials'); - expect(init.body).toContain('client_id=test-client-id'); - expect(init.body).toContain('client_secret=test-secret'); - expect(init.body).toContain('scope='); - expect(result).toEqual(tokenResponse); }); - // Non-ok response throws - it('should throw error when response is not ok', async () => { - mockFetch.mockResolvedValueOnce({ + it('should refuse redirects and set a deadline on the token request', async () => { + const timeoutSpy = vi.spyOn(AbortSignal, 'timeout'); + fetchMock.mockResolvedValueOnce( + tokenResponse({ access_token: 'test-token', token_type: 'Bearer' }) + ); + + await getClientCredentialsToken(); + + const init = fetchMock.mock.calls[0][1]; + expect(init.redirect).toBe('error'); + expect(init.signal).toBeInstanceOf(AbortSignal); + expect(timeoutSpy).toHaveBeenCalledWith(TOKEN_TIMEOUT_MS); + expect(TOKEN_TIMEOUT_MS).toBe(10_000); + }); + + it('should send grant_type, credentials, and the MP scope in the form body', async () => { + fetchMock.mockResolvedValueOnce( + tokenResponse({ access_token: 'test-token', token_type: 'bearer' }) + ); + + await getClientCredentialsToken(); + + const body = new URLSearchParams(fetchMock.mock.calls[0][1].body); + + expect(body.get('grant_type')).toBe('client_credentials'); + expect(body.get('client_id')).toBe('test-client-id'); + expect(body.get('client_secret')).toBe(SECRET); + expect(body.get('scope')).toBe( + 'http://www.thinkministry.com/dataplatform/scopes/all' + ); + }); + + it('should throw with the status and status text when the token request is rejected', async () => { + fetchMock.mockResolvedValueOnce({ ok: false, + status: 401, statusText: 'Unauthorized', - json: async () => ({}), + json: vi.fn(), }); await expect(getClientCredentialsToken()).rejects.toThrow( - 'Failed to get client credentials token: Unauthorized' + 'Failed to get client credentials token: 401 Unauthorized' ); }); - // Network failure propagates - it('should propagate network errors from fetch', async () => { - mockFetch.mockRejectedValueOnce(new Error('Network error')); + it('should still report the status when statusText is empty (HTTP/2)', async () => { + fetchMock.mockResolvedValueOnce({ ok: false, status: 503, statusText: '', json: vi.fn() }); - await expect(getClientCredentialsToken()).rejects.toThrow('Network error'); + await expect(getClientCredentialsToken()).rejects.toThrow( + /^Failed to get client credentials token: 503$/ + ); }); - // Body uses URLSearchParams format - it('should format body as URLSearchParams', async () => { - mockFetch.mockResolvedValueOnce({ - ok: true, - json: async () => ({ access_token: 'x' }), + it('should not attempt to parse the body of a failed response', async () => { + const json = vi.fn(); + fetchMock.mockResolvedValueOnce({ + ok: false, + status: 500, + statusText: 'Internal Server Error', + json, }); - await getClientCredentialsToken(); + await expect(getClientCredentialsToken()).rejects.toThrow('500 Internal Server Error'); + expect(json).not.toHaveBeenCalled(); + }); - const [, init] = mockFetch.mock.calls[0]; - const body = init.body as string; - const parsed = new URLSearchParams(body); - expect(parsed.get('grant_type')).toBe('client_credentials'); - expect(parsed.get('client_id')).toBe('test-client-id'); - expect(parsed.get('client_secret')).toBe('test-secret'); - expect(parsed.get('scope')).toBe( - 'http://www.thinkministry.com/dataplatform/scopes/all' + it('should wrap network failures, keeping only the error name', async () => { + fetchMock.mockRejectedValueOnce(new TypeError('fetch failed: https://mp.example.org')); + + await expect(getClientCredentialsToken()).rejects.toThrow( + /^Failed to get client credentials token: TypeError$/ ); }); - // Uses MINISTRY_PLATFORM_BASE_URL env var - it('should use MINISTRY_PLATFORM_BASE_URL env var for URL construction', async () => { - process.env.MINISTRY_PLATFORM_BASE_URL = 'https://custom-domain.org'; - mockFetch.mockResolvedValueOnce({ - ok: true, - json: async () => ({ access_token: 'y' }), - }); + it('should reject a refused redirect (fetch throws under redirect: "error")', async () => { + // undici rejects with TypeError('fetch failed', { cause: 'unexpected redirect' }) + fetchMock.mockRejectedValueOnce( + new TypeError('fetch failed', { cause: new Error('unexpected redirect') }) + ); - await getClientCredentialsToken(); + await expect(getClientCredentialsToken()).rejects.toThrow( + 'Failed to get client credentials token: TypeError' + ); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); - const [url] = mockFetch.mock.calls[0]; - expect(url).toBe('https://custom-domain.org/oauth/connect/token'); + it('should reject when the deadline passes', async () => { + fetchMock.mockRejectedValueOnce( + new DOMException('The operation was aborted due to timeout', 'TimeoutError') + ); + + await expect(getClientCredentialsToken()).rejects.toThrow( + 'Failed to get client credentials token: TimeoutError' + ); }); - // Dev profile uses dev env vars - it('should use dev client id/secret when profile is "dev"', async () => { - mockFetch.mockResolvedValueOnce({ - ok: true, - json: async () => ({ access_token: 'dev-token' }), + describe('token response validation', () => { + it.each([ + ['missing access_token', { token_type: 'Bearer', expires_in: 3600 }], + ['empty access_token', { access_token: '', token_type: 'Bearer' }], + ['whitespace access_token', { access_token: ' ', token_type: 'Bearer' }], + ['non-string access_token', { access_token: 12345, token_type: 'Bearer' }], + ['missing token_type', { access_token: 'tok' }], + ['non-bearer token_type', { access_token: 'tok', token_type: 'mac' }], + ['non-string token_type', { access_token: 'tok', token_type: 1 }], + ['null body', null], + ])('should reject a 200 with %s', async (_label, body) => { + fetchMock.mockResolvedValueOnce(tokenResponse(body)); + + await expect(getClientCredentialsToken()).rejects.toThrow( + 'Failed to get client credentials token: invalid token response' + ); }); - await getClientCredentialsToken('dev'); + it('should accept token_type case-insensitively', async () => { + fetchMock.mockResolvedValueOnce( + tokenResponse({ access_token: 'tok', token_type: 'BEARER' }) + ); - const [, init] = mockFetch.mock.calls[0]; - const parsed = new URLSearchParams(init.body as string); - expect(parsed.get('client_id')).toBe('test-dev-client-id'); - expect(parsed.get('client_secret')).toBe('test-dev-secret'); - expect(parsed.get('grant_type')).toBe('client_credentials'); - expect(parsed.get('scope')).toBe( - 'http://www.thinkministry.com/dataplatform/scopes/all' - ); - }); + await expect(getClientCredentialsToken()).resolves.toMatchObject({ access_token: 'tok' }); + }); + + it('should reject a 204 (no token at all)', async () => { + fetchMock.mockResolvedValueOnce({ ok: true, status: 204, headers: mockHeaders({}), json: vi.fn() }); - // Default profile keeps using prod env vars - it('should use default (prod) client id/secret when profile is omitted', async () => { - mockFetch.mockResolvedValueOnce({ - ok: true, - json: async () => ({ access_token: 'prod-token' }), + await expect(getClientCredentialsToken()).rejects.toThrow('invalid token response'); }); - await getClientCredentialsToken(); + it('should refuse to parse a non-JSON 200 and not echo its body', async () => { + const json = vi.fn(); + fetchMock.mockResolvedValueOnce({ + ok: true, + status: 200, + headers: mockHeaders({ 'Content-Type': 'text/html' }), + json, + }); + + await expect(getClientCredentialsToken()).rejects.toThrow( + 'Failed to get client credentials token: unexpected content-type' + ); + expect(json).not.toHaveBeenCalled(); + }); - const [, init] = mockFetch.mock.calls[0]; - const parsed = new URLSearchParams(init.body as string); - expect(parsed.get('client_id')).toBe('test-client-id'); - expect(parsed.get('client_secret')).toBe('test-secret'); + it('should not leak a body fragment from a malformed JSON 200', async () => { + fetchMock.mockResolvedValueOnce({ + ok: true, + status: 200, + headers: mockHeaders({ 'Content-Type': 'application/json' }), + json: vi.fn().mockRejectedValue(new SyntaxError('Unexpected token "Jane Doe, "... is not valid JSON')), + }); + + try { + await getClientCredentialsToken(); + expect.unreachable('expected getClientCredentialsToken to throw'); + } catch (err) { + expect((err as Error).message).toBe( + 'Failed to get client credentials token: invalid JSON response' + ); + } + }); }); - // Dev profile throws when dev vars missing - it('should throw when dev profile is used without dev env vars', async () => { - delete process.env.MINISTRY_PLATFORM_DEV_CLIENT_ID; - delete process.env.MINISTRY_PLATFORM_DEV_CLIENT_SECRET; + describe('secret handling', () => { + it.each([ + ['a rejected token request', () => fetchMock.mockResolvedValueOnce({ ok: false, status: 400, statusText: 'Bad Request', json: vi.fn() })], + ['a network failure', () => fetchMock.mockRejectedValueOnce(new TypeError(`fetch failed ${SECRET}`))], + ['an invalid token response', () => fetchMock.mockResolvedValueOnce(tokenResponse({ error: SECRET }))], + ])('should never put the client secret in the error for %s', async (_label, arrange) => { + arrange(); - await expect(getClientCredentialsToken('dev')).rejects.toThrow( - /Dev client credentials are not configured/ - ); - expect(mockFetch).not.toHaveBeenCalled(); + const err = await getClientCredentialsToken().catch((e: unknown) => e); + expect(err).toBeInstanceOf(Error); + expect((err as Error).message).not.toContain(SECRET); + expect(JSON.stringify(err)).not.toContain(SECRET); + expect((err as Error).cause).toBeUndefined(); + }); }); - it('should throw when dev profile is used with only one of the two dev env vars', async () => { - delete process.env.MINISTRY_PLATFORM_DEV_CLIENT_SECRET; + describe('credential profiles', () => { + beforeEach(() => { + process.env.MINISTRY_PLATFORM_DEV_CLIENT_ID = 'test-dev-client-id'; + process.env.MINISTRY_PLATFORM_DEV_CLIENT_SECRET = 'test-dev-secret'; + }); - await expect(getClientCredentialsToken('dev')).rejects.toThrow( - /Dev client credentials are not configured/ - ); - expect(mockFetch).not.toHaveBeenCalled(); + it('should use dev client id/secret when profile is "dev"', async () => { + fetchMock.mockResolvedValueOnce(tokenResponse({ access_token: 'dev-token', token_type: 'Bearer' })); + + await getClientCredentialsToken('dev'); + + const [, init] = fetchMock.mock.calls[0]; + const parsed = new URLSearchParams(init.body as string); + expect(parsed.get('client_id')).toBe('test-dev-client-id'); + expect(parsed.get('client_secret')).toBe('test-dev-secret'); + expect(parsed.get('grant_type')).toBe('client_credentials'); + // The dev token request gets the same deadline and redirect refusal + expect(init.redirect).toBe('error'); + expect(init.signal).toBeInstanceOf(AbortSignal); + }); + + it('should use default (prod) client id/secret when profile is omitted', async () => { + fetchMock.mockResolvedValueOnce(tokenResponse({ access_token: 'prod-token', token_type: 'Bearer' })); + + await getClientCredentialsToken(); + + const [, init] = fetchMock.mock.calls[0]; + const parsed = new URLSearchParams(init.body as string); + expect(parsed.get('client_id')).toBe('test-client-id'); + expect(parsed.get('client_secret')).toBe(SECRET); + }); + + it('should validate the dev token response too', async () => { + fetchMock.mockResolvedValueOnce(tokenResponse({ access_token: 'dev-token' })); + + await expect(getClientCredentialsToken('dev')).rejects.toThrow( + 'Failed to get client credentials token: invalid token response' + ); + }); + + it('should throw when dev profile is used without dev env vars', async () => { + delete process.env.MINISTRY_PLATFORM_DEV_CLIENT_ID; + delete process.env.MINISTRY_PLATFORM_DEV_CLIENT_SECRET; + + await expect(getClientCredentialsToken('dev')).rejects.toThrow( + /Dev client credentials are not configured/ + ); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('should throw when dev profile is used with only one of the two dev env vars', async () => { + delete process.env.MINISTRY_PLATFORM_DEV_CLIENT_SECRET; + + await expect(getClientCredentialsToken('dev')).rejects.toThrow( + /Dev client credentials are not configured/ + ); + expect(fetchMock).not.toHaveBeenCalled(); + }); }); }); diff --git a/src/lib/providers/ministry-platform/auth/client-credentials.ts b/src/lib/providers/ministry-platform/auth/client-credentials.ts index 2471751..5d61d50 100644 --- a/src/lib/providers/ministry-platform/auth/client-credentials.ts +++ b/src/lib/providers/ministry-platform/auth/client-credentials.ts @@ -1,8 +1,31 @@ import { getMpBaseUrl } from "@/lib/env"; +import { readJsonResponse } from "../utils/http-client"; +import { errorName } from "../utils/logger"; export type CredentialProfile = 'default' | 'dev'; -export async function getClientCredentialsToken(profile: CredentialProfile = 'default') { +/** + * Shape of the OAuth2 client-credentials token response returned by MP's + * `/oauth/connect/token` endpoint. `expires_in` (seconds) is optional because + * the response is untrusted at the type level. + */ +export interface ClientCredentialsToken { + access_token: string; + token_type: string; + expires_in?: number; +} + +/** + * Deadline for the token request. It gates every MP call, so a stalled token + * endpoint must fail fast rather than hold requests for undici's 300 s default. + */ +export const TOKEN_TIMEOUT_MS = 10_000; + +const ERROR_PREFIX = "Failed to get client credentials token"; + +export async function getClientCredentialsToken( + profile: CredentialProfile = 'default' +): Promise { // Validated (https, no credentials/query, no trailing slash): this request // carries the service-account client secret. See src/lib/env.ts. const mpBaseUrl = getMpBaseUrl(); @@ -17,19 +40,48 @@ export async function getClientCredentialsToken(profile: CredentialProfile = 'de scope: "http://www.thinkministry.com/dataplatform/scopes/all", }); - const response = await fetch(`${mpOauthUrl}/connect/token`, { - method: "POST", - headers: { - "Content-Type": "application/x-www-form-urlencoded", - }, - body: params.toString(), - }); + let response: Response; + try { + response = await fetch(`${mpOauthUrl}/connect/token`, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, + body: params.toString(), + // A 307/308 would re-send the form body — client_secret included — to + // whatever origin it names. MP's token endpoint never redirects. + redirect: "error", + signal: AbortSignal.timeout(TOKEN_TIMEOUT_MS), + }); + } catch (error) { + // Name only (TypeError / TimeoutError): the raw error is not ours to log. + throw new Error(`${ERROR_PREFIX}: ${errorName(error)}`); + } if (!response.ok) { - throw new Error(`Failed to get client credentials token: ${response.statusText}`); + // Status first: statusText is empty over HTTP/2. Never the body. + throw new Error(`${ERROR_PREFIX}: ${response.status} ${response.statusText}`.trimEnd()); + } + + const token = await readJsonResponse | undefined>( + response, + ERROR_PREFIX + ); + + // A 200 without a usable bearer token would otherwise be cached and sent as + // "Authorization: Bearer undefined" until it expired. + if ( + !token || + typeof token !== "object" || + typeof token.access_token !== "string" || + token.access_token.trim() === "" || + typeof token.token_type !== "string" || + token.token_type.toLowerCase() !== "bearer" + ) { + throw new Error(`${ERROR_PREFIX}: invalid token response`); } - return await response.json(); + return token as ClientCredentialsToken; } function resolveCredentials(profile: CredentialProfile): { clientId: string; clientSecret: string } { diff --git a/src/lib/providers/ministry-platform/auth/index.ts b/src/lib/providers/ministry-platform/auth/index.ts index fb1a17d..02f270f 100644 --- a/src/lib/providers/ministry-platform/auth/index.ts +++ b/src/lib/providers/ministry-platform/auth/index.ts @@ -1,3 +1,3 @@ export { getClientCredentialsToken } from './client-credentials'; -export type { CredentialProfile } from './client-credentials'; +export type { CredentialProfile, ClientCredentialsToken } from './client-credentials'; export type { MinistryPlatformProfile } from './types'; diff --git a/src/lib/providers/ministry-platform/client.test.ts b/src/lib/providers/ministry-platform/client.test.ts index 4ae7f40..131233e 100644 --- a/src/lib/providers/ministry-platform/client.test.ts +++ b/src/lib/providers/ministry-platform/client.test.ts @@ -195,6 +195,8 @@ describe('MinistryPlatformClient', () => { await expect(client.ensureValidToken()).rejects.toThrow('transient failure'); // A subsequent call after failure must trigger a brand-new fetch (promise cleared in finally) + // once the negative-cache window (at most 30 s) has passed + vi.advanceTimersByTime(30 * 1000 + 1); await client.ensureValidToken(); expect(mockGetClientCredentialsToken).toHaveBeenCalledTimes(2); }); @@ -302,7 +304,7 @@ describe('MinistryPlatformClient', () => { // Each pipeline fetched its own token — one default call, one dev call expect(mockGetClientCredentialsToken).toHaveBeenCalledTimes(2); - expect(mockGetClientCredentialsToken).toHaveBeenNthCalledWith(1); + expect(mockGetClientCredentialsToken).toHaveBeenNthCalledWith(1, 'default'); expect(mockGetClientCredentialsToken).toHaveBeenNthCalledWith(2, 'dev'); // Calling ensureValidToken again should NOT trigger a dev fetch @@ -394,7 +396,7 @@ describe('MinistryPlatformClient', () => { expect(mockGetClientCredentialsToken).toHaveBeenCalledTimes(2); expect(mockGetClientCredentialsToken).toHaveBeenNthCalledWith(1, 'dev'); - expect(mockGetClientCredentialsToken).toHaveBeenNthCalledWith(2); + expect(mockGetClientCredentialsToken).toHaveBeenNthCalledWith(2, 'default'); }); }); @@ -435,9 +437,308 @@ describe('MinistryPlatformClient', () => { // First attempt fails await expect(client.ensureValidToken()).rejects.toThrow('Temporary error'); - // Second attempt succeeds + // Once the negative-cache window (at most 30 s) has passed, a retry succeeds + vi.advanceTimersByTime(30 * 1000 + 1); await client.ensureValidToken(); expect(mockGetClientCredentialsToken).toHaveBeenCalledTimes(2); }); + + it('should log only the error name on refresh failure, never its message', async () => { + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + mockGetClientCredentialsToken.mockRejectedValueOnce( + new Error('Unexpected token "Jane Doe, "... is not valid JSON') + ); + + const client = new MinistryPlatformClient(); + await expect(client.ensureValidToken()).rejects.toThrow(); + + expect(errorSpy).toHaveBeenCalledTimes(1); + expect(errorSpy.mock.calls[0]).toEqual([ + '[MP]', + 'mp.token.refresh_failed', + { profile: 'default', error: 'Error' }, + ]); + }); + }); + + describe('Single-flight refresh and negative cache', () => { + beforeEach(() => { + vi.spyOn(console, 'error').mockImplementation(() => {}); + }); + + it('should make one token request for 50 concurrent cold calls', async () => { + let resolveToken: (value: unknown) => void; + mockGetClientCredentialsToken.mockImplementation( + () => new Promise((resolve) => { resolveToken = resolve; }) + ); + + const client = new MinistryPlatformClient(); + const calls = Array.from({ length: 50 }, () => client.ensureValidToken()); + + resolveToken!({ access_token: 'shared-token', token_type: 'Bearer', expires_in: 3600 }); + await Promise.all(calls); + + expect(mockGetClientCredentialsToken).toHaveBeenCalledTimes(1); + }); + + it('should share one failed refresh across concurrent callers', async () => { + let rejectToken: (reason: unknown) => void; + mockGetClientCredentialsToken.mockImplementation( + () => new Promise((_, reject) => { rejectToken = reject; }) + ); + + const client = new MinistryPlatformClient(); + const calls = Array.from({ length: 20 }, () => client.ensureValidToken()); + + rejectToken!(new Error('503')); + const results = await Promise.allSettled(calls); + + expect(results.every((r) => r.status === 'rejected')).toBe(true); + expect(mockGetClientCredentialsToken).toHaveBeenCalledTimes(1); + }); + + it('should fail fast without a token request inside the jittered window', async () => { + vi.spyOn(Math, 'random').mockReturnValue(0.5); // 5 s + 12.5 s = 17.5 s + mockGetClientCredentialsToken + .mockRejectedValueOnce(new Error('down')) + .mockResolvedValueOnce({ access_token: 'back', token_type: 'Bearer', expires_in: 3600 }); + + const client = new MinistryPlatformClient(); + await expect(client.ensureValidToken()).rejects.toThrow('down'); + + vi.advanceTimersByTime(17 * 1000); + await expect(client.ensureValidToken()).rejects.toThrow( + 'MP access token unavailable: recent refresh failed' + ); + expect(mockGetClientCredentialsToken).toHaveBeenCalledTimes(1); + + vi.advanceTimersByTime(1000); + await client.ensureValidToken(); + expect(mockGetClientCredentialsToken).toHaveBeenCalledTimes(2); + }); + + it('should hold failures for at least 5 s even with zero jitter', async () => { + vi.spyOn(Math, 'random').mockReturnValue(0); + mockGetClientCredentialsToken.mockRejectedValue(new Error('down')); + + const client = new MinistryPlatformClient(); + await expect(client.ensureValidToken()).rejects.toThrow('down'); + + vi.advanceTimersByTime(4999); + await expect(client.ensureValidToken()).rejects.toThrow('recent refresh failed'); + + vi.advanceTimersByTime(2); + await expect(client.ensureValidToken()).rejects.toThrow('down'); + expect(mockGetClientCredentialsToken).toHaveBeenCalledTimes(2); + }); + + it('should clear the negative cache after a successful refresh', async () => { + vi.spyOn(Math, 'random').mockReturnValue(0); + mockGetClientCredentialsToken + .mockRejectedValueOnce(new Error('down')) + .mockResolvedValueOnce({ access_token: 'a', token_type: 'Bearer', expires_in: 60 }) + .mockResolvedValueOnce({ access_token: 'b', token_type: 'Bearer', expires_in: 60 }); + + const client = new MinistryPlatformClient(); + await expect(client.ensureValidToken()).rejects.toThrow('down'); + vi.advanceTimersByTime(5001); + await client.ensureValidToken(); + + // Token 'a' lives 5 min; the next refresh must not be blocked by the old failure + vi.advanceTimersByTime(5 * 60 * 1000 + 1); + await client.ensureValidToken(); + expect(mockGetClientCredentialsToken).toHaveBeenCalledTimes(3); + }); + }); + + describe('401 handling through the HttpClient', () => { + const originalEnv = { ...process.env }; + let fetchMock: ReturnType; + + const jsonHeaders = { get: (n: string) => (n.toLowerCase() === 'content-type' ? 'application/json' : null) }; + const ok = (body: unknown) => ({ ok: true, status: 200, statusText: 'OK', headers: jsonHeaders, json: () => Promise.resolve(body) }); + const unauthorized = () => ({ ok: false, status: 401, statusText: 'Unauthorized', headers: jsonHeaders, json: vi.fn() }); + const authHeader = (call: number) => fetchMock.mock.calls[call][1].headers.Authorization; + + beforeEach(() => { + process.env.MINISTRY_PLATFORM_BASE_URL = 'https://mp.example.org/ministryplatformapi'; + fetchMock = vi.fn(); + vi.stubGlobal('fetch', fetchMock); + vi.spyOn(console, 'error').mockImplementation(() => {}); + mockGetClientCredentialsToken + .mockResolvedValueOnce({ access_token: 'revoked', token_type: 'Bearer', expires_in: 3600 }) + .mockResolvedValueOnce({ access_token: 'fresh', token_type: 'Bearer', expires_in: 3600 }); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + process.env = { ...originalEnv }; + }); + + it('should invalidate the token, refresh once, and retry once', async () => { + fetchMock.mockResolvedValueOnce(unauthorized()).mockResolvedValueOnce(ok([{ Contact_ID: 1 }])); + + const client = new MinistryPlatformClient(); + await client.ensureValidToken(); + const result = await client.getHttpClient().get('/tables/Contacts'); + + expect(result).toEqual([{ Contact_ID: 1 }]); + expect(mockGetClientCredentialsToken).toHaveBeenCalledTimes(2); + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(authHeader(0)).toBe('Bearer revoked'); + expect(authHeader(1)).toBe('Bearer fresh'); + }); + + it('should not loop when the retry is also rejected', async () => { + fetchMock.mockResolvedValue(unauthorized()); + + const client = new MinistryPlatformClient(); + await client.ensureValidToken(); + + await expect(client.getHttpClient().get('/tables/Contacts')).rejects.toThrow( + 'GET /tables/Contacts failed: 401 Unauthorized' + ); + expect(mockGetClientCredentialsToken).toHaveBeenCalledTimes(2); + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + + it('should refresh once for a burst of 401s on the same stale token', async () => { + fetchMock + .mockResolvedValueOnce(unauthorized()) + .mockResolvedValueOnce(unauthorized()) + .mockResolvedValue(ok([])); + + const client = new MinistryPlatformClient(); + await client.ensureValidToken(); + const http = client.getHttpClient(); + await Promise.all([http.get('/tables/A'), http.get('/tables/B')]); + + expect(mockGetClientCredentialsToken).toHaveBeenCalledTimes(2); + expect(fetchMock).toHaveBeenCalledTimes(4); + }); + + it('should not invalidate a token that was already replaced', async () => { + fetchMock.mockResolvedValueOnce(unauthorized()).mockResolvedValue(ok([])); + + const client = new MinistryPlatformClient(); + await client.ensureValidToken(); // 'revoked' + await client.getHttpClient().get('/tables/A'); // 401 -> now 'fresh' + + // A late 401 for 'revoked' arriving after 'fresh' is in place is a no-op + const pipeline = (client as unknown as { main: { handleUnauthorized: (t: string) => Promise } }).main; + const handler = pipeline.handleUnauthorized.bind(pipeline); + await handler('revoked'); + + expect(mockGetClientCredentialsToken).toHaveBeenCalledTimes(2); + }); + + it('should surface the 401 when the refresh itself fails', async () => { + mockGetClientCredentialsToken.mockReset(); + mockGetClientCredentialsToken + .mockResolvedValueOnce({ access_token: 'revoked', token_type: 'Bearer', expires_in: 3600 }) + .mockRejectedValueOnce(new Error('token endpoint down')); + fetchMock.mockResolvedValue(unauthorized()); + + const client = new MinistryPlatformClient(); + await client.ensureValidToken(); + + await expect(client.getHttpClient().get('/tables/Contacts')).rejects.toThrow( + 'GET /tables/Contacts failed: 401 Unauthorized' + ); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + }); + + describe('Secret handling on token-refresh failure', () => { + const originalEnv = { ...process.env }; + const SECRET = 'super-secret-client-secret-value'; + + afterEach(() => { + vi.unstubAllGlobals(); + process.env = { ...originalEnv }; + }); + + it('should never pass the client secret to console.error', async () => { + // Exercise the real token function behind the mock, with a token + // endpoint that rejects the request + const actual = await vi.importActual< + typeof import('@/lib/providers/ministry-platform/auth/client-credentials') + >('@/lib/providers/ministry-platform/auth/client-credentials'); + mockGetClientCredentialsToken.mockImplementation(actual.getClientCredentialsToken); + + process.env.MINISTRY_PLATFORM_BASE_URL = 'https://mp.example.org/ministryplatformapi'; + process.env.MINISTRY_PLATFORM_CLIENT_ID = 'client-id'; + process.env.MINISTRY_PLATFORM_CLIENT_SECRET = SECRET; + + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + const fetchMock = vi.fn() + .mockResolvedValueOnce({ ok: false, status: 401, statusText: 'Unauthorized', json: vi.fn() }) + .mockRejectedValueOnce(new TypeError(`fetch failed (body: client_secret=${SECRET})`)); + vi.stubGlobal('fetch', fetchMock); + + const client = new MinistryPlatformClient(); + await expect(client.ensureValidToken()).rejects.toThrow(); + vi.advanceTimersByTime(31 * 1000); + await expect(client.ensureValidToken()).rejects.toThrow(); + + // The secret really was sent, so its absence from the logs is meaningful + expect(String(fetchMock.mock.calls[0][1].body)).toContain(SECRET); + expect(errorSpy).toHaveBeenCalledTimes(2); + for (const args of errorSpy.mock.calls) { + const logged = args + .map((a) => (a instanceof Error ? `${a.name} ${a.message} ${String(a.cause)}` : JSON.stringify(a))) + .join(' '); + expect(logged).not.toContain(SECRET); + } + }); + }); + + describe('Dev pipeline isolation (401 retry and negative cache)', () => { + let fetchMock: ReturnType; + const jsonHeaders = { get: (n: string) => (n.toLowerCase() === 'content-type' ? 'application/json' : null) }; + + beforeEach(() => { + fetchMock = vi.fn(); + vi.stubGlobal('fetch', fetchMock); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('should refresh the DEV token (not the default one) on a dev 401', async () => { + mockGetClientCredentialsToken + .mockResolvedValueOnce({ access_token: 'dev-revoked', token_type: 'Bearer' }) + .mockResolvedValueOnce({ access_token: 'dev-fresh', token_type: 'Bearer' }); + fetchMock + .mockResolvedValueOnce({ ok: false, status: 401, statusText: 'Unauthorized', headers: jsonHeaders }) + .mockResolvedValueOnce({ ok: true, status: 200, headers: jsonHeaders, json: () => Promise.resolve([[]]) }); + + const client = new MinistryPlatformClient(); + await client.ensureValidDevToken(); + await expect(client.getDevHttpClient().get('/procs/api_dev_X')).resolves.toEqual([[]]); + + expect(mockGetClientCredentialsToken).toHaveBeenCalledTimes(2); + expect(mockGetClientCredentialsToken).toHaveBeenNthCalledWith(1, 'dev'); + expect(mockGetClientCredentialsToken).toHaveBeenNthCalledWith(2, 'dev'); + expect(fetchMock.mock.calls[1][1].headers.Authorization).toBe('Bearer dev-fresh'); + }); + + it('should not let a failed dev refresh put the default pipeline in backoff', async () => { + mockGetClientCredentialsToken + .mockRejectedValueOnce(new Error('Dev client credentials are not configured')) + .mockResolvedValueOnce({ access_token: 'default-ok', token_type: 'Bearer' }); + + const client = new MinistryPlatformClient(); + await expect(client.ensureValidDevToken()).rejects.toThrow('not configured'); + await expect(client.ensureValidToken()).resolves.toBeUndefined(); + await expect(client.ensureValidDevToken()).rejects.toThrow('recent refresh failed'); + + expect(mockGetClientCredentialsToken).toHaveBeenCalledTimes(2); + expect(console.error).toHaveBeenCalledWith('[MP]', 'mp.token.refresh_failed', { + profile: 'dev', + error: 'Error', + }); + }); }); }); diff --git a/src/lib/providers/ministry-platform/client.ts b/src/lib/providers/ministry-platform/client.ts index ebf9dd7..3d333d2 100644 --- a/src/lib/providers/ministry-platform/client.ts +++ b/src/lib/providers/ministry-platform/client.ts @@ -1,17 +1,87 @@ -import { getClientCredentialsToken } from "./auth/client-credentials"; +import { getClientCredentialsToken, type CredentialProfile } from "./auth/client-credentials"; import { HttpClient } from "./utils/http-client"; -import { logger } from "./utils/logger"; +import { errorName, logger } from "./utils/logger"; import { getMpBaseUrl } from "@/lib/env"; -// Token refresh interval - refresh 5 minutes before actual expiration for safety +// Token refresh interval - refresh well before the real (1 hour) expiration. const TOKEN_LIFE = 5 * 60 * 1000; // 5 minutes +// After a failed refresh, fail fast for a short, jittered window instead of +// letting every concurrent caller hit a token endpoint that is already down. +export const NEGATIVE_CACHE_MIN_MS = 5 * 1000; +export const NEGATIVE_CACHE_JITTER_MS = 25 * 1000; + +/** + * One credential pipeline: a cached token, a single-flight refresh shared by + * concurrent callers, a negative cache after a failed refresh, and a 401 hook + * that invalidates the token the API just rejected. + */ +class TokenPipeline { + token = ""; + private expiresAt = 0; // epoch ms; 0 forces the first refresh + private inflight: Promise | null = null; + private failedUntil = 0; // epoch ms before which refreshes are not re-attempted + readonly httpClient: HttpClient; + + constructor(baseUrl: string, private readonly profile: CredentialProfile) { + this.httpClient = new HttpClient( + baseUrl, + () => this.token, + (rejectedToken) => this.handleUnauthorized(rejectedToken) + ); + } + + async ensureValid(): Promise { + if (this.expiresAt >= Date.now()) return; + + // Dedup concurrent callers: the first caller to find the token expired + // starts the refresh; subsequent callers await the same promise. + if (!this.inflight) { + if (Date.now() < this.failedUntil) { + throw new Error("MP access token unavailable: recent refresh failed"); + } + this.inflight = this.refresh().finally(() => { + this.inflight = null; + }); + } + return this.inflight; + } + + private async refresh(): Promise { + try { + // Validated in getClientCredentialsToken: non-empty access_token, + // bearer token_type. + const creds = await getClientCredentialsToken(this.profile); + this.token = creds.access_token; + this.expiresAt = Date.now() + TOKEN_LIFE; + this.failedUntil = 0; + } catch (error) { + this.failedUntil = + Date.now() + NEGATIVE_CACHE_MIN_MS + Math.floor(Math.random() * NEGATIVE_CACHE_JITTER_MS); + // Name only: never the raw error, which could carry response content. + logger.error("mp.token.refresh_failed", { profile: this.profile, error: errorName(error) }); + throw error; + } + } + + private async handleUnauthorized(rejectedToken: string): Promise { + // Only invalidate if nobody has replaced the token since it was sent, + // so a burst of 401s for one stale token triggers a single refresh. + if (this.token === rejectedToken) { + this.expiresAt = 0; + } + await this.ensureValid(); + } +} + /** * MinistryPlatformClient - Core HTTP client with automatic authentication management * * Manages OAuth2 client credentials authentication and provides a configured HttpClient * instance for all Ministry Platform API operations. Handles token lifecycle including - * automatic refresh before expiration. + * automatic refresh before expiration, one shared refresh for concurrent callers, a + * short negative cache after a failed refresh, and a forced refresh + single retry when + * the API rejects the token with a 401. * * Also manages a parallel, isolated dev-credentials token pipeline used exclusively for * executing `api_dev_*` stored procedures against a non-production MP environment. The @@ -20,19 +90,8 @@ const TOKEN_LIFE = 5 * 60 * 1000; // 5 minutes * through the dev pipeline. */ export class MinistryPlatformClient { - // Default (production) credential pipeline - private token: string = ""; - private expiresAt: Date = new Date(0); - private httpClient: HttpClient; - private refreshPromise: Promise | null = null; - - // Dev credential pipeline — used only by ProcedureService for api_dev_* procs - private devToken: string = ""; - private devExpiresAt: Date = new Date(0); - private devHttpClient: HttpClient; - private devRefreshPromise: Promise | null = null; - - private baseUrl: string; + private readonly main: TokenPipeline; + private readonly dev: TokenPipeline; /** * Creates a new MinistryPlatformClient instance @@ -40,40 +99,18 @@ export class MinistryPlatformClient { */ constructor() { // Validated and normalized (see src/lib/env.ts); throws on a bad value. - this.baseUrl = getMpBaseUrl(); - this.httpClient = new HttpClient(this.baseUrl, () => this.token); - this.devHttpClient = new HttpClient(this.baseUrl, () => this.devToken); + const baseUrl = getMpBaseUrl(); + this.main = new TokenPipeline(baseUrl, 'default'); + this.dev = new TokenPipeline(baseUrl, 'dev'); } /** * Ensures the default authentication token is valid and refreshes if necessary. * Call before making any default-pipeline API request. - * @throws Error if token refresh fails + * @throws Error if token refresh fails (or failed within the negative-cache window) */ public async ensureValidToken(): Promise { - - if (this.expiresAt >= new Date()) return; - - // Dedup concurrent callers: the first caller to find the token expired - // starts the refresh; subsequent callers await the same in-flight promise. - if (this.refreshPromise) return this.refreshPromise; - - - this.refreshPromise = (async () => { - try { - const creds = await getClientCredentialsToken(); - this.token = creds.access_token; - this.expiresAt = new Date(Date.now() + TOKEN_LIFE); - - } catch (error) { - logger.error("Failed to refresh token:", error); - throw error; - } finally { - this.refreshPromise = null; - } - })(); - - return this.refreshPromise; + return this.main.ensureValid(); } /** @@ -82,28 +119,7 @@ export class MinistryPlatformClient { * @throws Error if token refresh fails or dev credentials are not configured */ public async ensureValidDevToken(): Promise { - - if (this.devExpiresAt >= new Date()) return; - - // Dedup concurrent callers on the dev pipeline (symmetric with default pipeline). - if (this.devRefreshPromise) return this.devRefreshPromise; - - - this.devRefreshPromise = (async () => { - try { - const creds = await getClientCredentialsToken('dev'); - this.devToken = creds.access_token; - this.devExpiresAt = new Date(Date.now() + TOKEN_LIFE); - - } catch (error) { - logger.error("Failed to refresh dev token:", error); - throw error; - } finally { - this.devRefreshPromise = null; - } - })(); - - return this.devRefreshPromise; + return this.dev.ensureValid(); } /** @@ -111,7 +127,7 @@ export class MinistryPlatformClient { * @returns HttpClient bound to the default token */ public getHttpClient(): HttpClient { - return this.httpClient; + return this.main.httpClient; } /** @@ -120,6 +136,6 @@ export class MinistryPlatformClient { * @returns HttpClient bound to the dev token */ public getDevHttpClient(): HttpClient { - return this.devHttpClient; + return this.dev.httpClient; } } diff --git a/src/lib/providers/ministry-platform/helper.ts b/src/lib/providers/ministry-platform/helper.ts index c78a001..eba9900 100644 --- a/src/lib/providers/ministry-platform/helper.ts +++ b/src/lib/providers/ministry-platform/helper.ts @@ -385,7 +385,6 @@ export class MPHelper { * Retrieves available global filters for the domain * Global filters provide domain-wide data filtering capabilities * @param params - Optional parameters for the global filters request - * @param params.$ignorePermissions - Whether to ignore user permissions when retrieving filters * @param params.$userId - User context for permission-based filter access * @returns Promise resolving to array of global filter items with keys and display values * @throws Error if authentication fails or filters are inaccessible diff --git a/src/lib/providers/ministry-platform/services/communication.service.ts b/src/lib/providers/ministry-platform/services/communication.service.ts index 061fcf9..d9a92d0 100644 --- a/src/lib/providers/ministry-platform/services/communication.service.ts +++ b/src/lib/providers/ministry-platform/services/communication.service.ts @@ -1,6 +1,6 @@ import { MinistryPlatformClient } from "../client"; import { CommunicationInfo, Communication, MessageInfo, TableQueryParams, QueryParams } from "../types"; -import { logger } from "../utils/logger"; +import { errorName, logger } from "../utils/logger"; type UserIdParams = Pick; @@ -35,7 +35,7 @@ export class CommunicationService { return await this.client.getHttpClient().post('/communications', { ...communication }, queryParams); } } catch (error) { - logger.error('Error creating communication:', error); + logger.error('mp.communication.create_failed', { error: errorName(error) }); throw error; } } @@ -58,7 +58,7 @@ export class CommunicationService { return await this.client.getHttpClient().post('/messages', { ...message }, queryParams); } } catch (error) { - logger.error('Error sending message:', error); + logger.error('mp.message.send_failed', { error: errorName(error) }); throw error; } } diff --git a/src/lib/providers/ministry-platform/services/domain.service.test.ts b/src/lib/providers/ministry-platform/services/domain.service.test.ts index 90ab7bd..a62b965 100644 --- a/src/lib/providers/ministry-platform/services/domain.service.test.ts +++ b/src/lib/providers/ministry-platform/services/domain.service.test.ts @@ -79,7 +79,7 @@ describe('DomainService', () => { const result = await service.getGlobalFilters(); expect(mockClient.ensureValidToken).toHaveBeenCalledTimes(1); - expect(mockHttpClient.get).toHaveBeenCalledWith('/domain/filters', undefined); + expect(mockHttpClient.get).toHaveBeenCalledWith('/domain/filters', {}); expect(result).toEqual([]); }); @@ -93,6 +93,21 @@ describe('DomainService', () => { expect(result).toEqual(filters); }); + it('should drop a smuggled $ignorePermissions and forward only $userId', async () => { + (mockHttpClient.get as any).mockResolvedValueOnce([]); + + await service.getGlobalFilters({ $ignorePermissions: true, $userId: 7, $filter: 'x' } as any); + + expect(mockHttpClient.get).toHaveBeenCalledWith('/domain/filters', { $userId: 7 }); + }); + + it('should refuse a non-integer $userId before any token work', async () => { + await expect(service.getGlobalFilters({ $userId: '7 OR 1=1' } as any)).rejects.toThrow( + 'Expected positive integer for user ID' + ); + expect(mockClient.ensureValidToken).not.toHaveBeenCalled(); + }); + it('should propagate errors', async () => { (mockHttpClient.get as any).mockRejectedValueOnce(new Error('403 Forbidden')); diff --git a/src/lib/providers/ministry-platform/services/domain.service.ts b/src/lib/providers/ministry-platform/services/domain.service.ts index 07c6a56..e4b4507 100644 --- a/src/lib/providers/ministry-platform/services/domain.service.ts +++ b/src/lib/providers/ministry-platform/services/domain.service.ts @@ -1,6 +1,7 @@ import { MinistryPlatformClient } from "../client"; import { DomainInfo, GlobalFilterItem, GlobalFilterParams, QueryParams } from "../types"; -import { logger } from "../utils/logger"; +import { errorName, logger } from "../utils/logger"; +import { sanitizeRecordId } from "./guards"; export class DomainService { private client: MinistryPlatformClient; @@ -18,7 +19,7 @@ export class DomainService { await this.client.ensureValidToken(); return await this.client.getHttpClient().get('/domain'); } catch (error) { - logger.error('Error getting domain info:', error); + logger.error('mp.domain.get_info_failed', { error: errorName(error) }); throw error; } } @@ -27,15 +28,26 @@ export class DomainService { * Returns the lookup values to be used as global filters. The key corresponds to * an identifier and the value corresponds to a friendly name (description). Zero (0) * identifier corresponds to records with not assigned filters. + * + * Only `$userId` is forwarded. `$ignorePermissions` is deliberately + * dropped (and removed from the type): the request already runs as the + * admin-level service account, and nothing in the app needs MP to widen + * that further. An allowlist, not a pass-through, so an untyped caller + * that smuggles the flag in still does not get it. + * * @param params Optional parameters for the global filters request * @returns Promise with an array of global filter items */ public async getGlobalFilters(params?: GlobalFilterParams): Promise { + const query: QueryParams = {}; + if (params?.$userId !== undefined) { + query.$userId = sanitizeRecordId(params.$userId, 'user ID'); + } try { await this.client.ensureValidToken(); - return await this.client.getHttpClient().get('/domain/filters', params as QueryParams); + return await this.client.getHttpClient().get('/domain/filters', query); } catch (error) { - logger.error('Error getting global filters:', error); + logger.error('mp.domain.get_global_filters_failed', { error: errorName(error) }); throw error; } } diff --git a/src/lib/providers/ministry-platform/services/file.service.test.ts b/src/lib/providers/ministry-platform/services/file.service.test.ts index a1be7b6..d3eabb6 100644 --- a/src/lib/providers/ministry-platform/services/file.service.test.ts +++ b/src/lib/providers/ministry-platform/services/file.service.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { FileService } from '@/lib/providers/ministry-platform/services/file.service'; +import { FILE_CONTENT_TIMEOUT_MS, FileService } from '@/lib/providers/ministry-platform/services/file.service'; import type { MinistryPlatformClient } from '@/lib/providers/ministry-platform/client'; import type { HttpClient } from '@/lib/providers/ministry-platform/utils/http-client'; @@ -247,10 +247,14 @@ describe('FileService', () => { blob: async () => blob, }) as any; - const result = await service.getFileContentByUniqueId('abc'); + const result = await service.getFileContentByUniqueId('0f8fad5b-d9cb-469f-a165-70867728950e'); - expect(mockHttpClient.buildUrl).toHaveBeenCalledWith('/files/abc', {}); - expect(global.fetch).toHaveBeenCalledWith('https://example.com/files/abc', { method: 'GET' }); + expect(mockHttpClient.buildUrl).toHaveBeenCalledWith('/files/0f8fad5b-d9cb-469f-a165-70867728950e', {}); + expect(global.fetch).toHaveBeenCalledWith('https://example.com/files/abc', { + method: 'GET', + signal: expect.any(AbortSignal), + redirect: 'error', + }); expect(result).toBe(blob); }); @@ -261,9 +265,9 @@ describe('FileService', () => { blob: async () => new Blob(), }) as any; - await service.getFileContentByUniqueId('abc', true); + await service.getFileContentByUniqueId('0f8fad5b-d9cb-469f-a165-70867728950e', true); - expect(mockHttpClient.buildUrl).toHaveBeenCalledWith('/files/abc', { $thumbnail: 'true' }); + expect(mockHttpClient.buildUrl).toHaveBeenCalledWith('/files/0f8fad5b-d9cb-469f-a165-70867728950e', { $thumbnail: 'true' }); }); it('should pass $thumbnail=false', async () => { @@ -273,9 +277,9 @@ describe('FileService', () => { blob: async () => new Blob(), }) as any; - await service.getFileContentByUniqueId('abc', false); + await service.getFileContentByUniqueId('0f8fad5b-d9cb-469f-a165-70867728950e', false); - expect(mockHttpClient.buildUrl).toHaveBeenCalledWith('/files/abc', { $thumbnail: 'false' }); + expect(mockHttpClient.buildUrl).toHaveBeenCalledWith('/files/0f8fad5b-d9cb-469f-a165-70867728950e', { $thumbnail: 'false' }); }); it('should not call ensureValidToken', async () => { @@ -285,7 +289,7 @@ describe('FileService', () => { blob: async () => new Blob(), }) as any; - await service.getFileContentByUniqueId('abc'); + await service.getFileContentByUniqueId('0f8fad5b-d9cb-469f-a165-70867728950e'); expect(mockClient.ensureValidToken).not.toHaveBeenCalled(); }); @@ -298,8 +302,8 @@ describe('FileService', () => { statusText: 'Not Found', }) as any; - await expect(service.getFileContentByUniqueId('abc')).rejects.toThrow( - 'GET /files/abc failed: 404 Not Found' + await expect(service.getFileContentByUniqueId('0f8fad5b-d9cb-469f-a165-70867728950e')).rejects.toThrow( + 'GET /files/{uniqueId} failed: 404 Not Found' ); }); }); @@ -328,17 +332,115 @@ describe('FileService', () => { const meta = { FileId: 1, FileName: 'doc.pdf' }; (mockHttpClient.get as any).mockResolvedValueOnce(meta); - const result = await service.getFileMetadataByUniqueId('abc-123'); + const result = await service.getFileMetadataByUniqueId('0f8fad5b-d9cb-469f-a165-70867728950e'); expect(mockClient.ensureValidToken).toHaveBeenCalledTimes(1); - expect(mockHttpClient.get).toHaveBeenCalledWith('/files/abc-123/metadata'); + expect(mockHttpClient.get).toHaveBeenCalledWith('/files/0f8fad5b-d9cb-469f-a165-70867728950e/metadata'); expect(result).toEqual(meta); }); it('should propagate errors', async () => { (mockHttpClient.get as any).mockRejectedValueOnce(new Error('404 Not Found')); - await expect(service.getFileMetadataByUniqueId('abc')).rejects.toThrow('404 Not Found'); + await expect(service.getFileMetadataByUniqueId('0f8fad5b-d9cb-469f-a165-70867728950e')).rejects.toThrow('404 Not Found'); }); }); }); + +describe('FileService - path and ID guards', () => { + let service: FileService; + let mockClient: MinistryPlatformClient; + let mockHttpClient: HttpClient; + const GUID = '0f8fad5b-d9cb-469f-a165-70867728950e'; + + beforeEach(() => { + mockHttpClient = { + get: vi.fn().mockResolvedValue([]), + delete: vi.fn().mockResolvedValue(undefined), + buildUrl: vi.fn().mockReturnValue('https://example.com/files/x'), + postFormData: vi.fn().mockResolvedValue([]), + putFormData: vi.fn().mockResolvedValue({}), + } as unknown as HttpClient; + mockClient = { + ensureValidToken: vi.fn().mockResolvedValue(undefined), + getHttpClient: vi.fn().mockReturnValue(mockHttpClient), + } as unknown as MinistryPlatformClient; + service = new FileService(mockClient); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + function expectNothingSent() { + expect(mockClient.ensureValidToken).not.toHaveBeenCalled(); + expect(mockHttpClient.get).not.toHaveBeenCalled(); + expect(mockHttpClient.delete).not.toHaveBeenCalled(); + expect(mockHttpClient.postFormData).not.toHaveBeenCalled(); + expect(mockHttpClient.putFormData).not.toHaveBeenCalled(); + expect(mockHttpClient.buildUrl).not.toHaveBeenCalled(); + } + + it.each(['..', '../tables/Contacts', 'Contacts/1', '%2e%2e'])( + 'should refuse the table name %j', + async (table) => { + await expect(service.getFilesByRecord(table, 1)).rejects.toThrow(/^Invalid table name$/); + await expect(service.uploadFiles(table, 1, [])).rejects.toThrow(/^Invalid table name$/); + expectNothingSent(); + } + ); + + it.each([['a traversal', '1/../../tables/Contacts'], ['zero', 0], ['a fraction', 1.5]])( + 'should refuse %s as a record or file ID', + async (_label, id) => { + const bad = id as unknown as number; + await expect(service.getFilesByRecord('Contacts', bad)).rejects.toThrow(/^Expected positive integer for record ID$/); + await expect(service.uploadFiles('Contacts', bad, [])).rejects.toThrow(/^Expected positive integer for record ID$/); + await expect(service.updateFile(bad)).rejects.toThrow(/^Expected positive integer for file ID$/); + await expect(service.deleteFile(bad)).rejects.toThrow(/^Expected positive integer for file ID$/); + await expect(service.getFileMetadata(bad)).rejects.toThrow(/^Expected positive integer for file ID$/); + expectNothingSent(); + } + ); + + it.each(['abc', '..', `${GUID}/../../tables/Contacts`, `${GUID}?x=1`])( + 'should refuse the unique ID %j', + async (uniqueId) => { + await expect(service.getFileContentByUniqueId(uniqueId)).rejects.toThrow(/^Invalid GUID format$/); + await expect(service.getFileMetadataByUniqueId(uniqueId)).rejects.toThrow(/^Invalid GUID format$/); + expectNothingSent(); + } + ); + + it('should keep the unique ID (a download capability) out of logs and messages on a failed download', async () => { + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false, status: 404, statusText: 'Not Found' })); + + const err = await service.getFileContentByUniqueId(GUID).catch((e: Error) => e); + + expect((err as Error).message).toBe('GET /files/{uniqueId} failed: 404 Not Found'); + expect(JSON.stringify(errorSpy.mock.calls)).not.toContain(GUID); + expect(errorSpy).toHaveBeenCalledWith('[MP]', 'mp.files.get_content_failed', { error: 'Error' }); + }); + + it('should give the unauthenticated download its own deadline and refuse redirects', async () => { + const timeoutSpy = vi.spyOn(AbortSignal, 'timeout'); + const fetchMock = vi.fn().mockResolvedValue({ ok: true, blob: async () => new Blob([]) }); + vi.stubGlobal('fetch', fetchMock); + + await service.getFileContentByUniqueId(GUID); + + expect(timeoutSpy).toHaveBeenCalledWith(FILE_CONTENT_TIMEOUT_MS); + expect(fetchMock.mock.calls[0][1].redirect).toBe('error'); + }); + + it('should report a download timeout by name only', async () => { + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new DOMException(`timed out ${GUID}`, 'TimeoutError'))); + + await expect(service.getFileContentByUniqueId(GUID)).rejects.toThrow(); + + expect(errorSpy).toHaveBeenCalledWith('[MP]', 'mp.files.get_content_failed', { error: 'TimeoutError' }); + expect(JSON.stringify(errorSpy.mock.calls)).not.toContain(GUID); + }); +}); diff --git a/src/lib/providers/ministry-platform/services/file.service.ts b/src/lib/providers/ministry-platform/services/file.service.ts index c8805ff..a59be2d 100644 --- a/src/lib/providers/ministry-platform/services/file.service.ts +++ b/src/lib/providers/ministry-platform/services/file.service.ts @@ -1,6 +1,33 @@ import { MinistryPlatformClient } from "../client"; import { FileDescription, FileUpdateParams, FileUploadParams } from "../types"; import { logger } from "../utils/logger"; +import { errorName, sanitizeIdentifier, sanitizeRecordId, sanitizeUniqueId } from "./guards"; + +/** + * Timeout for the one fetch this service makes itself (the public file-content + * download). Every other call goes through HttpClient. + */ +export const FILE_CONTENT_TIMEOUT_MS = 20_000; + +/* + * Path segments for the `/files` endpoints. Each value is validated for its + * shape (identifier / positive integer / GUID) and then encoded, so a caller + * cannot aim the service bearer at another MP path with `..`, `/`, `?` or `#`. + * They run before any token or network work, and throw a fixed message that + * never echoes the input. File unique IDs are download capabilities (MP serves + * `/files/{uniqueId}` unauthenticated), so they never reach a log or message. + */ +function tableSegment(table: unknown): string { + return encodeURIComponent(sanitizeIdentifier(table, 'table name')); +} + +function idSegment(id: unknown, field: string): string { + return encodeURIComponent(String(sanitizeRecordId(id, field))); +} + +function guidSegment(uniqueFileId: unknown): string { + return encodeURIComponent(sanitizeUniqueId(uniqueFileId)); +} export class FileService { private client: MinistryPlatformClient; @@ -17,6 +44,7 @@ export class FileService { recordId: number, defaultOnly?: boolean ): Promise { + const endpoint = `/files/${tableSegment(table)}/${idSegment(recordId, 'record ID')}`; try { await this.client.ensureValidToken(); @@ -26,11 +54,11 @@ export class FileService { } return await this.client.getHttpClient().get( - `/files/${table}/${recordId}`, + endpoint, queryParams ); } catch (error) { - logger.error('Error getting files by record:', error); + logger.error('mp.files.get_by_record_failed', { table, error: errorName(error) }); throw error; } } @@ -44,6 +72,7 @@ export class FileService { files: File[], params?: FileUploadParams ): Promise { + const endpoint = `/files/${tableSegment(table)}/${idSegment(recordId, 'record ID')}`; try { await this.client.ensureValidToken(); @@ -71,12 +100,12 @@ export class FileService { if (params?.userId) queryParams['$userId'] = params.userId.toString(); return await this.client.getHttpClient().postFormData( - `/files/${table}/${recordId}`, + endpoint, formData, queryParams ); } catch (error) { - logger.error('Error uploading files:', error); + logger.error('mp.files.upload_failed', { table, count: files.length, error: errorName(error) }); throw error; } } @@ -89,6 +118,7 @@ export class FileService { file?: File, params?: FileUpdateParams ): Promise { + const endpoint = `/files/${idSegment(fileId, 'file ID')}`; try { await this.client.ensureValidToken(); @@ -120,12 +150,12 @@ export class FileService { if (params?.userId) queryParams['$userId'] = params.userId.toString(); return await this.client.getHttpClient().putFormData( - `/files/${fileId}`, + endpoint, formData, queryParams ); } catch (error) { - logger.error('Error updating file:', error); + logger.error('mp.files.update_failed', { error: errorName(error) }); throw error; } } @@ -137,6 +167,7 @@ export class FileService { fileId: number, userId?: number ): Promise { + const endpoint = `/files/${idSegment(fileId, 'file ID')}`; try { await this.client.ensureValidToken(); @@ -145,9 +176,9 @@ export class FileService { queryParams['$userId'] = userId.toString(); } - await this.client.getHttpClient().delete(`/files/${fileId}`, queryParams); + await this.client.getHttpClient().delete(endpoint, queryParams); } catch (error) { - logger.error('Error deleting file:', error); + logger.error('mp.files.delete_failed', { error: errorName(error) }); throw error; } } @@ -160,26 +191,31 @@ export class FileService { uniqueFileId: string, thumbnail?: boolean ): Promise { + const endpoint = `/files/${guidSegment(uniqueFileId)}`; try { const queryParams: Record = {}; if (thumbnail !== undefined) { queryParams['$thumbnail'] = thumbnail.toString(); } - const url = this.client.getHttpClient().buildUrl(`/files/${uniqueFileId}`, queryParams); + const url = this.client.getHttpClient().buildUrl(endpoint, queryParams); const response = await fetch(url, { - method: 'GET' - // No authorization header needed for this endpoint + method: 'GET', + // No authorization header needed for this endpoint. A stalled MP + // must not hold the request for undici's 300 s default, and the + // MP API has no legitimate redirects. + signal: AbortSignal.timeout(FILE_CONTENT_TIMEOUT_MS), + redirect: 'error', }); if (!response.ok) { - throw new Error(`GET /files/${uniqueFileId} failed: ${response.status} ${response.statusText}`); + throw new Error(`GET /files/{uniqueId} failed: ${response.status} ${response.statusText}`); } return await response.blob(); } catch (error) { - logger.error('Error getting file content by unique ID:', error); + logger.error('mp.files.get_content_failed', { error: errorName(error) }); throw error; } } @@ -188,12 +224,13 @@ export class FileService { * Returns the file metadata (description) corresponding to provided database identifier. */ public async getFileMetadata(fileId: number): Promise { + const endpoint = `/files/${idSegment(fileId, 'file ID')}/metadata`; try { await this.client.ensureValidToken(); - return await this.client.getHttpClient().get(`/files/${fileId}/metadata`); + return await this.client.getHttpClient().get(endpoint); } catch (error) { - logger.error('Error getting file metadata:', error); + logger.error('mp.files.get_metadata_failed', { error: errorName(error) }); throw error; } } @@ -202,12 +239,13 @@ export class FileService { * Returns the file metadata (description) corresponding to provided globally unique identifier. */ public async getFileMetadataByUniqueId(uniqueFileId: string): Promise { + const endpoint = `/files/${guidSegment(uniqueFileId)}/metadata`; try { await this.client.ensureValidToken(); - return await this.client.getHttpClient().get(`/files/${uniqueFileId}/metadata`); + return await this.client.getHttpClient().get(endpoint); } catch (error) { - logger.error('Error getting file metadata by unique ID:', error); + logger.error('mp.files.get_metadata_by_unique_id_failed', { error: errorName(error) }); throw error; } } diff --git a/src/lib/providers/ministry-platform/services/guards.test.ts b/src/lib/providers/ministry-platform/services/guards.test.ts new file mode 100644 index 0000000..40fe685 --- /dev/null +++ b/src/lib/providers/ministry-platform/services/guards.test.ts @@ -0,0 +1,102 @@ +import { describe, it, expect } from 'vitest'; +import { + errorName, + sanitizeIdentifier, + sanitizeRecordId, + sanitizeUniqueId, + tableEndpoint, +} from '@/lib/providers/ministry-platform/services/guards'; + +describe('sanitizeIdentifier', () => { + it.each(['Contacts', 'Contact_Log', 'dp_Users', '_x', 'api_Custom_Get_Contacts2', 'a'.repeat(128)])( + 'should accept %j', + (name) => { + expect(sanitizeIdentifier(name, 'table name')).toBe(name); + } + ); + + it.each([ + '..', + '.', + 'a/b', + 'a\\b', + 'a?b', + 'a#b', + 'a%2e', + 'a b', + 'a-b', + 'a.b', + '9a', + '', + 'a'.repeat(129), + 'Contacts\n', + 'Contacts', // full-width letter + ])('should refuse %j with a fixed message', (name) => { + expect(() => sanitizeIdentifier(name, 'table name')).toThrow(/^Invalid table name$/); + }); + + it.each([undefined, null, 1, {}, ['Contacts']])('should refuse the non-string %j', (value) => { + expect(() => sanitizeIdentifier(value, 'procedure name')).toThrow('Invalid procedure name'); + }); +}); + +describe('errorName', () => { + it('should return the class name of an Error, never its message', () => { + expect(errorName(new SyntaxError('"Jane Doe" is not valid JSON'))).toBe('SyntaxError'); + expect(errorName(new TypeError('fetch failed'))).toBe('TypeError'); + }); + + it('should return a DOMException name such as TimeoutError', () => { + expect(errorName(new DOMException('timed out', 'TimeoutError'))).toBe('TimeoutError'); + }); + + it('should return the typeof a non-Error value', () => { + expect(errorName('raw body')).toBe('string'); + expect(errorName(undefined)).toBe('undefined'); + expect(errorName(null)).toBe('object'); + expect(errorName({ message: 'x' })).toBe('object'); + }); + + it('should not log a name that is not class-name shaped', () => { + expect(errorName({ name: 'Jane Doe, 12 Main St' })).toBe('object'); + expect(errorName({ name: 42 })).toBe('object'); + expect(errorName({ name: 'x'.repeat(65) })).toBe('object'); + }); +}); + +describe('sanitizeRecordId', () => { + it.each([1, 42, Number.MAX_SAFE_INTEGER])('should accept %j', (id) => { + expect(sanitizeRecordId(id, 'record ID')).toBe(id); + }); + + it.each([0, -1, 1.5, NaN, Infinity, 2 ** 53, '1', '1/../x', null, undefined, {}, [1]])( + 'should refuse %j with a message naming the field only', + (id) => { + expect(() => sanitizeRecordId(id, 'file ID')).toThrow(/^Expected positive integer for file ID$/); + } + ); +}); + +describe('sanitizeUniqueId', () => { + it('should accept a GUID', () => { + const guid = '0F8FAD5B-D9CB-469F-A165-70867728950E'; + expect(sanitizeUniqueId(guid)).toBe(guid); + }); + + it.each(['abc', '../tables/Contacts', `0f8fad5b-d9cb-469f-a165-70867728950e/../x`, 1, undefined])( + 'should refuse %j without echoing it', + (value) => { + expect(() => sanitizeUniqueId(value)).toThrow(/^Invalid GUID format$/); + } + ); +}); + +describe('tableEndpoint', () => { + it('should build /tables/{table} for a valid name', () => { + expect(tableEndpoint('Contact_Log')).toBe('/tables/Contact_Log'); + }); + + it('should refuse a traversal', () => { + expect(() => tableEndpoint('..')).toThrow('Invalid table name'); + }); +}); diff --git a/src/lib/providers/ministry-platform/services/guards.ts b/src/lib/providers/ministry-platform/services/guards.ts new file mode 100644 index 0000000..829cf41 --- /dev/null +++ b/src/lib/providers/ministry-platform/services/guards.ts @@ -0,0 +1,63 @@ +/** + * Input guards shared by the provider services. + * + * These services are thin passthroughs to an admin-level service account, so + * the values they interpolate into request paths are checked here, on their + * own, whatever the caller (or the central HttpClient path guard) already did. + * TypeScript types are erased at runtime; a `string` table name can hold + * `../procs/...` and a `number` record ID can hold `"1/../../x"`. + * + * Every guard throws a fixed message naming the FIELD, never the value + * (CLAUDE.md rule 14), and runs before any token or network work. + */ +import { validateGuid, validatePositiveInt } from "@/lib/validation"; + +export { errorName } from "../utils/logger"; + +/** + * A SQL-style identifier: letter or underscore, then letters, digits or + * underscores. Covers every MP table and stored procedure name (`Contacts`, + * `dp_Users`, `api_MPNextTools_GetPages`) and admits nothing that could change + * the shape of a URL path (`.`, `/`, `\`, `?`, `#`, `%`, whitespace). + */ +const IDENTIFIER_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/; + +/** SQL Server's `sysname` limit. */ +const MAX_IDENTIFIER_LENGTH = 128; + +/** + * Validates a table or procedure name for use as a URL path segment. + * + * @param value - The candidate name, from any source + * @param field - Used in the error message (never the offending value) + * @returns The validated name + * @throws Error if the value is not a plain identifier + */ +export function sanitizeIdentifier(value: unknown, field: string): string { + if ( + typeof value !== 'string' || + value.length > MAX_IDENTIFIER_LENGTH || + !IDENTIFIER_PATTERN.test(value) + ) { + throw new Error(`Invalid ${field}`); + } + return value; +} + +/** + * Validates a record / file ID (positive safe integer) for use as a path + * segment or `id=` query value. Delegates to {@link validatePositiveInt}. + */ +export function sanitizeRecordId(value: unknown, field: string): number { + return validatePositiveInt(value as number, field); +} + +/** Validates a file unique ID (GUID). Delegates to {@link validateGuid}. */ +export function sanitizeUniqueId(value: unknown): string { + return validateGuid(value as string); +} + +/** `/tables/{table}` for a validated table name. */ +export function tableEndpoint(table: unknown): string { + return `/tables/${encodeURIComponent(sanitizeIdentifier(table, 'table name'))}`; +} diff --git a/src/lib/providers/ministry-platform/services/metadata.service.ts b/src/lib/providers/ministry-platform/services/metadata.service.ts index 84ef579..ffe041c 100644 --- a/src/lib/providers/ministry-platform/services/metadata.service.ts +++ b/src/lib/providers/ministry-platform/services/metadata.service.ts @@ -1,6 +1,6 @@ import { MinistryPlatformClient } from "../client"; import { TableMetadata } from "../types"; -import { logger } from "../utils/logger"; +import { errorName, logger } from "../utils/logger"; export class MetadataService { private client: MinistryPlatformClient; @@ -17,7 +17,7 @@ export class MetadataService { await this.client.ensureValidToken(); await this.client.getHttpClient().get('/refreshMetadata'); } catch (error) { - logger.error('Error refreshing metadata:', error); + logger.error('mp.metadata.refresh_failed', { error: errorName(error) }); throw error; } } @@ -32,7 +32,7 @@ export class MetadataService { const params = search ? { $search: search } : undefined; return await this.client.getHttpClient().get('/tables', params); } catch (error) { - logger.error('Error getting tables:', error); + logger.error('mp.metadata.get_tables_failed', { error: errorName(error) }); throw error; } } diff --git a/src/lib/providers/ministry-platform/services/procedure.service.test.ts b/src/lib/providers/ministry-platform/services/procedure.service.test.ts index d35156a..8ff8ce2 100644 --- a/src/lib/providers/ministry-platform/services/procedure.service.test.ts +++ b/src/lib/providers/ministry-platform/services/procedure.service.test.ts @@ -114,13 +114,10 @@ describe('ProcedureService', () => { expect(mockHttpClient.get).toHaveBeenCalledWith('/procs/api_Get_Contacts', { '@DomainID': 1 }); }); - it('should URL-encode procedure name with special chars', async () => { - (mockHttpClient.get as any).mockResolvedValueOnce([[]]); - - await service.executeProcedure('api_Test Proc'); - - const endpoint = (mockHttpClient.get as any).mock.calls[0][0]; - expect(endpoint).toBe('/procs/api_Test%20Proc'); + it('should refuse a procedure name that is not a plain identifier, before any token work', async () => { + await expect(service.executeProcedure('api_Test Proc')).rejects.toThrow(/^Invalid procedure name$/); + expect(mockClient.ensureValidToken).not.toHaveBeenCalled(); + expect(mockHttpClient.get).not.toHaveBeenCalled(); }); it('should propagate errors', async () => { @@ -189,13 +186,10 @@ describe('ProcedureService', () => { ); }); - it('should URL-encode procedure name with special chars', async () => { - (mockHttpClient.post as any).mockResolvedValueOnce([[]]); - - await service.executeProcedureWithBody('api_Test Proc', {}); - - const endpoint = (mockHttpClient.post as any).mock.calls[0][0]; - expect(endpoint).toBe('/procs/api_Test%20Proc'); + it('should refuse a procedure name that is not a plain identifier, before any token work', async () => { + await expect(service.executeProcedureWithBody('../tables/Contacts', {})).rejects.toThrow(/^Invalid procedure name$/); + expect(mockClient.ensureValidToken).not.toHaveBeenCalled(); + expect(mockHttpClient.post).not.toHaveBeenCalled(); }); it('should propagate errors', async () => { diff --git a/src/lib/providers/ministry-platform/services/procedure.service.ts b/src/lib/providers/ministry-platform/services/procedure.service.ts index 873e10f..6bdfb39 100644 --- a/src/lib/providers/ministry-platform/services/procedure.service.ts +++ b/src/lib/providers/ministry-platform/services/procedure.service.ts @@ -1,7 +1,8 @@ import { MinistryPlatformClient } from "../client"; import { ProcedureInfo, QueryParams } from "../types"; import { HttpClient } from "../utils/http-client"; -import { logger } from "../utils/logger"; +import { errorName, logger } from "../utils/logger"; +import { sanitizeIdentifier } from "./guards"; const DEV_PROC_PREFIX = 'api_dev_'; @@ -22,7 +23,7 @@ export class ProcedureService { const params: QueryParams | undefined = search ? { $search: search } : undefined; return await this.client.getHttpClient().get('/procs', params); } catch (error) { - logger.error('Error getting procedures:', error); + logger.error('mp.procs.list_failed', { error: errorName(error) }); throw error; } } @@ -36,16 +37,16 @@ export class ProcedureService { procedure: string, params?: QueryParams ): Promise { + // Validated before any token or network work; the name is then safe to log. + const name = sanitizeIdentifier(procedure, 'procedure name'); + const endpoint = `/procs/${encodeURIComponent(name)}`; try { - const http = await this.resolveHttpClient(procedure); - - - const endpoint = `/procs/${encodeURIComponent(procedure)}`; + const http = await this.resolveHttpClient(name); const data = await http.get(endpoint, params); return data; } catch (error) { - logger.error(`Error executing procedure ${procedure}:`, error); + logger.error('mp.procs.execute_failed', { procedure: name, error: errorName(error) }); throw error; } } @@ -64,16 +65,16 @@ export class ProcedureService { parameters: Record, queryParams?: QueryParams ): Promise { + // Validated before any token or network work; the name is then safe to log. + const name = sanitizeIdentifier(procedure, 'procedure name'); + const endpoint = `/procs/${encodeURIComponent(name)}`; try { - const http = await this.resolveHttpClient(procedure); - - - const endpoint = `/procs/${encodeURIComponent(procedure)}`; + const http = await this.resolveHttpClient(name); const data = await http.post(endpoint, parameters, queryParams); return data; } catch (error) { - logger.error(`Error executing procedure ${procedure}:`, error); + logger.error('mp.procs.execute_failed', { procedure: name, error: errorName(error) }); throw error; } } @@ -95,5 +96,5 @@ export class ProcedureService { } function isDevProcedure(procedure: string): boolean { - return procedure.trim().toLowerCase().startsWith(DEV_PROC_PREFIX); + return procedure.toLowerCase().startsWith(DEV_PROC_PREFIX); } diff --git a/src/lib/providers/ministry-platform/services/table.service.test.ts b/src/lib/providers/ministry-platform/services/table.service.test.ts index d0fb8c9..4781e6e 100644 --- a/src/lib/providers/ministry-platform/services/table.service.test.ts +++ b/src/lib/providers/ministry-platform/services/table.service.test.ts @@ -400,22 +400,15 @@ describe('TableService', () => { expect(result).toEqual(copiedRecords); }); - it('should URL-encode table name with special characters', async () => { + it('should refuse a table name that is not a plain identifier', async () => { const pattern = { Type: 'Daily' as const, Interval: 1, StartDate: '2026-01-01', }; - (mockHttpClient.post as ReturnType).mockResolvedValueOnce([]); - - await tableService.copyRecord('My Table', 42, pattern); - - expect(mockHttpClient.post).toHaveBeenCalledWith( - '/tables/My%20Table/42/copy', - pattern, - undefined - ); + await expect(tableService.copyRecord('My Table', 42, pattern)).rejects.toThrow('Invalid table name'); + expect(mockHttpClient.post).not.toHaveBeenCalled(); }); it('should pass $select and $userId query params', async () => { @@ -529,3 +522,115 @@ describe('TableService', () => { }); }); }); + +describe('TableService - path and ID guards', () => { + let tableService: TableService; + let mockClient: MinistryPlatformClient; + let mockHttpClient: HttpClient; + const pattern = { Type: 'Daily' as const, Interval: 1, StartDate: '2026-01-01' }; + + beforeEach(() => { + mockHttpClient = { + get: vi.fn().mockResolvedValue([]), + post: vi.fn().mockResolvedValue([]), + put: vi.fn().mockResolvedValue([]), + delete: vi.fn().mockResolvedValue([]), + } as unknown as HttpClient; + mockClient = { + ensureValidToken: vi.fn().mockResolvedValue(undefined), + getHttpClient: vi.fn().mockReturnValue(mockHttpClient), + } as unknown as MinistryPlatformClient; + tableService = new TableService(mockClient); + }); + + function expectNothingSent() { + expect(mockClient.ensureValidToken).not.toHaveBeenCalled(); + expect(mockHttpClient.get).not.toHaveBeenCalled(); + expect(mockHttpClient.post).not.toHaveBeenCalled(); + expect(mockHttpClient.put).not.toHaveBeenCalled(); + expect(mockHttpClient.delete).not.toHaveBeenCalled(); + } + + it.each([ + '..', + '../procs/api_X', + 'Contacts/../dp_Users', + '%2e%2e', + 'Contacts?$select=Email_Address', + 'Contacts#', + 'a b', + '', + ])('should refuse the table name %j on every method, before any token work', async (table) => { + await expect(tableService.getTableRecords(table)).rejects.toThrow(/^Invalid table name$/); + await expect(tableService.createTableRecords(table, [])).rejects.toThrow(/^Invalid table name$/); + await expect(tableService.updateTableRecords(table, [])).rejects.toThrow(/^Invalid table name$/); + await expect(tableService.deleteTableRecords(table, [1])).rejects.toThrow(/^Invalid table name$/); + await expect(tableService.copyRecord(table, 1, pattern)).rejects.toThrow(/^Invalid table name$/); + await expect(tableService.copyRecordWithSubpages(table, 1, {} as never)).rejects.toThrow(/^Invalid table name$/); + expectNothingSent(); + }); + + it.each([ + ['a traversal string', '1/../../tables/Contacts'], + ['a numeric string', '42'], + ['zero', 0], + ['a negative', -1], + ['a fraction', 1.5], + ['NaN', NaN], + ['an unsafe integer', 2 ** 53], + ['an object', { toString: () => '1' }], + ])('should refuse %s as a record ID for copyRecord / copyRecordWithSubpages', async (_label, recordId) => { + await expect(tableService.copyRecord('Events', recordId as number, pattern)).rejects.toThrow( + /^Expected positive integer for record ID$/ + ); + await expect( + tableService.copyRecordWithSubpages('Events', recordId as number, {} as never) + ).rejects.toThrow(/^Expected positive integer for record ID$/); + expectNothingSent(); + }); + + it('should never echo a refused value in the error message', async () => { + const err = await tableService + .copyRecord('Events', '7/../../procs/secret' as unknown as number, pattern) + .catch((e: Error) => e); + expect((err as Error).message).not.toContain('secret'); + }); + + it('should refuse a non-array ids and any bad id for deleteTableRecords', async () => { + await expect(tableService.deleteTableRecords('Contacts', '1,2' as unknown as number[])).rejects.toThrow( + 'Invalid record IDs' + ); + await expect(tableService.deleteTableRecords('Contacts', [1, '2' as unknown as number])).rejects.toThrow( + 'Expected positive integer for record ID' + ); + expectNothingSent(); + }); + + it('should log the table and error NAME only, never the raw error', async () => { + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + (mockHttpClient.get as ReturnType).mockRejectedValueOnce( + new SyntaxError('Unexpected token "Jane Doe" is not valid JSON') + ); + + await expect(tableService.getTableRecords('Contacts')).rejects.toThrow(); + + expect(errorSpy).toHaveBeenCalledWith('[MP]', 'mp.table.get_failed', { + table: 'Contacts', + error: 'SyntaxError', + }); + expect(JSON.stringify(errorSpy.mock.calls)).not.toContain('Jane Doe'); + }); + + it('should log the validated record ID on a failed copy', async () => { + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + (mockHttpClient.post as ReturnType).mockRejectedValueOnce(new Error('POST failed: 500')); + + await expect(tableService.copyRecordWithSubpages('Events', 42, {} as never)).rejects.toThrow(); + + expect(errorSpy).toHaveBeenCalledWith('[MP]', 'mp.table.copy_with_subpages_failed', { + table: 'Events', + recordId: 42, + error: 'Error', + }); + }); +}); diff --git a/src/lib/providers/ministry-platform/services/table.service.ts b/src/lib/providers/ministry-platform/services/table.service.ts index 1bf251b..df43abb 100644 --- a/src/lib/providers/ministry-platform/services/table.service.ts +++ b/src/lib/providers/ministry-platform/services/table.service.ts @@ -1,7 +1,15 @@ import { MinistryPlatformClient } from "../client"; import { TableQueryParams, TableRecord, QueryParams, RecurrencePattern, CopyParameters } from "../types"; import { logger } from "../utils/logger"; +import { errorName, sanitizeRecordId, tableEndpoint } from "./guards"; +/* + * Path segments are validated (identifier / positive integer) BEFORE the try + * block, so a bad value throws a fixed message before any token or network + * work, and so every value that reaches a log line is a validated identifier. + * Errors are logged as { table, recordId?, error: } — never the + * raw error, whose message can carry a response-body fragment. + */ export class TableService { private client: MinistryPlatformClient; @@ -12,59 +20,53 @@ export class TableService { /** * Returns the list of records from the specified table satisfying the provided search criteria. */ - public async getTableRecords(table: string, params?: TableQueryParams): Promise { - try { - await this.client.ensureValidToken(); - - - const endpoint = `/tables/${encodeURIComponent(table)}`; - const data = await this.client.getHttpClient().get(endpoint, params as QueryParams); - - return data; - } catch (error) { - logger.error(`Error fetching records from table ${table}:`, error); - throw error; - } + public async getTableRecords(table: string, params?: TableQueryParams): Promise { + const endpoint = tableEndpoint(table); + try { + await this.client.ensureValidToken(); + return await this.client.getHttpClient().get(endpoint, params as QueryParams); + } catch (error) { + logger.error('mp.table.get_failed', { table, error: errorName(error) }); + throw error; } + } /** * Creates new records in the specified table. */ public async createTableRecords( - table: string, - records: T[], + table: string, + records: T[], params?: Pick ): Promise { + const endpoint = tableEndpoint(table); try { await this.client.ensureValidToken(); - - const endpoint = `/tables/${encodeURIComponent(table)}`; - const result = await this.client.getHttpClient().post(endpoint, records as unknown as Record, params); - return result; + return await this.client.getHttpClient().post(endpoint, records as unknown as Record, params); } catch (error) { - logger.error(`Error creating records in table ${table}:`, error); + logger.error('mp.table.create_failed', { table, error: errorName(error) }); throw error; } } + /** * Updates provided records in the specified table. */ public async updateTableRecords( - table: string, - records: T[], + table: string, + records: T[], params?: Pick ): Promise { + const endpoint = tableEndpoint(table); try { await this.client.ensureValidToken(); - - const endpoint = `/tables/${encodeURIComponent(table)}`; - const result = await this.client.getHttpClient().put(endpoint, records as unknown as Record, params); - return result; + return await this.client.getHttpClient().put(endpoint, records as unknown as Record, params); } catch (error) { - logger.error(`Error updating records in table ${table}:`, error); + logger.error('mp.table.update_failed', { table, error: errorName(error) }); throw error; } } + /** * Creates copies of a record using a recurrence pattern. * Does NOT copy related sub-pages or attached files. @@ -78,18 +80,17 @@ export class TableService { pattern: RecurrencePattern, params?: Pick ): Promise { + const id = sanitizeRecordId(recordId, 'record ID'); + const endpoint = `${tableEndpoint(table)}/${id}/copy`; try { await this.client.ensureValidToken(); - - const endpoint = `/tables/${encodeURIComponent(table)}/${recordId}/copy`; - const result = await this.client.getHttpClient().post( + return await this.client.getHttpClient().post( endpoint, pattern as unknown as Record, params as QueryParams ); - return result; } catch (error) { - logger.error(`Error copying record ${recordId} in table ${table}:`, error); + logger.error('mp.table.copy_failed', { table, recordId: id, error: errorName(error) }); throw error; } } @@ -107,18 +108,17 @@ export class TableService { copyParams: CopyParameters, params?: Pick ): Promise { + const id = sanitizeRecordId(recordId, 'record ID'); + const endpoint = `${tableEndpoint(table)}/${id}/copy-record`; try { await this.client.ensureValidToken(); - - const endpoint = `/tables/${encodeURIComponent(table)}/${recordId}/copy-record`; - const result = await this.client.getHttpClient().post( + return await this.client.getHttpClient().post( endpoint, copyParams as unknown as Record, params as QueryParams ); - return result; } catch (error) { - logger.error(`Error copying record ${recordId} with subpages in table ${table}:`, error); + logger.error('mp.table.copy_with_subpages_failed', { table, recordId: id, error: errorName(error) }); throw error; } } @@ -127,22 +127,26 @@ export class TableService { * Deletes multiple records from the specified table. */ public async deleteTableRecords( - table: string, - ids: number[], + table: string, + ids: number[], params?: Pick ): Promise { + const endpoint = tableEndpoint(table); + // Each id becomes an `id=` query value; refuse anything that is not a + // list of positive integers before anything is sent. + if (!Array.isArray(ids)) { + throw new Error('Invalid record IDs'); + } + const safeIds = ids.map((id) => sanitizeRecordId(id, 'record ID')); try { await this.client.ensureValidToken(); // Combine the ids and other params - const queryParams = { ...params, id: ids }; - const endpoint = `/tables/${encodeURIComponent(table)}`; - - const result = await this.client.getHttpClient().delete(endpoint, queryParams); - return result; + const queryParams = { ...params, id: safeIds }; + return await this.client.getHttpClient().delete(endpoint, queryParams); } catch (error) { - logger.error(`Error deleting records from table ${table}:`, error); + logger.error('mp.table.delete_failed', { table, count: safeIds.length, error: errorName(error) }); throw error; } } -} \ No newline at end of file +} diff --git a/src/lib/providers/ministry-platform/types/provider.types.ts b/src/lib/providers/ministry-platform/types/provider.types.ts index 0c986e1..e6ea194 100644 --- a/src/lib/providers/ministry-platform/types/provider.types.ts +++ b/src/lib/providers/ministry-platform/types/provider.types.ts @@ -98,7 +98,6 @@ export interface GlobalFilterItem { } export interface GlobalFilterParams { - $ignorePermissions?: boolean; $userId?: number; } diff --git a/src/lib/providers/ministry-platform/utils/http-client.test.ts b/src/lib/providers/ministry-platform/utils/http-client.test.ts index a872ce9..e31c53f 100644 --- a/src/lib/providers/ministry-platform/utils/http-client.test.ts +++ b/src/lib/providers/ministry-platform/utils/http-client.test.ts @@ -1,5 +1,9 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { HttpClient } from '@/lib/providers/ministry-platform/utils/http-client'; +import { + API_TIMEOUT_MS, + HttpClient, + UPLOAD_TIMEOUT_MS, +} from '@/lib/providers/ministry-platform/utils/http-client'; /** * HttpClient Tests @@ -17,17 +21,17 @@ import { HttpClient } from '@/lib/providers/ministry-platform/utils/http-client' const mockFetch = vi.fn(); global.fetch = mockFetch; -/** - * These tests deliberately drive failure paths, and the code under test logs - * them on purpose. Silence the channel so a real, unexpected error still - * stands out in the runner output instead of drowning in expected noise. - * `mockImplementation` keeps the spy recording, so assertions on what was - * logged still work. - */ -beforeEach(() => { - vi.spyOn(console, 'error').mockImplementation(() => {}); - vi.spyOn(console, 'warn').mockImplementation(() => {}); -}); +// Minimal Headers stand-in for mocked responses +function mockHeaders(values: Record) { + const lower = Object.fromEntries( + Object.entries(values).map(([k, v]) => [k.toLowerCase(), v]) + ); + return { get: (name: string) => lower[name.toLowerCase()] ?? null }; +} +const JSON_HEADERS = mockHeaders({ 'Content-Type': 'application/json; charset=utf-8' }); + +// Every request must carry a deadline and refuse redirects +const SAFE_INIT = { redirect: 'error', signal: expect.any(AbortSignal) }; describe('HttpClient', () => { const baseUrl = 'https://api.ministryplatform.com'; @@ -112,6 +116,7 @@ describe('HttpClient', () => { it('should make GET request with authorization header', async () => { mockFetch.mockResolvedValueOnce({ ok: true, + headers: JSON_HEADERS, json: () => Promise.resolve([{ Contact_ID: 1, Display_Name: 'John Doe' }]), }); @@ -120,6 +125,7 @@ describe('HttpClient', () => { expect(mockFetch).toHaveBeenCalledWith( 'https://api.ministryplatform.com/tables/Contacts', { + ...SAFE_INIT, method: 'GET', headers: { 'Authorization': 'Bearer test-access-token-123', @@ -133,6 +139,7 @@ describe('HttpClient', () => { it('should make GET request with query parameters', async () => { mockFetch.mockResolvedValueOnce({ ok: true, + headers: JSON_HEADERS, json: () => Promise.resolve([]), }); @@ -193,6 +200,7 @@ describe('HttpClient', () => { const newRecord = { First_Name: 'John', Last_Name: 'Doe' }; mockFetch.mockResolvedValueOnce({ ok: true, + headers: JSON_HEADERS, json: () => Promise.resolve([{ Contact_ID: 1, ...newRecord }]), }); @@ -201,6 +209,7 @@ describe('HttpClient', () => { expect(mockFetch).toHaveBeenCalledWith( 'https://api.ministryplatform.com/tables/Contacts', { + ...SAFE_INIT, method: 'POST', headers: { 'Authorization': 'Bearer test-access-token-123', @@ -216,6 +225,7 @@ describe('HttpClient', () => { it('should make POST request without body', async () => { mockFetch.mockResolvedValueOnce({ ok: true, + headers: JSON_HEADERS, json: () => Promise.resolve({ success: true }), }); @@ -224,6 +234,7 @@ describe('HttpClient', () => { expect(mockFetch).toHaveBeenCalledWith( 'https://api.ministryplatform.com/some/endpoint', { + ...SAFE_INIT, method: 'POST', headers: { 'Authorization': 'Bearer test-access-token-123', @@ -238,6 +249,7 @@ describe('HttpClient', () => { it('should make POST request with query parameters', async () => { mockFetch.mockResolvedValueOnce({ ok: true, + headers: JSON_HEADERS, json: () => Promise.resolve([]), }); @@ -254,7 +266,6 @@ describe('HttpClient', () => { ok: false, status: 400, statusText: 'Bad Request', - text: () => Promise.resolve(''), }); await expect( @@ -270,6 +281,7 @@ describe('HttpClient', () => { mockFetch.mockResolvedValueOnce({ ok: true, + headers: JSON_HEADERS, json: () => Promise.resolve({ FileId: 1 }), }); @@ -278,6 +290,7 @@ describe('HttpClient', () => { expect(mockFetch).toHaveBeenCalledWith( 'https://api.ministryplatform.com/files', { + ...SAFE_INIT, method: 'POST', headers: { 'Authorization': 'Bearer test-access-token-123', @@ -296,27 +309,12 @@ describe('HttpClient', () => { ok: false, status: 413, statusText: 'Payload Too Large', - text: () => Promise.resolve(''), }); await expect(httpClient.postFormData('/files', formData)).rejects.toThrow( 'POST /files failed: 413 Payload Too Large' ); }); - - it('does NOT leak the response body into the POST FormData error message', async () => { - const formData = new FormData(); - mockFetch.mockResolvedValueOnce({ - ok: false, - status: 400, - statusText: 'Bad Request', - text: () => Promise.resolve('File type not allowed'), - }); - - await expect(httpClient.postFormData('/files', formData)).rejects.toThrow( - 'POST /files failed: 400 Bad Request' - ); - }); }); describe('PUT Requests', () => { @@ -324,6 +322,7 @@ describe('HttpClient', () => { const updatedRecord = { Contact_ID: 1, First_Name: 'Jane' }; mockFetch.mockResolvedValueOnce({ ok: true, + headers: JSON_HEADERS, json: () => Promise.resolve([updatedRecord]), }); @@ -332,6 +331,7 @@ describe('HttpClient', () => { expect(mockFetch).toHaveBeenCalledWith( 'https://api.ministryplatform.com/tables/Contacts', { + ...SAFE_INIT, method: 'PUT', headers: { 'Authorization': 'Bearer test-access-token-123', @@ -344,7 +344,7 @@ describe('HttpClient', () => { expect(result).toEqual([updatedRecord]); }); - it('does NOT leak the response body into the thrown error message on failed PUT', async () => { + it('should throw error on failed PUT request with response body', async () => { mockFetch.mockResolvedValueOnce({ ok: false, status: 400, @@ -356,19 +356,6 @@ describe('HttpClient', () => { httpClient.put('/tables/Contacts', { Invalid: 'data' }) ).rejects.toThrow('PUT /tables/Contacts failed: 400 Bad Request'); }); - - it('should omit body segment from PUT error message when body is empty', async () => { - mockFetch.mockResolvedValueOnce({ - ok: false, - status: 400, - statusText: 'Bad Request', - text: () => Promise.resolve(''), - }); - - await expect( - httpClient.put('/tables/Contacts', { Invalid: 'data' }) - ).rejects.toThrow('PUT /tables/Contacts failed: 400 Bad Request'); - }); }); describe('PUT FormData Requests', () => { @@ -378,6 +365,7 @@ describe('HttpClient', () => { mockFetch.mockResolvedValueOnce({ ok: true, + headers: JSON_HEADERS, json: () => Promise.resolve({ FileId: 1, FileName: 'updated.txt' }), }); @@ -386,6 +374,7 @@ describe('HttpClient', () => { expect(mockFetch).toHaveBeenCalledWith( 'https://api.ministryplatform.com/files/1', { + ...SAFE_INIT, method: 'PUT', headers: { 'Authorization': 'Bearer test-access-token-123', @@ -397,35 +386,37 @@ describe('HttpClient', () => { expect(result).toEqual({ FileId: 1, FileName: 'updated.txt' }); }); - it('should throw error on failed PUT FormData request', async () => { + it('should throw on a non-OK PUT FormData response', async () => { const formData = new FormData(); - formData.append('file', new Blob(['data']), 'fail.txt'); + formData.append('file', new Blob(['updated']), 'updated.txt'); mockFetch.mockResolvedValueOnce({ ok: false, - status: 400, - statusText: 'Bad Request', - text: () => Promise.resolve(''), + status: 413, + statusText: 'Payload Too Large', + json: () => Promise.resolve({}), }); await expect(httpClient.putFormData('/files/1', formData)).rejects.toThrow( - 'PUT /files/1 failed: 400 Bad Request' + 'PUT /files/1 failed: 413 Payload Too Large' ); }); - it('does NOT leak the response body into the PUT FormData error message', async () => { + it('should append query params to a PUT FormData request', async () => { const formData = new FormData(); - formData.append('file', new Blob(['bad']), 'fail.txt'); + formData.append('file', new Blob(['updated']), 'updated.txt'); mockFetch.mockResolvedValueOnce({ - ok: false, - status: 422, - statusText: 'Unprocessable Entity', - text: () => Promise.resolve('Unsupported MIME type'), + ok: true, + headers: JSON_HEADERS, + json: () => Promise.resolve({ FileId: 1 }), }); - await expect(httpClient.putFormData('/files/1', formData)).rejects.toThrow( - 'PUT /files/1 failed: 422 Unprocessable Entity' + await httpClient.putFormData('/files/1', formData, { $userId: 7 }); + + expect(mockFetch).toHaveBeenCalledWith( + 'https://api.ministryplatform.com/files/1?$userId=7', + expect.objectContaining({ method: 'PUT' }) ); }); }); @@ -434,6 +425,7 @@ describe('HttpClient', () => { it('should make DELETE request', async () => { mockFetch.mockResolvedValueOnce({ ok: true, + headers: JSON_HEADERS, json: () => Promise.resolve([{ Contact_ID: 1 }]), }); @@ -442,6 +434,7 @@ describe('HttpClient', () => { expect(mockFetch).toHaveBeenCalledWith( expect.stringContaining('id=1'), { + ...SAFE_INIT, method: 'DELETE', headers: { 'Authorization': 'Bearer test-access-token-123', @@ -457,26 +450,12 @@ describe('HttpClient', () => { ok: false, status: 403, statusText: 'Forbidden', - text: () => Promise.resolve(''), }); await expect(httpClient.delete('/tables/Contacts', { id: [1] })).rejects.toThrow( 'DELETE /tables/Contacts failed: 403 Forbidden' ); }); - - it('does NOT leak the response body into the DELETE error message', async () => { - mockFetch.mockResolvedValueOnce({ - ok: false, - status: 409, - statusText: 'Conflict', - text: () => Promise.resolve('Record has dependent rows'), - }); - - await expect(httpClient.delete('/tables/Contacts', { id: [1] })).rejects.toThrow( - 'DELETE /tables/Contacts failed: 409 Conflict' - ); - }); }); describe('Token Injection', () => { @@ -486,6 +465,7 @@ describe('HttpClient', () => { mockFetch.mockResolvedValue({ ok: true, + headers: JSON_HEADERS, json: () => Promise.resolve([]), }); @@ -523,6 +503,7 @@ describe('HttpClient', () => { mockFetch.mockResolvedValueOnce({ ok: true, + headers: JSON_HEADERS, json: () => Promise.resolve([{ Contact_ID: 1, Display_Name: 'Test' }]), }); @@ -532,6 +513,443 @@ describe('HttpClient', () => { expect(result[0].Display_Name).toBe('Test'); }); }); + + describe('Logging safety (F5)', () => { + // Member PII and pastoral notes must never reach info-level logs, and a + // failure log/message must never echo the query string ($filter) or the + // response body. See CLAUDE.md rule 14. + let logSpy: ReturnType; + let errorSpy: ReturnType; + + beforeEach(() => { + logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + }); + + it('should not log anything on a successful GET', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + headers: JSON_HEADERS, + json: () => Promise.resolve([{ Contact_ID: 1, Notes: 'Pastoral note' }]), + }); + + await httpClient.get('/tables/Contact_Log'); + + expect(logSpy).not.toHaveBeenCalled(); + expect(errorSpy).not.toHaveBeenCalled(); + }); + + it('should not log the response body or the full URL/query string on a failed GET', async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 500, + statusText: 'Internal Server Error', + text: () => Promise.resolve('Notes: "Confidential pastoral note", Display_Name: "Jane Doe"'), + }); + + await expect( + httpClient.get('/tables/Contact_Log', { $filter: "Notes LIKE '%grief%'" }) + ).rejects.toThrow('GET /tables/Contact_Log failed: 500 Internal Server Error'); + + expect(errorSpy).toHaveBeenCalledTimes(1); + const loggedArgs = errorSpy.mock.calls[0].map((a) => JSON.stringify(a)).join(' '); + expect(loggedArgs).not.toContain('Confidential pastoral note'); + expect(loggedArgs).not.toContain('Jane Doe'); + expect(loggedArgs).not.toContain('grief'); + expect(loggedArgs).not.toContain('$filter'); + }); + + it('should not include the response body in the thrown error message on a failed GET', async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 400, + statusText: 'Bad Request', + text: () => Promise.resolve('Notes: "Confidential pastoral note"'), + }); + + try { + await httpClient.get('/tables/Contact_Log'); + expect.unreachable('expected httpClient.get to throw'); + } catch (err) { + expect(err).toBeInstanceOf(Error); + expect((err as Error).message).not.toContain('Confidential pastoral note'); + expect((err as Error).message).toBe('GET /tables/Contact_Log failed: 400 Bad Request'); + } + }); + + it('should not log the request body or response body on a failed PUT', async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 400, + statusText: 'Bad Request', + text: () => Promise.resolve('Notes: "Confidential pastoral note"'), + }); + + await expect( + httpClient.put('/tables/Contact_Log', { Notes: 'Confidential pastoral note' }) + ).rejects.toThrow('PUT /tables/Contact_Log failed: 400 Bad Request'); + + expect(logSpy).not.toHaveBeenCalled(); + expect(errorSpy).toHaveBeenCalledTimes(1); + const loggedArgs = errorSpy.mock.calls[0].map((a) => JSON.stringify(a)).join(' '); + expect(loggedArgs).not.toContain('Confidential pastoral note'); + }); + + it.each([ + ['POST', () => httpClient.post('/tables/Contact_Log', { Notes: 'Confidential pastoral note' })], + ['POST', () => httpClient.postFormData('/files/Contact_Log/1', new FormData())], + ['PUT', () => httpClient.putFormData('/files/1', new FormData())], + ['DELETE', () => httpClient.delete('/tables/Contact_Log', { id: [1] })], + ])('should log %s failures with identifiers only', async (method, call) => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 409, + statusText: 'Conflict', + text: () => Promise.resolve('Notes: "Confidential pastoral note"'), + }); + + await expect(call()).rejects.toThrow(`${method} `); + + expect(errorSpy).toHaveBeenCalledTimes(1); + const [prefix, message, details] = errorSpy.mock.calls[0]; + expect(prefix).toBe('[MP]'); + expect(message).toBe('mp.request.failed'); + expect(details).toEqual({ + method, + endpoint: expect.any(String), + status: 409, + statusText: 'Conflict', + }); + expect(JSON.stringify(errorSpy.mock.calls)).not.toContain('Confidential pastoral note'); + }); + + it('should redact a file unique ID from failure logs and messages', async () => { + const uniqueId = '0f8fad5b-d9cb-469f-a165-70867728950e'; + mockFetch.mockResolvedValueOnce({ ok: false, status: 404, statusText: 'Not Found' }); + + await expect(httpClient.get(`/files/${uniqueId}/metadata`)).rejects.toThrow( + 'GET /files/{uniqueId}/metadata failed: 404 Not Found' + ); + expect(JSON.stringify(errorSpy.mock.calls)).not.toContain(uniqueId); + expect(JSON.stringify(errorSpy.mock.calls)).toContain('/files/{uniqueId}/metadata'); + }); + + it('should redact a file unique ID from network-failure messages', async () => { + const uniqueId = '0F8FAD5B-D9CB-469F-A165-70867728950E'; + mockFetch.mockRejectedValueOnce(new TypeError(`fetch failed for ${uniqueId}`)); + + const err = await httpClient.delete(`/files/${uniqueId}`).catch((e: unknown) => e); + expect((err as Error).message).toBe('DELETE /files/{uniqueId} failed: TypeError'); + expect(JSON.stringify(errorSpy.mock.calls)).not.toContain(uniqueId); + }); + }); + + describe('Timeouts and redirects', () => { + let errorSpy: ReturnType; + + beforeEach(() => { + errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + }); + + it('should use the API deadline for JSON calls and the upload deadline for form data', async () => { + const timeoutSpy = vi.spyOn(AbortSignal, 'timeout'); + mockFetch.mockResolvedValue({ ok: true, headers: JSON_HEADERS, json: () => Promise.resolve({}) }); + + await httpClient.get('/tables/Contacts'); + await httpClient.post('/tables/Contacts', {}); + await httpClient.put('/tables/Contacts', {}); + await httpClient.delete('/tables/Contacts'); + expect(timeoutSpy.mock.calls.map((c) => c[0])).toEqual([ + API_TIMEOUT_MS, API_TIMEOUT_MS, API_TIMEOUT_MS, API_TIMEOUT_MS, + ]); + + timeoutSpy.mockClear(); + await httpClient.postFormData('/files/Contacts/1', new FormData()); + await httpClient.putFormData('/files/1', new FormData()); + expect(timeoutSpy.mock.calls.map((c) => c[0])).toEqual([UPLOAD_TIMEOUT_MS, UPLOAD_TIMEOUT_MS]); + + expect(API_TIMEOUT_MS).toBe(20_000); + expect(UPLOAD_TIMEOUT_MS).toBe(60_000); + }); + + it('should reject at the deadline when MP never responds', async () => { + const realTimeout = AbortSignal.timeout.bind(AbortSignal); + vi.spyOn(AbortSignal, 'timeout').mockImplementation(() => realTimeout(10)); + // A server that never answers: settle only when the signal aborts + mockFetch.mockImplementation( + (_url: string, init: RequestInit) => + new Promise((_, reject) => { + init.signal!.addEventListener('abort', () => reject(init.signal!.reason)); + }) + ); + + await expect(httpClient.get('/tables/Contacts')).rejects.toThrow( + 'GET /tables/Contacts failed: TimeoutError' + ); + expect(errorSpy).toHaveBeenCalledWith('[MP]', 'mp.request.failed', { + method: 'GET', + endpoint: '/tables/Contacts', + error: 'TimeoutError', + }); + }); + + it('should fail (not follow) when fetch refuses a redirect', async () => { + // undici's behaviour under redirect: "error" + mockFetch.mockRejectedValueOnce( + new TypeError('fetch failed', { cause: new Error('unexpected redirect') }) + ); + + await expect(httpClient.post('/tables/Contact_Log', { Notes: 'x' })).rejects.toThrow( + 'POST /tables/Contact_Log failed: TypeError' + ); + expect(mockFetch).toHaveBeenCalledTimes(1); + expect(mockFetch.mock.calls[0][1].redirect).toBe('error'); + }); + + it('should report a non-Error rejection by its type', async () => { + mockFetch.mockRejectedValueOnce('boom'); + + await expect(httpClient.get('/tables/Contacts')).rejects.toThrow( + 'GET /tables/Contacts failed: string' + ); + }); + + it('should never put the query string in a network-failure message', async () => { + mockFetch.mockRejectedValueOnce(new TypeError('fetch failed')); + + const err = await httpClient + .get('/tables/Contact_Log', { $filter: "Notes LIKE '%grief%'" }) + .catch((e: unknown) => e); + expect((err as Error).message).not.toContain('grief'); + expect(JSON.stringify(errorSpy.mock.calls)).not.toContain('grief'); + }); + }); + + describe('Response parsing', () => { + it('should accept +json media types such as application/vnd.api+json', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + headers: mockHeaders({ 'content-type': 'application/vnd.api+json' }), + json: () => Promise.resolve({ a: 1 }), + }); + + await expect(httpClient.get('/tables/Contacts')).resolves.toEqual({ a: 1 }); + }); + + it.each([ + ['text/html', mockHeaders({ 'content-type': 'text/html' })], + ['application/jsonp', mockHeaders({ 'content-type': 'application/jsonp' })], + ['a missing content-type', mockHeaders({})], + ['no headers at all', undefined], + ])('should refuse to parse %s', async (_label, headers) => { + const json = vi.fn(); + mockFetch.mockResolvedValueOnce({ ok: true, status: 200, headers, json }); + + await expect(httpClient.get('/tables/Contacts')).rejects.toThrow( + 'GET /tables/Contacts failed: unexpected content-type' + ); + expect(json).not.toHaveBeenCalled(); + }); + + it('should not leak a body fragment from malformed JSON', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + headers: JSON_HEADERS, + json: () => Promise.reject(new SyntaxError('Unexpected token "Jane Doe, "... is not valid JSON')), + }); + + const err = await httpClient.get('/tables/Contacts').catch((e: unknown) => e); + expect((err as Error).message).toBe('GET /tables/Contacts failed: invalid JSON response'); + }); + + it('should return undefined for an empty body', async () => { + const json = vi.fn(); + mockFetch + .mockResolvedValueOnce({ ok: true, status: 204, headers: mockHeaders({}), json }) + .mockResolvedValueOnce({ ok: true, status: 200, headers: mockHeaders({ 'Content-Length': '0' }), json }); + + await expect(httpClient.delete('/files/1')).resolves.toBeUndefined(); + await expect(httpClient.get('/refreshMetadata')).resolves.toBeUndefined(); + expect(json).not.toHaveBeenCalled(); + }); + }); + + describe('401 retry', () => { + let errorSpy: ReturnType; + const unauthorized = { ok: false, status: 401, statusText: 'Unauthorized' }; + const okJson = (body: unknown) => ({ ok: true, status: 200, headers: JSON_HEADERS, json: () => Promise.resolve(body) }); + + beforeEach(() => { + errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + }); + + it('should call the handler with the rejected token and retry once with the new one', async () => { + let token = 'stale'; + const onUnauthorized = vi.fn(async () => { token = 'fresh'; }); + const client = new HttpClient(baseUrl, () => token, onUnauthorized); + mockFetch.mockResolvedValueOnce(unauthorized).mockResolvedValueOnce(okJson([1])); + + await expect(client.post('/tables/Contacts', { A: 1 })).resolves.toEqual([1]); + + expect(onUnauthorized).toHaveBeenCalledExactlyOnceWith('stale'); + expect(mockFetch).toHaveBeenCalledTimes(2); + expect(mockFetch.mock.calls[1][1].headers.Authorization).toBe('Bearer fresh'); + // The body is re-sent on the retry + expect(mockFetch.mock.calls[1][1].body).toBe(JSON.stringify({ A: 1 })); + expect(errorSpy).not.toHaveBeenCalled(); + }); + + it('should retry only once', async () => { + const onUnauthorized = vi.fn(async () => {}); + const client = new HttpClient(baseUrl, getToken, onUnauthorized); + mockFetch.mockResolvedValue(unauthorized); + + await expect(client.get('/tables/Contacts')).rejects.toThrow( + 'GET /tables/Contacts failed: 401 Unauthorized' + ); + expect(onUnauthorized).toHaveBeenCalledTimes(1); + expect(mockFetch).toHaveBeenCalledTimes(2); + }); + + it('should not retry other failures', async () => { + const onUnauthorized = vi.fn(async () => {}); + const client = new HttpClient(baseUrl, getToken, onUnauthorized); + mockFetch.mockResolvedValueOnce({ ok: false, status: 403, statusText: 'Forbidden' }); + + await expect(client.get('/tables/Contacts')).rejects.toThrow('403 Forbidden'); + expect(onUnauthorized).not.toHaveBeenCalled(); + expect(mockFetch).toHaveBeenCalledTimes(1); + }); + + it('should surface the original 401 and log only the error name when the refresh fails', async () => { + const onUnauthorized = vi.fn(async () => { + throw new Error('Failed to get client credentials token: 400 Bad Request'); + }); + const client = new HttpClient(baseUrl, getToken, onUnauthorized); + mockFetch.mockResolvedValueOnce(unauthorized); + + await expect(client.get('/tables/Contacts')).rejects.toThrow( + 'GET /tables/Contacts failed: 401 Unauthorized' + ); + expect(mockFetch).toHaveBeenCalledTimes(1); + expect(errorSpy).toHaveBeenCalledWith('[MP]', 'mp.token.refresh_after_401_failed', { + method: 'GET', + endpoint: '/tables/Contacts', + error: 'Error', + }); + }); + }); + + describe('Endpoint guard (path traversal)', () => { + const mpBase = 'https://mp.example.org/ministryplatformapi'; + let client: HttpClient; + + beforeEach(() => { + client = new HttpClient(mpBase, getToken); + }); + + it.each([ + '/tables/Contacts', + '/tables/dp_Users', + '/tables/Contact%20Log', + '/tables', + '/procs', + '/procs/api_Common_GetPrefetchData', + '/files/Contacts/42', + '/files/123', + '/files/123/metadata', + '/files/0f8fad5b-d9cb-469f-a165-70867728950e', + '/files/0f8fad5b-d9cb-469f-a165-70867728950e/metadata', + '/domain', + '/domain/filters', + '/refreshMetadata', + '/communications', + '/messages', + '/tables/Contacts.v2', + ])('should allow the legitimate endpoint %s', (endpoint) => { + expect(client.buildUrl(endpoint)).toBe(`${mpBase}${endpoint}`); + }); + + it.each([ + ['a dot-dot segment', '/files/../tables/Contacts'], + ['a bare dot-dot', '/tables/..'], + ['a query smuggled into the path', '/files/x?$select=Email_Address'], + ['a fragment', '/files/x#'], + ['a backslash', '/files/..\\tables'], + ['a lone backslash', '/files\\x'], + ['%2e (lower case)', '/files/%2e%2e/tables'], + ['%2E (upper case)', '/files/%2E%2E/tables'], + ['%2f (lower case)', '/files/x%2ftables'], + ['%2F (upper case)', '/files/x%2Ftables'], + ['%5c', '/files/x%5Ctables'], + ['a tab (stripped by the URL parser)', '/files/.\t./tables'], + ['a newline', '/files/.\n./tables'], + ['a NUL', '/files/x\u0000'], + ['no leading slash', 'tables/Contacts'], + ['a userinfo/host injection', '@evil.example/x'], + ['an absolute URL', 'https://evil.example/x'], + ['an empty endpoint', ''], + ])('should reject %s before fetch', async (_label, endpoint) => { + expect(() => client.buildUrl(endpoint)).toThrow('Refusing unsafe MP API endpoint'); + await expect(client.get(endpoint)).rejects.toThrow('Refusing unsafe MP API endpoint'); + await expect(client.delete(endpoint)).rejects.toThrow('Refusing unsafe MP API endpoint'); + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it('should reject a non-string endpoint at runtime', () => { + expect(() => client.buildUrl(undefined as unknown as string)).toThrow( + 'Refusing unsafe MP API endpoint' + ); + }); + + it.each([ + // The review's repro payloads (FileService / TableService shapes) + '/files/../tables/Contacts?$select=Contact_ID,Email_Address&$top=1000#/metadata', + '/files/../procs/api_Some_Proc?@ContactID=1#/1', + '/files/../tables/Contacts?id=1#', + `/tables/${encodeURIComponent('..')}`, + `/procs/${encodeURIComponent('../tables/Contacts')}`, + ])('should reject the review repro %s', (endpoint) => { + expect(() => client.buildUrl(endpoint)).toThrow('Refusing unsafe MP API endpoint'); + }); + + it('should refuse a request whose resolved URL leaves the API root', () => { + // The URL parser strips a trailing space from the base on its own, but + // not once a path follows it, so the resolved URL no longer sits under root + const misconfigured = new HttpClient(`${mpBase} `, getToken); + expect(() => misconfigured.buildUrl('/tables/Contacts')).toThrow( + 'Refusing unsafe MP API endpoint' + ); + }); + + it('should refuse to build a URL when the base URL is not a URL', () => { + const broken = new HttpClient('not a url', getToken); + expect(() => broken.buildUrl('/tables/Contacts')).toThrow('Invalid MP API base URL'); + }); + + it('should accept a base URL with a trailing slash', () => { + const trailing = new HttpClient(`${mpBase}/`, getToken); + expect(trailing.buildUrl('/tables/Contacts')).toBe(`${mpBase}//tables/Contacts`); + }); + }); + + describe('Query key encoding', () => { + it('should encode caller-shaped query keys', () => { + const url = httpClient.buildUrl('/tables/Contacts', { + 'a&$filter=1': 'x', + 'k#frag': 'y', + 'sp ace': ['1', '2'], + }); + expect(url).toBe( + 'https://api.ministryplatform.com/tables/Contacts?a%26$filter%3D1=x&k%23frag=y&sp%20ace=1&sp%20ace=2' + ); + }); + + it('should keep $ and @ literal in keys (MP query options and proc parameters)', () => { + const url = httpClient.buildUrl('/procs/api_Proc', { $userId: 1, '@ContactID': 2 }); + expect(url).toBe('https://api.ministryplatform.com/procs/api_Proc?$userId=1&@ContactID=2'); + }); + }); }); /** diff --git a/src/lib/providers/ministry-platform/utils/http-client.ts b/src/lib/providers/ministry-platform/utils/http-client.ts index de81032..19ad5a0 100644 --- a/src/lib/providers/ministry-platform/utils/http-client.ts +++ b/src/lib/providers/ministry-platform/utils/http-client.ts @@ -1,167 +1,146 @@ import { QueryParams, RequestBody } from "../types/provider.types"; -import { logger } from "./logger"; +import { errorName, logger } from "./logger"; +/** + * Deadline for a JSON API call. Without one a stalled MP holds the request + * (and the serverless function running it) until undici's 300 s default. + */ +export const API_TIMEOUT_MS = 20_000; + +/** Multipart uploads carry file bodies, so they get a longer deadline. */ +export const UPLOAD_TIMEOUT_MS = 60_000; + +/** + * Endpoint content that could re-aim the service bearer at another API path + * once the URL parser runs: dot-segments, a query/fragment smuggled into the + * path, backslashes (treated as "/" for http(s)), their percent-encoded forms, + * and control characters (the parser silently strips tabs/newlines, so ".\t." + * would become ".."). + */ +const UNSAFE_ENDPOINT = /\.\.|[?#\\]|%2e|%2f|%5c|[\u0000-\u001f\u007f]/i; + +/** + * MP file unique IDs are GUIDs, and MP serves `/files/{uniqueId}` without + * authentication, so the ID is a download capability: keep it out of logs and + * error messages. + */ +const GUID = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi; + +const UNSAFE_ENDPOINT_MESSAGE = 'Refusing unsafe MP API endpoint'; + +/** An endpoint safe to put in a log line or error message. */ +export function redactEndpoint(endpoint: string): string { + return endpoint.replace(GUID, '{uniqueId}'); +} + +/** + * Parses a successful response as JSON without letting a non-JSON body leak + * into an error: V8's `SyntaxError` message embeds a fragment of the input, + * and callers log errors. An empty body (204, or `Content-Length: 0`) yields + * `undefined`. Anything else must declare a JSON content-type. + * + * @param label prefix for thrown messages, e.g. "GET /tables/Contacts failed" + */ +export async function readJsonResponse(response: Response, label: string): Promise { + const headers = response.headers; + if (response.status === 204 || headers?.get('content-length') === '0') { + return undefined as T; + } + + const contentType = (headers?.get('content-type') ?? '').trim().toLowerCase(); + if (!/^application\/([\w.-]+\+)?json(\s*;|$)/.test(contentType)) { + throw new Error(`${label}: unexpected content-type`); + } + + try { + return (await response.json()) as T; + } catch { + throw new Error(`${label}: invalid JSON response`); + } +} + +/** + * Called on a 401 with the token that was rejected. Expected to invalidate it + * (if it is still current) and obtain a fresh one; the request is then retried + * exactly once. + */ +export type UnauthorizedHandler = (rejectedToken: string) => Promise; + +interface RequestOptions { + queryParams?: QueryParams; + body?: BodyInit; + json?: boolean; + timeoutMs?: number; +} + +/** + * HTTP client for the MP REST API. Every request is sent with the service + * account's bearer (`dataplatform/scopes/all`), so every request: + * + * - carries a deadline (`AbortSignal.timeout`, fresh per attempt); + * - refuses redirects (`redirect: "error"`) — a 307/308 would re-send the + * body, i.e. member data, to wherever it points; + * - goes through the path guard in {@link HttpClient.buildUrl}; + * - parses its response with {@link readJsonResponse}; + * - on a 401, asks the owner for a fresh token and retries once. + * + * Logs and thrown messages carry IDENTIFIERS AND SHAPE ONLY — method, redacted + * endpoint (path, GUIDs replaced), status, error name. Never the URL (its + * query string carries the `$filter`), a request body or a response body. + */ export class HttpClient { private baseUrl: string; private getToken: () => string; + private onUnauthorized?: UnauthorizedHandler; - constructor(baseUrl: string, getToken: () => string) { + constructor(baseUrl: string, getToken: () => string, onUnauthorized?: UnauthorizedHandler) { this.baseUrl = baseUrl; this.getToken = getToken; - } - - /** - * Shared error-handling for non-2xx responses. - * - * Logs and throws IDENTIFIERS AND SHAPE ONLY — method, endpoint, status, - * statusText. Deliberately NOT the response body and NOT the full URL: - * - * - The body of a failed MP request routinely echoes back record content - * (names, emails, notes) and the `$filter` string that produced it. - * - `endpoint` is the path only; the query string carries the `$filter`. - * - A thrown message propagates much further than a log line — into error - * reporters, client-visible action results and, previously, into every - * downstream log that stringified the error. Appending the body there - * leaked it everywhere at once. - * - * All HTTP methods (GET/POST/POST FormData/PUT/PUT FormData/DELETE) route - * through this helper so callers get the same shape of error regardless of - * method. - */ - private async handleFailedResponse( - method: string, - endpoint: string, - response: Response - ): Promise { - logger.error('mp.request.failed', { - method, - endpoint, - status: response.status, - statusText: response.statusText, - }); - throw new Error( - `${method} ${endpoint} failed: ${response.status} ${response.statusText}` - ); + this.onUnauthorized = onUnauthorized; } async get(endpoint: string, queryParams?: QueryParams): Promise { - const url = this.buildUrl(endpoint, queryParams); - - const response = await fetch(url, { - method: 'GET', - headers: { - 'Authorization': `Bearer ${this.getToken()}`, - 'Accept': 'application/json' - } - }); - - if (!response.ok) { - await this.handleFailedResponse('GET', endpoint, response); - } - - return await response.json() as T; + return this.request('GET', endpoint, { queryParams }); } async post(endpoint: string, body?: RequestBody, queryParams?: QueryParams): Promise { - const url = this.buildUrl(endpoint, queryParams); - - const response = await fetch(url, { - method: 'POST', - headers: { - 'Authorization': `Bearer ${this.getToken()}`, - 'Content-Type': 'application/json', - 'Accept': 'application/json' - }, - body: body ? JSON.stringify(body) : undefined + return this.request('POST', endpoint, { + queryParams, + body: body ? JSON.stringify(body) : undefined, + json: true, }); - - if (!response.ok) { - await this.handleFailedResponse('POST', endpoint, response); - } - - return await response.json() as T; } async postFormData(endpoint: string, formData: FormData, queryParams?: QueryParams): Promise { - const url = this.buildUrl(endpoint, queryParams); - - const response = await fetch(url, { - method: 'POST', - headers: { - 'Authorization': `Bearer ${this.getToken()}`, - 'Accept': 'application/json' - // Don't set Content-Type for FormData - }, - body: formData + return this.request('POST', endpoint, { + queryParams, + body: formData, + timeoutMs: UPLOAD_TIMEOUT_MS, }); - - if (!response.ok) { - await this.handleFailedResponse('POST', endpoint, response); - } - - return await response.json() as T; } async put(endpoint: string, body: RequestBody, queryParams?: QueryParams): Promise { - const url = this.buildUrl(endpoint, queryParams); - - - const response = await fetch(url, { - method: 'PUT', - headers: { - 'Authorization': `Bearer ${this.getToken()}`, - 'Content-Type': 'application/json', - 'Accept': 'application/json' - }, - body: JSON.stringify(body) + return this.request('PUT', endpoint, { + queryParams, + body: JSON.stringify(body), + json: true, }); - - if (!response.ok) { - await this.handleFailedResponse('PUT', endpoint, response); - } - - return await response.json() as T; } async putFormData(endpoint: string, formData: FormData, queryParams?: QueryParams): Promise { - const url = this.buildUrl(endpoint, queryParams); - - const response = await fetch(url, { - method: 'PUT', - headers: { - 'Authorization': `Bearer ${this.getToken()}`, - 'Accept': 'application/json' - // Don't set Content-Type for FormData - }, - body: formData + return this.request('PUT', endpoint, { + queryParams, + body: formData, + timeoutMs: UPLOAD_TIMEOUT_MS, }); - - if (!response.ok) { - await this.handleFailedResponse('PUT', endpoint, response); - } - - return await response.json() as T; } async delete(endpoint: string, queryParams?: QueryParams): Promise { - const url = this.buildUrl(endpoint, queryParams); - - const response = await fetch(url, { - method: 'DELETE', - headers: { - 'Authorization': `Bearer ${this.getToken()}`, - 'Accept': 'application/json' - } - }); - - if (!response.ok) { - await this.handleFailedResponse('DELETE', endpoint, response); - } - - return await response.json() as T; + return this.request('DELETE', endpoint, { queryParams }); } public buildUrl(endpoint: string, queryParams?: QueryParams): string { + this.assertSafeEndpoint(endpoint); const url = `${this.baseUrl}${endpoint}`; if (!queryParams) return url; @@ -169,15 +148,129 @@ export class HttpClient { return queryString ? `${url}?${queryString}` : url; } + /** + * Central path guard. The endpoint is appended to the service account's + * base URL and sent with its bearer, so it must not be able to leave the + * API root or reach a different API path than the one it names. Services + * `encodeURIComponent` their path segments, but + * `encodeURIComponent("..") === ".."`, so the check has to live here too. + */ + private assertSafeEndpoint(endpoint: string): void { + // Runtime check: types are erased, and endpoints can carry caller input. + if (typeof endpoint !== 'string' || !endpoint.startsWith('/') || UNSAFE_ENDPOINT.test(endpoint)) { + throw new Error(UNSAFE_ENDPOINT_MESSAGE); + } + + let resolved: string; + let root: string; + try { + root = new URL(this.baseUrl).href.replace(/\/+$/, '') + '/'; + resolved = new URL(`${this.baseUrl}${endpoint}`).href; + } catch { + throw new Error('Invalid MP API base URL'); + } + // Defensive: the checks above already rule out every known way out. + if (!resolved.startsWith(root)) { + throw new Error(UNSAFE_ENDPOINT_MESSAGE); + } + } + private buildQueryString(params: QueryParams): string { return Object.entries(params) .filter(([, value]) => value !== undefined && value !== null) .map(([key, value]) => { + const k = encodeQueryKey(key); if (Array.isArray(value)) { - return value.map(v => `${key}=${encodeURIComponent(String(v))}`).join('&'); + return value.map(v => `${k}=${encodeURIComponent(String(v))}`).join('&'); } - return `${key}=${encodeURIComponent(String(value))}`; + return `${k}=${encodeURIComponent(String(value))}`; }) .join('&'); } -} \ No newline at end of file + + private async request(method: string, endpoint: string, options: RequestOptions): Promise { + const url = this.buildUrl(endpoint, options.queryParams); + const safeEndpoint = redactEndpoint(endpoint); + + let token = this.getToken(); + let response = await this.send(method, url, safeEndpoint, token, options); + + if (response.status === 401 && this.onUnauthorized) { + // The token may have been revoked server-side before its local + // expiry: drop it, fetch a new one, and retry exactly once. A + // failed refresh falls through to the ordinary 401 error below. + let refreshed = false; + try { + await this.onUnauthorized(token); + refreshed = true; + } catch (error) { + logger.error('mp.token.refresh_after_401_failed', { + method, + endpoint: safeEndpoint, + error: errorName(error), + }); + } + if (refreshed) { + token = this.getToken(); + response = await this.send(method, url, safeEndpoint, token, options); + } + } + + if (!response.ok) { + logger.error('mp.request.failed', { + method, + endpoint: safeEndpoint, + status: response.status, + statusText: response.statusText, + }); + throw new Error(`${method} ${safeEndpoint} failed: ${response.status} ${response.statusText}`.trimEnd()); + } + + return readJsonResponse(response, `${method} ${safeEndpoint} failed`); + } + + private async send( + method: string, + url: string, + safeEndpoint: string, + token: string, + options: RequestOptions + ): Promise { + const headers: Record = { + 'Authorization': `Bearer ${token}`, + 'Accept': 'application/json', + }; + // Don't set Content-Type for FormData — fetch adds the boundary. + if (options.json) headers['Content-Type'] = 'application/json'; + + const init: RequestInit = { + method, + headers, + // The MP API has no legitimate redirects, and a 307/308 would + // re-send the body (member data) to wherever it points. + redirect: 'error', + // A fresh signal per attempt, so a 401 retry gets its own deadline. + signal: AbortSignal.timeout(options.timeoutMs ?? API_TIMEOUT_MS), + }; + if (method === 'POST' || method === 'PUT') init.body = options.body; + + try { + return await fetch(url, init); + } catch (error) { + // Network error, timeout, or refused redirect. Name only — never + // the URL (it carries the $filter) or the raw error. + const name = errorName(error); + logger.error('mp.request.failed', { method, endpoint: safeEndpoint, error: name }); + throw new Error(`${method} ${safeEndpoint} failed: ${name}`); + } + } +} + +/** + * Query keys are caller-shaped too. `$` (MP's `$filter`/`$top`/...) and `@` + * (stored-procedure parameters) are legal in a query component and MP expects + * them literally, so they are left unescaped. + */ +function encodeQueryKey(key: string): string { + return encodeURIComponent(key).replace(/%24/g, '$').replace(/%40/g, '@'); +} diff --git a/src/lib/providers/ministry-platform/utils/logger.ts b/src/lib/providers/ministry-platform/utils/logger.ts index 04fa39a..deddaf5 100644 --- a/src/lib/providers/ministry-platform/utils/logger.ts +++ b/src/lib/providers/ministry-platform/utils/logger.ts @@ -19,3 +19,25 @@ export const logger = { error: (...args: unknown[]) => console.error('[MP]', ...args), }; + +/** + * What the provider logs for a caught error: its class name only + * (`Error`, `TypeError`, `TimeoutError`, `SyntaxError`), never the message. + * + * Messages can carry response-body fragments (V8's JSON `SyntaxError` quotes + * the body it failed to parse), request URLs with a `$filter` in them, or + * member data. The name is enough to triage; the error itself is still + * re-thrown to the caller. + * + * Duck-typed rather than `instanceof Error` because a `DOMException` (what + * `AbortSignal.timeout` rejects with) is not an `Error` in every realm. Only a + * plain class-name-shaped `name` is returned; anything else falls back to + * `typeof`, so a hostile `name` cannot smuggle content into a log line. + */ +export function errorName(error: unknown): string { + const name = + typeof error === 'object' && error !== null + ? (error as { name?: unknown }).name + : undefined; + return typeof name === 'string' && /^[A-Za-z]{1,64}$/.test(name) ? name : typeof error; +} From 14f6ba95f21a91555df34abbea52aa87482daf87 Mon Sep 17 00:00:00 2001 From: Chris Kehayias Date: Sat, 10 Oct 2026 09:34:25 -0400 Subject: [PATCH 08/24] chore: remove the GrapesJS template editor and its dependencies (#40) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary The template editor was a demo, not a supported tool. This removes it entirely. - **Code:** `src/components/template-editor/`, `src/app/(web)/tools/templateeditor/`, `src/styles/grapesjs-overrides.css`, the home-page card, and the editor entries in `page.test.tsx` / `require-tool-access.test.ts`. - **Dependencies:** `grapesjs`, `@grapesjs/react`, `grapesjs-mjml`, `mjml`, `@types/mjml`, and the mjml `serverExternalPackages` entry in `next.config.ts`. About 200 lockfile entries removed; no other package versions changed (diffed against `HEAD`). - **Docs:** README, CLAUDE.md (component tree, `grapesjs` 0.23 hold dropped), `/update-deps` and `/newtool` commands, reference docs. ADR-023 marked Superseded, GOTCHA-038/039 Retired (numbering kept stable). Dated history records untouched. - **TODOs:** both open template-editor TODOs closed as won't-fix; held CSP item and Step 6 no longer carry the GrapesJS Google Fonts question; `INDEX.md` counts regenerated (12 open). ## Test plan - [x] `npm run lint` - [x] `npm run typecheck` (after clearing stale `.next/` generated types) - [x] `npm run test:coverage` — 116 files, 2,084 tests; 98.97% statements / 99.71% lines (thresholds hold) ## Notes - `npm audit` reports high advisories against `next` 16.3.7 and `braces` (via `eslint-config-next`). They predate this change and are unrelated; next up is `/update-deps`. - `src/auth.shared-instance.test.ts` failed once under full-suite load, then passed alone and in the next full run. Looks timing-sensitive. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 5.5 (1M context) --- ...ts-template-editor-merge-token-resolver.md | 12 +- ...nents-template-editor-no-mp-persistence.md | 12 +- ...026-09-30-held-csp-customer-deployments.md | 16 +- ...26-09-30-step6-headers-csp-build-guards.md | 2 +- .claude/TODO/INDEX.md | 57 +- .claude/commands/newtool.md | 2 +- .claude/commands/update-deps.md | 7 +- .claude/references/DECISIONS.md | 2 +- .claude/references/GLOSSARY.md | 16 - .claude/references/GOTCHAS.md | 20 +- .claude/references/components/README.md | 2 - .../references/components/template-editor.md | 284 -- .claude/references/data-flow/error-catalog.md | 4 +- .claude/references/dto-constants/constants.md | 6 +- .claude/references/routing/app-router.md | 5 +- .claude/references/utils/tool-params.md | 3 +- CLAUDE.md | 2 - README.md | 30 +- next.config.ts | 1 - package-lock.json | 2819 +---------------- package.json | 5 - src/app/(web)/page.test.tsx | 1 - src/app/(web)/page.tsx | 14 - .../(web)/tools/require-tool-access.test.ts | 2 +- .../(web)/tools/templateeditor/loading.tsx | 11 - .../(web)/tools/templateeditor/page.test.tsx | 59 - src/app/(web)/tools/templateeditor/page.tsx | 16 - .../templateeditor/template-editor.test.tsx | 86 - .../tools/templateeditor/template-editor.tsx | 42 - .../template-editor/actions.test.ts | 112 - src/components/template-editor/actions.ts | 55 - .../template-editor/editor-canvas.test.tsx | 140 - .../template-editor/editor-canvas.tsx | 65 - .../editor-code-dialog.test.tsx | 144 - .../template-editor/editor-code-dialog.tsx | 180 -- .../editor-export-dialog.test.tsx | 153 - .../template-editor/editor-export-dialog.tsx | 217 -- .../editor-import-dialog.test.tsx | 106 - .../template-editor/editor-import-dialog.tsx | 101 - .../template-editor/editor-toolbar.test.tsx | 327 -- .../template-editor/editor-toolbar.tsx | 340 -- .../template-editor/grapes-config.test.ts | 51 - .../template-editor/grapes-config.ts | 73 - src/components/template-editor/index.ts | 1 - .../merge-field-picker.test.tsx | 136 - .../template-editor/merge-field-picker.tsx | 110 - .../template-editor/merge-fields.test.ts | 60 - .../template-editor/merge-fields.ts | 59 - .../template-editor-form.test.tsx | 85 - .../template-editor/template-editor-form.tsx | 40 - src/components/template-editor/types.ts | 22 - src/styles/grapesjs-overrides.css | 293 -- 52 files changed, 229 insertions(+), 6179 deletions(-) delete mode 100644 .claude/references/components/template-editor.md delete mode 100644 src/app/(web)/tools/templateeditor/loading.tsx delete mode 100644 src/app/(web)/tools/templateeditor/page.test.tsx delete mode 100644 src/app/(web)/tools/templateeditor/page.tsx delete mode 100644 src/app/(web)/tools/templateeditor/template-editor.test.tsx delete mode 100644 src/app/(web)/tools/templateeditor/template-editor.tsx delete mode 100644 src/components/template-editor/actions.test.ts delete mode 100644 src/components/template-editor/actions.ts delete mode 100644 src/components/template-editor/editor-canvas.test.tsx delete mode 100644 src/components/template-editor/editor-canvas.tsx delete mode 100644 src/components/template-editor/editor-code-dialog.test.tsx delete mode 100644 src/components/template-editor/editor-code-dialog.tsx delete mode 100644 src/components/template-editor/editor-export-dialog.test.tsx delete mode 100644 src/components/template-editor/editor-export-dialog.tsx delete mode 100644 src/components/template-editor/editor-import-dialog.test.tsx delete mode 100644 src/components/template-editor/editor-import-dialog.tsx delete mode 100644 src/components/template-editor/editor-toolbar.test.tsx delete mode 100644 src/components/template-editor/editor-toolbar.tsx delete mode 100644 src/components/template-editor/grapes-config.test.ts delete mode 100644 src/components/template-editor/grapes-config.ts delete mode 100644 src/components/template-editor/index.ts delete mode 100644 src/components/template-editor/merge-field-picker.test.tsx delete mode 100644 src/components/template-editor/merge-field-picker.tsx delete mode 100644 src/components/template-editor/merge-fields.test.ts delete mode 100644 src/components/template-editor/merge-fields.ts delete mode 100644 src/components/template-editor/template-editor-form.test.tsx delete mode 100644 src/components/template-editor/template-editor-form.tsx delete mode 100644 src/components/template-editor/types.ts delete mode 100644 src/styles/grapesjs-overrides.css diff --git a/.claude/TODO/2026-04-17-components-template-editor-merge-token-resolver.md b/.claude/TODO/2026-04-17-components-template-editor-merge-token-resolver.md index 224a6d1..8d18034 100644 --- a/.claude/TODO/2026-04-17-components-template-editor-merge-token-resolver.md +++ b/.claude/TODO/2026-04-17-components-template-editor-merge-token-resolver.md @@ -9,7 +9,7 @@ files: - src/components/template-editor/actions.ts discovered: 2026-04-17 discovered_by: components-template-editor -status: open +status: resolved --- ## Problem @@ -35,3 +35,13 @@ status: open - Silent data-quality bug: recipients receive emails with literal `{{First_Name}}`. - The "Merge Fields" feature is misleading — the picker implies substitution happens. - Adding a resolver later without tests will risk resolver ↔ picker drift (new tokens in the picker, none in the resolver). + +--- + +## Resolution (2026-10-10) — won't fix: tool removed +The owner removed the template editor entirely (it was a demo, not a supported +tool): `src/components/template-editor/`, `src/app/(web)/tools/templateeditor/`, +`src/styles/grapesjs-overrides.css`, the home-page card, and the +`grapesjs`, `@grapesjs/react`, `grapesjs-mjml`, `mjml` and `@types/mjml` +packages (about 200 lockfile entries). There is no code left for this defect to +live in. If a template tool returns, start from the problem statement above. diff --git a/.claude/TODO/2026-04-17-components-template-editor-no-mp-persistence.md b/.claude/TODO/2026-04-17-components-template-editor-no-mp-persistence.md index 79d338d..e087db3 100644 --- a/.claude/TODO/2026-04-17-components-template-editor-no-mp-persistence.md +++ b/.claude/TODO/2026-04-17-components-template-editor-no-mp-persistence.md @@ -10,7 +10,7 @@ files: - src/components/template-editor/actions.ts discovered: 2026-04-17 discovered_by: components-template-editor -status: open +status: resolved --- ## Problem @@ -42,3 +42,13 @@ Choose one of: - Users lose work unexpectedly. - Multiple users on a shared workstation clobber each other's drafts. - Expectations set by the tool's info text ("Create and edit email templates") don't match behavior — credibility and support cost. + +--- + +## Resolution (2026-10-10) — won't fix: tool removed +The owner removed the template editor entirely (it was a demo, not a supported +tool): `src/components/template-editor/`, `src/app/(web)/tools/templateeditor/`, +`src/styles/grapesjs-overrides.css`, the home-page card, and the +`grapesjs`, `@grapesjs/react`, `grapesjs-mjml`, `mjml` and `@types/mjml` +packages (about 200 lockfile entries). There is no code left for this defect to +live in. If a template tool returns, start from the problem statement above. diff --git a/.claude/TODO/2026-09-30-held-csp-customer-deployments.md b/.claude/TODO/2026-09-30-held-csp-customer-deployments.md index 36be5f9..1b17763 100644 --- a/.claude/TODO/2026-09-30-held-csp-customer-deployments.md +++ b/.claude/TODO/2026-09-30-held-csp-customer-deployments.md @@ -1,9 +1,9 @@ --- -title: "Held — CSP enforcement notes for customer-deployed clones (report endpoint, GrapesJS fonts, browser walk)" +title: "Held — CSP enforcement notes for customer-deployed clones (report endpoint, browser walk)" severity: medium tags: [security, doc] area: routing -files: [src/lib/security-headers.ts, src/proxy.ts, src/components/template-editor/grapes-config.ts] +files: [src/lib/security-headers.ts, src/proxy.ts] discovered: 2026-09-30 discovered_by: playbook-audit (port-downstream-hardening.md F9) status: open @@ -12,21 +12,21 @@ status: open ## Problem Owner decision (2026-09-30): **skip for now; document for customer clones.** The nonce CSP (`security-headers.ts`, `proxy.ts:33-59`) is enforced by default via `CSP_ENFORCE`, but: -- The GrapesJS canvas loads `fonts.googleapis.com` (`grapes-config.ts:64`), which `style-src`/`font-src` block. - No CSP report endpoint is configured. -- The enforced-CSP browser walk (sign-in, sign-out, images, every Radix surface, template editor) hasn't been done +- The enforced-CSP browser walk (sign-in, sign-out, images, every Radix surface) hasn't been done against a production build. ## Notes for customer-deployed clones - **Report endpoint:** set `report-to`/`report-uri` to a collector the customer owns (Sentry, report-uri.com, or a small route that logs shape only). Run `CSP_ENFORCE=false` (report-only) for at least one release cycle first. -- **Fonts:** either allow `https://fonts.googleapis.com` in `style-src` and `https://fonts.gstatic.com` in - `font-src`, or self-host the fonts and drop the Google URL from `grapes-config.ts` (preferred — no third-party - request, no CSP widening). - **Customer additions:** any analytics, chat widget or embedded MP widget a customer adds needs its own origin added — do it per deployment, never with `'unsafe-inline'` or a wildcard. - **Walk:** after any CSP change, `npm run build && npm start`, open DevTools console, walk every tool; zero CSP violations is the bar. ## Impact if not fixed -Template editor fonts silently fall back; violations go unseen without a report endpoint. +Violations go unseen without a report endpoint. + +## Update (2026-10-10) +The GrapesJS font item is gone: the template editor was removed, and it was the only third-party +origin the CSP blocked. What remains is the report endpoint and the enforced-CSP browser walk. diff --git a/.claude/TODO/2026-09-30-step6-headers-csp-build-guards.md b/.claude/TODO/2026-09-30-step6-headers-csp-build-guards.md index 8ea0c18..5f110e1 100644 --- a/.claude/TODO/2026-09-30-step6-headers-csp-build-guards.md +++ b/.claude/TODO/2026-09-30-step6-headers-csp-build-guards.md @@ -20,7 +20,7 @@ status: open ## Proposed fix Port per the playbooks. Verify `server-only` by planting a client import that must break `next build`. -**CSP enforcement walk and the GrapesJS Google Fonts decision are held** — see +**The CSP enforcement walk is held** (the GrapesJS fonts question went away with the template editor, removed 2026-10-10) — see `2026-09-30-held-csp-customer-deployments.md`. ## Impact if not fixed diff --git a/.claude/TODO/INDEX.md b/.claude/TODO/INDEX.md index 01eea01..3ba274b 100644 --- a/.claude/TODO/INDEX.md +++ b/.claude/TODO/INDEX.md @@ -1,7 +1,7 @@ --- title: TODO Index type: index -last_updated: 2026-09-30 +last_updated: 2026-10-10 --- @@ -14,6 +14,7 @@ last_updated: 2026-09-30 + @@ -27,7 +28,7 @@ Severity tiers: - **medium**: doc drift, missing test, refactor with real cost - **low**: nits, minor doc fixes, stylistic improvements -Total: **14 open TODOs**. +Total: **12 open TODOs**. > **2026-09-30 — MPNext playbook audit.** The five `S:\MP\MPNext\.claude\playbooks` > port playbooks were audited against this fork. Remediation is split into nine @@ -59,7 +60,7 @@ _none open_ | auth | security, doc | Ops — Rotate BETTER_AUTH_SECRET; advise downstream clones | [→](2026-09-30-ops-rotate-better-auth-secret.md) | | auth | security, doc | Ops — Set MP_SECURITY_ROLES=* in every environment | [→](2026-09-30-ops-set-mp-security-roles-env.md) | -### Medium (7) +### Medium (5) | Area | Tags | Title | File | |---|---|---|---| | auth | security, doc | Make MP_OIDC_CLIENT_ID / _SECRET required (remove the service-account fallback) | [→](2026-09-30-require-dedicated-oidc-client.md) | @@ -67,8 +68,6 @@ _none open_ | services | drift, missing-test | Step 7 — Write-audit gaps and datetime drift cleanup | [→](2026-09-30-step7-write-audit-and-datetime-cleanup.md) | | routing | security, doc | Held — CSP notes for customer-deployed clones | [→](2026-09-30-held-csp-customer-deployments.md) | | mp-provider | security | Held — Deny-all stored-procedure allowlist | [→](2026-09-30-held-stored-procedure-allowlist.md) | -| components | bug, drift | Template editor ignores pageID/recordID (no MP persistence) | [→](2026-04-17-components-template-editor-no-mp-persistence.md) | -| components | bug, refactor | Merge tokens `{{Field_Name}}` have no resolver anywhere | [→](2026-04-17-components-template-editor-merge-token-resolver.md) | ### Low (5) | Area | Tags | Title | File | @@ -84,28 +83,41 @@ _none open_ ## By tag ### security (9) -- all open 2026-09-30 step/ops/held items except step7 (step1–step5 resolved), plus require-dedicated-oidc-client - -### bug (3) -- components-template-editor-no-mp-persistence — medium -- components-template-editor-merge-token-resolver — medium +- ops-rotate-better-auth-secret — high +- ops-set-mp-security-roles-env — high +- require-dedicated-oidc-client — medium +- step6-headers-csp-build-guards — medium +- held-csp-customer-deployments — medium +- held-stored-procedure-allowlist — medium +- step8-fork-specific-low-risk-items — low +- step9-ci-setup-and-docs — low +- held-github-repo-security-settings — low + +### bug (2) +- step8-fork-specific-low-risk-items — low - search-empty-state-never-renders — low -### drift (1) -- components-template-editor-no-mp-persistence — medium +### drift (2) +- step6-headers-csp-build-guards — medium +- step7-write-audit-and-datetime-cleanup — medium -### missing-test (1) +### missing-test (2) +- step7-write-audit-and-datetime-cleanup — medium - dead-empty-fields-branch-handlenext — low -### refactor (2) -- components-template-editor-merge-token-resolver — medium +### refactor (1) - dead-empty-fields-branch-handlenext — low ### testing (0) _none open_ -### doc (1) +### doc (6) +- ops-rotate-better-auth-secret — high +- ops-set-mp-security-roles-env — high - require-dedicated-oidc-client — medium +- held-csp-customer-deployments — medium +- step9-ci-setup-and-docs — low +- held-github-repo-security-settings — low ### perf (0) _none open_ @@ -116,13 +128,14 @@ _none open_ | Area | Count | |---|---| -| components | 4 | -| testing | 0 | -| services | 0 | | auth | 3 | -| mp-provider | 0 | +| components | 3 | +| routing | 2 | +| commands | 2 | +| services | 1 | +| mp-provider | 1 | +| testing | 0 | | utils | 0 | -| routing | 0 | | mp-schema | 0 | | contexts | 0 | | dto-constants | 0 | @@ -134,6 +147,8 @@ _none open_ | Date | Title | File | |---|---|---| +| 2026-10-10 | Template editor ignores pageID/recordID — won't fix, tool removed | [→](2026-04-17-components-template-editor-no-mp-persistence.md) | +| 2026-10-10 | Merge tokens have no resolver — won't fix, tool removed | [→](2026-04-17-components-template-editor-merge-token-resolver.md) | | 2026-09-30 | Step 5 — MP HTTP client: timeouts, redirect:error, path guard, safe JSON, token validation, single-flight + negative cache, 401 retry; provider path guards, $ignorePermissions removed, identifier-only logging | [→](2026-09-30-step5-mp-http-client-and-provider-hardening.md) | | 2026-09-30 | Step 4 — OIDC lazy discovery (no MP call at boot), app-side id_token verification, sub binding, dedicated OIDC client, sign-out id_token_hint | [→](2026-09-30-step4-oidc-lazy-discovery-and-sub-binding.md) | | 2026-09-30 | Step 3 — 12 h session ceiling, JWE cookie, no user OAuth tokens, boot guards + env.ts, secrets hygiene, client-IP header | [→](2026-09-30-step3-session-lifetime-and-secrets-hygiene.md) | diff --git a/.claude/commands/newtool.md b/.claude/commands/newtool.md index 3ce740d..a5a34a2 100644 --- a/.claude/commands/newtool.md +++ b/.claude/commands/newtool.md @@ -179,4 +179,4 @@ Creates both tools and adds both cards to the homepage. - The `ToolContainer` renders a `DevPanel` above the title bar automatically on localhost in development builds — no manual scaffolding needed. - The `page.tsx` uses a default export (Next.js requirement), but the tool component uses a named export per project convention -- Route names should be lowercase with no separators (matching existing pattern: `template`, `groupwizard`, `templateeditor`) +- Route names should be lowercase with no separators (matching existing pattern: `template`, `groupwizard`, `addresslabels`) diff --git a/.claude/commands/update-deps.md b/.claude/commands/update-deps.md index dff63a9..8195b25 100644 --- a/.claude/commands/update-deps.md +++ b/.claude/commands/update-deps.md @@ -112,8 +112,8 @@ npm view @ peerDependencies engines --json Then confirm the rest of the tree can satisfy those peers — especially any wrapper package. A wrapper's peer range on its wrapped library is the usual -blocker (`@grapesjs/react` → `grapesjs`, `eslint-config-next` → -`eslint-plugin-react`). Check whether the wrapper's **latest** release supports +blocker (`eslint-config-next` → `eslint-plugin-react`, +`typescript-eslint` → `typescript`). Check whether the wrapper's **latest** release supports the target; if it does not, the upgrade is blocked upstream and no override fixes it — record it as a hold. @@ -170,8 +170,7 @@ const all=[...seen.values()]; **Triage every hit before reporting it.** OSV ranges are sometimes malformed — fetch `https://api.osv.dev/v1/vulns/` and read `affected[].ranges` directly. A record with `{"introduced":"0"}` and no `fixed` event matches every version and -is almost always a false positive. Known standing false positive: `grapesjs` / -GHSA-589f-c66p-hxr4. +is almost always a false positive. For richer context on a specific library's breaking changes, `context7` is available (`resolve-library-id` then `query-docs`). diff --git a/.claude/references/DECISIONS.md b/.claude/references/DECISIONS.md index df5ab6d..3890681 100644 --- a/.claude/references/DECISIONS.md +++ b/.claude/references/DECISIONS.md @@ -325,7 +325,7 @@ Architectural decisions captured by the context-engineering review at SHA `971c4 ### ADR-023: Client-only GrapesJS via `next/dynamic(ssr: false)` **Date:** 2026-04-17 -**Status:** Accepted +**Status:** Superseded (2026-10-10) — the template editor and every GrapesJS/MJML dependency were removed. Kept for numbering; nothing in `src/` follows this decision any more. **Context:** GrapesJS and `@grapesjs/react` depend on DOM globals and cannot run under Node SSR. **Decision:** `src/components/template-editor/template-editor-form.tsx:12` wraps the editor canvas in `next/dynamic(..., { ssr: false })` with a `Skeleton` fallback. All editor files carry `"use client"`. **Consequences:** No SSR for the template editor; initial paint shows a skeleton and hydrates. The rest of the `/tools` route stays server-rendered. diff --git a/.claude/references/GLOSSARY.md b/.claude/references/GLOSSARY.md index 0ace4d3..4718cf2 100644 --- a/.claude/references/GLOSSARY.md +++ b/.claude/references/GLOSSARY.md @@ -148,14 +148,6 @@ Better Auth plugin wiring arbitrary OIDC providers; configured with `providerId: **Defined in:** `src/lib/auth.ts:32` **See:** `auth/oauth-flow.md` -## GrapesJS (aliases: grapesjs, @grapesjs/react) - -Client-only drag-and-drop visual editor used for the template editor; wrapped via `next/dynamic` with `ssr: false`. - -**Not to be confused with:** MJML (the markup GrapesJS edits). -**Defined in:** `src/components/template-editor/editor-canvas.tsx:4` -**See:** `components/template-editor.md` - ## H ## HttpClient @@ -228,14 +220,6 @@ Process-singleton orchestrator that owns one `MinistryPlatformClient` and six do **Defined in:** `src/lib/providers/ministry-platform/provider.ts:44` **See:** `mp-provider/architecture.md` -## MJML - -Responsive-email markup language; source is edited in GrapesJS and server-compiled to HTML via the `mjml` package in `compileMjml`. - -**Not to be confused with:** GrapesJS (the editor that emits MJML). -**Defined in:** `src/components/template-editor/actions.ts:22` -**See:** `components/template-editor.md` - ## mock class (aliases: class mock) Pattern of mocking a constructable import as a `class { method = mockFn }` literal rather than `vi.fn().mockImplementation()`; required for `MPHelper`. diff --git a/.claude/references/GOTCHAS.md b/.claude/references/GOTCHAS.md index 0124a89..7b73b0b 100644 --- a/.claude/references/GOTCHAS.md +++ b/.claude/references/GOTCHAS.md @@ -58,8 +58,8 @@ Known pitfalls for agents working on MPNext-Tools. Each entry has a symptom-firs - GOTCHA-035: Dev-panel `UserToolsPanel` always shows "NOT AUTHORIZED" - GOTCHA-036: Edit-mode Review screen shows "ID: <n>" for Primary Contact / Parent Group - GOTCHA-037: Renaming `Secure_Check-in` breaks compilation in non-obvious places -- GOTCHA-038: SSR/build error "window is not defined" when loading `/tools/templateeditor` -- GOTCHA-039: Merge tokens like `{{First_Name}}` appear verbatim in sent emails +- GOTCHA-038: _Retired 2026-10-10 (template editor removed)_ +- GOTCHA-039: _Retired 2026-10-10 (template editor removed)_ ### Utils / labels - GOTCHA-040: IMb barcode silently becomes POSTNET (or disappears) with no log @@ -387,21 +387,13 @@ Known pitfalls for agents working on MPNext-Tools. Each entry has a symptom-firs **Enforced where:** `src/components/group-wizard/schema.ts:43` (declaration). **Related:** [components/group-wizard.md](components/group-wizard.md) -### GOTCHA-038: SSR/build error "window is not defined" when loading `/tools/templateeditor` +### GOTCHA-038: _Retired_ -**Symptom:** Route errors during SSR or production build: `ReferenceError: window is not defined`. -**Root cause:** GrapesJS touches `window` / `document` at import time. Any server-side import of a GrapesJS module blows up. -**Fix:** Import `EditorCanvas` (or anything that imports `grapesjs`) via `next/dynamic` with `ssr: false`. Keep `"use client"` at the top of every file that imports from `grapesjs`. -**Enforced where:** `src/components/template-editor/template-editor-form.tsx:6-15` (canonical pattern). -**Related:** [components/template-editor.md](components/template-editor.md) +Retired 2026-10-10: applied only to the GrapesJS template editor, which was removed along with its dependencies. -### GOTCHA-039: Merge tokens like `{{First_Name}}` appear verbatim in sent emails +### GOTCHA-039: _Retired_ -**Symptom:** A template authored in the editor with `{{First_Name}}` tokens gets sent to recipients with the literal text `{{First_Name}}` visible. -**Root cause:** The template-editor has no token resolver. Tokens are emitted literally by the MJML compile step; the downstream send pipeline is responsible for substitution but does not yet exist. -**Fix:** Resolve tokens in the downstream send/render pipeline before emitting the final HTML. TODO: `.claude/TODO/2026-04-17-components-template-editor-merge-token-resolver.md`. -**Enforced where:** `src/components/template-editor/merge-fields.ts:9-28` (defines tokens but no resolver exists). -**Related:** [components/template-editor.md](components/template-editor.md) +Retired 2026-10-10: applied only to the template editor's merge tokens; the editor was removed. --- diff --git a/.claude/references/components/README.md b/.claude/references/components/README.md index a49a6c3..9a9b7d9 100644 --- a/.claude/references/components/README.md +++ b/.claude/references/components/README.md @@ -16,7 +16,6 @@ Reference docs for `src/components/` — the tool layout framework, layout wrapp | `address-labels.md` | Address label printing feature (shard 12) | Working on label rendering, IMb/POSTNET barcodes, mail-merge | | `group-wizard.md` | Multi-step group wizard (shard 13) | Editing the 6-step create/edit group flow | | `dev-panel.md` | Localhost-only developer overlay (shard 14) | Understanding `ToolContainer` auto-rendering behavior | -| `template-editor.md` | GrapesJS email/document template editor (shard 15) | Working on templates or merge fields | | `field-management.md` | Drag-and-drop page field ordering (shard 16) | Working on MP page field layout UX | | `user-menu.md` | User dropdown + OIDC sign-out (shard 17) | Working on sign-out or RP-initiated logout | @@ -31,7 +30,6 @@ Reference docs for `src/components/` — the tool layout framework, layout wrapp | `src/components/dev-panel/` | Dev overlay — 3 files + `panels/` subdir | | `src/components/field-management/` | Field-order editor — 10 files | | `src/components/group-wizard/` | Group wizard — 14 files | -| `src/components/template-editor/` | Template editor — 12 files | | `src/components/user-menu/` | User menu — 4 files | ## Conventions (domain-wide) diff --git a/.claude/references/components/template-editor.md b/.claude/references/components/template-editor.md deleted file mode 100644 index 2780250..0000000 --- a/.claude/references/components/template-editor.md +++ /dev/null @@ -1,284 +0,0 @@ ---- -title: Template Editor -domain: components -type: reference -applies_to: - - src/components/template-editor/index.ts - - src/components/template-editor/template-editor-form.tsx - - src/components/template-editor/editor-canvas.tsx - - src/components/template-editor/editor-toolbar.tsx - - src/components/template-editor/editor-code-dialog.tsx - - src/components/template-editor/editor-import-dialog.tsx - - src/components/template-editor/editor-export-dialog.tsx - - src/components/template-editor/merge-field-picker.tsx - - src/components/template-editor/merge-fields.ts - - src/components/template-editor/grapes-config.ts - - src/components/template-editor/actions.ts - - src/components/template-editor/types.ts - - src/app/(web)/tools/templateeditor/page.tsx - - src/app/(web)/tools/templateeditor/template-editor.tsx -symbols: - - TemplateEditorForm - - EditorCanvas - - EditorToolbar - - EditorCodeDialog - - EditorImportDialog - - EditorExportDialog - - MergeFieldPicker - - MERGE_FIELDS - - MERGE_FIELD_CATEGORIES - - getFieldsByCategory - - registerMergeFieldBlocks - - createEditorConfig - - DEFAULT_MJML_TEMPLATE - - STORAGE_KEY - - compileMjml -related: - - ../routing/app-router.md - - ../data-flow/README.md -last_verified: 2026-04-17 ---- - -## Purpose -Drag-and-drop MJML email template editor built on GrapesJS, with MP merge-field insertion, MJML/HTML code editing, import/export, and localStorage autosave. - -## Files - -| File | Role | -|---|---| -| `src/components/template-editor/index.ts` | Barrel export — only re-exports `TemplateEditorForm` | -| `src/components/template-editor/template-editor-form.tsx` | Top-level wrapper; dynamic-imports `EditorCanvas` with `ssr: false` and a skeleton fallback | -| `src/components/template-editor/editor-canvas.tsx` | GrapesJS React wrapper (`GjsEditor` + `Canvas`); registers merge-field blocks on ready and loads `DEFAULT_MJML_TEMPLATE` when no saved state | -| `src/components/template-editor/editor-toolbar.tsx` | React toolbar: undo/redo, device toggle (Desktop/Mobile), panel toggles (Blocks/Layers/Styles), code/import/export/merge-field/clear buttons; drives GrapesJS via `useEditor()` | -| `src/components/template-editor/editor-code-dialog.tsx` | Dialog to view/edit MJML source or compiled HTML preview (server-compiled via `compileMjml`) | -| `src/components/template-editor/editor-import-dialog.tsx` | Dialog to paste MJML or JSON editor state and replace canvas content | -| `src/components/template-editor/editor-export-dialog.tsx` | Dialog to export MJML / compiled HTML / GrapesJS JSON project data (copy or download) | -| `src/components/template-editor/merge-field-picker.tsx` | Popover listing merge fields grouped by category; inserts into selected `mj-text` or appends a new `mj-section > mj-column > mj-text` | -| `src/components/template-editor/merge-fields.ts` | `MERGE_FIELDS` list, category helpers, and `registerMergeFieldBlocks(editor)` to add pre-canned merge blocks to GrapesJS Blocks panel | -| `src/components/template-editor/grapes-config.ts` | `createEditorConfig()` — GrapesJS `EditorConfig` factory; `DEFAULT_MJML_TEMPLATE`; `STORAGE_KEY = 'mp-template-editor'` | -| `src/components/template-editor/actions.ts` | `'use server'` — `compileMjml(source)`; auth-gates via `auth.api.getSession`; 500KB cap; dynamic-imports `mjml` | -| `src/components/template-editor/types.ts` | `MjmlCompileResult`, `MjmlCompileError`, `EditorCanvasProps`, `EditorToolbarProps` | -| `src/app/(web)/tools/templateeditor/page.tsx` | Tool route (server) at `/tools/templateeditor`; parses `ToolParams` via `parseToolParams` | -| `src/app/(web)/tools/templateeditor/template-editor.tsx` | `"use client"` shell — `ToolContainer` with `hideFooter`, renders `TemplateEditorForm` | - -No test files exist under `src/components/template-editor/` (see TODO `2026-04-17-components-template-editor-missing-tests.md`). - -## Key concepts - -### GrapesJS integration - -- **Packages** (verified in `package.json`): - - `grapesjs@^0.22.14` — core editor - - `@grapesjs/react@^2.0.0` — React bindings (`GjsEditor`, `Canvas`, `WithEditor`, `useEditor`) - - `grapesjs-mjml@^1.0.8` — MJML block/component support - - `mjml@^4.18.0` + `@types/mjml` — server-side MJML → HTML compilation -- **Client-only**: GrapesJS touches the DOM; `EditorCanvas` is loaded via `next/dynamic` with `ssr: false` from `template-editor-form.tsx:6-15`, and every interactive file starts with `"use client"`. -- **Default panels disabled**: `createEditorConfig()` sets `panels: { defaults: [] }` — the app renders its own React toolbar instead of GrapesJS's built-in chrome. -- **Toolbar drives GrapesJS via commands/managers**: `core:open-blocks`, `core:open-layers`, `core:open-styles-manager` (run/stop), `editor.setDevice`, `editor.UndoManager`, `editor.DomComponents.clear()`, `editor.CssComposer.clear()` (see `editor-toolbar.tsx:84-142`). -- **Sidebar width hack**: Toolbar and canvas-ready code manipulate `.gjs-pn-views-container` and `.gjs-cv-canvas` widths directly (DOM query) to toggle the 240px sidebar (`editor-toolbar.tsx:101-111`, `editor-canvas.tsx:38-41`). -- **Device presets**: `Desktop` 600px, `Mobile` 320px (widthMedia 480px) — `grapes-config.ts:40-50`. -- **Block customization**: `registerMergeFieldBlocks` adds `merge-field-contact` and `merge-field-unsubscribe` blocks; `EditorCanvas` additionally removes `mj-navbar` and `mj-navbar-link` blocks from the default MJML plugin (`editor-canvas.tsx:21-23`). - -### Persistence - -- **Storage**: GrapesJS `storageManager` type `'local'` with `autosave`, `autoload`, `stepsBeforeSave: 1`, key `STORAGE_KEY = 'mp-template-editor'` (`grapes-config.ts:23-36`). -- **Default template**: If `localStorage.getItem(STORAGE_KEY)` is null on first ready, `editor.setComponents(DEFAULT_MJML_TEMPLATE)` seeds a minimal MJML skeleton (`editor-canvas.tsx:27-35`). -- **Clear**: "Clear Canvas" confirms via `AlertDialog`, then clears components, CSS, undo stack, and removes the localStorage key (`editor-toolbar.tsx:133-142`). -- No MP-side persistence: the editor does not load or save templates to Ministry Platform. The route accepts `ToolParams` (`pageID`, `recordID`) but only displays them in info content — no MP template record is loaded or written. - -### Merge fields - -- **Token format**: `{{Field_Name}}` (double-brace, snake_case MP column names). Defined in `merge-fields.ts:11-27`. -- **Categories** (from `MERGE_FIELDS`): `Contact`, `Household`, `Church`, `System`. -- **Insertion semantics** (`merge-field-picker.tsx:27-55`): - - If the selected component is `mj-text`, append the token to its `content`. - - Otherwise, append a new `mj-section > mj-column > mj-text` into the `mj-body` (or the wrapper if no body exists). -- **No substitution layer** — merge tokens are emitted as literal text in MJML and compiled HTML; there is no code in this directory that resolves them against MP contact data. - -### MJML compilation - -- Server action `compileMjml(mjmlSource)` (`actions.ts:15-38`): - - Requires session (`session.user.id`) or throws `Unauthorized`. - - Rejects empty or >500KB (`MAX_MJML_SIZE = 512_000`) source. - - Dynamic-imports `mjml` and runs with `validationLevel: 'soft'`, `minify: false`. - - Returns `{ html, errors: MjmlCompileError[] }` where each error carries `line`, `message`, `tagName`, `formattedMessage`. -- Called from `EditorCodeDialog` (HTML preview tab) and `EditorExportDialog` (HTML tab). - -### Import / Export - -- **Import** (`editor-import-dialog.tsx`): - - MJML mode → `editor.setComponents(source)` (replaces content). - - JSON mode → `JSON.parse` then `editor.loadData(data)`; invalid JSON surfaces a friendly error. -- **Export** (`editor-export-dialog.tsx`): - - MJML → `editor.getHtml()` (GrapesJS + MJML plugin return MJML source here, not compiled HTML). - - HTML → server-compiled via `compileMjml`. - - JSON → `editor.getProjectData()` serialized (copy to clipboard or download `template-state.json` as a Blob). - -## API / Interface - -```typescript -// template-editor-form.tsx -interface TemplateEditorFormProps { - onClose: () => void; -} -export function TemplateEditorForm({ onClose }: TemplateEditorFormProps): JSX.Element; - -// types.ts -export interface EditorCanvasProps { - onEditorReady?: (editor: Editor) => void; - onClose: () => void; -} -export interface EditorToolbarProps { - onClose: () => void; -} -export interface MjmlCompileResult { - html: string; - errors: MjmlCompileError[]; -} -export interface MjmlCompileError { - line: number; - message: string; - tagName: string; - formattedMessage: string; -} - -// grapes-config.ts -export const STORAGE_KEY: string; -export const DEFAULT_MJML_TEMPLATE: string; -export function createEditorConfig(): EditorConfig; - -// merge-fields.ts -export interface MergeField { label: string; value: string; category: string } -export const MERGE_FIELDS: MergeField[]; -export const MERGE_FIELD_CATEGORIES: string[]; -export function getFieldsByCategory(category: string): MergeField[]; -export function registerMergeFieldBlocks(editor: Editor): void; - -// actions.ts ('use server') -export async function compileMjml(mjmlSource: string): Promise; -``` - -## How it works - -- `/tools/templateeditor` (server) → `TemplateEditor` (client shell with `ToolContainer`) → `TemplateEditorForm` → dynamic `EditorCanvas` (SSR disabled). -- `EditorCanvas` mounts `GjsEditor` with `grapesjs`, `grapesjsMjml` plugin, and config from `createEditorConfig()`. -- On `onReady(editor)`: - 1. Stash editor in `useRef`. - 2. `registerMergeFieldBlocks(editor)`. - 3. Remove `mj-navbar`, `mj-navbar-link` blocks. - 4. If no saved state, seed `DEFAULT_MJML_TEMPLATE`. - 5. Set canvas width to leave 240px for the sidebar. -- `EditorToolbar` (wrapped in ``) reads `useEditor()` and drives command execution, UndoManager polling via `change:changesCount`, device switching, and dialog opening. -- Server action `compileMjml` is called only from the two dialogs (code-dialog, export-dialog). - -## Usage - -Barrel import (the only public entry point): - -```typescript -// src/app/(web)/tools/templateeditor/template-editor.tsx:5 -import { TemplateEditorForm } from "@/components/template-editor"; -``` - -Tool shell: - -```typescript -// src/app/(web)/tools/templateeditor/template-editor.tsx:12-42 -export function TemplateEditor({ params }: TemplateEditorProps) { - const router = useRouter(); - const handleClose = () => { router.back(); }; - return ( - - - - ); -} -``` - -Server-compile MJML from a client component: - -```typescript -// src/components/template-editor/editor-code-dialog.tsx:40-54 -const handleCompileHtml = async () => { - setIsCompiling(true); - setCompileErrors([]); - try { - const result = await compileMjml(mjmlSource); - setHtmlPreview(result.html); - if (result.errors.length > 0) { - setCompileErrors(result.errors.map((e) => e.formattedMessage)); - } - } catch (err) { - setCompileErrors([err instanceof Error ? err.message : 'Compilation failed']); - } finally { - setIsCompiling(false); - } -}; -``` - -## Gotchas - -- **GrapesJS must be client-only.** It touches `window`/DOM on construction. `EditorCanvas` is loaded via `next/dynamic` with `ssr: false` in `template-editor-form.tsx:6-15`; every component file that references `grapesjs`/`@grapesjs/react` begins with `"use client"`. Importing `editor-canvas.tsx` from a server component or removing the dynamic wrapper will break SSR. -- **Merge tokens are literal `{{Field_Name}}` strings.** No component in this directory resolves them. They survive MJML → HTML compilation unchanged and must be substituted by a downstream consumer (not implemented in-tree at this SHA). -- **Route uses `ToolParams` but ignores them.** `pageID` and `recordID` from query string are shown in info content only; no MP template record is fetched or saved. Users landing here from an MP page context will not see their template auto-loaded. -- **Autosave is local-only.** State persists to `localStorage['mp-template-editor']` on the user's browser. Clearing browser storage or switching devices discards work; there is no server-side save. -- **Direct DOM queries for sidebar width.** `editor-toolbar.tsx:101-111` and `editor-canvas.tsx:38-41` reach into `.gjs-pn-views-container` and `.gjs-cv-canvas` via `document.querySelector`. Fragile against GrapesJS class-name changes. -- **Toolbar's `.test-html-..` mock class** *(not applicable)*. -- **`editor.getHtml()` returns MJML source**, not HTML — because the MJML plugin overrides that method. Used in both `EditorCodeDialog` and `EditorExportDialog` to expose the MJML tree. Compile via `compileMjml` for real HTML. -- **No tests** for this directory (37 test files project-wide, none under `template-editor/`). See TODO. - -## Related docs - -- `../routing/app-router.md` — tool route conventions (`/tools/`) and `ToolParams` usage -- `../data-flow/README.md` — server-action pattern used by `compileMjml` - ---- - -```json -{ - "shard": "components-template-editor", - "docs_written": [".claude/references/components/template-editor.md"], - "todos_dropped": [ - ".claude/TODO/2026-04-17-components-template-editor-missing-tests.md", - ".claude/TODO/2026-04-17-components-template-editor-no-mp-persistence.md", - ".claude/TODO/2026-04-17-components-template-editor-merge-token-resolver.md" - ], - "glossary_terms": [ - {"term": "GrapesJS", "aliases": ["grapesjs", "@grapesjs/react"], "definition": "Client-only drag-and-drop visual editor used for the template editor; wrapped via next/dynamic with ssr:false.", "defined_in": "src/components/template-editor/editor-canvas.tsx:4"}, - {"term": "MJML", "aliases": [], "definition": "Responsive-email markup language; source is edited in GrapesJS and server-compiled to HTML via the mjml package in compileMjml.", "defined_in": "src/components/template-editor/actions.ts:22"}, - {"term": "Merge field", "aliases": ["merge token"], "definition": "Template placeholder written as {{Field_Name}} using MP column names; inserted via MergeFieldPicker and emitted literally into compiled HTML (no in-tree resolver).", "defined_in": "src/components/template-editor/merge-fields.ts:11"}, - {"term": "MJML default template", "aliases": ["DEFAULT_MJML_TEMPLATE"], "definition": "Minimal mjml/mj-body/mj-section skeleton seeded into the canvas when no localStorage state exists.", "defined_in": "src/components/template-editor/grapes-config.ts:5"} - ], - "gotchas": [ - {"symptom": "SSR/build error 'window is not defined' or 'ReferenceError: document' when loading /tools/templateeditor", "root_cause": "GrapesJS touches DOM at import time and cannot run under Node SSR.", "fix": "Import EditorCanvas via next/dynamic with ssr:false (as in template-editor-form.tsx) and keep `\"use client\"` on every file that imports grapesjs or @grapesjs/react.", "enforced_where": "src/components/template-editor/template-editor-form.tsx:6"}, - {"symptom": "Merge tokens like {{First_Name}} appear verbatim in sent emails", "root_cause": "The template-editor directory contains no token resolver; tokens are emitted literally by MJML compilation.", "fix": "Resolve tokens in a downstream send/render pipeline before delivery, or add a resolver in a new service.", "enforced_where": "src/components/template-editor/merge-fields.ts:9"}, - {"symptom": "User's saved template disappears after clearing browser data or switching devices", "root_cause": "GrapesJS storageManager is configured as type:'local' (localStorage only) with key 'mp-template-editor'; no server persistence.", "fix": "Either add MP-side save/load using the route's ToolParams, or warn users in the UI that storage is local-only.", "enforced_where": "src/components/template-editor/grapes-config.ts:23"}, - {"symptom": "Export 'MJML' tab shows MJML, not HTML, despite calling editor.getHtml()", "root_cause": "The grapesjs-mjml plugin overrides getHtml() to return MJML source.", "fix": "Use compileMjml server action (as EditorExportDialog HTML tab does) to obtain real HTML.", "enforced_where": "src/components/template-editor/editor-export-dialog.tsx:33"} - ], - "adrs": [ - {"title": "Client-only GrapesJS via next/dynamic(ssr:false)", "context": "GrapesJS and @grapesjs/react are DOM-bound and cannot run under Node SSR.", "decision": "Wrap EditorCanvas in next/dynamic with ssr:false and a Skeleton fallback; mark every editor file as `\"use client\"`.", "consequences": "No SSR for the editor; initial paint shows skeleton then hydrates. Keeps the rest of the tools route server-rendered.", "alternatives": "Conditional import inside useEffect (rejected — harder to type and worse DX)."}, - {"title": "Local-only template persistence (Phase 1)", "context": "Phase 1 of the template editor did not yet have MP-side save/load.", "decision": "Use GrapesJS storageManager type:'local' with key 'mp-template-editor'; autosave on every change step.", "consequences": "Work is tied to browser storage; no multi-device or multi-user persistence; no MP template integration despite tool route exposing pageID/recordID.", "alternatives": "MP REST storage via a dedicated service (deferred)."}, - {"title": "Server-side MJML compilation instead of bundling in the client", "context": "MJML compiler is heavy and not intended for browser use.", "decision": "Expose compileMjml as a `'use server'` action with auth gate and 500KB cap; dynamic-import mjml inside the handler.", "consequences": "Compilation requires a round-trip and an authenticated session; keeps client bundle small.", "alternatives": "Use mjml-browser in the client (rejected — bundle size)."} - ], - "call_graphs": [ - {"name": "template-editor-render", "hint": "entry: src/app/(web)/tools/templateeditor/page.tsx:TemplateEditorPage → src/app/(web)/tools/templateeditor/template-editor.tsx:TemplateEditor → src/components/template-editor/template-editor-form.tsx:TemplateEditorForm → dynamic import src/components/template-editor/editor-canvas.tsx:EditorCanvas → @grapesjs/react:GjsEditor + grapesjs + grapesjs-mjml"}, - {"name": "mjml-compile", "hint": "entry: src/components/template-editor/editor-code-dialog.tsx:handleCompileHtml (and editor-export-dialog.tsx:handleCompile) → src/components/template-editor/actions.ts:compileMjml (`'use server'`) → auth.api.getSession → dynamic import('mjml') → mjml2html"}, - {"name": "merge-field-insert", "hint": "entry: src/components/template-editor/merge-field-picker.tsx:handleInsertField → editor.getSelected | editor.getWrapper → component.append({type:'mj-section'...}) (no server call)"} - ], - "errors": [ - {"layer": "oauth", "type": "Unauthorized (thrown Error when session is missing)", "thrown_at": "src/components/template-editor/actions.ts:9", "caught_at": "src/components/template-editor/editor-code-dialog.tsx:49 (try/catch in handleCompileHtml)"}, - {"layer": "validation", "type": "MJML size guard (empty or >500KB)", "thrown_at": "src/components/template-editor/actions.ts:19", "caught_at": "src/components/template-editor/editor-code-dialog.tsx:49, src/components/template-editor/editor-export-dialog.tsx:54"}, - {"layer": "client-fetch", "type": "Invalid JSON import", "thrown_at": "src/components/template-editor/editor-import-dialog.tsx:36 (JSON.parse throws)", "caught_at": "src/components/template-editor/editor-import-dialog.tsx:36 (try/catch sets error state)"} - ], - "cross_refs_needed": [ - "routing/app-router.md", - "data-flow/README.md" - ], - "critical_flag": null -} -``` diff --git a/.claude/references/data-flow/error-catalog.md b/.claude/references/data-flow/error-catalog.md index 04719fe..c517a10 100644 --- a/.claude/references/data-flow/error-catalog.md +++ b/.claude/references/data-flow/error-catalog.md @@ -57,7 +57,6 @@ All MP REST errors originate in `src/lib/providers/ministry-platform/utils/http- | `UnauthorizedError('Not authorized')` (`code: "UNAUTHORIZED"`) | `src/services/authorizationService.ts:186` (`requireSecurityRole`) — the gate every feature action and service method calls | component `try/catch` → `setError` (or `ActionError` shape for group-wizard) | toast / inline error | yes — `console.warn` of `{table, operation, reason, userId}` only (identifiers/shape, never record content) | | `Error('Unauthorized - Missing user session data')` | `src/components/dev-panel/panels/require-dev-session.ts:34` — single guard shared by every dev-panel action | component catch → `setError` | as above | no | | ``Error(`${featureLabel} is not available in production.`)`` | `src/components/dev-panel/panels/require-dev-session.ts:30` (`featureLabel` defaults to `"Dev panel"`; deploy-tool passes `"Deploy Tool"`) | dev-panel / deploy-tool panel catch → `setError` | "production" warning to user | no | -| `Error('MJML source must be between 1 and 512000 characters')` | `src/components/template-editor/actions.ts:19` | editor dialogs (`editor-code-dialog.tsx`, `editor-export-dialog.tsx`) catch | inline error state | no | | Zod `ZodError` via `zodResolver` (group wizard) | `src/components/group-wizard/schema.ts` (`groupWizardSchema`) | RHF `form.trigger(...)` in `src/app/(web)/tools/groupwizard/group-wizard.tsx:104` | in-form field error messages | no | | `Error('Input must contain only digits')` (IMb) | `src/lib/imb-encoder.ts:323` | `src/lib/barcode-helpers.ts:48` **silent catch — falls through to POSTNET** | no user-facing error; label prints without IMb | no (silent) | | `Error('Input must be 20, 25, 29, or 31 digits (got N)')` | `src/lib/imb-encoder.ts:328` | `src/lib/barcode-helpers.ts:48` silent fallthrough | as above | no | @@ -95,14 +94,13 @@ These do NOT throw to the component; server action returns `{ success: false, er | Profile fetch failure in `UserProvider` | `src/components/shared-actions/user.ts:28` (`Unauthorized` — session has no `userGuid`) or downstream MP error from `UserService.getUserProfile` | `src/contexts/user-context.tsx:43-46` try/catch sets `error` state | silent (consumers of `useUser()` inspect `error`) | no | | `Error("useUser must be used within a UserProvider")` | `src/contexts/user-context.tsx:79` | uncaught — surfaces to nearest React error boundary | white-screen error unless a boundary is present | no | | `Error("useFormField should be used within ")` | `src/components/ui/form.tsx:53` | uncaught — React error boundary | same as above | no | -| "Invalid JSON data…" (template-editor import) | `JSON.parse` throws at `src/components/template-editor/editor-import-dialog.tsx:34` | `:36` silent catch → `setError('Invalid JSON data…')` | inline error | no | | "IMb requires a 6 or 9 digit USPS Mailer ID" (control-flow) | `src/components/address-labels/mail-merge-tab.tsx:111-114` | same function → `setError` early return | inline error | no | ## Error-handling patterns ### Server action failures Two shapes exist; actions are inconsistent. -- **Throw-then-catch (most actions):** action throws a plain `Error`; calling component wraps call in `try/catch` and calls `setError(err instanceof Error ? err.message : 'fallback')`. Examples: `selection-actions.ts`, `user-tools-actions.ts`, `field-management/actions.ts`, `contact-records-actions.ts`, `template-editor/actions.ts`. +- **Throw-then-catch (most actions):** action throws a plain `Error`; calling component wraps call in `try/catch` and calls `setError(err instanceof Error ? err.message : 'fallback')`. Examples: `selection-actions.ts`, `user-tools-actions.ts`, `field-management/actions.ts`, `contact-records-actions.ts`. - **Envelope shape (`ActionError`):** action returns `{ success: false, error: string }`. Used by `address-labels/actions.ts` (PDF/docx/merge), `group-wizard/actions.ts` (`fetchGroupRecord`, `createGroup`, `updateGroup`), `field-management/actions.ts:61-72` (`savePageFieldOrder`). - **Mixed in same file** is common — auth/validation errors throw; domain operations return envelope. No central policy; see `address-labels/actions.ts` for both shapes in one file. diff --git a/.claude/references/dto-constants/constants.md b/.claude/references/dto-constants/constants.md index 81c9dec..62274bc 100644 --- a/.claude/references/dto-constants/constants.md +++ b/.claude/references/dto-constants/constants.md @@ -2,8 +2,8 @@ title: Shared Constants & Validation Helpers domain: dto-constants type: reference -applies_to: [src/lib/dto/address-label.dto.ts, src/lib/label-stock.ts, src/lib/validation.ts, src/components/group-wizard/types.ts, src/services/addressLabelService.ts, src/services/toolService.ts, src/components/template-editor/grapes-config.ts] -symbols: [SERVICE_TYPES, LABEL_STOCKS, LabelStockConfig, LabelPosition, WIZARD_STEPS, WizardStepIndex, DEFAULT_MJML_TEMPLATE, validateGuid, validatePositiveInt, validateColumnName, escapeFilterString, BATCH_SIZE, MAX_TEMPLATE_SIZE, MAX_FILE_SIZE, MAX_MJML_SIZE] +applies_to: [src/lib/dto/address-label.dto.ts, src/lib/label-stock.ts, src/lib/validation.ts, src/components/group-wizard/types.ts, src/services/addressLabelService.ts, src/services/toolService.ts] +symbols: [SERVICE_TYPES, LABEL_STOCKS, LabelStockConfig, LabelPosition, WIZARD_STEPS, WizardStepIndex, validateGuid, validatePositiveInt, validateColumnName, escapeFilterString, BATCH_SIZE, MAX_TEMPLATE_SIZE, MAX_FILE_SIZE] related: [dtos.md, ../services/README.md, ../utils/README.md] last_verified: 2026-04-17 --- @@ -18,7 +18,6 @@ Inventory of module-level runtime constants and SQL-safety validation helpers. A - `src/lib/validation.ts` — 4 SQL-safety helpers + 2 regexes - `src/lib/validation.test.ts` — 19 test cases - `src/components/group-wizard/types.ts:51-60` — `WIZARD_STEPS`, `WizardStepIndex` -- `src/components/template-editor/grapes-config.ts:5` — `DEFAULT_MJML_TEMPLATE` - `src/services/addressLabelService.ts:21` — `BATCH_SIZE = 100` (module-local) - `src/services/toolService.ts:283` — `BATCH_SIZE = 100` (class static) @@ -124,7 +123,6 @@ Items found in `src/` outside `src/lib/dto/` and `src/lib/validation.ts`: | `BATCH_SIZE = 100` | `src/services/addressLabelService.ts:21` (module) + `src/services/toolService.ts:283` (class static) | MP record fetch batch size | Yes — two independent copies, merge into single export (e.g. `src/lib/constants.ts`) | | `MAX_TEMPLATE_SIZE = 5 * 1024 * 1024` | `src/components/address-labels/actions.ts:203` | 5MB docx upload cap | Possibly | | `MAX_FILE_SIZE = 5 * 1024 * 1024` | `src/components/address-labels/mail-merge-tab.tsx:13` | 5MB docx upload cap (client mirror) | Yes — duplicated literal | -| `MAX_MJML_SIZE = 512_000` | `src/components/template-editor/actions.ts:13` | 500KB MJML cap | Feature-local, OK | | `'040'` default | `src/app/(web)/tools/addresslabels/address-labels.tsx:53` | Default USPS service type | Consider `DEFAULT_SERVICE_TYPE = SERVICE_TYPES[0].id` | | `serialLength = mailerId.length === 6 ? 9 : 6` | `src/lib/barcode-helpers.ts:23` | USPS IMb rule: 6-digit MID → 9-digit serial, 9-digit MID → 6-digit serial | Domain rule — documented in comment, OK | | `barcodeId = '00'` | `src/lib/barcode-helpers.ts:22` | IMb header byte | OK (domain constant, inline) | diff --git a/.claude/references/routing/app-router.md b/.claude/references/routing/app-router.md index 98cfe8d..cc22369 100644 --- a/.claude/references/routing/app-router.md +++ b/.claude/references/routing/app-router.md @@ -14,11 +14,11 @@ Next.js 16 App Router tree. A `(web)` route group gates all authenticated pages ## Files - `src/app/layout.tsx` — root layout, imports `globals.css`, renders `{children}` - `src/app/(web)/layout.tsx` — group layout; wraps children in `AuthWrapper` then `Providers`, injects Geist fonts, sets `metadata` + `viewport` -- `src/app/(web)/page.tsx` — dashboard `/` — 5 tool cards (Template, Template Editor, Address Labels, Group Wizard, Field Management) +- `src/app/(web)/page.tsx` — dashboard `/` — 5 tool cards (Template, Address Labels, Group Wizard, Field Management, Add/Edit Family) - `src/app/(web)/home/page.tsx` — `/home` → `redirect('/')` - `src/app/(web)/tools/layout.tsx` — tools sub-layout, `flex flex-col h-screen bg-gray-50`; role-less users get a UX redirect to `/no-access` — **not** enforcement (a layout redirect does not stop a page segment rendering in Next 16) - `src/app/(web)/tools/require-tool-access.ts` — `requireToolAccess()`, the page-level enforcement every tool page awaits first -- `src/app/(web)/tools//page.tsx` — per-tool entry (`addresslabels`, `fieldmanagement`, `groupwizard`, `template`, `templateeditor`) +- `src/app/(web)/tools//page.tsx` — per-tool entry (`addeditfamily`, `addresslabels`, `fieldmanagement`, `groupwizard`, `template`) - `src/app/(web)/error.tsx` — `"use client"` error boundary for the group - `src/app/signin/page.tsx` — public OAuth redirect page - `src/app/api/auth/[...all]/route.ts` — Better Auth catch-all handler (`export const { GET, POST } = toNextJsHandler(auth)`) @@ -39,7 +39,6 @@ Next.js 16 App Router tree. A `(web)` route group gates all authenticated pages ├── / # (web)/page.tsx — dashboard ├── /home # (web)/home/page.tsx — redirects to / ├── /tools/template -├── /tools/templateeditor ├── /tools/addresslabels ├── /tools/groupwizard ├── /tools/fieldmanagement diff --git a/.claude/references/utils/tool-params.md b/.claude/references/utils/tool-params.md index 5d55f9e..81adbad 100644 --- a/.claude/references/utils/tool-params.md +++ b/.claude/references/utils/tool-params.md @@ -153,12 +153,11 @@ Server-side (call `parseToolParams`): - `src/app/(web)/tools/fieldmanagement/page.tsx` - `src/app/(web)/tools/groupwizard/page.tsx` - `src/app/(web)/tools/template/page.tsx` -- `src/app/(web)/tools/templateeditor/page.tsx` Client/UI (consume the `ToolParams` type and/or predicates): - `src/components/tool/tool-container.tsx` - `src/components/dev-panel/dev-panel.tsx` + `panels/*` -- Each tool's root component (`group-wizard.tsx`, `field-management.tsx`, `template-tool.tsx`, `address-labels.tsx`, `template-editor.tsx`) +- Each tool's root component (`group-wizard.tsx`, `field-management.tsx`, `template-tool.tsx`, `address-labels.tsx`) ## Gotchas - **`pageData` may be `undefined` even when `pageID` is set.** The fetch is wrapped in `try/catch` and swallowed — callers must guard. See `src/lib/tool-params.ts:54-60`. diff --git a/CLAUDE.md b/CLAUDE.md index 0282359..f0eaea7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -111,7 +111,6 @@ src/components/ ├── group-wizard/ # Multi-step group creation/edit wizard (6 steps) ├── layout/ # Layout components (AuthWrapper) ├── shared-actions/ # Shared actions used across features -├── template-editor/ # Email/document template editor (GrapesJS) ├── tool/ # Tool framework (Container, Header, Footer) ├── ui/ # shadcn/ui components (22) ├── user-menu/ # User dropdown with OIDC sign-out @@ -294,7 +293,6 @@ condition that clears it. Do not re-derive that analysis. |---|---|---| | `typescript` 7 | No stable Compiler API until 7.1; `typescript-eslint` peers `typescript: >=4.8.4 <6.1.0` | `npm view typescript-eslint peerDependencies` | | `eslint` 10 | `eslint-plugin-react@7.37.5` (latest) peers `eslint ^9.7` and calls a removed context method | `npm view eslint-plugin-react peerDependencies` | -| `grapesjs` 0.23 | `@grapesjs/react@2.0.0` (latest) peers `grapesjs ^0.22.5` | `npm view @grapesjs/react peerDependencies` | | `@types/node` 25+ | Runtime is pinned to Node 24 (`engines.node: ^24.15.0`) because 24.x is Vercel's current default/newest offering; types must not lead the runtime | Vercel's [supported Node versions](https://vercel.com/docs/functions/runtimes/node-js/node-js-versions) | ## Reference Documents diff --git a/README.md b/README.md index 505eb9a..9969e9a 100644 --- a/README.md +++ b/README.md @@ -27,7 +27,6 @@ A Ministry Platform page tools application powered by Next.js 16, React 19, Bett - [Tools](#tools) - [Address Labels](#address-labels) - [Field Management](#field-management) - - [Template Editor](#template-editor) - [Template Tool](#template-tool) - [Building Custom Tools](#building-custom-tools) - [Ministry Platform Integration](#ministry-platform-integration) @@ -44,7 +43,6 @@ A Ministry Platform page tools application powered by Next.js 16, React 19, Bett - **Authentication**: Better Auth with Ministry Platform OAuth (genericOAuth plugin), stateless encrypted-cookie sessions, and OIDC RP-initiated logout - **Tools Framework**: Reusable components for building Ministry Platform page tools with URL parameter parsing and dual-mode support (create/edit) - **Address Labels**: Print address labels with USPS Intelligent Mail Barcodes (IMb), POSTNET fallback, and Word document mail merge -- **Template Editor**: Visual email/document template editor with GrapesJS, merge field support, and MP template integration - **Modern UI**: 22 Radix UI + shadcn/ui components with Tailwind CSS v4 - **Type-Safe API**: Full TypeScript strict mode with auto-generated model and schema files from Ministry Platform - **REST API Client**: Six specialized services covering tables, procedures, communications, files, metadata, and domain operations @@ -310,8 +308,7 @@ MPNext-Tools/ │ │ │ │ ├── layout.tsx # Full-height gray background │ │ │ │ ├── addresslabels/ # Address label printing tool │ │ │ │ ├── fieldmanagement/ # MP page field layout editor -│ │ │ │ ├── template/ # Template tool (scaffold/demo) -│ │ │ │ └── templateeditor/ # Visual template editor +│ │ │ │ └── template/ # Template tool (scaffold/demo) │ │ │ └── layout.tsx # Auth + Providers wrapper │ │ ├── api/auth/[...all]/ # Better Auth route handler │ │ ├── signin/ # OAuth sign-in page @@ -323,7 +320,6 @@ MPNext-Tools/ │ │ ├── field-management/ # Drag-and-drop MP page field order editor │ │ ├── layout/ # AuthWrapper (server component) │ │ ├── shared-actions/ # Cross-feature server actions -│ │ ├── template-editor/ # GrapesJS template editor │ │ ├── tool/ # Tool framework (Container, Header, Footer) │ │ ├── ui/ # 22 shadcn/ui components │ │ ├── user-menu/ # User dropdown with OIDC sign-out @@ -410,28 +406,6 @@ A tool for printing address labels with USPS Intelligent Mail Barcodes (IMb) and - `PostnetBarcode` — POSTNET barcode SVG - `MailMergeTab` — Word document mail merge interface -### Template Editor - -**Route**: `/tools/templateeditor` - -A visual email/document template editor built with GrapesJS for creating and editing Ministry Platform templates. - -**Features:** -- Visual drag-and-drop template editing via GrapesJS -- Merge field picker for Ministry Platform data tokens -- HTML code editor for advanced editing -- Template import/export -- MP template integration - -**Components** (`src/components/template-editor/`): -- `TemplateEditorForm` — Main editor orchestrator -- `EditorCanvas` — GrapesJS editor wrapper -- `EditorToolbar` — Editor action toolbar -- `EditorCodeDialog` — HTML source code editor -- `EditorImportDialog` — Template import dialog -- `EditorExportDialog` — Template export dialog -- `MergeFieldPicker` — MP merge field selection - ### Field Management **Route**: `/tools/fieldmanagement` @@ -610,7 +584,6 @@ Alert, Alert Dialog, Avatar, Badge, Breadcrumb, Button, Card, Checkbox, Command, - **address-labels/** — 12 components for address label printing, barcode generation, and mail merge - **field-management/** — Drag-and-drop editor for MP page field order and flags (`PageSearch`, `FieldOrderEditor`, sortable group/field items, `useFieldOrderState`) -- **template-editor/** — 12 components for visual template editing with GrapesJS - **user-menu/** — User dropdown with profile display and OIDC sign-out action - **dev-panel/** — Unified developer overlay (localhost-only) showing parsed URL params, MP selection data, contact records, and authorized tools - **shared-actions/** — Cross-feature server actions (`getCurrentUserProfile()` — returns the *calling* user's MP profile; the `User_GUID` comes from the session, never from a parameter) @@ -843,7 +816,6 @@ src/components/ ├── field-management/ # Drag-and-drop MP page field order editor ├── layout/ # AuthWrapper ├── shared-actions/ # Cross-feature server actions -├── template-editor/ # Visual template editor (12 files) ├── tool/ # Tool framework (Container, Header, Footer) ├── ui/ # shadcn/ui components (22) ├── user-menu/ # User dropdown + sign-out action diff --git a/next.config.ts b/next.config.ts index d1152b8..2f73fde 100644 --- a/next.config.ts +++ b/next.config.ts @@ -37,7 +37,6 @@ const hstsHeader = { }; const nextConfig: NextConfig = { - serverExternalPackages: ['mjml', 'mjml-core', 'mjml-validator', 'uglify-js'], async headers() { return [ { diff --git a/package-lock.json b/package-lock.json index ee5ec79..89018b4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,7 +11,6 @@ "@dnd-kit/abstract": "^0.5.0", "@dnd-kit/helpers": "^0.5.0", "@dnd-kit/react": "^0.5.0", - "@grapesjs/react": "^2.0.0", "@heroicons/react": "^2.2.0", "@hookform/resolvers": "^5.9.1", "@radix-ui/react-alert-dialog": "^1.1.23", @@ -35,11 +34,8 @@ "docxtemplater": "^3.71.0", "docxtemplater-image": "^0.1.2", "dotenv": "^18.0.4", - "grapesjs": "^0.22.16", - "grapesjs-mjml": "^1.0.8", "jose": "^6.2.12", "lucide-react": "^1.49.0", - "mjml": "^5.4.1", "next": "^16.3.7", "pizzip": "^3.3.0", "react": "^19.3.0", @@ -57,7 +53,6 @@ "@testing-library/jest-dom": "^7.0.1", "@testing-library/react": "^16.3.3", "@testing-library/user-event": "^14.6.7", - "@types/mjml": "^5.0.0", "@types/node": "^24.19.0", "@types/react": "^19.3.0", "@types/react-dom": "^19.3.0", @@ -154,6 +149,7 @@ "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, "license": "MIT", "dependencies": { "@babel/helper-validator-identifier": "^7.29.7", @@ -295,6 +291,7 @@ "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "devOptional": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -549,12 +546,6 @@ "specificity": "bin/cli.js" } }, - "node_modules/@colordx/core": { - "version": "5.8.0", - "resolved": "https://registry.npmjs.org/@colordx/core/-/core-5.8.0.tgz", - "integrity": "sha512-cG0QJAO6VkaRUlIb0zOzX9gfJgs1pjoOL9gZ/PK1kfvBs0GCkADu5oQh6gPxXgQnZ3gV575h+lQRC0NlMDTclA==", - "license": "MIT" - }, "node_modules/@csstools/color-helpers": { "version": "6.1.2", "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.2.tgz", @@ -1449,23 +1440,6 @@ "integrity": "sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==", "license": "MIT" }, - "node_modules/@grapesjs/react": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/@grapesjs/react/-/react-2.0.0.tgz", - "integrity": "sha512-RpQhLqqr+6PjPB6bonQ4GgANfc9eoKyqa3SplLKPBoWX8OrzYUg62q5taTbyL7MTT+OzrmAHPOLyPGdqux9+Pg==", - "license": "MIT", - "dependencies": { - "@types/react": "^19.0.0", - "@types/react-dom": "^19.0.0", - "react": "^19.0.0", - "react-dom": "^19.0.0" - }, - "peerDependencies": { - "grapesjs": "^0.22.5", - "react": "^18.0.0 || ^19.0.0", - "react-dom": "^18.0.0 || ^19.0.0" - } - }, "node_modules/@heroicons/react": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/@heroicons/react/-/react-2.2.0.tgz", @@ -2877,12 +2851,6 @@ "node": ">=12.4.0" } }, - "node_modules/@one-ini/wasm": { - "version": "0.2.1", - "resolved": "https://registry.npmjs.org/@one-ini/wasm/-/wasm-0.2.1.tgz", - "integrity": "sha512-TUqERXGNTifZ9y2g3wPxQrw3HpHv/02DsW3D90T9x0hhonrL1ZqpSmNrU2XkoIq0fP1N6gZfVQzy2Fw1ZvGBNg==", - "license": "MIT" - }, "node_modules/@opentelemetry/semantic-conventions": { "version": "1.43.0", "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.43.0.tgz", @@ -4686,16 +4654,6 @@ "license": "MIT", "peer": true }, - "node_modules/@types/backbone": { - "version": "1.4.15", - "resolved": "https://registry.npmjs.org/@types/backbone/-/backbone-1.4.15.tgz", - "integrity": "sha512-WWeKtYlsIMtDyLbbhkb96taJMEbfQBnuz7yw1u0pkphCOtksemoWhIXhK74VRCY9hbjnsH3rsJu2uUiFtnsEYg==", - "license": "MIT", - "dependencies": { - "@types/jquery": "*", - "@types/underscore": "*" - } - }, "node_modules/@types/chai": { "version": "5.2.3", "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", @@ -4721,12 +4679,6 @@ "devOptional": true, "license": "MIT" }, - "node_modules/@types/jquery": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/@types/jquery/-/jquery-4.0.1.tgz", - "integrity": "sha512-9a59A/tycXgYuPABcp6/3spSShn0NT2UOM4EfHvMumjYi4lJWTsK5SZWjhx3yRm9IHGCeWXdV2YfNsrWrft/CA==", - "license": "MIT" - }, "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", @@ -4741,22 +4693,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/mjml": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/@types/mjml/-/mjml-5.0.0.tgz", - "integrity": "sha512-z6P0yjEOVOz6cZVyD3vkPSifzVH0fa9M8BFM8Jl9HtpeFkBJyCZHRLPx1uFnkZijgAYmtO/JfSbp4EF1fNwsXQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/mjml-core": "*" - } - }, - "node_modules/@types/mjml-core": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/@types/mjml-core/-/mjml-core-5.1.0.tgz", - "integrity": "sha512-dYYmvGIHFi4wzsV+zX7UhP4C5QFSKeTjk53uQcadyIpTrrpJSs8JCZEKfSCrbKS7Hev3aR62b2dg58IeuwiR/w==", - "license": "MIT" - }, "node_modules/@types/node": { "version": "24.19.0", "resolved": "https://registry.npmjs.org/@types/node/-/node-24.19.0.tgz", @@ -4771,6 +4707,7 @@ "version": "19.3.0", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.3.0.tgz", "integrity": "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==", + "devOptional": true, "license": "MIT", "dependencies": { "csstype": "^3.2.2" @@ -4780,23 +4717,12 @@ "version": "19.3.0", "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.3.0.tgz", "integrity": "sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q==", + "devOptional": true, "license": "MIT", "peerDependencies": { "@types/react": "^19.3.0" } }, - "node_modules/@types/relateurl": { - "version": "0.2.33", - "resolved": "https://registry.npmjs.org/@types/relateurl/-/relateurl-0.2.33.tgz", - "integrity": "sha512-bTQCKsVbIdzLqZhLkF5fcJQreE4y1ro4DIyVrlDNSCJRRwHhB8Z+4zXXa8jN6eDvc2HbRsEYgbvrnGvi54EpSw==", - "license": "MIT" - }, - "node_modules/@types/underscore": { - "version": "1.13.0", - "resolved": "https://registry.npmjs.org/@types/underscore/-/underscore-1.13.0.tgz", - "integrity": "sha512-L6LBgy1f0EFQZ+7uSA57+n2g/s4Qs5r06Vwrwn0/nuK1de+adz00NWaztRQ30aEqw5qOaWbPI8u2cGQ52lj6VA==", - "license": "MIT" - }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.71.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.71.0.tgz", @@ -5585,15 +5511,6 @@ "node": ">=14.6" } }, - "node_modules/abbrev": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-5.0.0.tgz", - "integrity": "sha512-/XrFJgzQQQHpti1raDJC6m4ws6aNktmjBlhk8Fdlk7LwCEuDoieEJJY9OFHjfiFJFFRM2tK+Ky/IsfbbmlMu1w==", - "license": "ISC", - "engines": { - "node": "^22.22.2 || ^24.15.0 || >=26.0.0" - } - }, "node_modules/abs-svg-path": { "version": "0.1.1", "resolved": "https://registry.npmjs.org/abs-svg-path/-/abs-svg-path-0.1.1.tgz", @@ -5623,20 +5540,13 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, - "node_modules/ansi-colors": { - "version": "4.1.3", - "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.3.tgz", - "integrity": "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==", - "license": "MIT", - "engines": { - "node": ">=6" - } - }, "node_modules/ansi-regex": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=8" } @@ -5659,6 +5569,7 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, "license": "Python-2.0" }, "node_modules/aria-hidden": { @@ -5925,26 +5836,6 @@ "node": ">= 0.4" } }, - "node_modules/backbone": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/backbone/-/backbone-1.4.1.tgz", - "integrity": "sha512-ADy1ztN074YkWbHi8ojJVFe3vAanO/lrzMGZWUClIP7oDD/Pjy2vrASraUP+2EVCfIiTtCW4FChVow01XneivA==", - "license": "MIT", - "dependencies": { - "underscore": ">=1.8.3" - } - }, - "node_modules/backbone-undo": { - "version": "0.2.6", - "resolved": "https://registry.npmjs.org/backbone-undo/-/backbone-undo-0.2.6.tgz", - "integrity": "sha512-AsfpNiljLXlk7TcffDUu3EAUq7CxWbyTNwARWrql5XTzN4vh6WzEEBZYaKK4kTTz+iW1tSzqUooaGRIwO83kWA==", - "deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.", - "license": "MIT", - "dependencies": { - "backbone": ">=1.0.0", - "underscore": ">=1.4.4" - } - }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", @@ -6123,12 +6014,6 @@ "require-from-string": "^2.0.2" } }, - "node_modules/boolbase": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", - "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==", - "license": "ISC" - }, "node_modules/brace-expansion": { "version": "1.1.21", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz", @@ -6166,6 +6051,7 @@ "version": "4.29.3", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.29.3.tgz", "integrity": "sha512-1R4kiYKXGViqEN0CnoDrXc1StD9niAwu+j2dukWzrD4bJgsD4lDmEp0CRbc6E/vYJIfTHwPmwyaKtVSudICdPA==", + "dev": true, "funding": [ { "type": "opencollective", @@ -6249,6 +6135,7 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -6304,105 +6191,6 @@ "dev": true, "license": "MIT" }, - "node_modules/cheerio": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/cheerio/-/cheerio-1.2.0.tgz", - "integrity": "sha512-WDrybc/gKFpTYQutKIK6UvfcuxijIZfMfXaYm8NMsPQxSYvf+13fXUJ4rztGGbJcBQ/GF55gvrZ0Bc0bj/mqvg==", - "license": "MIT", - "dependencies": { - "cheerio-select": "^2.1.0", - "dom-serializer": "^2.0.0", - "domhandler": "^5.0.3", - "domutils": "^3.2.2", - "encoding-sniffer": "^0.2.1", - "htmlparser2": "^10.1.0", - "parse5": "^7.3.0", - "parse5-htmlparser2-tree-adapter": "^7.1.0", - "parse5-parser-stream": "^7.1.2", - "undici": "^7.19.0", - "whatwg-mimetype": "^4.0.0" - }, - "engines": { - "node": ">=20.18.1" - }, - "funding": { - "url": "https://github.com/cheeriojs/cheerio?sponsor=1" - } - }, - "node_modules/cheerio-select": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/cheerio-select/-/cheerio-select-2.1.0.tgz", - "integrity": "sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g==", - "license": "BSD-2-Clause", - "dependencies": { - "boolbase": "^1.0.0", - "css-select": "^5.1.0", - "css-what": "^6.1.0", - "domelementtype": "^2.3.0", - "domhandler": "^5.0.3", - "domutils": "^3.0.1" - }, - "funding": { - "url": "https://github.com/sponsors/fb55" - } - }, - "node_modules/cheerio/node_modules/entities": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", - "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", - "license": "BSD-2-Clause", - "engines": { - "node": ">=0.12" - }, - "funding": { - "url": "https://github.com/fb55/entities?sponsor=1" - } - }, - "node_modules/cheerio/node_modules/parse5": { - "version": "7.3.0", - "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", - "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", - "license": "MIT", - "dependencies": { - "entities": "^6.0.0" - }, - "funding": { - "url": "https://github.com/inikulin/parse5?sponsor=1" - } - }, - "node_modules/cheerio/node_modules/undici": { - "version": "7.30.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-7.30.0.tgz", - "integrity": "sha512-dkrQXeHSaoamnItlYbmzG0wFYrM0ZwDxCIg0A7aKjTyyhh9svRzCNFEzV+Vm05/yehjCzjDZ31KXfGEjYSztDQ==", - "license": "MIT", - "engines": { - "node": ">=20.18.1" - } - }, - "node_modules/cheerio/node_modules/whatwg-mimetype": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", - "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", - "license": "MIT", - "engines": { - "node": ">=18" - } - }, - "node_modules/chokidar": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", - "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", - "license": "MIT", - "dependencies": { - "readdirp": "^4.0.1" - }, - "engines": { - "node": ">= 14.16.0" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, "node_modules/class-variance-authority": { "version": "0.7.1", "resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz", @@ -6431,20 +6219,6 @@ "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==", "license": "MIT" }, - "node_modules/cliui": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", - "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", - "license": "ISC", - "dependencies": { - "string-width": "^4.2.0", - "strip-ansi": "^6.0.1", - "wrap-ansi": "^7.0.0" - }, - "engines": { - "node": ">=12" - } - }, "node_modules/clone": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/clone/-/clone-2.1.2.tgz", @@ -6479,22 +6253,11 @@ "react-dom": "^18 || ^19 || ^19.0.0-rc" } }, - "node_modules/codemirror": { - "version": "5.63.0", - "resolved": "https://registry.npmjs.org/codemirror/-/codemirror-5.63.0.tgz", - "integrity": "sha512-KlLWRPggDg2rBD1Mx7/EqEhaBdy+ybBCVh/efgjBDsPpMeEu6MbTAJzIT4TuCzvmbTEgvKOGzVT6wdBTNusqrg==", - "license": "MIT" - }, - "node_modules/codemirror-formatting": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/codemirror-formatting/-/codemirror-formatting-1.0.0.tgz", - "integrity": "sha512-br9yM6eJI3pJHekEnoyHaBEb1B7XxxDjju+vRyBe8QGLp5saTIXXkZ+eFCTqXSAtI8QEZDFVEX2/SOjH2sVWRQ==", - "license": "MIT" - }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, "license": "MIT", "dependencies": { "color-name": "~1.1.4" @@ -6507,6 +6270,7 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, "license": "MIT" }, "node_modules/color-name": { @@ -6530,15 +6294,6 @@ "node": ">=18" } }, - "node_modules/commander": { - "version": "14.0.3", - "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz", - "integrity": "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==", - "license": "MIT", - "engines": { - "node": ">=20" - } - }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -6546,16 +6301,6 @@ "dev": true, "license": "MIT" }, - "node_modules/config-chain": { - "version": "1.1.13", - "resolved": "https://registry.npmjs.org/config-chain/-/config-chain-1.1.13.tgz", - "integrity": "sha512-qj+f8APARXHrM0hraqXYb2/bOVSV4PvJQlNZ/DVj0QrmNM2q2euizkeuVckQ57J+W0mRH6Hvi+k50M4Jul2VRQ==", - "license": "MIT", - "dependencies": { - "ini": "^1.3.4", - "proto-list": "~1.2.1" - } - }, "node_modules/convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", @@ -6569,32 +6314,6 @@ "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", "license": "MIT" }, - "node_modules/cosmiconfig": { - "version": "9.0.2", - "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-9.0.2.tgz", - "integrity": "sha512-gtTZxTDau1wL7Y7zifc2dd8jHSK/k6BTx/2Xp/BpdlAdnlYWFVt7qhJqgwi7637yRwRQ3qL4ZidbB4I8tA5VOg==", - "license": "MIT", - "dependencies": { - "env-paths": "^2.2.1", - "import-fresh": "^3.3.0", - "js-yaml": "^4.1.0", - "parse-json": "^5.2.0" - }, - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/d-fischer" - }, - "peerDependencies": { - "typescript": ">=4.9.5" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } - } - }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -6610,38 +6329,11 @@ "node": ">= 8" } }, - "node_modules/css-declaration-sorter": { - "version": "7.4.0", - "resolved": "https://registry.npmjs.org/css-declaration-sorter/-/css-declaration-sorter-7.4.0.tgz", - "integrity": "sha512-LTuzjPoyA2vMGKKcaOqKSp7Ub2eGrNfKiZH4LpezxpNrsICGCSFvsQOI29psISxNZtaXibkC2CXzrQ5enMeGGw==", - "license": "ISC", - "engines": { - "node": "^14 || ^16 || >=18" - }, - "peerDependencies": { - "postcss": "^8.0.9" - } - }, - "node_modules/css-select": { - "version": "5.2.2", - "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz", - "integrity": "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==", - "license": "BSD-2-Clause", - "dependencies": { - "boolbase": "^1.0.0", - "css-what": "^6.1.0", - "domhandler": "^5.0.2", - "domutils": "^3.0.1", - "nth-check": "^2.0.1" - }, - "funding": { - "url": "https://github.com/sponsors/fb55" - } - }, "node_modules/css-tree": { "version": "3.2.1", "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "devOptional": true, "license": "MIT", "dependencies": { "mdn-data": "2.27.1", @@ -6651,18 +6343,6 @@ "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" } }, - "node_modules/css-what": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz", - "integrity": "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==", - "license": "BSD-2-Clause", - "engines": { - "node": ">= 6" - }, - "funding": { - "url": "https://github.com/sponsors/fb55" - } - }, "node_modules/css.escape": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", @@ -6670,85 +6350,11 @@ "dev": true, "license": "MIT" }, - "node_modules/cssesc": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", - "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", - "license": "MIT", - "bin": { - "cssesc": "bin/cssesc" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/cssnano-preset-lite": { - "version": "4.0.6", - "resolved": "https://registry.npmjs.org/cssnano-preset-lite/-/cssnano-preset-lite-4.0.6.tgz", - "integrity": "sha512-EI/VDoucl8SmVkXUZtWIux31cWoxgNUbF7njnpPxdz5ZbnKOjAd5DueLuCE1RKKLrOPQsEUaNfUgB1taohIIyQ==", - "license": "MIT", - "dependencies": { - "cssnano-utils": "^5.0.3", - "postcss-discard-comments": "^7.0.8", - "postcss-discard-empty": "^7.0.3", - "postcss-normalize-whitespace": "^7.0.3" - }, - "engines": { - "node": "^18.12.0 || ^20.9.0 || >=22.0" - }, - "peerDependencies": { - "postcss": "^8.5.13" - } - }, - "node_modules/cssnano-utils": { - "version": "5.0.3", - "resolved": "https://registry.npmjs.org/cssnano-utils/-/cssnano-utils-5.0.3.tgz", - "integrity": "sha512-ynIREMICLxkxm7e9bCR9sh75s4Q5drICi0ua1yxo5jH2XPBqSKkl4dOh4EbFqtUmnTMhRffHgYL0EKKkMjtJTg==", - "license": "MIT", - "engines": { - "node": "^18.12.0 || ^20.9.0 || >=22.0" - }, - "peerDependencies": { - "postcss": "^8.5.13" - } - }, - "node_modules/csso": { - "version": "5.0.5", - "resolved": "https://registry.npmjs.org/csso/-/csso-5.0.5.tgz", - "integrity": "sha512-0LrrStPOdJj+SPCCrGhzryycLjwcgUSHBtxNA8aIDxf0GLsRh1cKYhB00Gd1lDOS4yGH69+SNn13+TWbVHETFQ==", - "license": "MIT", - "dependencies": { - "css-tree": "~2.2.0" - }, - "engines": { - "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0", - "npm": ">=7.0.0" - } - }, - "node_modules/csso/node_modules/css-tree": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-2.2.1.tgz", - "integrity": "sha512-OA0mILzGc1kCOCSJerOeqDxDQ4HOh+G8NbOJFOTgOCzpw7fCBubk0fEyxp8AgOL/jvLgYA/uV0cMbe43ElF1JA==", - "license": "MIT", - "dependencies": { - "mdn-data": "2.0.28", - "source-map-js": "^1.0.1" - }, - "engines": { - "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0", - "npm": ">=7.0.0" - } - }, - "node_modules/csso/node_modules/mdn-data": { - "version": "2.0.28", - "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.28.tgz", - "integrity": "sha512-aylIc7Z9y4yzHYAJNuESG3hfhC+0Ibp/MAMiaOZgNv4pmEdFyfZhhhny4MNiAfWdBQ1RQ2mfDWmM1x8SvGyp8g==", - "license": "CC0-1.0" - }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "devOptional": true, "license": "MIT" }, "node_modules/damerau-levenshtein": { @@ -6935,12 +6541,6 @@ "node": ">=8" } }, - "node_modules/detect-node": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/detect-node/-/detect-node-2.1.0.tgz", - "integrity": "sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==", - "license": "MIT" - }, "node_modules/detect-node-es": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz", @@ -7028,61 +6628,6 @@ "license": "MIT", "peer": true }, - "node_modules/dom-serializer": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", - "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", - "license": "MIT", - "dependencies": { - "domelementtype": "^2.3.0", - "domhandler": "^5.0.2", - "entities": "^4.2.0" - }, - "funding": { - "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" - } - }, - "node_modules/domelementtype": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", - "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fb55" - } - ], - "license": "BSD-2-Clause" - }, - "node_modules/domhandler": { - "version": "5.0.3", - "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", - "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", - "license": "BSD-2-Clause", - "dependencies": { - "domelementtype": "^2.3.0" - }, - "engines": { - "node": ">= 4" - }, - "funding": { - "url": "https://github.com/fb55/domhandler?sponsor=1" - } - }, - "node_modules/domutils": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", - "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==", - "license": "BSD-2-Clause", - "dependencies": { - "dom-serializer": "^2.0.0", - "domelementtype": "^2.3.0", - "domhandler": "^5.0.3" - }, - "funding": { - "url": "https://github.com/fb55/domutils?sponsor=1" - } - }, "node_modules/dotenv": { "version": "18.0.4", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-18.0.4.tgz", @@ -7113,76 +6658,11 @@ "node": ">= 0.4" } }, - "node_modules/editorconfig": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/editorconfig/-/editorconfig-3.0.2.tgz", - "integrity": "sha512-T0ix8GhtxyKVfUFEcvdNDt3YGqlwkFHbD4/5bgFUDgFmxhI/cSRAeJ87/Sz//Cq8Eam6JX/e23RkoFO71P7aAA==", - "license": "MIT", - "dependencies": { - "@one-ini/wasm": "0.2.1", - "commander": "^14.0.3", - "minimatch": "~10.2.4", - "semver": "^7.7.4" - }, - "bin": { - "editorconfig": "bin/editorconfig" - }, - "engines": { - "node": ">=20" - } - }, - "node_modules/editorconfig/node_modules/balanced-match": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", - "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", - "license": "MIT", - "engines": { - "node": "18 || 20 || >=22" - } - }, - "node_modules/editorconfig/node_modules/brace-expansion": { - "version": "5.0.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", - "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", - "license": "MIT", - "dependencies": { - "balanced-match": "^4.0.2" - }, - "engines": { - "node": "20 || >=22" - } - }, - "node_modules/editorconfig/node_modules/minimatch": { - "version": "10.2.6", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", - "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", - "license": "BlueOak-1.0.0", - "dependencies": { - "brace-expansion": "^5.0.8" - }, - "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/editorconfig/node_modules/semver": { - "version": "7.8.5", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", - "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/electron-to-chromium": { "version": "1.5.442", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.442.tgz", "integrity": "sha512-najZYZ3+ZpjN1z3VOsrBiv5ej18vQgfV2lvEmxWzfmKrk4bdm3Owseyud4yGU6DfFn9pyunoAHDDyM4KmP78Ew==", + "dev": true, "license": "ISC" }, "node_modules/emoji-regex": { @@ -7198,31 +6678,6 @@ "integrity": "sha512-LRlerrMYoIDrT6jgpeZ2YYl/L8EulRTt5hQcYjy5AInh7HWXKimpqx68aknBFpGL2+/IcogTcaydJEgaTmOpDg==", "license": "MIT" }, - "node_modules/encoding-sniffer": { - "version": "0.2.1", - "resolved": "https://registry.npmjs.org/encoding-sniffer/-/encoding-sniffer-0.2.1.tgz", - "integrity": "sha512-5gvq20T6vfpekVtqrYQsSCFZ1wEg5+wW0/QaZMWkFr6BqD3NfKs0rLCx4rrVlSWJeZb5NBJgVLswK/w2MWU+Gw==", - "license": "MIT", - "dependencies": { - "iconv-lite": "^0.6.3", - "whatwg-encoding": "^3.1.1" - }, - "funding": { - "url": "https://github.com/fb55/encoding-sniffer?sponsor=1" - } - }, - "node_modules/encoding-sniffer/node_modules/iconv-lite": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", - "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", - "license": "MIT", - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/enhanced-resolve": { "version": "5.26.0", "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.26.0.tgz", @@ -7237,36 +6692,6 @@ "node": ">=10.13.0" } }, - "node_modules/entities": { - "version": "4.5.0", - "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", - "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", - "license": "BSD-2-Clause", - "engines": { - "node": ">=0.12" - }, - "funding": { - "url": "https://github.com/fb55/entities?sponsor=1" - } - }, - "node_modules/env-paths": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", - "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/error-ex": { - "version": "1.3.4", - "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", - "integrity": "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==", - "license": "MIT", - "dependencies": { - "is-arrayish": "^0.2.1" - } - }, "node_modules/es-abstract": { "version": "1.24.2", "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.2.tgz", @@ -7518,23 +6943,12 @@ "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, "license": "MIT", "engines": { "node": ">=6" } }, - "node_modules/escape-goat": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/escape-goat/-/escape-goat-3.0.0.tgz", - "integrity": "sha512-w3PwNZJwRxlp47QGzhuEBldEqVHHhh8/tIPcl6ecf2Bou99cdAt0knihBV0Ecc7CGxYduXVBDheH1K2oADRlvw==", - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/escape-string-regexp": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", @@ -8319,15 +7733,6 @@ "node": ">=6.9.0" } }, - "node_modules/get-caller-file": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", - "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", - "license": "ISC", - "engines": { - "node": "6.* || 8.* || >= 10.*" - } - }, "node_modules/get-intrinsic": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", @@ -8407,23 +7812,6 @@ "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" } }, - "node_modules/glob": { - "version": "13.0.6", - "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", - "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", - "license": "BlueOak-1.0.0", - "dependencies": { - "minimatch": "^10.2.2", - "minipass": "^7.1.3", - "path-scurry": "^2.0.2" - }, - "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/glob-parent": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", @@ -8437,42 +7825,6 @@ "node": ">=10.13.0" } }, - "node_modules/glob/node_modules/balanced-match": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", - "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", - "license": "MIT", - "engines": { - "node": "18 || 20 || >=22" - } - }, - "node_modules/glob/node_modules/brace-expansion": { - "version": "5.0.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", - "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", - "license": "MIT", - "dependencies": { - "balanced-match": "^4.0.2" - }, - "engines": { - "node": "20 || >=22" - } - }, - "node_modules/glob/node_modules/minimatch": { - "version": "10.2.6", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", - "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", - "license": "BlueOak-1.0.0", - "dependencies": { - "brace-expansion": "^5.0.8" - }, - "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/globals": { "version": "14.0.0", "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", @@ -8523,41 +7875,6 @@ "dev": true, "license": "ISC" }, - "node_modules/grapesjs": { - "version": "0.22.16", - "resolved": "https://registry.npmjs.org/grapesjs/-/grapesjs-0.22.16.tgz", - "integrity": "sha512-kCfphgpC7pqJPuMYmIhMR6ueyB3+V67isdpMZOvmuGeWDMomkgzqRWOMH3matfdqIJW7LUivHZo9GeyVQAGmLw==", - "license": "BSD-3-Clause", - "dependencies": { - "@types/backbone": "1.4.15", - "backbone": "1.4.1", - "backbone-undo": "0.2.6", - "codemirror": "5.63.0", - "codemirror-formatting": "1.0.0", - "html-entities": "~1.4.0", - "promise-polyfill": "8.3.0", - "underscore": "1.13.8" - } - }, - "node_modules/grapesjs-mjml": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/grapesjs-mjml/-/grapesjs-mjml-1.0.8.tgz", - "integrity": "sha512-cgaKwuGcBVgFCyqqK39kraPfw5DiA8qIyHKsDoMpOUqPY+ubMznx6U2M1NC9UKwD+gDCy/VVctyhb/aJAgyPNw==", - "license": "BSD-3-Clause", - "dependencies": { - "@types/mjml": "^4.7.4", - "mjml-browser": "^4.18.0" - } - }, - "node_modules/grapesjs-mjml/node_modules/@types/mjml": { - "version": "4.7.4", - "resolved": "https://registry.npmjs.org/@types/mjml/-/mjml-4.7.4.tgz", - "integrity": "sha512-vyi1vzWgMzFMwZY7GSZYX0GU0dmtC8vLHwpgk+NWmwbwRSrlieVyJ9sn5elodwUfklJM7yGl0zQeet1brKTWaQ==", - "license": "MIT", - "dependencies": { - "@types/mjml-core": "*" - } - }, "node_modules/has-bigints": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz", @@ -8707,94 +8024,6 @@ "node": "^22.13.0 || >=24.0.0" } }, - "node_modules/html-entities": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/html-entities/-/html-entities-1.4.0.tgz", - "integrity": "sha512-8nxjcBcd8wovbeKx7h3wTji4e6+rhaVuPNpMqwWgnHh+N9ToqsCs6XztWRBPQ+UtzsoMAdKZtUENoVzU/EMtZA==", - "license": "MIT" - }, - "node_modules/htmlnano": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/htmlnano/-/htmlnano-3.3.1.tgz", - "integrity": "sha512-UXOwkDA1WUFSamPXpCzJz131mqbuaenm4Agra3VTpP6lpbuCytILqXEAG5kwzkeksDnFQ+JG3EhVVqpBPsh51w==", - "license": "MIT", - "dependencies": { - "@types/relateurl": "^0.2.33", - "commander": "^14.0.0", - "cosmiconfig": "^9.0.0", - "posthtml": "^0.16.5" - }, - "bin": { - "htmlnano": "dist/bin.js" - }, - "peerDependencies": { - "cssnano": "^8.0.0", - "postcss": "^8.3.11", - "purgecss": "^8.0.0", - "relateurl": "^0.2.7", - "srcset": "^5.0.1", - "svgo": "^4.0.0", - "terser": "^5.21.0", - "uncss": "^0.17.3" - }, - "peerDependenciesMeta": { - "cssnano": { - "optional": true - }, - "postcss": { - "optional": true - }, - "purgecss": { - "optional": true - }, - "relateurl": { - "optional": true - }, - "srcset": { - "optional": true - }, - "svgo": { - "optional": true - }, - "terser": { - "optional": true - }, - "uncss": { - "optional": true - } - } - }, - "node_modules/htmlparser2": { - "version": "10.1.0", - "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.1.0.tgz", - "integrity": "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==", - "funding": [ - "https://github.com/fb55/htmlparser2?sponsor=1", - { - "type": "github", - "url": "https://github.com/sponsors/fb55" - } - ], - "license": "MIT", - "dependencies": { - "domelementtype": "^2.3.0", - "domhandler": "^5.0.3", - "domutils": "^3.2.2", - "entities": "^7.0.1" - } - }, - "node_modules/htmlparser2/node_modules/entities": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", - "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", - "license": "BSD-2-Clause", - "engines": { - "node": ">=0.12" - }, - "funding": { - "url": "https://github.com/fb55/entities?sponsor=1" - } - }, "node_modules/hyphen": { "version": "1.6.6", "resolved": "https://registry.npmjs.org/hyphen/-/hyphen-1.6.6.tgz", @@ -8838,6 +8067,7 @@ "version": "3.3.1", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, "license": "MIT", "dependencies": { "parent-module": "^1.0.0", @@ -8876,12 +8106,6 @@ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "license": "ISC" }, - "node_modules/ini": { - "version": "1.3.8", - "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", - "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", - "license": "ISC" - }, "node_modules/internal-slot": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", @@ -8915,12 +8139,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/is-arrayish": { - "version": "0.2.1", - "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", - "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", - "license": "MIT" - }, "node_modules/is-async-function": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-async-function/-/is-async-function-2.1.1.tgz", @@ -9103,15 +8321,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/is-fullwidth-code-point": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/is-generator-function": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz", @@ -9145,12 +8354,6 @@ "node": ">=0.10.0" } }, - "node_modules/is-json": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/is-json/-/is-json-2.0.1.tgz", - "integrity": "sha512-6BEnpVn1rcf3ngfmViLM6vjUjGErbdrL4rwlv+u1NO1XO8kqT4YGL8+19Q+Z/bas8tY90BTWMk2+fW1g6hQjbA==", - "license": "ISC" - }, "node_modules/is-map": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz", @@ -9421,33 +8624,6 @@ "url": "https://github.com/sponsors/panva" } }, - "node_modules/js-beautify": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/js-beautify/-/js-beautify-2.0.3.tgz", - "integrity": "sha512-cyFbh3tkPhknnTD/0bLf0T0yy2ZIbqL05mttzbt4y1Zfr7NxqXQZ62dkBLKs3oHH/lpjmDRAnciJiSUyOy8XwQ==", - "license": "MIT", - "dependencies": { - "config-chain": "^1.1.13", - "editorconfig": "^3.0.2", - "glob": "^13.0.6", - "js-cookie": "^3.0.8", - "nopt": "^10.0.1" - }, - "bin": { - "css-beautify": "js/bin/css-beautify.js", - "html-beautify": "js/bin/html-beautify.js", - "js-beautify": "js/bin/js-beautify.js" - }, - "engines": { - "node": ">=14" - } - }, - "node_modules/js-cookie": { - "version": "3.0.8", - "resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-3.0.8.tgz", - "integrity": "sha512-yeJd4aNAdYZQjaon2bpD/Gb0B/omw7HQOsynXXcOiWVCacbBcPlgn8S/d1X6blFSaHao7ozqtW7NZW19xpCtIw==", - "license": "MIT" - }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -9458,6 +8634,7 @@ "version": "4.3.2", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz", "integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==", + "dev": true, "funding": [ { "type": "github", @@ -9546,12 +8723,6 @@ "dev": true, "license": "MIT" }, - "node_modules/json-parse-even-better-errors": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", - "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", - "license": "MIT" - }, "node_modules/json-stable-stringify-without-jsonify": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", @@ -9600,162 +8771,23 @@ "setimmediate": "^1.0.5" } }, - "node_modules/juice": { - "version": "11.1.1", - "resolved": "https://registry.npmjs.org/juice/-/juice-11.1.1.tgz", - "integrity": "sha512-4SBfZqKcc6DrIS+5b/WiGoWaZsdUPBH+e6SbRlNjJpaIRtfoBhYReAtobIEW6mcLeFFDXLBJMuZwkJLkBJjs2w==", - "license": "MIT", - "dependencies": { - "cheerio": "1.0.0", - "commander": "^12.1.0", - "entities": "^7.0.0", - "mensch": "^0.3.4", - "slick": "^1.12.2", - "web-resource-inliner": "^8.0.0" - }, - "bin": { - "juice": "bin/juice" - }, - "engines": { - "node": ">=18.17" - } - }, - "node_modules/juice/node_modules/cheerio": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/cheerio/-/cheerio-1.0.0.tgz", - "integrity": "sha512-quS9HgjQpdaXOvsZz82Oz7uxtXiy6UIsIQcpBj7HRw2M63Skasm9qlDocAM7jNuaxdhpPU7c4kJN+gA5MCu4ww==", + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, "license": "MIT", "dependencies": { - "cheerio-select": "^2.1.0", - "dom-serializer": "^2.0.0", - "domhandler": "^5.0.3", - "domutils": "^3.1.0", - "encoding-sniffer": "^0.2.0", - "htmlparser2": "^9.1.0", - "parse5": "^7.1.2", - "parse5-htmlparser2-tree-adapter": "^7.0.0", - "parse5-parser-stream": "^7.1.2", - "undici": "^6.19.5", - "whatwg-mimetype": "^4.0.0" - }, - "engines": { - "node": ">=18.17" - }, - "funding": { - "url": "https://github.com/cheeriojs/cheerio?sponsor=1" + "json-buffer": "3.0.1" } }, - "node_modules/juice/node_modules/commander": { - "version": "12.1.0", - "resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz", - "integrity": "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==", + "node_modules/kysely": { + "version": "0.29.6", + "resolved": "https://registry.npmjs.org/kysely/-/kysely-0.29.6.tgz", + "integrity": "sha512-hHaB8C/rfzDDtr/t8YZwxAuPJTT0zHyaPoVzcXwDYhYNAgH/4sIfVhi/XLLIY+bL/FqaIJnjATDbi8ObSELmxg==", "license": "MIT", "engines": { - "node": ">=18" - } - }, - "node_modules/juice/node_modules/entities": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", - "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", - "license": "BSD-2-Clause", - "engines": { - "node": ">=0.12" - }, - "funding": { - "url": "https://github.com/fb55/entities?sponsor=1" - } - }, - "node_modules/juice/node_modules/htmlparser2": { - "version": "9.1.0", - "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-9.1.0.tgz", - "integrity": "sha512-5zfg6mHUoaer/97TxnGpxmbR7zJtPwIYFMZ/H5ucTlPZhKvtum05yiPK3Mgai3a0DyVxv7qYqoweaEd2nrYQzQ==", - "funding": [ - "https://github.com/fb55/htmlparser2?sponsor=1", - { - "type": "github", - "url": "https://github.com/sponsors/fb55" - } - ], - "license": "MIT", - "dependencies": { - "domelementtype": "^2.3.0", - "domhandler": "^5.0.3", - "domutils": "^3.1.0", - "entities": "^4.5.0" - } - }, - "node_modules/juice/node_modules/htmlparser2/node_modules/entities": { - "version": "4.5.0", - "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", - "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", - "license": "BSD-2-Clause", - "engines": { - "node": ">=0.12" - }, - "funding": { - "url": "https://github.com/fb55/entities?sponsor=1" - } - }, - "node_modules/juice/node_modules/parse5": { - "version": "7.3.0", - "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", - "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", - "license": "MIT", - "dependencies": { - "entities": "^6.0.0" - }, - "funding": { - "url": "https://github.com/inikulin/parse5?sponsor=1" - } - }, - "node_modules/juice/node_modules/parse5/node_modules/entities": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", - "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", - "license": "BSD-2-Clause", - "engines": { - "node": ">=0.12" - }, - "funding": { - "url": "https://github.com/fb55/entities?sponsor=1" - } - }, - "node_modules/juice/node_modules/undici": { - "version": "6.29.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-6.29.0.tgz", - "integrity": "sha512-R+RODBqp6i2pPflGdq+xIOUkl+RNfGgHwoinecKu/JCuf2uO06cOKoDbI2P7Dn6KcswdKwrczbU6IYJ6K8X+wg==", - "license": "MIT", - "engines": { - "node": ">=18.17" - } - }, - "node_modules/juice/node_modules/whatwg-mimetype": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", - "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", - "license": "MIT", - "engines": { - "node": ">=18" - } - }, - "node_modules/keyv": { - "version": "4.5.4", - "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", - "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", - "dev": true, - "license": "MIT", - "dependencies": { - "json-buffer": "3.0.1" - } - }, - "node_modules/kysely": { - "version": "0.29.6", - "resolved": "https://registry.npmjs.org/kysely/-/kysely-0.29.6.tgz", - "integrity": "sha512-hHaB8C/rfzDDtr/t8YZwxAuPJTT0zHyaPoVzcXwDYhYNAgH/4sIfVhi/XLLIY+bL/FqaIJnjATDbi8ObSELmxg==", - "license": "MIT", - "engines": { - "node": ">=22.0.0" + "node": ">=22.0.0" } }, "node_modules/language-subtag-registry": { @@ -10074,18 +9106,6 @@ "url": "https://opencollective.com/parcel" } }, - "node_modules/lilconfig": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", - "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", - "license": "MIT", - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/antonk52" - } - }, "node_modules/linebreak": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/linebreak/-/linebreak-1.1.0.tgz", @@ -10105,12 +9125,6 @@ "node": ">= 0.4" } }, - "node_modules/lines-and-columns": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", - "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", - "license": "MIT" - }, "node_modules/locate-path": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", @@ -10127,18 +9141,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/lodash": { - "version": "4.18.1", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", - "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", - "license": "MIT" - }, - "node_modules/lodash.memoize": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz", - "integrity": "sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag==", - "license": "MIT" - }, "node_modules/lodash.merge": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", @@ -10146,12 +9148,6 @@ "dev": true, "license": "MIT" }, - "node_modules/lodash.uniq": { - "version": "4.5.0", - "resolved": "https://registry.npmjs.org/lodash.uniq/-/lodash.uniq-4.5.0.tgz", - "integrity": "sha512-xfBaXQd9ryd9dlSDvnvI0lvxfLJlYAZzXomUYzLKtUeOQvOP5piqAWuGtrhWeqaXK9hhoM/iyJc5AV+XfsX3HQ==", - "license": "MIT" - }, "node_modules/loose-envify": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", @@ -10189,1045 +9185,123 @@ } } }, - "node_modules/lz-string": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", - "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", - "dev": true, - "license": "MIT", - "peer": true, - "bin": { - "lz-string": "bin/bin.js" - } - }, - "node_modules/magic-string": { - "version": "0.30.21", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", - "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.5" - } - }, - "node_modules/magicast": { - "version": "0.5.5", - "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.5.tgz", - "integrity": "sha512-UicdXN8zQ3JHlxVq+28afMXPr1z7WNY6+7EJnzTdQWkTAlMLF5fNCCKxJHBQwGaNGR11581EiQmQzx73+MvszA==", - "devOptional": true, - "license": "MIT", - "dependencies": { - "@babel/parser": "^7.29.7", - "@babel/types": "^7.29.7", - "source-map-js": "^1.2.1" - } - }, - "node_modules/math-intrinsics": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", - "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/mdn-data": { - "version": "2.27.1", - "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", - "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", - "license": "CC0-1.0" - }, - "node_modules/media-engine": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/media-engine/-/media-engine-2.0.0.tgz", - "integrity": "sha512-FqNmlXKYrp5d3g7xEOMJb+6gXZE0fTssdyIQqYR4LbkifbNbS0hDylhNDOh8r6tCgLboHd8+mwgH2njgSYDpnQ==", - "license": "MIT" - }, - "node_modules/mensch": { - "version": "0.3.4", - "resolved": "https://registry.npmjs.org/mensch/-/mensch-0.3.4.tgz", - "integrity": "sha512-IAeFvcOnV9V0Yk+bFhYR07O3yNina9ANIN5MoXBKYJ/RLYPurd2d0yw14MDhpr9/momp0WofT1bPUh3hkzdi/g==", - "license": "MIT" - }, - "node_modules/merge2": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", - "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 8" - } - }, - "node_modules/micromatch": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", - "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", - "dev": true, - "license": "MIT", - "dependencies": { - "braces": "^3.0.3", - "picomatch": "^2.3.1" - }, - "engines": { - "node": ">=8.6" - } - }, - "node_modules/mime": { - "version": "2.6.0", - "resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz", - "integrity": "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==", - "license": "MIT", - "bin": { - "mime": "cli.js" - }, - "engines": { - "node": ">=4.0.0" - } - }, - "node_modules/min-indent": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", - "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=4" - } - }, - "node_modules/minimalistic-assert": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz", - "integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==", - "license": "ISC" - }, - "node_modules/minimatch": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", - "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, - "node_modules/minimist": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", - "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/minipass": { - "version": "7.1.3", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", - "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", - "license": "BlueOak-1.0.0", - "engines": { - "node": ">=16 || 14 >=14.17" - } - }, - "node_modules/mjml": { - "version": "5.4.1", - "resolved": "https://registry.npmjs.org/mjml/-/mjml-5.4.1.tgz", - "integrity": "sha512-yreEmxjcU1jf5aXihZ3oNwRzuTxxoOiY9QoKBgbCwjfrQTDddQJQVrYSDJk/yBTwt4mhTcyjK3zQgybyf7mF2Q==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.28.4", - "mjml-cli": "5.4.1", - "mjml-core": "5.4.1", - "mjml-preset-core": "5.4.1", - "mjml-validator": "5.4.1" - }, - "bin": { - "mjml": "bin/mjml" - } - }, - "node_modules/mjml-accordion": { - "version": "5.4.1", - "resolved": "https://registry.npmjs.org/mjml-accordion/-/mjml-accordion-5.4.1.tgz", - "integrity": "sha512-yrgWznpffr6/A4Q2VMRP5UuuaFSm0i21MsQt2zHtbdf7t9pavdGt2biTxEu2+2E35qa7eFu0ILjjmutYC3FdaQ==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.28.4", - "lodash": "^4.17.21", - "mjml-core": "5.4.1" - } - }, - "node_modules/mjml-body": { - "version": "5.4.1", - "resolved": "https://registry.npmjs.org/mjml-body/-/mjml-body-5.4.1.tgz", - "integrity": "sha512-CuQjys9nV9wafYxI5MhdVTR0I9uoW6WgGsVEHCjZ/9W/2jQ16Y6i+RVrCqACgEU8vniUh6o9HlbVmWwdyNdpCw==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.28.4", - "lodash": "^4.17.21", - "mjml-core": "5.4.1" - } - }, - "node_modules/mjml-browser": { - "version": "4.18.0", - "resolved": "https://registry.npmjs.org/mjml-browser/-/mjml-browser-4.18.0.tgz", - "integrity": "sha512-Y3kpr3IFBk3Wm7AwONZ5vDAX7FxAaMk+RKbcKKewsuGI9oNCOSM2dWNcWVFdzZ9PF7awoaCgBSQudnJaJbUiBA==", - "license": "MIT" - }, - "node_modules/mjml-button": { - "version": "5.4.1", - "resolved": "https://registry.npmjs.org/mjml-button/-/mjml-button-5.4.1.tgz", - "integrity": "sha512-QJfYgJNQ7l6gJvDdz6pksJoKXUCdbA1BRn0SUdUUj6Vbja80F21rizgwaBjYkGW8MNH5FeHm92EQ8zpNVHmWVw==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.28.4", - "lodash": "^4.17.21", - "mjml-core": "5.4.1" - } - }, - "node_modules/mjml-carousel": { - "version": "5.4.1", - "resolved": "https://registry.npmjs.org/mjml-carousel/-/mjml-carousel-5.4.1.tgz", - "integrity": "sha512-3kHTNU7uZUNWPgAwHWSFtlGk/S2Z8751tWF+c+H+9RieLHuNXud6dyF1MMRnEgE6nIukO0By+KN8pFyJnMr+iQ==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.28.4", - "lodash": "^4.17.21", - "mjml-core": "5.4.1" - } - }, - "node_modules/mjml-cli": { - "version": "5.4.1", - "resolved": "https://registry.npmjs.org/mjml-cli/-/mjml-cli-5.4.1.tgz", - "integrity": "sha512-rLb+b44VSWEoxl5I0jXOx673Q0Zz2vZE+8Ya7njuV2RPgxBaRClUDgU6yhIzAuLItUz7jIOKZKm/8tHYlk6OXA==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.28.4", - "chokidar": "^4.0.3", - "glob": "^13.0.6", - "lodash": "^4.17.21", - "minimatch": "^10.2.6", - "mjml-core": "5.4.1", - "mjml-parser-xml": "5.4.1", - "mjml-preset-core": "5.4.1", - "mjml-validator": "5.4.1", - "yargs": "^17.7.2" - }, - "bin": { - "mjml-cli": "bin/mjml" - } - }, - "node_modules/mjml-cli/node_modules/balanced-match": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", - "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", - "license": "MIT", - "engines": { - "node": "18 || 20 || >=22" - } - }, - "node_modules/mjml-cli/node_modules/brace-expansion": { - "version": "5.0.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", - "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", - "license": "MIT", - "dependencies": { - "balanced-match": "^4.0.2" - }, - "engines": { - "node": "20 || >=22" - } - }, - "node_modules/mjml-cli/node_modules/minimatch": { - "version": "10.2.6", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", - "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", - "license": "BlueOak-1.0.0", - "dependencies": { - "brace-expansion": "^5.0.8" - }, - "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/mjml-column": { - "version": "5.4.1", - "resolved": "https://registry.npmjs.org/mjml-column/-/mjml-column-5.4.1.tgz", - "integrity": "sha512-6d/tIjWg5P0JIRhO/jpaCxNdjOErS8N0BEvdRlBg7P+qif4jBKsMyB8BIomsTYJlftMX9y0B11bLf0sk5h05Ew==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.28.4", - "lodash": "^4.17.21", - "mjml-core": "5.4.1" - } - }, - "node_modules/mjml-core": { - "version": "5.4.1", - "resolved": "https://registry.npmjs.org/mjml-core/-/mjml-core-5.4.1.tgz", - "integrity": "sha512-zM2b9/cGklq/vxNeKjnF5nEgUyFlFztQVR1nDJUvnCyaWZkWbXFg5ITaR8FDb27J5pqsIhfCeAr1xHhMdDxeLA==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.28.4", - "cheerio": "^1.2.0", - "cssnano": "^7.1.9", - "cssnano-preset-lite": "^4.0.6", - "detect-node": "^2.0.4", - "htmlnano": "3.3.1", - "js-beautify": "^2.0.3", - "juice": "^11.1.1", - "lodash": "^4.17.21", - "mjml-parser-xml": "5.4.1", - "mjml-validator": "5.4.1", - "postcss": "^8.5.28" - } - }, - "node_modules/mjml-core/node_modules/caniuse-api": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/caniuse-api/-/caniuse-api-3.0.0.tgz", - "integrity": "sha512-bsTwuIg/BZZK/vreVTYYbSWoe2F+71P7K5QGEX+pT250DZbfU1MQ5prOKpPR+LL6uWKK3KMwMCAS74QB3Um1uw==", - "license": "MIT", - "dependencies": { - "browserslist": "^4.0.0", - "caniuse-lite": "^1.0.0", - "lodash.memoize": "^4.1.2", - "lodash.uniq": "^4.5.0" - } - }, - "node_modules/mjml-core/node_modules/cssnano": { - "version": "7.1.9", - "resolved": "https://registry.npmjs.org/cssnano/-/cssnano-7.1.9.tgz", - "integrity": "sha512-uPR75+5Dk/WJ/YSPR1/YDHdwMM9c5FsaARljfKWgeCKLKOtJ0we21xy/RcCjn53fZnD/f6yYEIZ8pu18+GnbNQ==", - "license": "MIT", - "dependencies": { - "cssnano-preset-default": "^7.0.17", - "lilconfig": "^3.1.3" - }, - "engines": { - "node": "^18.12.0 || ^20.9.0 || >=22.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/cssnano" - }, - "peerDependencies": { - "postcss": "^8.5.13" - } - }, - "node_modules/mjml-core/node_modules/cssnano-preset-default": { - "version": "7.0.17", - "resolved": "https://registry.npmjs.org/cssnano-preset-default/-/cssnano-preset-default-7.0.17.tgz", - "integrity": "sha512-11qO63A+czwguQFJCaTdICvbaxn0pJzz/XghLlv+OT7WyToDxAMR0Xb3/26/l0y0hQJywwNbj/SLSQlGBHE1OA==", - "license": "MIT", - "dependencies": { - "browserslist": "^4.28.2", - "css-declaration-sorter": "^7.2.0", - "cssnano-utils": "^5.0.3", - "postcss-calc": "^10.1.1", - "postcss-colormin": "^7.0.10", - "postcss-convert-values": "^7.0.12", - "postcss-discard-comments": "^7.0.8", - "postcss-discard-duplicates": "^7.0.4", - "postcss-discard-empty": "^7.0.3", - "postcss-discard-overridden": "^7.0.3", - "postcss-merge-longhand": "^7.0.7", - "postcss-merge-rules": "^7.0.11", - "postcss-minify-font-values": "^7.0.3", - "postcss-minify-gradients": "^7.0.5", - "postcss-minify-params": "^7.0.9", - "postcss-minify-selectors": "^7.1.2", - "postcss-normalize-charset": "^7.0.3", - "postcss-normalize-display-values": "^7.0.3", - "postcss-normalize-positions": "^7.0.4", - "postcss-normalize-repeat-style": "^7.0.4", - "postcss-normalize-string": "^7.0.3", - "postcss-normalize-timing-functions": "^7.0.3", - "postcss-normalize-unicode": "^7.0.9", - "postcss-normalize-url": "^7.0.3", - "postcss-normalize-whitespace": "^7.0.3", - "postcss-ordered-values": "^7.0.4", - "postcss-reduce-initial": "^7.0.9", - "postcss-reduce-transforms": "^7.0.3", - "postcss-svgo": "^7.1.3", - "postcss-unique-selectors": "^7.0.7" - }, - "engines": { - "node": "^18.12.0 || ^20.9.0 || >=22.0" - }, - "peerDependencies": { - "postcss": "^8.5.13" - } - }, - "node_modules/mjml-core/node_modules/postcss-colormin": { - "version": "7.0.10", - "resolved": "https://registry.npmjs.org/postcss-colormin/-/postcss-colormin-7.0.10.tgz", - "integrity": "sha512-yFr6JezOolHLta/buLE71VKPh2mXursp4saVe98/ol8ZnEWhL+racShqPKlvd/DKWLre/39B6HhcMXf7RZ3hxg==", - "license": "MIT", - "dependencies": { - "@colordx/core": "^5.4.3", - "browserslist": "^4.28.2", - "caniuse-api": "^3.0.0", - "postcss-value-parser": "^4.2.0" - }, - "engines": { - "node": "^18.12.0 || ^20.9.0 || >=22.0" - }, - "peerDependencies": { - "postcss": "^8.5.13" - } - }, - "node_modules/mjml-core/node_modules/postcss-convert-values": { - "version": "7.0.12", - "resolved": "https://registry.npmjs.org/postcss-convert-values/-/postcss-convert-values-7.0.12.tgz", - "integrity": "sha512-xurKu5qqk4viR3Cp3p4xBR4KfnZm4w4ys6+UBwBmeuBSNkH7+DtLnYOYnOffgtE4yx8sH9S1VZ6RAAvROXzP2Q==", - "license": "MIT", - "dependencies": { - "browserslist": "^4.28.2", - "postcss-value-parser": "^4.2.0" - }, - "engines": { - "node": "^18.12.0 || ^20.9.0 || >=22.0" - }, - "peerDependencies": { - "postcss": "^8.5.13" - } - }, - "node_modules/mjml-core/node_modules/postcss-discard-duplicates": { - "version": "7.0.4", - "resolved": "https://registry.npmjs.org/postcss-discard-duplicates/-/postcss-discard-duplicates-7.0.4.tgz", - "integrity": "sha512-VBNn1+EuMZkeGVVtz0gRfbNGtx9IFgAsAV+E2pHtXPrp4qfGBkhTIiAuE/wrb+Y6Pakg9NewAlfTpYIFAWODtw==", - "license": "MIT", - "engines": { - "node": "^18.12.0 || ^20.9.0 || >=22.0" - }, - "peerDependencies": { - "postcss": "^8.5.13" - } - }, - "node_modules/mjml-core/node_modules/postcss-discard-overridden": { - "version": "7.0.3", - "resolved": "https://registry.npmjs.org/postcss-discard-overridden/-/postcss-discard-overridden-7.0.3.tgz", - "integrity": "sha512-aNovXo9UsZuRNLzHJtp13lHIvinDPfiXBPePpXkSjCbgp++iU2FqE+YxvjIsg6EdyPZsASFbfu+JcBFVsErXIQ==", - "license": "MIT", - "engines": { - "node": "^18.12.0 || ^20.9.0 || >=22.0" - }, - "peerDependencies": { - "postcss": "^8.5.13" - } - }, - "node_modules/mjml-core/node_modules/postcss-merge-longhand": { - "version": "7.0.7", - "resolved": "https://registry.npmjs.org/postcss-merge-longhand/-/postcss-merge-longhand-7.0.7.tgz", - "integrity": "sha512-b3mfYUxR388u5Pt0HPcVIUtUDn/k15UfTY9M+ORW+meCR6JLNxoZffiYvXyOYQoRYQNZyX/UFkMCM/mNHxe1qA==", - "license": "MIT", - "dependencies": { - "postcss-value-parser": "^4.2.0", - "stylehacks": "^7.0.11" - }, - "engines": { - "node": "^18.12.0 || ^20.9.0 || >=22.0" - }, - "peerDependencies": { - "postcss": "^8.5.13" - } - }, - "node_modules/mjml-core/node_modules/postcss-merge-rules": { - "version": "7.0.11", - "resolved": "https://registry.npmjs.org/postcss-merge-rules/-/postcss-merge-rules-7.0.11.tgz", - "integrity": "sha512-SJUPM18g2BmPhf8BVlbwqWz4aK3pLu6u6xjfwEzra7xL6IBR10sUaiB++EzqcVfadPHrKBSMlNdP+XieykhI+Q==", - "license": "MIT", - "dependencies": { - "browserslist": "^4.28.2", - "caniuse-api": "^3.0.0", - "cssnano-utils": "^5.0.3", - "postcss-selector-parser": "^7.1.1" - }, - "engines": { - "node": "^18.12.0 || ^20.9.0 || >=22.0" - }, - "peerDependencies": { - "postcss": "^8.5.13" - } - }, - "node_modules/mjml-core/node_modules/postcss-minify-font-values": { - "version": "7.0.3", - "resolved": "https://registry.npmjs.org/postcss-minify-font-values/-/postcss-minify-font-values-7.0.3.tgz", - "integrity": "sha512-yilG/VOaNI74IylQvAQQxm3/wZVBkXyYUqNUAdxqwtbWUXPsbK1q8Ms0mL83v+f8YicgcyfYCRZtWACUdYajpA==", - "license": "MIT", - "dependencies": { - "postcss-value-parser": "^4.2.0" - }, - "engines": { - "node": "^18.12.0 || ^20.9.0 || >=22.0" - }, - "peerDependencies": { - "postcss": "^8.5.13" - } - }, - "node_modules/mjml-core/node_modules/postcss-minify-gradients": { - "version": "7.0.5", - "resolved": "https://registry.npmjs.org/postcss-minify-gradients/-/postcss-minify-gradients-7.0.5.tgz", - "integrity": "sha512-YraROyQRg3BI1+Hg8E05B/JPdnTm8EDSVu4P2BxdM+CRiOyfmou809+chGIqo6fQqwjPGQ947nbGncSjmTU1WQ==", - "license": "MIT", - "dependencies": { - "@colordx/core": "^5.4.3", - "cssnano-utils": "^5.0.3", - "postcss-value-parser": "^4.2.0" - }, - "engines": { - "node": "^18.12.0 || ^20.9.0 || >=22.0" - }, - "peerDependencies": { - "postcss": "^8.5.13" - } - }, - "node_modules/mjml-core/node_modules/postcss-minify-params": { - "version": "7.0.9", - "resolved": "https://registry.npmjs.org/postcss-minify-params/-/postcss-minify-params-7.0.9.tgz", - "integrity": "sha512-R8itbB8BhlpoYyBm1ou0dD+vJnQ3F6adQipR4UnkCHUwlo+S9WXJaDRg1RHjC8YVAtIdrQzSWvJl40HnGDTKjA==", - "license": "MIT", - "dependencies": { - "browserslist": "^4.28.2", - "cssnano-utils": "^5.0.3", - "postcss-value-parser": "^4.2.0" - }, - "engines": { - "node": "^18.12.0 || ^20.9.0 || >=22.0" - }, - "peerDependencies": { - "postcss": "^8.5.13" - } - }, - "node_modules/mjml-core/node_modules/postcss-minify-selectors": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/postcss-minify-selectors/-/postcss-minify-selectors-7.1.2.tgz", - "integrity": "sha512-aQtrEWKwqafNlExcKHQvPGsXR2+vlUqqJtf5XsCQcgsSb5PL4wlujWBYDJuWsP4UnQX1YHDHU8qRlD+1PzTQ+Q==", - "license": "MIT", - "dependencies": { - "browserslist": "^4.28.1", - "caniuse-api": "^3.0.0", - "cssesc": "^3.0.0", - "postcss-selector-parser": "^7.1.1" - }, - "engines": { - "node": "^18.12.0 || ^20.9.0 || >=22.0" - }, - "peerDependencies": { - "postcss": "^8.5.13" - } - }, - "node_modules/mjml-core/node_modules/postcss-normalize-charset": { - "version": "7.0.3", - "resolved": "https://registry.npmjs.org/postcss-normalize-charset/-/postcss-normalize-charset-7.0.3.tgz", - "integrity": "sha512-NoBfZu8PR4c2NlmjvrqQTzCzLY79hwcSRgNQ3ZiNK0ABzf9kYKloE/jNj+/8GQY1wsm8pRRgANk6ydLH8cwo0Q==", - "license": "MIT", - "engines": { - "node": "^18.12.0 || ^20.9.0 || >=22.0" - }, - "peerDependencies": { - "postcss": "^8.5.13" - } - }, - "node_modules/mjml-core/node_modules/postcss-normalize-display-values": { - "version": "7.0.3", - "resolved": "https://registry.npmjs.org/postcss-normalize-display-values/-/postcss-normalize-display-values-7.0.3.tgz", - "integrity": "sha512-ldsCX0QIt05pKIOobZtVQ48wXJecr+czw4+e1/YjVhLMqslShgpVxgPtI2CefURR8oyVoYaU/l829MMwExDMLw==", - "license": "MIT", - "dependencies": { - "postcss-value-parser": "^4.2.0" - }, - "engines": { - "node": "^18.12.0 || ^20.9.0 || >=22.0" - }, - "peerDependencies": { - "postcss": "^8.5.13" - } - }, - "node_modules/mjml-core/node_modules/postcss-normalize-positions": { - "version": "7.0.4", - "resolved": "https://registry.npmjs.org/postcss-normalize-positions/-/postcss-normalize-positions-7.0.4.tgz", - "integrity": "sha512-VEvlpeGd3Ju1Hqa/oN4jaP3+ms4laYwkEL9N9u+B6k54PZjXbW1n6wI+aVprf1BQXlCYpS5+1pl/7/vHiKgARg==", - "license": "MIT", - "dependencies": { - "postcss-value-parser": "^4.2.0" - }, - "engines": { - "node": "^18.12.0 || ^20.9.0 || >=22.0" - }, - "peerDependencies": { - "postcss": "^8.5.13" - } - }, - "node_modules/mjml-core/node_modules/postcss-normalize-repeat-style": { - "version": "7.0.4", - "resolved": "https://registry.npmjs.org/postcss-normalize-repeat-style/-/postcss-normalize-repeat-style-7.0.4.tgz", - "integrity": "sha512-6mPKlY/8cSaDHxX502wERADarJsccwlky6yIrOapHH2ZgfoKAV94SbiTKfKEs4EEpdazuc3J72WsqeYk7hp9+Q==", - "license": "MIT", - "dependencies": { - "postcss-value-parser": "^4.2.0" - }, - "engines": { - "node": "^18.12.0 || ^20.9.0 || >=22.0" - }, - "peerDependencies": { - "postcss": "^8.5.13" - } - }, - "node_modules/mjml-core/node_modules/postcss-normalize-string": { - "version": "7.0.3", - "resolved": "https://registry.npmjs.org/postcss-normalize-string/-/postcss-normalize-string-7.0.3.tgz", - "integrity": "sha512-HnEQPUchi1eznmDKEYrKUTqrprEq97SrpUYClgUkv7V2zRODD9DFoUsYU+m9ZOetmD5ku7fEMZB/lwy8IT6xVQ==", - "license": "MIT", - "dependencies": { - "postcss-value-parser": "^4.2.0" - }, - "engines": { - "node": "^18.12.0 || ^20.9.0 || >=22.0" - }, - "peerDependencies": { - "postcss": "^8.5.13" - } - }, - "node_modules/mjml-core/node_modules/postcss-normalize-timing-functions": { - "version": "7.0.3", - "resolved": "https://registry.npmjs.org/postcss-normalize-timing-functions/-/postcss-normalize-timing-functions-7.0.3.tgz", - "integrity": "sha512-zmEzHdvpZBZu0OKlbJSfgASQvaayyAoVuWtvyr34IJ/LyS+DaOKvvR3EvFJ9RWWtNIx+CMvO125OVophaxNYew==", - "license": "MIT", - "dependencies": { - "postcss-value-parser": "^4.2.0" - }, - "engines": { - "node": "^18.12.0 || ^20.9.0 || >=22.0" - }, - "peerDependencies": { - "postcss": "^8.5.13" - } - }, - "node_modules/mjml-core/node_modules/postcss-normalize-unicode": { - "version": "7.0.9", - "resolved": "https://registry.npmjs.org/postcss-normalize-unicode/-/postcss-normalize-unicode-7.0.9.tgz", - "integrity": "sha512-DRAdWfeh/TjmhLJsw91vdiWCnUod9iwvM7xyS02/nF/sLsCR3A8l3pztrSUrWG8DSBqfX7yEk9FM0USaVJ2mSg==", - "license": "MIT", - "dependencies": { - "browserslist": "^4.28.2", - "postcss-value-parser": "^4.2.0" - }, - "engines": { - "node": "^18.12.0 || ^20.9.0 || >=22.0" - }, - "peerDependencies": { - "postcss": "^8.5.13" - } - }, - "node_modules/mjml-core/node_modules/postcss-normalize-url": { - "version": "7.0.3", - "resolved": "https://registry.npmjs.org/postcss-normalize-url/-/postcss-normalize-url-7.0.3.tgz", - "integrity": "sha512-CL93wmloq5qsffmFv+bw24MIRbmhHrp53qoh1LDAb/5TtjWEXI/np4xcP/Gw9oWCb2XyWnqHYLDUwiKRoJBA1Q==", - "license": "MIT", - "dependencies": { - "postcss-value-parser": "^4.2.0" - }, - "engines": { - "node": "^18.12.0 || ^20.9.0 || >=22.0" - }, - "peerDependencies": { - "postcss": "^8.5.13" - } - }, - "node_modules/mjml-core/node_modules/postcss-ordered-values": { - "version": "7.0.4", - "resolved": "https://registry.npmjs.org/postcss-ordered-values/-/postcss-ordered-values-7.0.4.tgz", - "integrity": "sha512-nubSi49hDHQk4E8KIj+IbLY8Bg+8OcSUEhgyolgM+atnOvXjV7EjaR6bac4YGZoFyPa9mWoAF3EaYbWdFkKqVg==", - "license": "MIT", - "dependencies": { - "cssnano-utils": "^5.0.3", - "postcss-value-parser": "^4.2.0" - }, - "engines": { - "node": "^18.12.0 || ^20.9.0 || >=22.0" - }, - "peerDependencies": { - "postcss": "^8.5.13" - } - }, - "node_modules/mjml-core/node_modules/postcss-reduce-initial": { - "version": "7.0.9", - "resolved": "https://registry.npmjs.org/postcss-reduce-initial/-/postcss-reduce-initial-7.0.9.tgz", - "integrity": "sha512-ztTNPdIxXTxtBcG03E9u8v44M4ElXbMIRT7pf2onlquGula0Y83nKKxqM22FA/hMgkfCjN7ohevkVlaNwI8iOQ==", - "license": "MIT", - "dependencies": { - "browserslist": "^4.28.2", - "caniuse-api": "^3.0.0" - }, - "engines": { - "node": "^18.12.0 || ^20.9.0 || >=22.0" - }, - "peerDependencies": { - "postcss": "^8.5.13" - } - }, - "node_modules/mjml-core/node_modules/postcss-reduce-transforms": { - "version": "7.0.3", - "resolved": "https://registry.npmjs.org/postcss-reduce-transforms/-/postcss-reduce-transforms-7.0.3.tgz", - "integrity": "sha512-FXsnN9ZwcZTT8Yf8cAHA8qIGUXcX6WfLd9JoYhrdDfmvsVhhfqkkv7m4AC3rwFOfz+GzkUa87OCKF9dUcicd+g==", - "license": "MIT", - "dependencies": { - "postcss-value-parser": "^4.2.0" - }, - "engines": { - "node": "^18.12.0 || ^20.9.0 || >=22.0" - }, - "peerDependencies": { - "postcss": "^8.5.13" - } - }, - "node_modules/mjml-core/node_modules/postcss-svgo": { - "version": "7.1.3", - "resolved": "https://registry.npmjs.org/postcss-svgo/-/postcss-svgo-7.1.3.tgz", - "integrity": "sha512-2QfoFOYMcj8lwcVEf9WeTlkVIAm7u2QvOEhMzkQU3KUhhGX/l8hVV9EtjMv4iq3E9iI3OeeMN0YoMLbGusuigw==", - "license": "MIT", - "dependencies": { - "postcss-value-parser": "^4.2.0", - "svgo": "^4.0.1" - }, - "engines": { - "node": "^18.12.0 || ^20.9.0 || >= 18" - }, - "peerDependencies": { - "postcss": "^8.5.13" - } - }, - "node_modules/mjml-core/node_modules/postcss-unique-selectors": { - "version": "7.0.7", - "resolved": "https://registry.npmjs.org/postcss-unique-selectors/-/postcss-unique-selectors-7.0.7.tgz", - "integrity": "sha512-d+sCkaRnSefghOUdH8CMJZV9yUQhj2ojpe8Nw/lA+LV1UOfeleGkLTl6XdCFFSai9UJ+DJPb69FFuqthXYsY8w==", - "license": "MIT", - "dependencies": { - "postcss-selector-parser": "^7.1.1" - }, - "engines": { - "node": "^18.12.0 || ^20.9.0 || >=22.0" - }, - "peerDependencies": { - "postcss": "^8.5.13" - } - }, - "node_modules/mjml-core/node_modules/stylehacks": { - "version": "7.0.11", - "resolved": "https://registry.npmjs.org/stylehacks/-/stylehacks-7.0.11.tgz", - "integrity": "sha512-iODNfhXVLqc5LADs+Y6Oh5wJuK5ZcHbVng8aiK3y9pjMQdc5hLrBW0eFU6FtnpNrE6PoEg/MmFTU4waotj5WNg==", - "license": "MIT", - "dependencies": { - "browserslist": "^4.28.2", - "postcss-selector-parser": "^7.1.1" - }, - "engines": { - "node": "^18.12.0 || ^20.9.0 || >=22.0" - }, - "peerDependencies": { - "postcss": "^8.5.13" - } - }, - "node_modules/mjml-divider": { - "version": "5.4.1", - "resolved": "https://registry.npmjs.org/mjml-divider/-/mjml-divider-5.4.1.tgz", - "integrity": "sha512-9BR8qhzLSr22WcVhv77gjWuKEFeVlh4DslUUHZiYM7f4rMo96gLnygzBdVF6scx6QYLLBS8HDQXCBXj0bY0QuA==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.28.4", - "lodash": "^4.17.21", - "mjml-core": "5.4.1" - } - }, - "node_modules/mjml-group": { - "version": "5.4.1", - "resolved": "https://registry.npmjs.org/mjml-group/-/mjml-group-5.4.1.tgz", - "integrity": "sha512-4GgQd7hFyg3bCS9PJVq+OScMEGEQkBab9o9Q3EBWBdnXtrCv0quB3XACoT8DYKu4a8j790EXfKRFB577W+Ybhw==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.28.4", - "lodash": "^4.17.21", - "mjml-core": "5.4.1" - } - }, - "node_modules/mjml-head": { - "version": "5.4.1", - "resolved": "https://registry.npmjs.org/mjml-head/-/mjml-head-5.4.1.tgz", - "integrity": "sha512-4QVjlgtX6tsWkWvQmcnJ9bxmNJeW+sBdnjuEf28Vz3+Px3XfOFmm275DPBqlSIyvqPUZuDE6sT1d5ElJQv8TBA==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.28.4", - "lodash": "^4.17.21", - "mjml-core": "5.4.1" - } - }, - "node_modules/mjml-head-attributes": { - "version": "5.4.1", - "resolved": "https://registry.npmjs.org/mjml-head-attributes/-/mjml-head-attributes-5.4.1.tgz", - "integrity": "sha512-+F1Bv2F6232R488Zl7+SyL4CzRdzxaBIged3D5OzyFu6V9OAERj8Qcg48t0LeXTbtZFMXrkrjM87OzczN+SX/g==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.28.4", - "lodash": "^4.17.21", - "mjml-core": "5.4.1" - } - }, - "node_modules/mjml-head-breakpoint": { - "version": "5.4.1", - "resolved": "https://registry.npmjs.org/mjml-head-breakpoint/-/mjml-head-breakpoint-5.4.1.tgz", - "integrity": "sha512-UAUs/x2saT9dJxYSpHDb7tnrl5rvppZafd52Hr3qkqOuXdBQh9Z1PPSi7eQ21Zy6DvZ3UQukUWvMg7VI2+RljA==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.28.4", - "lodash": "^4.17.21", - "mjml-core": "5.4.1" - } - }, - "node_modules/mjml-head-font": { - "version": "5.4.1", - "resolved": "https://registry.npmjs.org/mjml-head-font/-/mjml-head-font-5.4.1.tgz", - "integrity": "sha512-UxIL8qx0l9lW3B8rOBh4a0h20B1QqfbVYR8j4BG3jWZwGJqeDKAlz6d0sE6ARnWLrgX4kvvp3pl5Kc/p0ML6sw==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.28.4", - "lodash": "^4.17.21", - "mjml-core": "5.4.1" - } - }, - "node_modules/mjml-head-html-attributes": { - "version": "5.4.1", - "resolved": "https://registry.npmjs.org/mjml-head-html-attributes/-/mjml-head-html-attributes-5.4.1.tgz", - "integrity": "sha512-iGG/HSExMjD+UKlF2C+5EPF2EvcTQ+VdoSlsbJCPkqaNy8adeMMq7QVkF0XMUfXovKqldm7lmyvI4n+gnr70UA==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.28.4", - "lodash": "^4.17.21", - "mjml-core": "5.4.1" - } - }, - "node_modules/mjml-head-preview": { - "version": "5.4.1", - "resolved": "https://registry.npmjs.org/mjml-head-preview/-/mjml-head-preview-5.4.1.tgz", - "integrity": "sha512-v0LUD9fChbmO5tMTmd6RNRWR9Li21TqdQlbCVJdMXbeuAqu/pShv4Xdx09TA7NV8WvsvOhAO0HGcY9YLhbMnMw==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.28.4", - "lodash": "^4.17.21", - "mjml-core": "5.4.1" - } - }, - "node_modules/mjml-head-style": { - "version": "5.4.1", - "resolved": "https://registry.npmjs.org/mjml-head-style/-/mjml-head-style-5.4.1.tgz", - "integrity": "sha512-GtwX7FjWtVxEN58YTLIdkaxeOldSvZeWle0rC1mYQcq2RfAKxuB9d39kJlQ6H94+fO4IGBVVtWEZ8og+y+CA3g==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.28.4", - "lodash": "^4.17.21", - "mjml-core": "5.4.1" - } - }, - "node_modules/mjml-head-title": { - "version": "5.4.1", - "resolved": "https://registry.npmjs.org/mjml-head-title/-/mjml-head-title-5.4.1.tgz", - "integrity": "sha512-T37ZxWmoZ9zwR0qUimVXeJ638L81V359NgxTkV81MeaR35UfaPTgfa4Sidot1i163p5LPW4Zd7zjVnYs1E861A==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.28.4", - "lodash": "^4.17.21", - "mjml-core": "5.4.1" - } - }, - "node_modules/mjml-hero": { - "version": "5.4.1", - "resolved": "https://registry.npmjs.org/mjml-hero/-/mjml-hero-5.4.1.tgz", - "integrity": "sha512-43kaBO5pwLIDeJJTvXIlv14Mqa0jjiguKHf9R6++Dx8uGOEzuhojw6s527ZPKaKAAIDUqzsJhslj0V9rEJuSHQ==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.28.4", - "lodash": "^4.17.21", - "mjml-core": "5.4.1" - } - }, - "node_modules/mjml-image": { - "version": "5.4.1", - "resolved": "https://registry.npmjs.org/mjml-image/-/mjml-image-5.4.1.tgz", - "integrity": "sha512-Tp4srn0oGf2yiSIgDyw3stPVwqM/kYGe1YOzRzVeTKvU+YTevjOHm6KoAzqhn/qSQvhQLvwDJJt/hKnWBmQ0Wg==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.28.4", - "lodash": "^4.17.21", - "mjml-core": "5.4.1" - } - }, - "node_modules/mjml-navbar": { - "version": "5.4.1", - "resolved": "https://registry.npmjs.org/mjml-navbar/-/mjml-navbar-5.4.1.tgz", - "integrity": "sha512-cGVYt3gxBGkHPL7dcSU7pSWhUZeG/ExJ9gxrtwmds076FxerMfPawSMA6FlCKiAD4glLb5Eotg7+ql+Tme+mOg==", + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.28.4", - "lodash": "^4.17.21", - "mjml-core": "5.4.1" + "peer": true, + "bin": { + "lz-string": "bin/bin.js" } }, - "node_modules/mjml-parser-xml": { - "version": "5.4.1", - "resolved": "https://registry.npmjs.org/mjml-parser-xml/-/mjml-parser-xml-5.4.1.tgz", - "integrity": "sha512-2Jxs7hxHO5coSDoWNmEfvHlTilTii0jsbzFkRK8mxCvpdLmXdVN1TDWN372ax67HloIZkwHyKJsbQNRZFQwL4Q==", + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, "license": "MIT", "dependencies": { - "@babel/runtime": "^7.28.4", - "detect-node": "2.1.0", - "htmlparser2": "^9.1.0", - "lodash": "^4.18.1" + "@jridgewell/sourcemap-codec": "^1.5.5" } }, - "node_modules/mjml-parser-xml/node_modules/htmlparser2": { - "version": "9.1.0", - "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-9.1.0.tgz", - "integrity": "sha512-5zfg6mHUoaer/97TxnGpxmbR7zJtPwIYFMZ/H5ucTlPZhKvtum05yiPK3Mgai3a0DyVxv7qYqoweaEd2nrYQzQ==", - "funding": [ - "https://github.com/fb55/htmlparser2?sponsor=1", - { - "type": "github", - "url": "https://github.com/sponsors/fb55" - } - ], + "node_modules/magicast": { + "version": "0.5.5", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.5.tgz", + "integrity": "sha512-UicdXN8zQ3JHlxVq+28afMXPr1z7WNY6+7EJnzTdQWkTAlMLF5fNCCKxJHBQwGaNGR11581EiQmQzx73+MvszA==", + "devOptional": true, "license": "MIT", "dependencies": { - "domelementtype": "^2.3.0", - "domhandler": "^5.0.3", - "domutils": "^3.1.0", - "entities": "^4.5.0" + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7", + "source-map-js": "^1.2.1" } }, - "node_modules/mjml-preset-core": { - "version": "5.4.1", - "resolved": "https://registry.npmjs.org/mjml-preset-core/-/mjml-preset-core-5.4.1.tgz", - "integrity": "sha512-xFyTol21FRycPFkGDLnnf5knwyHcAWufT4AwKUrIiGDJFpTS1guEvm9euSWIRLj3mA2gXkc4p/XVImcmYIAfnQ==", + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "dev": true, "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.28.4", - "mjml-accordion": "5.4.1", - "mjml-body": "5.4.1", - "mjml-button": "5.4.1", - "mjml-carousel": "5.4.1", - "mjml-column": "5.4.1", - "mjml-divider": "5.4.1", - "mjml-group": "5.4.1", - "mjml-head": "5.4.1", - "mjml-head-attributes": "5.4.1", - "mjml-head-breakpoint": "5.4.1", - "mjml-head-font": "5.4.1", - "mjml-head-html-attributes": "5.4.1", - "mjml-head-preview": "5.4.1", - "mjml-head-style": "5.4.1", - "mjml-head-title": "5.4.1", - "mjml-hero": "5.4.1", - "mjml-image": "5.4.1", - "mjml-navbar": "5.4.1", - "mjml-raw": "5.4.1", - "mjml-section": "5.4.1", - "mjml-social": "5.4.1", - "mjml-spacer": "5.4.1", - "mjml-table": "5.4.1", - "mjml-text": "5.4.1", - "mjml-wrapper": "5.4.1" + "engines": { + "node": ">= 0.4" } }, - "node_modules/mjml-raw": { - "version": "5.4.1", - "resolved": "https://registry.npmjs.org/mjml-raw/-/mjml-raw-5.4.1.tgz", - "integrity": "sha512-zKVIUr7FEeUM/fsw00ux9NHUk0ZlFKjUyX2KDjXxQPEm9k0mJT/bNPHPimKiLCnBzAETNA/N2DL9X6i5dclJzg==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.28.4", - "lodash": "^4.17.21", - "mjml-core": "5.4.1" - } + "node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "devOptional": true, + "license": "CC0-1.0" }, - "node_modules/mjml-section": { - "version": "5.4.1", - "resolved": "https://registry.npmjs.org/mjml-section/-/mjml-section-5.4.1.tgz", - "integrity": "sha512-yybLdM9IUctWDY/NwoIP7Fe5EHYuYsQoO6lRsdD9LeqTatXtD4+2bh9/CLhSOQdAL35EUHLjkDWbhdVmNBk99w==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.28.4", - "lodash": "^4.17.21", - "mjml-core": "5.4.1" - } + "node_modules/media-engine": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/media-engine/-/media-engine-2.0.0.tgz", + "integrity": "sha512-FqNmlXKYrp5d3g7xEOMJb+6gXZE0fTssdyIQqYR4LbkifbNbS0hDylhNDOh8r6tCgLboHd8+mwgH2njgSYDpnQ==", + "license": "MIT" }, - "node_modules/mjml-social": { - "version": "5.4.1", - "resolved": "https://registry.npmjs.org/mjml-social/-/mjml-social-5.4.1.tgz", - "integrity": "sha512-n+9qx+x+WM4deiuArt7Z1pd3IGFuaBNpBm5C1dURU+QAkFzR7AY/S/MHJ/VR+87f+LxuwjFEb64iZ+kM7+PFwQ==", + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.28.4", - "lodash": "^4.17.21", - "mjml-core": "5.4.1" + "engines": { + "node": ">= 8" } }, - "node_modules/mjml-spacer": { - "version": "5.4.1", - "resolved": "https://registry.npmjs.org/mjml-spacer/-/mjml-spacer-5.4.1.tgz", - "integrity": "sha512-hXZLTYs3cjadycKsOF+TCAL+kU4emPBZgrZPGVpGxw10aT1n0H9aOFRZZDZ6F2aZHk61w4k5uZmCqq8cY9TbYQ==", + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, "license": "MIT", "dependencies": { - "@babel/runtime": "^7.28.4", - "lodash": "^4.17.21", - "mjml-core": "5.4.1" + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" } }, - "node_modules/mjml-table": { - "version": "5.4.1", - "resolved": "https://registry.npmjs.org/mjml-table/-/mjml-table-5.4.1.tgz", - "integrity": "sha512-iTuSn1PxKHJGN6TZtiEx33p2W4u8Unh8A2xuUKNU5paugvILhHZ2AclOlZw5iPkla3zh/uxh4cSbR1W6B+iLmg==", + "node_modules/min-indent": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", + "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", + "dev": true, "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.28.4", - "lodash": "^4.17.21", - "mjml-core": "5.4.1" + "engines": { + "node": ">=4" } }, - "node_modules/mjml-text": { - "version": "5.4.1", - "resolved": "https://registry.npmjs.org/mjml-text/-/mjml-text-5.4.1.tgz", - "integrity": "sha512-DXJuA/2kOp1k8Qjae6hju6u1qRXBdO1HI1eT+DmxwqGjFI5S5MVXLA+FGA7hxzXrROGsldF3ZnJNn8e67Bzm0Q==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.28.4", - "lodash": "^4.17.21", - "mjml-core": "5.4.1" - } + "node_modules/minimalistic-assert": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz", + "integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==", + "license": "ISC" }, - "node_modules/mjml-validator": { - "version": "5.4.1", - "resolved": "https://registry.npmjs.org/mjml-validator/-/mjml-validator-5.4.1.tgz", - "integrity": "sha512-a5Tm5vY4rBqe7zhtpIJ1z1+h41N24OPg3Q0+iJqyHlYk6aZLEJmCyLMVV13Qb2zg14zRV4L3LrZgC5aSZCD7bQ==", - "license": "MIT", + "node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", "dependencies": { - "@babel/runtime": "^7.28.4" + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" } }, - "node_modules/mjml-wrapper": { - "version": "5.4.1", - "resolved": "https://registry.npmjs.org/mjml-wrapper/-/mjml-wrapper-5.4.1.tgz", - "integrity": "sha512-P/6cue6bF6k9U8FoL8EpFFi4+xzkQnu1fRA3ZApdIFX/y4uafbRl82AAlznREOPFBsdXuqkub7PEhTHU+9ct+A==", + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dev": true, "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.28.4", - "lodash": "^4.17.21", - "mjml-core": "5.4.1", - "mjml-section": "5.4.1" + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, "node_modules/ms": { @@ -11425,26 +9499,12 @@ "version": "2.0.57", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.57.tgz", "integrity": "sha512-kQK9LGGFiHtrWiNhZtA7Qbw17AQz+dmsEKODRIVTXA9+e5MS/2gZEBhYJt13GrAz5/IOZKddH/0Z3TP/Zgo+yw==", + "dev": true, "license": "MIT", "engines": { "node": ">=18" } }, - "node_modules/nopt": { - "version": "10.0.1", - "resolved": "https://registry.npmjs.org/nopt/-/nopt-10.0.1.tgz", - "integrity": "sha512-df3sBr/6ax9hSGuC3CspvLlbnX8cP5L5nZwXF8cGN8l0zSWR6BvzmQ6jPUKjvo6+/xdpkNvEcucBNUdBeeV13g==", - "license": "ISC", - "dependencies": { - "abbrev": "^5.0.0" - }, - "bin": { - "nopt": "bin/nopt.js" - }, - "engines": { - "node": "^22.22.2 || ^24.15.0 || >=26.0.0" - } - }, "node_modules/normalize-svg-path": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/normalize-svg-path/-/normalize-svg-path-1.1.0.tgz", @@ -11454,18 +9514,6 @@ "svg-arc-to-cubic-bezier": "^3.0.0" } }, - "node_modules/nth-check": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", - "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==", - "license": "BSD-2-Clause", - "dependencies": { - "boolbase": "^1.0.0" - }, - "funding": { - "url": "https://github.com/fb55/nth-check?sponsor=1" - } - }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -11681,6 +9729,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, "license": "MIT", "dependencies": { "callsites": "^3.0.0" @@ -11689,24 +9738,6 @@ "node": ">=6" } }, - "node_modules/parse-json": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", - "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.0.0", - "error-ex": "^1.3.1", - "json-parse-even-better-errors": "^2.3.0", - "lines-and-columns": "^1.1.6" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/parse-svg-path": { "version": "0.1.2", "resolved": "https://registry.npmjs.org/parse-svg-path/-/parse-svg-path-0.1.2.tgz", @@ -11726,79 +9757,6 @@ "url": "https://github.com/inikulin/parse5?sponsor=1" } }, - "node_modules/parse5-htmlparser2-tree-adapter": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/parse5-htmlparser2-tree-adapter/-/parse5-htmlparser2-tree-adapter-7.1.0.tgz", - "integrity": "sha512-ruw5xyKs6lrpo9x9rCZqZZnIUntICjQAd0Wsmp396Ul9lN/h+ifgVV1x1gZHi8euej6wTfpqX8j+BFQxF0NS/g==", - "license": "MIT", - "dependencies": { - "domhandler": "^5.0.3", - "parse5": "^7.0.0" - }, - "funding": { - "url": "https://github.com/inikulin/parse5?sponsor=1" - } - }, - "node_modules/parse5-htmlparser2-tree-adapter/node_modules/entities": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", - "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", - "license": "BSD-2-Clause", - "engines": { - "node": ">=0.12" - }, - "funding": { - "url": "https://github.com/fb55/entities?sponsor=1" - } - }, - "node_modules/parse5-htmlparser2-tree-adapter/node_modules/parse5": { - "version": "7.3.0", - "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", - "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", - "license": "MIT", - "dependencies": { - "entities": "^6.0.0" - }, - "funding": { - "url": "https://github.com/inikulin/parse5?sponsor=1" - } - }, - "node_modules/parse5-parser-stream": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/parse5-parser-stream/-/parse5-parser-stream-7.1.2.tgz", - "integrity": "sha512-JyeQc9iwFLn5TbvvqACIF/VXG6abODeB3Fwmv/TGdLk2LfbWkaySGY72at4+Ty7EkPZj854u4CrICqNk2qIbow==", - "license": "MIT", - "dependencies": { - "parse5": "^7.0.0" - }, - "funding": { - "url": "https://github.com/inikulin/parse5?sponsor=1" - } - }, - "node_modules/parse5-parser-stream/node_modules/entities": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", - "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", - "license": "BSD-2-Clause", - "engines": { - "node": ">=0.12" - }, - "funding": { - "url": "https://github.com/fb55/entities?sponsor=1" - } - }, - "node_modules/parse5-parser-stream/node_modules/parse5": { - "version": "7.3.0", - "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", - "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", - "license": "MIT", - "dependencies": { - "entities": "^6.0.0" - }, - "funding": { - "url": "https://github.com/inikulin/parse5?sponsor=1" - } - }, "node_modules/parse5/node_modules/entities": { "version": "8.1.0", "resolved": "https://registry.npmjs.org/entities/-/entities-8.1.0.tgz", @@ -11839,31 +9797,6 @@ "dev": true, "license": "MIT" }, - "node_modules/path-scurry": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", - "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", - "license": "BlueOak-1.0.0", - "dependencies": { - "lru-cache": "^11.0.0", - "minipass": "^7.1.2" - }, - "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/path-scurry/node_modules/lru-cache": { - "version": "11.5.3", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.3.tgz", - "integrity": "sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==", - "license": "BlueOak-1.0.0", - "engines": { - "node": "20 || >=22" - } - }, "node_modules/pdfkit": { "version": "0.20.1", "resolved": "https://registry.npmjs.org/pdfkit/-/pdfkit-0.20.1.tgz", @@ -11965,102 +9898,32 @@ } }, "node_modules/postcss": { - "version": "8.5.28", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz", - "integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==", - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/postcss" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "nanoid": "^3.3.18", - "picocolors": "^1.1.1", - "source-map-js": "^1.2.1" - }, - "engines": { - "node": "^10 || ^12 || >=14" - } - }, - "node_modules/postcss-calc": { - "version": "10.1.1", - "resolved": "https://registry.npmjs.org/postcss-calc/-/postcss-calc-10.1.1.tgz", - "integrity": "sha512-NYEsLHh8DgG/PRH2+G9BTuUdtf9ViS+vdoQ0YA5OQdGsfN4ztiwtDWNtBl9EKeqNMFnIu8IKZ0cLxEQ5r5KVMw==", - "license": "MIT", - "dependencies": { - "postcss-selector-parser": "^7.0.0", - "postcss-value-parser": "^4.2.0" - }, - "engines": { - "node": "^18.12 || ^20.9 || >=22.0" - }, - "peerDependencies": { - "postcss": "^8.4.38" - } - }, - "node_modules/postcss-discard-comments": { - "version": "7.0.8", - "resolved": "https://registry.npmjs.org/postcss-discard-comments/-/postcss-discard-comments-7.0.8.tgz", - "integrity": "sha512-CvvS5S9WrXblFXCEJ9nVo+4z+eA7zSC7Z88V1HEJuwlQhlFnYTIjg1xJY+BCUiG2bvICap2tXii4mP22BD108Q==", - "license": "MIT", - "dependencies": { - "postcss-selector-parser": "^7.1.1" - }, - "engines": { - "node": "^18.12.0 || ^20.9.0 || >=22.0" - }, - "peerDependencies": { - "postcss": "^8.5.13" - } - }, - "node_modules/postcss-discard-empty": { - "version": "7.0.3", - "resolved": "https://registry.npmjs.org/postcss-discard-empty/-/postcss-discard-empty-7.0.3.tgz", - "integrity": "sha512-M2pyjQCU+/7cMHVtL6bKTHjv0lZnPLMpicgr67Dlth7AbuV9gjVTtUqaRwn6Pp6BwSDspUzhz8SaUrRykJU5Dw==", - "license": "MIT", - "engines": { - "node": "^18.12.0 || ^20.9.0 || >=22.0" - }, - "peerDependencies": { - "postcss": "^8.5.13" - } - }, - "node_modules/postcss-normalize-whitespace": { - "version": "7.0.3", - "resolved": "https://registry.npmjs.org/postcss-normalize-whitespace/-/postcss-normalize-whitespace-7.0.3.tgz", - "integrity": "sha512-FdHjjn+Ht5Z2ZRjNOmeCbNq6lq09sUYKpmlF/Aq0XjVNSLTL6fmHlA/3swN2wP2caY9GV/tjSDcIIyS7aN7W0A==", - "license": "MIT", - "dependencies": { - "postcss-value-parser": "^4.2.0" - }, - "engines": { - "node": "^18.12.0 || ^20.9.0 || >=22.0" - }, - "peerDependencies": { - "postcss": "^8.5.13" - } - }, - "node_modules/postcss-selector-parser": { - "version": "7.1.6", - "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.6.tgz", - "integrity": "sha512-7qASPzhKF2l2KLboRZux8CCTRMdGiV08vWmyKzPz22qZ7ZjQBOeY7rNzNoCLSUiftJ7HUq0GERHmxw/t0dCdMw==", + "version": "8.5.28", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz", + "integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==", + "devOptional": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], "license": "MIT", "dependencies": { - "cssesc": "^3.0.0", - "util-deprecate": "^1.0.2" + "nanoid": "^3.3.18", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" }, "engines": { - "node": ">=4" + "node": "^10 || ^12 || >=14" } }, "node_modules/postcss-value-parser": { @@ -12073,6 +9936,7 @@ "version": "3.3.19", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.19.tgz", "integrity": "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==", + "devOptional": true, "funding": [ { "type": "github", @@ -12087,126 +9951,6 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, - "node_modules/posthtml": { - "version": "0.16.7", - "resolved": "https://registry.npmjs.org/posthtml/-/posthtml-0.16.7.tgz", - "integrity": "sha512-7Hc+IvlQ7hlaIfQFZnxlRl0jnpWq2qwibORBhQYIb0QbNtuicc5ZxvKkVT71HJ4Py1wSZ/3VR1r8LfkCtoCzhw==", - "license": "MIT", - "dependencies": { - "posthtml-parser": "^0.11.0", - "posthtml-render": "^3.0.0" - }, - "engines": { - "node": ">=12.0.0" - } - }, - "node_modules/posthtml-parser": { - "version": "0.11.0", - "resolved": "https://registry.npmjs.org/posthtml-parser/-/posthtml-parser-0.11.0.tgz", - "integrity": "sha512-QecJtfLekJbWVo/dMAA+OSwY79wpRmbqS5TeXvXSX+f0c6pW4/SE6inzZ2qkU7oAMCPqIDkZDvd/bQsSFUnKyw==", - "license": "MIT", - "dependencies": { - "htmlparser2": "^7.1.1" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/posthtml-parser/node_modules/dom-serializer": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-1.4.1.tgz", - "integrity": "sha512-VHwB3KfrcOOkelEG2ZOfxqLZdfkil8PtJi4P8N2MMXucZq2yLp75ClViUlOVwyoHEDjYU433Aq+5zWP61+RGag==", - "license": "MIT", - "dependencies": { - "domelementtype": "^2.0.1", - "domhandler": "^4.2.0", - "entities": "^2.0.0" - }, - "funding": { - "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" - } - }, - "node_modules/posthtml-parser/node_modules/dom-serializer/node_modules/entities": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/entities/-/entities-2.2.0.tgz", - "integrity": "sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A==", - "license": "BSD-2-Clause", - "funding": { - "url": "https://github.com/fb55/entities?sponsor=1" - } - }, - "node_modules/posthtml-parser/node_modules/domhandler": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-4.3.1.tgz", - "integrity": "sha512-GrwoxYN+uWlzO8uhUXRl0P+kHE4GtVPfYzVLcUxPL7KNdHKj66vvlhiweIHqYYXWlw+T8iLMp42Lm67ghw4WMQ==", - "license": "BSD-2-Clause", - "dependencies": { - "domelementtype": "^2.2.0" - }, - "engines": { - "node": ">= 4" - }, - "funding": { - "url": "https://github.com/fb55/domhandler?sponsor=1" - } - }, - "node_modules/posthtml-parser/node_modules/domutils": { - "version": "2.8.0", - "resolved": "https://registry.npmjs.org/domutils/-/domutils-2.8.0.tgz", - "integrity": "sha512-w96Cjofp72M5IIhpjgobBimYEfoPjx1Vx0BSX9P30WBdZW2WIKU0T1Bd0kz2eNZ9ikjKgHbEyKx8BB6H1L3h3A==", - "license": "BSD-2-Clause", - "dependencies": { - "dom-serializer": "^1.0.1", - "domelementtype": "^2.2.0", - "domhandler": "^4.2.0" - }, - "funding": { - "url": "https://github.com/fb55/domutils?sponsor=1" - } - }, - "node_modules/posthtml-parser/node_modules/entities": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/entities/-/entities-3.0.1.tgz", - "integrity": "sha512-WiyBqoomrwMdFG1e0kqvASYfnlb0lp8M5o5Fw2OFq1hNZxxcNk8Ik0Xm7LxzBhuidnZB/UtBqVCgUz3kBOP51Q==", - "license": "BSD-2-Clause", - "engines": { - "node": ">=0.12" - }, - "funding": { - "url": "https://github.com/fb55/entities?sponsor=1" - } - }, - "node_modules/posthtml-parser/node_modules/htmlparser2": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-7.2.0.tgz", - "integrity": "sha512-H7MImA4MS6cw7nbyURtLPO1Tms7C5H602LRETv95z1MxO/7CP7rDVROehUYeYBUYEON94NXXDEPmZuq+hX4sog==", - "funding": [ - "https://github.com/fb55/htmlparser2?sponsor=1", - { - "type": "github", - "url": "https://github.com/sponsors/fb55" - } - ], - "license": "MIT", - "dependencies": { - "domelementtype": "^2.0.1", - "domhandler": "^4.2.2", - "domutils": "^2.8.0", - "entities": "^3.0.1" - } - }, - "node_modules/posthtml-render": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/posthtml-render/-/posthtml-render-3.0.0.tgz", - "integrity": "sha512-z+16RoxK3fUPgwaIgH9NGnK1HKY9XIDpydky5eQGgAFVXTCSezalv9U2jQuNV+Z9qV1fDWNzldcw4eK0SSbqKA==", - "license": "MIT", - "dependencies": { - "is-json": "^2.0.1" - }, - "engines": { - "node": ">=12" - } - }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -12239,12 +9983,6 @@ "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", "license": "MIT" }, - "node_modules/promise-polyfill": { - "version": "8.3.0", - "resolved": "https://registry.npmjs.org/promise-polyfill/-/promise-polyfill-8.3.0.tgz", - "integrity": "sha512-H5oELycFml5yto/atYqmjyigJoAo3+OXwolYiH7OfQuYlAqhxNvTfiNMbV9hsC6Yp83yE5r2KTVmtrG6R9i6Pg==", - "license": "MIT" - }, "node_modules/prop-types": { "version": "15.8.1", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", @@ -12262,12 +10000,6 @@ "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", "license": "MIT" }, - "node_modules/proto-list": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/proto-list/-/proto-list-1.2.4.tgz", - "integrity": "sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA==", - "license": "ISC" - }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -12449,19 +10181,6 @@ "util-deprecate": "~1.0.1" } }, - "node_modules/readdirp": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", - "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", - "license": "MIT", - "engines": { - "node": ">= 14.18.0" - }, - "funding": { - "type": "individual", - "url": "https://paulmillr.com/funding/" - } - }, "node_modules/redent": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", @@ -12520,15 +10239,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/require-directory": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", - "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/require-from-string": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", @@ -12566,6 +10276,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, "license": "MIT", "engines": { "node": ">=4" @@ -12742,6 +10453,7 @@ "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true, "license": "MIT" }, "node_modules/sax": { @@ -13018,15 +10730,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/slick": { - "version": "1.12.2", - "resolved": "https://registry.npmjs.org/slick/-/slick-1.12.2.tgz", - "integrity": "sha512-4qdtOGcBjral6YIBCWJ0ljFSKNLz9KkhbWtuGvUyRowl1kxfuE1x/Z/aJcaiilpb3do9bl5K7/1h9XC5wWpY/A==", - "license": "MIT (http://mootools.net/license.txt)", - "engines": { - "node": "*" - } - }, "node_modules/sonner": { "version": "2.0.8", "resolved": "https://registry.npmjs.org/sonner/-/sonner-2.0.8.tgz", @@ -13089,26 +10792,6 @@ "safe-buffer": "~5.1.0" } }, - "node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "license": "MIT", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/string-width/node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "license": "MIT" - }, "node_modules/string.prototype.includes": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/string.prototype.includes/-/string.prototype.includes-2.0.1.tgz", @@ -13223,18 +10906,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/strip-bom": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", @@ -13326,68 +10997,6 @@ "integrity": "sha512-djbJ/vZKZO+gPoSDThGNpKDO+o+bAeA4XQKovvkNCqnIS2t+S4qnLAGQhyyrulhCFRl1WWzAp0wUDV8PpTVU3g==", "license": "ISC" }, - "node_modules/svgo": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/svgo/-/svgo-4.1.0.tgz", - "integrity": "sha512-bkxnTg1kSU0guhIBmibA6UUhrQmPVA1XsQLN+ylCd+UWzbnLkySOcXpyk1mrl05f+pcaCx2eHb+sp6BgMZWX+Q==", - "license": "MIT", - "dependencies": { - "commander": "^11.1.0", - "css-select": "^6.0.0", - "css-tree": "^3.0.1", - "css-what": "^7.0.0", - "csso": "^5.0.5", - "picocolors": "^1.1.1", - "sax": "1.6.1" - }, - "bin": { - "svgo": "bin/svgo.js" - }, - "engines": { - "node": ">=16" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/svgo" - } - }, - "node_modules/svgo/node_modules/commander": { - "version": "11.1.0", - "resolved": "https://registry.npmjs.org/commander/-/commander-11.1.0.tgz", - "integrity": "sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ==", - "license": "MIT", - "engines": { - "node": ">=16" - } - }, - "node_modules/svgo/node_modules/css-select": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/css-select/-/css-select-6.0.0.tgz", - "integrity": "sha512-rZZVSLle8v0+EY8QAkDWrKhpgt6SA5OtHsgBnsj6ZaLb5dmDVOWUDtQitd9ydxxvEjhewNudS6eTVU7uOyzvXw==", - "license": "BSD-2-Clause", - "dependencies": { - "boolbase": "^1.0.0", - "css-what": "^7.0.0", - "domhandler": "^5.0.3", - "domutils": "^3.2.2", - "nth-check": "^2.1.1" - }, - "funding": { - "url": "https://github.com/sponsors/fb55" - } - }, - "node_modules/svgo/node_modules/css-what": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/css-what/-/css-what-7.0.0.tgz", - "integrity": "sha512-wD5oz5xibMOPHzy13CyGmogB3phdvcDaB5t0W/Nr5Z2O/agcB8YwOz6e2Lsp10pNDzBoDO9nVa3RGs/2BttpHQ==", - "license": "BSD-2-Clause", - "engines": { - "node": ">= 6" - }, - "funding": { - "url": "https://github.com/sponsors/fb55" - } - }, "node_modules/tailwind-merge": { "version": "3.7.0", "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.7.0.tgz", @@ -13729,7 +11338,7 @@ "version": "6.0.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", - "devOptional": true, + "dev": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", @@ -13782,12 +11391,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/underscore": { - "version": "1.13.8", - "resolved": "https://registry.npmjs.org/underscore/-/underscore-1.13.8.tgz", - "integrity": "sha512-DXtD3ZtEQzc7M8m4cXotyHR+FAS18C64asBYY5vqZexfYryNNnDc02W4hKg3rdQuqOYas1jkseX0+nZXjTXnvQ==", - "license": "MIT" - }, "node_modules/undici": { "version": "8.11.2", "resolved": "https://registry.npmjs.org/undici/-/undici-8.11.2.tgz", @@ -13873,6 +11476,7 @@ "version": "1.3.3", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.3.tgz", "integrity": "sha512-pJ2sYawQS0R/WI928Gj5GlPhTGzbMelq0+4INtSYNDV9ErKJcX6xjGWkoG/VnB3dpUm00zALaqkrUD77pO5TDQ==", + "dev": true, "funding": [ { "type": "opencollective", @@ -13958,15 +11562,6 @@ "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", "license": "MIT" }, - "node_modules/valid-data-url": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/valid-data-url/-/valid-data-url-3.0.1.tgz", - "integrity": "sha512-jOWVmzVceKlVVdwjNSenT4PbGghU0SBIizAev8ofZVgivk/TVHXSbNL8LP6M3spZvkR9/QolkyJavGSX5Cs0UA==", - "license": "MIT", - "engines": { - "node": ">=10" - } - }, "node_modules/vaul": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/vaul/-/vaul-1.1.2.tgz", @@ -14466,41 +12061,6 @@ "node": "^22.22.2 || ^24.15.0 || >=26.0.0" } }, - "node_modules/web-resource-inliner": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/web-resource-inliner/-/web-resource-inliner-8.0.0.tgz", - "integrity": "sha512-Ezr98sqXW/+OCGoUEXuOKVR+oVFlSdn1tIySEEJdiSAw4IjrW8hQkwARSSBJTSB5Us5dnytDgL0ZDliAYBhaNA==", - "license": "MIT", - "dependencies": { - "ansi-colors": "^4.1.1", - "escape-goat": "^3.0.0", - "htmlparser2": "^9.1.0", - "mime": "^2.4.6", - "valid-data-url": "^3.0.0" - }, - "engines": { - "node": ">=10.0.0" - } - }, - "node_modules/web-resource-inliner/node_modules/htmlparser2": { - "version": "9.1.0", - "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-9.1.0.tgz", - "integrity": "sha512-5zfg6mHUoaer/97TxnGpxmbR7zJtPwIYFMZ/H5ucTlPZhKvtum05yiPK3Mgai3a0DyVxv7qYqoweaEd2nrYQzQ==", - "funding": [ - "https://github.com/fb55/htmlparser2?sponsor=1", - { - "type": "github", - "url": "https://github.com/sponsors/fb55" - } - ], - "license": "MIT", - "dependencies": { - "domelementtype": "^2.3.0", - "domhandler": "^5.0.3", - "domutils": "^3.1.0", - "entities": "^4.5.0" - } - }, "node_modules/webidl-conversions": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", @@ -14511,31 +12071,6 @@ "node": ">=20" } }, - "node_modules/whatwg-encoding": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", - "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", - "deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation", - "license": "MIT", - "dependencies": { - "iconv-lite": "0.6.3" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/whatwg-encoding/node_modules/iconv-lite": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", - "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", - "license": "MIT", - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/whatwg-mimetype": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", @@ -14696,38 +12231,6 @@ "node": ">=0.10.0" } }, - "node_modules/wrap-ansi": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", - "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, - "node_modules/wrap-ansi/node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "license": "MIT", - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, "node_modules/xml": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/xml/-/xml-1.0.1.tgz", @@ -14763,15 +12266,6 @@ "devOptional": true, "license": "MIT" }, - "node_modules/y18n": { - "version": "5.0.8", - "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", - "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", - "license": "ISC", - "engines": { - "node": ">=10" - } - }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", @@ -14779,33 +12273,6 @@ "dev": true, "license": "ISC" }, - "node_modules/yargs": { - "version": "17.7.3", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.3.tgz", - "integrity": "sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g==", - "license": "MIT", - "dependencies": { - "cliui": "^8.0.1", - "escalade": "^3.1.1", - "get-caller-file": "^2.0.5", - "require-directory": "^2.1.1", - "string-width": "^4.2.3", - "y18n": "^5.0.5", - "yargs-parser": "^21.1.1" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/yargs-parser": { - "version": "21.1.1", - "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", - "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", - "license": "ISC", - "engines": { - "node": ">=12" - } - }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", diff --git a/package.json b/package.json index 7307ddc..d3e7e5d 100644 --- a/package.json +++ b/package.json @@ -26,7 +26,6 @@ "@dnd-kit/abstract": "^0.5.0", "@dnd-kit/helpers": "^0.5.0", "@dnd-kit/react": "^0.5.0", - "@grapesjs/react": "^2.0.0", "@heroicons/react": "^2.2.0", "@hookform/resolvers": "^5.9.1", "@radix-ui/react-alert-dialog": "^1.1.23", @@ -50,11 +49,8 @@ "docxtemplater": "^3.71.0", "docxtemplater-image": "^0.1.2", "dotenv": "^18.0.4", - "grapesjs": "^0.22.16", - "grapesjs-mjml": "^1.0.8", "jose": "^6.2.12", "lucide-react": "^1.49.0", - "mjml": "^5.4.1", "next": "^16.3.7", "pizzip": "^3.3.0", "react": "^19.3.0", @@ -72,7 +68,6 @@ "@testing-library/jest-dom": "^7.0.1", "@testing-library/react": "^16.3.3", "@testing-library/user-event": "^14.6.7", - "@types/mjml": "^5.0.0", "@types/node": "^24.19.0", "@types/react": "^19.3.0", "@types/react-dom": "^19.3.0", diff --git a/src/app/(web)/page.test.tsx b/src/app/(web)/page.test.tsx index 226e64b..937db94 100644 --- a/src/app/(web)/page.test.tsx +++ b/src/app/(web)/page.test.tsx @@ -13,7 +13,6 @@ afterEach(cleanup); const TOOLS = [ { name: 'Template Tool', href: '/tools/template' }, - { name: 'Template Editor', href: '/tools/templateeditor' }, { name: 'Address Labels', href: '/tools/addresslabels' }, { name: 'Group Wizard', href: '/tools/groupwizard' }, { name: 'Field Management', href: '/tools/fieldmanagement' }, diff --git a/src/app/(web)/page.tsx b/src/app/(web)/page.tsx index addfe12..de60751 100644 --- a/src/app/(web)/page.tsx +++ b/src/app/(web)/page.tsx @@ -24,20 +24,6 @@ export default function Home() { - - - Template Editor - - Create and edit templates in Ministry Platform - - - - - - - - - Address Labels diff --git a/src/app/(web)/tools/require-tool-access.test.ts b/src/app/(web)/tools/require-tool-access.test.ts index ee653d2..a6bdf93 100644 --- a/src/app/(web)/tools/require-tool-access.test.ts +++ b/src/app/(web)/tools/require-tool-access.test.ts @@ -60,7 +60,7 @@ describe('requireToolAccess', () => { */ describe('every tool page self-gates', () => { const toolsDir = path.resolve(__dirname); - const pages = ['addeditfamily', 'addresslabels', 'fieldmanagement', 'groupwizard', 'template', 'templateeditor']; + const pages = ['addeditfamily', 'addresslabels', 'fieldmanagement', 'groupwizard', 'template']; it('covers every tool directory', async () => { const { readdirSync, existsSync } = await import('node:fs'); diff --git a/src/app/(web)/tools/templateeditor/loading.tsx b/src/app/(web)/tools/templateeditor/loading.tsx deleted file mode 100644 index f5bd147..0000000 --- a/src/app/(web)/tools/templateeditor/loading.tsx +++ /dev/null @@ -1,11 +0,0 @@ -import { Skeleton } from "@/components/ui/skeleton"; - -export default function Loading() { - return ( -
- - - -
- ); -} diff --git a/src/app/(web)/tools/templateeditor/page.test.tsx b/src/app/(web)/tools/templateeditor/page.test.tsx deleted file mode 100644 index d0809d1..0000000 --- a/src/app/(web)/tools/templateeditor/page.test.tsx +++ /dev/null @@ -1,59 +0,0 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; - -const { mockParseToolParams } = vi.hoisted(() => ({ - mockParseToolParams: vi.fn(), -})); - -vi.mock('@/lib/tool-params.server', () => ({ - parseToolParams: mockParseToolParams, -})); - -vi.mock('./template-editor', () => ({ - TemplateEditor: () => null, -})); - -const mockRequireToolAccess = vi.hoisted(() => vi.fn(async () => 42)); - -vi.mock('@/app/(web)/tools/require-tool-access', () => ({ - requireToolAccess: mockRequireToolAccess, -})); - -import TemplateEditorPage from './page'; -import { TemplateEditor } from './template-editor'; - -describe('TemplateEditorPage', () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - afterEach(() => { - vi.restoreAllMocks(); - }); - - it('parses search params and passes them through to TemplateEditor', async () => { - const params = { pageID: 292, recordID: 7 }; - mockParseToolParams.mockResolvedValue(params); - const searchParams = Promise.resolve({ pageID: '292', recordID: '7' }); - - const element = await TemplateEditorPage({ searchParams }); - - expect(mockParseToolParams).toHaveBeenCalledWith({ pageID: '292', recordID: '7' }); - expect(element.type).toBe(TemplateEditor); - expect(element.props).toEqual({ params }); - }); -}); - -describe('TemplateEditorPage self-gate', () => { - it('gates before parsing params and renders nothing when refused', async () => { - // Adversarial: the tools layout's redirect does not stop a page segment - // rendering in Next 16, so the page itself must refuse. - mockParseToolParams.mockClear(); - mockRequireToolAccess.mockRejectedValueOnce(new Error('NEXT_REDIRECT:/no-access')); - - await expect(TemplateEditorPage({ searchParams: Promise.resolve({}) })).rejects.toThrow( - 'NEXT_REDIRECT:/no-access', - ); - expect(mockRequireToolAccess).toHaveBeenCalledWith({ table: 'dp_Tools', operation: 'read' }); - expect(mockParseToolParams).not.toHaveBeenCalled(); - }); -}); diff --git a/src/app/(web)/tools/templateeditor/page.tsx b/src/app/(web)/tools/templateeditor/page.tsx deleted file mode 100644 index 66bc9f1..0000000 --- a/src/app/(web)/tools/templateeditor/page.tsx +++ /dev/null @@ -1,16 +0,0 @@ -import { TemplateEditor } from "./template-editor"; -import { requireToolAccess } from "@/app/(web)/tools/require-tool-access"; -import { parseToolParams } from "@/lib/tool-params.server"; - -interface TemplateEditorPageProps { - searchParams: Promise<{ [key: string]: string | string[] | undefined }>; -} - -export default async function TemplateEditorPage({ searchParams }: TemplateEditorPageProps) { - // Self-gating, before ANY param parsing or data call — see require-tool-access.ts. - await requireToolAccess({ table: "dp_Tools", operation: "read" }); - - const params = await parseToolParams(await searchParams); - - return ; -} diff --git a/src/app/(web)/tools/templateeditor/template-editor.test.tsx b/src/app/(web)/tools/templateeditor/template-editor.test.tsx deleted file mode 100644 index 0cbc97c..0000000 --- a/src/app/(web)/tools/templateeditor/template-editor.test.tsx +++ /dev/null @@ -1,86 +0,0 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { render, screen, cleanup, fireEvent } from '@testing-library/react'; -import type { ToolParams } from '@/lib/tool-params'; - -const { mockRouterBack } = vi.hoisted(() => ({ - mockRouterBack: vi.fn(), -})); - -vi.mock('next/navigation', () => ({ - useRouter: () => ({ back: mockRouterBack }), -})); - -vi.mock('@/components/tool', () => ({ - ToolContainer: ({ - title, - infoContent, - children, - }: { - title: string; - infoContent?: React.ReactNode; - children?: React.ReactNode; - }) => ( -
-
{title}
-
{infoContent}
- {children} -
- ), -})); - -vi.mock('@/components/template-editor', () => ({ - TemplateEditorForm: ({ onClose }: { onClose: () => void }) => ( - - ), -})); - -import { TemplateEditor } from './template-editor'; - -describe('TemplateEditor', () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - afterEach(() => { - cleanup(); - vi.restoreAllMocks(); - }); - - it('renders the ToolContainer with the Template Editor title and form', () => { - const params: ToolParams = {}; - render(); - - expect(screen.getByTestId('title')).toHaveTextContent('Template Editor'); - expect(screen.getByTestId('template-editor-form')).toBeInTheDocument(); - }); - - it('navigates back via router.back() when the form requests close', () => { - render(); - - fireEvent.click(screen.getByTestId('template-editor-form')); - - expect(mockRouterBack).toHaveBeenCalledTimes(1); - }); - - it('omits the pageID/recordID line from the info popover when pageID is absent', () => { - render(); - expect(screen.queryByText(/Page ID:/)).not.toBeInTheDocument(); - }); - - it('shows pageID (without recordID) in the info popover when only pageID is set', () => { - render(); - expect(screen.getByText('Page ID: 292')).toBeInTheDocument(); - }); - - it('shows pageID and recordID together in the info popover when both are set', () => { - render(); - expect(screen.getByText('Page ID: 292 | Record: 7')).toBeInTheDocument(); - }); - - it('shows the recordID line even when recordID is 0 (falsy but defined)', () => { - render(); - expect(screen.getByText('Page ID: 292 | Record: 0')).toBeInTheDocument(); - }); -}); diff --git a/src/app/(web)/tools/templateeditor/template-editor.tsx b/src/app/(web)/tools/templateeditor/template-editor.tsx deleted file mode 100644 index 2750025..0000000 --- a/src/app/(web)/tools/templateeditor/template-editor.tsx +++ /dev/null @@ -1,42 +0,0 @@ -"use client"; - -import { useRouter } from "next/navigation"; -import { ToolContainer } from "@/components/tool"; -import { TemplateEditorForm } from "@/components/template-editor"; -import type { ToolParams } from "@/lib/tool-params"; - -interface TemplateEditorProps { - params: ToolParams; -} - -export function TemplateEditor({ params }: TemplateEditorProps) { - const router = useRouter(); - - const handleClose = () => { - router.back(); - }; - - return ( - -

Template Editor

-

- Create and edit email templates using a drag-and-drop MJML editor. -

- {params.pageID && ( -

- Page ID: {params.pageID} - {params.recordID !== undefined && ` | Record: ${params.recordID}`} -

- )} - - } - hideFooter - > - -
- ); -} diff --git a/src/components/template-editor/actions.test.ts b/src/components/template-editor/actions.test.ts deleted file mode 100644 index 16a442d..0000000 --- a/src/components/template-editor/actions.test.ts +++ /dev/null @@ -1,112 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; - -/** - * `compileMjml` gates through AuthorizationService.requireSecurityRole rather - * than a bare session check (CLAUDE.md rule 12). The default implementation - * resolves so happy-path tests don't need to think about auth; individual - * tests override it with a rejection to prove the gate actually blocks. - */ -const { mockRequireSecurityRole } = vi.hoisted(() => ({ - mockRequireSecurityRole: vi.fn(), -})); - -vi.mock('@/services/authorizationService', () => ({ - AuthorizationService: { - getInstance: () => ({ - requireSecurityRole: mockRequireSecurityRole, - }), - }, -})); - -const { mockMjml2html } = vi.hoisted(() => ({ - mockMjml2html: vi.fn(), -})); - -vi.mock('mjml', () => ({ - default: mockMjml2html, -})); - -import { compileMjml } from './actions'; - -const VALID_MJML = 'Hi'; - -describe('compileMjml', () => { - beforeEach(() => { - vi.clearAllMocks(); - mockRequireSecurityRole.mockResolvedValue(42); - mockMjml2html.mockResolvedValue({ - html: 'compiled', - errors: [], - }); - }); - - it('gates through AuthorizationService.requireSecurityRole for the dp_Tools table on read', async () => { - await compileMjml(VALID_MJML); - - expect(mockRequireSecurityRole).toHaveBeenCalledWith({ - table: 'dp_Tools', - operation: 'read', - }); - }); - - it('propagates rejection when the caller is not authorized', async () => { - mockRequireSecurityRole.mockRejectedValueOnce(new Error('Not authorized')); - - await expect(compileMjml(VALID_MJML)).rejects.toThrow('Not authorized'); - expect(mockMjml2html).not.toHaveBeenCalled(); - }); - - it('rejects an empty MJML source', async () => { - await expect(compileMjml('')).rejects.toThrow(/must be between 1 and/); - expect(mockMjml2html).not.toHaveBeenCalled(); - }); - - it('rejects MJML source larger than the 500KB cap', async () => { - const oversized = 'a'.repeat(512_001); - await expect(compileMjml(oversized)).rejects.toThrow(/must be between 1 and/); - expect(mockMjml2html).not.toHaveBeenCalled(); - }); - - it('accepts MJML source right at the 500KB cap', async () => { - const atCap = 'a'.repeat(512_000); - await expect(compileMjml(atCap)).resolves.toBeDefined(); - expect(mockMjml2html).toHaveBeenCalledWith(atCap, { - validationLevel: 'soft', - minify: false, - }); - }); - - it('returns the compiled html and maps empty errors', async () => { - const result = await compileMjml(VALID_MJML); - - expect(result).toEqual({ - html: 'compiled', - errors: [], - }); - }); - - it('maps mjml compile errors into the MjmlCompileError shape', async () => { - mockMjml2html.mockResolvedValueOnce({ - html: 'partial', - errors: [ - { - line: 3, - message: 'Unknown tag', - tagName: 'mj-bogus', - formattedMessage: 'Line 3 of mj-bogus: Unknown tag', - }, - ], - }); - - const result = await compileMjml(VALID_MJML); - - expect(result.errors).toEqual([ - { - line: 3, - message: 'Unknown tag', - tagName: 'mj-bogus', - formattedMessage: 'Line 3 of mj-bogus: Unknown tag', - }, - ]); - }); -}); diff --git a/src/components/template-editor/actions.ts b/src/components/template-editor/actions.ts deleted file mode 100644 index f8d9d2c..0000000 --- a/src/components/template-editor/actions.ts +++ /dev/null @@ -1,55 +0,0 @@ -'use server'; - -import { AuthorizationService } from '@/services/authorizationService'; -import type { MjmlCompileResult } from '@/components/template-editor/types'; - -/** - * Authorization gate for this feature's server actions. - * - * A server action is a callable POST endpoint whether or not the page that - * renders it was ever fetched, so the page-level gate in the tools layout is - * not sufficient on its own. This replaces the previous bare session check: - * MP's OIDC endpoint authenticates ANY dp_Users record, and this app reads MP - * with its own service account, so "a session exists" proves nothing about - * whether the caller may see or change this data. - * - * The service layer gates again — that is deliberate defence in depth, and the - * per-request memoization in AuthorizationService keeps it to one MP read. - */ -async function requireAccess( - table: string, - operation: 'read' | 'create' | 'update' | 'delete', -): Promise { - return AuthorizationService.getInstance().requireSecurityRole({ table, operation }); -} - - -const MAX_MJML_SIZE = 512_000; // 500KB - -export async function compileMjml(mjmlSource: string): Promise { - // Touches no MP data, but it is a tool feature and the policy is that tools - // require an MP security role — gating here keeps that rule uniform rather - // than creating a carve-out that has to be reasoned about later. - await requireAccess('dp_Tools', 'read'); - - if (!mjmlSource || mjmlSource.length > MAX_MJML_SIZE) { - throw new Error(`MJML source must be between 1 and ${MAX_MJML_SIZE} characters`); - } - - const mjml2html = (await import('mjml')).default; - - const result = await mjml2html(mjmlSource, { - validationLevel: 'soft', - minify: false, - }); - - return { - html: result.html, - errors: result.errors.map((e) => ({ - line: e.line, - message: e.message, - tagName: e.tagName, - formattedMessage: e.formattedMessage, - })), - }; -} diff --git a/src/components/template-editor/editor-canvas.test.tsx b/src/components/template-editor/editor-canvas.test.tsx deleted file mode 100644 index 91d939b..0000000 --- a/src/components/template-editor/editor-canvas.test.tsx +++ /dev/null @@ -1,140 +0,0 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { render, cleanup, screen } from '@testing-library/react'; -import type { Editor } from 'grapesjs'; - -// EditorToolbar has its own dedicated test file — stub it here so -// EditorCanvas tests focus on GjsEditor wiring (onReady setup, storage -// gating for the default template) rather than toolbar internals. -vi.mock('./editor-toolbar', () => ({ - EditorToolbar: ({ onClose }: { onClose: () => void }) => ( - - ), -})); - -vi.mock('grapesjs', () => ({ - default: {}, -})); - -vi.mock('grapesjs-mjml', () => ({ - default: {}, -})); - -// jsdom/vitest route real .css imports through the project's PostCSS/Tailwind -// config, which isn't valid outside a Next.js build — stub both stylesheet -// imports so only the component logic is under test. -vi.mock('grapesjs/dist/css/grapes.min.css', () => ({})); -vi.mock('@/styles/grapesjs-overrides.css', () => ({})); - -const { mockRegisterMergeFieldBlocks } = vi.hoisted(() => ({ - mockRegisterMergeFieldBlocks: vi.fn(), -})); - -vi.mock('./merge-fields', () => ({ - registerMergeFieldBlocks: mockRegisterMergeFieldBlocks, -})); - -const { _mockBlocksAdd, mockBlocksRemove, mockSetComponents, buildFakeEditor } = vi.hoisted(() => { - const _mockBlocksAdd = vi.fn(); - const mockBlocksRemove = vi.fn(); - const mockSetComponents = vi.fn(); - const buildFakeEditor = () => ({ - Blocks: { add: _mockBlocksAdd, remove: mockBlocksRemove }, - setComponents: mockSetComponents, - }); - return { _mockBlocksAdd, mockBlocksRemove, mockSetComponents, buildFakeEditor }; -}); - -vi.mock('@grapesjs/react', () => ({ - __esModule: true, - // Named `GjsEditorMock` rather than declared inline on `default:` so the - // react-hooks lint rule recognises it as a component and allows useEffect. - default: function GjsEditorMock({ - onReady, - children, - className, - }: { - onReady?: (editor: Editor) => void; - children?: React.ReactNode; - className?: string; - }) { - const React = require('react'); - React.useEffect(() => { - onReady?.(buildFakeEditor() as unknown as Editor); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - return React.createElement('div', { className, 'data-testid': 'gjs-editor' }, children); - }, - Canvas: ({ className }: { className?: string }) => ( - // Real GrapesJS mounts its canvas iframe under a `.gjs-cv-canvas` node - // regardless of the className prop — replicate that class here so the - // component's `document.querySelector('.gjs-cv-canvas')` sizing logic - // has something to find, the same as it would in a real DOM. -
- ), - WithEditor: ({ children }: { children?: React.ReactNode }) => <>{children}, -})); - -import { EditorCanvas } from './editor-canvas'; -import { STORAGE_KEY, DEFAULT_MJML_TEMPLATE } from './grapes-config'; - -describe('EditorCanvas', () => { - const onClose = vi.fn(); - - beforeEach(() => { - vi.clearAllMocks(); - localStorage.clear(); - }); - - afterEach(() => { - cleanup(); - vi.restoreAllMocks(); - }); - - it('renders the canvas and the (stubbed) toolbar', () => { - render(); - expect(screen.getByTestId('canvas')).toBeInTheDocument(); - expect(screen.getByTestId('toolbar-close')).toBeInTheDocument(); - }); - - it('registers merge field blocks and removes navbar blocks on ready', () => { - render(); - - expect(mockRegisterMergeFieldBlocks).toHaveBeenCalledTimes(1); - expect(mockBlocksRemove).toHaveBeenCalledWith('mj-navbar'); - expect(mockBlocksRemove).toHaveBeenCalledWith('mj-navbar-link'); - }); - - it('loads the default MJML template when no saved state exists in localStorage', () => { - render(); - expect(mockSetComponents).toHaveBeenCalledWith(DEFAULT_MJML_TEMPLATE); - }); - - it('does not overwrite saved state when localStorage already has a draft', () => { - localStorage.setItem(STORAGE_KEY, '{"some":"state"}'); - render(); - expect(mockSetComponents).not.toHaveBeenCalled(); - }); - - it('tolerates localStorage being unavailable and still seeds the default template', () => { - vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => { - throw new Error('unavailable'); - }); - render(); - expect(mockSetComponents).toHaveBeenCalledWith(DEFAULT_MJML_TEMPLATE); - }); - - it('invokes the onEditorReady callback prop with the editor instance', () => { - const onEditorReady = vi.fn(); - render(); - expect(onEditorReady).toHaveBeenCalledTimes(1); - expect(onEditorReady).toHaveBeenCalledWith(expect.objectContaining({ setComponents: mockSetComponents })); - }); - - it('passes onClose through to the toolbar', () => { - render(); - screen.getByTestId('toolbar-close').click(); - expect(onClose).toHaveBeenCalledTimes(1); - }); -}); diff --git a/src/components/template-editor/editor-canvas.tsx b/src/components/template-editor/editor-canvas.tsx deleted file mode 100644 index c3f3f8d..0000000 --- a/src/components/template-editor/editor-canvas.tsx +++ /dev/null @@ -1,65 +0,0 @@ -"use client"; - -import { useRef } from 'react'; -import grapesjs, { Editor } from 'grapesjs'; -import GjsEditor, { Canvas, WithEditor } from '@grapesjs/react'; -import grapesjsMjml from 'grapesjs-mjml'; -import 'grapesjs/dist/css/grapes.min.css'; -import '@/styles/grapesjs-overrides.css'; -import { createEditorConfig, DEFAULT_MJML_TEMPLATE, STORAGE_KEY } from '@/components/template-editor/grapes-config'; -import { EditorToolbar } from '@/components/template-editor/editor-toolbar'; -import { registerMergeFieldBlocks } from '@/components/template-editor/merge-fields'; -import type { EditorCanvasProps } from '@/components/template-editor/types'; - -export function EditorCanvas({ onEditorReady, onClose }: EditorCanvasProps) { - const editorRef = useRef(null); - - const handleEditorReady = (editor: Editor) => { - editorRef.current = editor; - registerMergeFieldBlocks(editor); - - // Remove navbar blocks (not useful for email templates) - const bm = editor.Blocks; - bm.remove('mj-navbar'); - bm.remove('mj-navbar-link'); - - // Load default MJML template if no saved state exists - let hasSavedState = false; - try { - hasSavedState = !!localStorage.getItem(STORAGE_KEY); - } catch { - // localStorage may be unavailable - } - if (!hasSavedState) { - editor.setComponents(DEFAULT_MJML_TEMPLATE); - } - - // Set initial canvas width to account for sidebar - const canvas = document.querySelector('.gjs-cv-canvas') as HTMLElement; - if (canvas) { - canvas.style.width = 'calc(100% - 240px)'; - } - - onEditorReady?.(editor); - }; - - const editorConfig = createEditorConfig(); - - return ( - - - - -
- -
-
- ); -} diff --git a/src/components/template-editor/editor-code-dialog.test.tsx b/src/components/template-editor/editor-code-dialog.test.tsx deleted file mode 100644 index adb8f8d..0000000 --- a/src/components/template-editor/editor-code-dialog.test.tsx +++ /dev/null @@ -1,144 +0,0 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { render, screen, cleanup, fireEvent, waitFor } from '@testing-library/react'; - -const { mockGetHtml, mockSetComponents, mockEditor } = vi.hoisted(() => { - const mockGetHtml = vi.fn(); - const mockSetComponents = vi.fn(); - // A single stable object reference — the component's effect depends on - // `editor` by identity, so a fresh object literal per render would retrigger - // the effect on every render and infinite-loop the test. - return { - mockGetHtml, - mockSetComponents, - mockEditor: { getHtml: mockGetHtml, setComponents: mockSetComponents }, - }; -}); - -vi.mock('@grapesjs/react', () => ({ - useEditor: () => mockEditor, -})); - -const { mockCompileMjml } = vi.hoisted(() => ({ - mockCompileMjml: vi.fn(), -})); - -vi.mock('./actions', () => ({ - compileMjml: mockCompileMjml, -})); - -const mockWriteText = vi.hoisted(() => vi.fn()); - -import { EditorCodeDialog } from './editor-code-dialog'; - -describe('EditorCodeDialog', () => { - const onOpenChange = vi.fn(); - - beforeEach(() => { - vi.clearAllMocks(); - mockGetHtml.mockReturnValue(''); - mockCompileMjml.mockResolvedValue({ html: 'ok', errors: [] }); - Object.assign(navigator, { clipboard: { writeText: mockWriteText } }); - mockWriteText.mockResolvedValue(undefined); - }); - - afterEach(() => { - cleanup(); - vi.restoreAllMocks(); - }); - - it('renders nothing when closed', () => { - render(); - expect(screen.queryByText('Code Editor')).not.toBeInTheDocument(); - }); - - it('seeds the MJML textarea from editor.getHtml() when opened', () => { - render(); - expect(screen.getByDisplayValue('')).toBeInTheDocument(); - }); - - it('applies edited MJML to the editor and closes the dialog', () => { - render(); - - const textarea = screen.getByDisplayValue(''); - fireEvent.change(textarea, { target: { value: 'edited' } }); - fireEvent.click(screen.getByRole('button', { name: 'Apply to Editor' })); - - expect(mockSetComponents).toHaveBeenCalledWith('edited'); - expect(onOpenChange).toHaveBeenCalledWith(false); - }); - - it('copies the MJML source to the clipboard and shows Copied! feedback', async () => { - render(); - - fireEvent.click(screen.getByRole('button', { name: 'Copy MJML' })); - - expect(mockWriteText).toHaveBeenCalledWith(''); - expect(await screen.findByText('Copied!')).toBeInTheDocument(); - }); - - it('compiles HTML automatically on first switch to the HTML tab', async () => { - render(); - - fireEvent.click(screen.getByRole('button', { name: 'HTML Preview' })); - - expect(screen.getByText(/Compiling MJML/)).toBeInTheDocument(); - await waitFor(() => expect(mockCompileMjml).toHaveBeenCalledWith('')); - expect(await screen.findByDisplayValue('ok')).toBeInTheDocument(); - }); - - it('does not recompile automatically the second time the HTML tab is opened', async () => { - render(); - - fireEvent.click(screen.getByRole('button', { name: 'HTML Preview' })); - await screen.findByDisplayValue('ok'); - expect(mockCompileMjml).toHaveBeenCalledTimes(1); - - fireEvent.click(screen.getByRole('button', { name: 'MJML Source' })); - fireEvent.click(screen.getByRole('button', { name: 'HTML Preview' })); - - expect(mockCompileMjml).toHaveBeenCalledTimes(1); - }); - - it('shows compile warnings returned alongside html', async () => { - mockCompileMjml.mockResolvedValueOnce({ - html: 'partial', - errors: [{ line: 1, message: 'bad', tagName: 'mj-x', formattedMessage: 'Line 1: bad' }], - }); - - render(); - fireEvent.click(screen.getByRole('button', { name: 'HTML Preview' })); - - expect(await screen.findByText('Line 1: bad')).toBeInTheDocument(); - }); - - it('shows an error message when compileMjml rejects', async () => { - mockCompileMjml.mockRejectedValueOnce(new Error('boom')); - - render(); - fireEvent.click(screen.getByRole('button', { name: 'HTML Preview' })); - - expect(await screen.findByText('boom')).toBeInTheDocument(); - }); - - it('falls back to a generic message when compileMjml throws a non-Error', async () => { - mockCompileMjml.mockRejectedValueOnce('nope'); - - render(); - fireEvent.click(screen.getByRole('button', { name: 'HTML Preview' })); - - expect(await screen.findByText('Compilation failed')).toBeInTheDocument(); - }); - - it('recompile button re-invokes compileMjml and copy button copies html output', async () => { - render(); - fireEvent.click(screen.getByRole('button', { name: 'HTML Preview' })); - await screen.findByDisplayValue('ok'); - - fireEvent.click(screen.getByRole('button', { name: 'Recompile' })); - await waitFor(() => expect(mockCompileMjml).toHaveBeenCalledTimes(2)); - - fireEvent.click(screen.getByRole('button', { name: 'Copy HTML' })); - expect(mockWriteText).toHaveBeenCalledWith('ok'); - expect(await screen.findByText('Copied!')).toBeInTheDocument(); - }); -}); diff --git a/src/components/template-editor/editor-code-dialog.tsx b/src/components/template-editor/editor-code-dialog.tsx deleted file mode 100644 index efce66e..0000000 --- a/src/components/template-editor/editor-code-dialog.tsx +++ /dev/null @@ -1,180 +0,0 @@ -"use client"; - -import { useState, useEffect } from 'react'; -import { useEditor } from '@grapesjs/react'; -import { - Dialog, - DialogContent, - DialogDescription, - DialogHeader, - DialogTitle, -} from '@/components/ui/dialog'; -import { Button } from '@/components/ui/button'; -import { Textarea } from '@/components/ui/textarea'; -import { compileMjml } from '@/components/template-editor/actions'; - -interface EditorCodeDialogProps { - open: boolean; - onOpenChange: (open: boolean) => void; -} - -export function EditorCodeDialog({ open, onOpenChange }: EditorCodeDialogProps) { - const editor = useEditor(); - const [activeTab, setActiveTab] = useState<'mjml' | 'html'>('mjml'); - const [mjmlSource, setMjmlSource] = useState(''); - const [htmlPreview, setHtmlPreview] = useState(''); - const [compileErrors, setCompileErrors] = useState([]); - const [isCompiling, setIsCompiling] = useState(false); - const [copiedSource, setCopiedSource] = useState<'mjml' | 'html' | null>(null); - - useEffect(() => { - if (open) { - // Sync editor's current MJML into dialog state when opened, reset preview. - const mjml = editor.getHtml(); - // eslint-disable-next-line react-hooks/set-state-in-effect - setMjmlSource(mjml); - - setHtmlPreview(''); - - setCompileErrors([]); - - setCopiedSource(null); - } - }, [open, editor]); - - const handleCompileHtml = async () => { - setIsCompiling(true); - setCompileErrors([]); - try { - const result = await compileMjml(mjmlSource); - setHtmlPreview(result.html); - if (result.errors.length > 0) { - setCompileErrors(result.errors.map((e) => e.formattedMessage)); - } - } catch (err) { - setCompileErrors([err instanceof Error ? err.message : 'Compilation failed']); - } finally { - setIsCompiling(false); - } - }; - - const handleApplyMjml = () => { - editor.setComponents(mjmlSource); - onOpenChange(false); - }; - - const handleCopy = async (text: string, source: 'mjml' | 'html') => { - await navigator.clipboard.writeText(text); - setCopiedSource(source); - setTimeout(() => setCopiedSource(null), 2000); - }; - - return ( - - - - Code Editor - - View and edit the MJML source or preview the compiled HTML output. - - - - {/* Tabs */} -
- - -
- - {/* Content */} -
- {activeTab === 'mjml' && ( -
-