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 deleted file mode 100644 index 224a6d1..0000000 --- a/.claude/TODO/2026-04-17-components-template-editor-merge-token-resolver.md +++ /dev/null @@ -1,37 +0,0 @@ ---- -title: Merge tokens {{Field_Name}} have no resolver anywhere in-tree -severity: medium -tags: [bug, refactor] -area: components -files: - - src/components/template-editor/merge-fields.ts - - src/components/template-editor/merge-field-picker.tsx - - src/components/template-editor/actions.ts -discovered: 2026-04-17 -discovered_by: components-template-editor -status: open ---- - -## Problem -`MergeFieldPicker` inserts tokens of the form `{{First_Name}}`, `{{Email_Address}}`, `{{Unsubscribe_URL}}`, etc., into MJML content. `compileMjml` passes the tokens through as literal text — the HTML output still contains `{{...}}`. Nothing in `src/components/template-editor/`, `src/lib/`, or the services layer replaces these tokens with contact data before send or export. A user generating HTML from this tool and pasting it into a mail client will send literal `{{First_Name}}` to recipients. - -## Evidence -- `src/components/template-editor/merge-fields.ts:11-27` — tokens defined as plain `{{...}}` strings. -- `src/components/template-editor/merge-field-picker.tsx:27-55` — insertion writes the raw token into component content. -- `src/components/template-editor/actions.ts:15-38` — `compileMjml` only calls `mjml2html`; no token substitution. -- `Grep "{{First_Name}}" src/` — only appears in template-editor sources (definition + test fixtures if any); no resolver. - -## Proposed fix -1. Define a token resolver: `resolveMergeTokens(html: string, ctx: { contact, household, congregation, system }): string` — can live in a new `src/lib/merge-tokens.ts` or as a service method. -2. Invoke it at the point of use: - - If sending email from this tool → call after `compileMjml` before dispatch. - - If exporting for downstream MP use → document that MP's mail merge is the resolver and keep tokens literal. -3. Add a coverage table mapping each `MERGE_FIELDS` entry to its source (MP column / computed value) so drift between the picker list and the resolver is catchable in tests. -4. Until a resolver lands, either: - - Put a visible banner in `EditorExportDialog` noting "tokens will not be resolved by this export," or - - Compile a warning list in `EditorCodeDialog`/`EditorExportDialog` showing unresolved tokens in the current output. - -## Impact if not fixed -- 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). diff --git a/.claude/TODO/2026-04-17-components-template-editor-missing-tests.md b/.claude/TODO/2026-04-17-components-template-editor-missing-tests.md deleted file mode 100644 index 5b184da..0000000 --- a/.claude/TODO/2026-04-17-components-template-editor-missing-tests.md +++ /dev/null @@ -1,78 +0,0 @@ ---- -title: No tests for src/components/template-editor/ -severity: medium -tags: [missing-test] -area: components -files: - - 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 -discovered: 2026-04-17 -discovered_by: components-template-editor -status: resolved ---- - -## Problem -The entire `src/components/template-editor/` directory has zero co-located test files, despite containing a server action (`compileMjml`) with auth-gating and size validation, token helpers (`merge-fields.ts`), a config factory (`grapes-config.ts`), and a non-trivial insertion rule in `MergeFieldPicker`. The baseline facts snapshot lists 37 test files; none are under `template-editor/`. - -## Evidence -- `Glob src/components/template-editor/**/*.test.*` — no matches -- `.claude/references/_meta/facts/2026-04-17.md` — 37 test files inventory, none under `template-editor/` -- `src/components/template-editor/actions.ts:15-38` — `compileMjml` has auth + size checks that are untested -- `src/components/template-editor/merge-fields.ts:34-59` — `getFieldsByCategory`, `registerMergeFieldBlocks` untested - -## Proposed fix -Add at minimum: - -1. `src/components/template-editor/actions.test.ts` — mock `@/lib/auth` (`auth.api.getSession`) and `next/headers`; assert `Unauthorized` when no session; assert size-guard rejects empty and >500KB input; mock `mjml` dynamic import and assert result shape. -2. `src/components/template-editor/merge-fields.test.ts` — assert `MERGE_FIELD_CATEGORIES` dedup order, `getFieldsByCategory` filtering, and that `registerMergeFieldBlocks` calls `editor.Blocks.add` twice with expected ids. -3. (Optional) `src/components/template-editor/merge-field-picker.test.tsx` — render-with-mocked-editor covering the "selected mj-text" vs "new mj-section" insertion branches. - -## Impact if not fixed -- Regressions in `compileMjml`'s auth gate or size cap can ship unnoticed (security/DoS risk). -- Merge-field category changes or taxonomy refactors will not surface breakage. -- Phase 2 of the template editor (adding MP persistence, token resolution) will be harder to land safely without a baseline. - ---- - -## Resolution (2026-09-13) -Closed by the coverage push. Every file in `src/components/template-editor/` -now has a co-located test, plus the two route files: - -| File | Tests | Statements | Lines | -|---|---|---|---| -| `merge-fields.ts` | 5 | 100% | 100% | -| `grapes-config.ts` | 6 | 100% | 100% | -| `actions.ts` | 7 | 100% | 100% | -| `merge-field-picker.tsx` | 7 | 100% | 100% | -| `editor-import-dialog.tsx` | 8 | 95.65% | 100% | -| `editor-code-dialog.tsx` | 11 | 97.67% | 100% | -| `editor-export-dialog.tsx` | 11 | 98.08% | 100% | -| `editor-toolbar.tsx` | 21 | 100% | 100% | -| `editor-canvas.tsx` | 7 | 100% | 100% | -| `template-editor-form.tsx` | 3 | 100% | 100% | -| `templateeditor/template-editor.tsx` | 6 | 100% | 100% | -| `templateeditor/page.tsx` | 1 | 100% | 100% | - -Directory aggregate: 98.8% statements, 100% lines, across 90 tests. - -Two mocking notes worth keeping, both learned the hard way: -- `useEditor()` must be mocked with a **stable object reference** built once via - `vi.hoisted()`. Several components run `useEffect`s keyed on the editor - object's identity, so a `useEditor: () => ({...})` factory returns a new - object on every render and spins an infinite render loop that hangs the runner. -- `editor-canvas.tsx` imports real `.css`, which this repo's Vite/PostCSS - config cannot process under test — both stylesheet imports are mocked to - empty modules. - -The two sibling template-editor TODOs (`-no-mp-persistence`, -`-merge-token-resolver`) were re-verified against the code during this work and -remain **accurate and open**. The new tests document current behaviour; they do -not paper over either defect. 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 deleted file mode 100644 index 79d338d..0000000 --- a/.claude/TODO/2026-04-17-components-template-editor-no-mp-persistence.md +++ /dev/null @@ -1,44 +0,0 @@ ---- -title: Template editor ignores pageID/recordID and never saves to MP -severity: medium -tags: [bug, drift] -area: components -files: - - src/app/(web)/tools/templateeditor/template-editor.tsx - - src/components/template-editor/grapes-config.ts - - src/components/template-editor/template-editor-form.tsx - - src/components/template-editor/actions.ts -discovered: 2026-04-17 -discovered_by: components-template-editor -status: open ---- - -## Problem -The `/tools/templateeditor` route accepts `ToolParams` (`pageID`, `recordID`) via `parseToolParams` and displays them in the info popover, but no code loads an MP template record on mount or writes changes back. `grapes-config.ts` hard-codes `storageManager.type = 'local'` with a single shared key `'mp-template-editor'`, so every user and every invocation (regardless of `pageID`/`recordID`) shares the same browser-local draft. The in-doc comment states "Storage: localStorage for Phase 1" — this is drift between the tool's apparent contract (MP-integrated template tool) and its actual behavior (browser-scratchpad). - -Users opening the tool from a specific MP page/record will: -- Not see that template loaded. -- Overwrite their unrelated local draft if they start editing. -- Lose all work on browser-data clear or device switch. -- Have no way to save changes to MP. - -## Evidence -- `src/app/(web)/tools/templateeditor/template-editor.tsx:28-35` — `params.pageID` and `params.recordID` are only rendered as text; no fetch. -- `src/components/template-editor/grapes-config.ts:23-36` — `storageManager: { type: 'local', ..., options: { local: { key: STORAGE_KEY } } }` with a constant key. -- `src/components/template-editor/actions.ts` — only exports `compileMjml`; no `loadTemplate`/`saveTemplate` actions. -- `src/components/template-editor/editor-canvas.tsx:27-35` — seeds `DEFAULT_MJML_TEMPLATE` when localStorage is empty, regardless of route params. - -## Proposed fix -Choose one of: - -1. **MP-integrated persistence** (preferred for a "Template Editor" tool): - - Add server actions `loadTemplate(pageID, recordID)` and `saveTemplate(pageID, recordID, mjml)` in `src/components/template-editor/actions.ts` backed by a new `TemplateService` or an existing MP table (e.g., a `Templates` / `Email_Templates` entity — pick per MP schema). - - Thread `params` from `TemplateEditor` into `TemplateEditorForm`/`EditorCanvas` so it loads on mount and saves on explicit action (replace or complement autosave). - - Scope the localStorage key by `pageID`/`recordID` or disable local storage once server persistence is in place. - -2. **UI honesty** (minimum): update the info popover + tool copy to say "browser-local drafts only; MP save coming in a later phase," and hide the `pageID`/`recordID` line since they have no effect. - -## Impact if not fixed -- 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. diff --git a/.claude/TODO/2026-09-13-components-select-uncontrolled-to-controlled.md b/.claude/TODO/2026-09-13-components-select-uncontrolled-to-controlled.md deleted file mode 100644 index e39eaba..0000000 --- a/.claude/TODO/2026-09-13-components-select-uncontrolled-to-controlled.md +++ /dev/null @@ -1,71 +0,0 @@ ---- -title: Radix Select fields switched from uncontrolled to controlled on first selection -severity: medium -tags: [bug] -area: components -files: [src/components/group-wizard/step-attributes.tsx, src/components/group-wizard/step-identity.tsx, src/components/group-wizard/step-meeting.tsx, src/components/group-wizard/step-organization.tsx, src/app/(web)/tools/addeditfamily/add-edit-family.tsx] -discovered: 2026-09-13 -discovered_by: coverage-review-orchestrator -status: resolved ---- - -## Problem -Fourteen `Select` fields across the group wizard and Add/Edit Family passed -`undefined` as their value while nothing was selected: - -```tsx -value={field.value ? String(field.value) : undefined} -``` - -A Radix `Select` with `value={undefined}` is **uncontrolled**. As soon as the -user picked an option, `field.value` became truthy and the component received a -string — switching to **controlled** mid-life. React logs: - -``` -Select is changing from uncontrolled to controlled. Components should not -switch from controlled to uncontrolled (or vice versa). Decide between using -a controlled or uncontrolled value for the lifetime of the component. -``` - -Beyond the console noise, this is a real correctness problem: while the field -is uncontrolled, React state is not the source of truth for it. Programmatic -resets — `form.reset()` between wizard runs, or clearing a field back to its -empty value — are not guaranteed to be reflected in the rendered trigger, -because the component keeps its own internal value until it becomes controlled. - -The falsy check compounds it. `field.value` of `0` is a legitimate lookup id in -Ministry Platform, and it is falsy, so a genuinely-selected id of 0 would also -have rendered as "no selection". - -## Evidence -- `src/components/group-wizard/step-meeting.tsx:49,95,123,151` -- `src/components/group-wizard/step-attributes.tsx:70,98,126,154` -- `src/components/group-wizard/step-organization.tsx:59,87,162` -- `src/components/group-wizard/step-identity.tsx:64,128` -- `src/app/(web)/tools/addeditfamily/add-edit-family.tsx:1168` - (variant: `value={value > 0 ? String(value) : undefined}`) -- Surfaced by the new step tests — 7 warnings per `vitest run`, e.g. - `step-organization.test.tsx > StepOrganization > selects a congregation`. - -## Fix applied -All fourteen sites now pass `""` instead of `undefined`: - -```tsx -value={field.value ? String(field.value) : ""} -``` - -`""` is Radix's documented "no selection" value for a controlled `Select`: the -`SelectValue` placeholder still renders, and the component is controlled for -its entire lifetime. (`SelectItem` may not use `""` as its own value, which is -what makes it safe as the sentinel.) - -Verified: 1,535 tests pass, `npm run test:run` emits zero warnings, `tsc ---noEmit` is clean, and `npm run build` succeeds. - -## Follow-up worth considering -The `field.value ? ...` truthiness test is still wrong for a legitimate id of -`0`. No current lookup uses 0 as a real id, so this is latent rather than -live — but the correct predicate is `field.value != null`, and a future MP -lookup that does use 0 would fail silently. Left as-is here because changing -the predicate is a behavioural change beyond the warning fix, and it deserves -its own review against the actual lookup data. diff --git a/.claude/TODO/2026-09-13-dead-empty-fields-branch-handlenext.md b/.claude/TODO/2026-09-13-dead-empty-fields-branch-handlenext.md deleted file mode 100644 index 643b5f0..0000000 --- a/.claude/TODO/2026-09-13-dead-empty-fields-branch-handlenext.md +++ /dev/null @@ -1,66 +0,0 @@ ---- -title: Unreachable "empty STEP_FIELDS" branch in GroupWizard.handleNext -severity: low -tags: [refactor, missing-test] -area: components -files: [src/app/(web)/tools/groupwizard/group-wizard.tsx] -discovered: 2026-09-13 -discovered_by: coverage-agent-group-wizard -status: open ---- - -## Problem -`handleNext` in `group-wizard.tsx` (lines 120-133) special-cases a step whose -`STEP_FIELDS[currentStep]` entry is missing or empty by skipping validation -and advancing immediately: - -```ts -const fields = STEP_FIELDS[currentStep]; -if (!fields || fields.length === 0) { - setCompletedSteps((prev) => new Set(prev).add(currentStep)); - setCurrentStep((prev) => Math.min(prev + 1, WIZARD_STEPS.length - 1)); - return; -} -``` - -The only entry in `STEP_FIELDS` (see `schema.ts`) with an empty array is -index `5` (the Review step, `STEP_FIELDS[5] = []`). But `WizardNavigation` -never renders a "Next" button on the last step (`currentStep === totalSteps - 1`) -— it renders the Submit/"Save Changes" button instead, which calls -`handleSubmit`, not `handleNext`. `STEP_FIELDS` is otherwise fully populated -for indices 0-4, and `currentStep` never exceeds `WIZARD_STEPS.length - 1` -(5), so this branch is unreachable through the actual UI. - -This isn't a security or correctness bug (nothing lets a user skip real -validation — the branch simply can never execute), but it is 3 statements of -dead defensive code that both inflates the file's apparent branch count and -cannot be exercised by any user-facing test, which is why coverage tooling -flags `group-wizard.tsx` at 94.5% statements instead of ≥95% despite every -reachable branch being covered. - -## Evidence -- `src/app/(web)/tools/groupwizard/group-wizard.tsx:120-126` — the - `!fields || fields.length === 0` branch. -- `src/components/group-wizard/schema.ts:58-65` — `STEP_FIELDS`, only index - `5` is empty, and index 5 is the last step. -- `src/components/group-wizard/wizard-navigation.tsx:28,55-75` — `isReviewStep` - gates rendering Submit vs. Next; the last step never shows the Next button - that calls `handleNext`. -- `npx vitest run --coverage` on `group-wizard.tsx` reports lines 123-125 - uncovered even after `src/app/(web)/tools/groupwizard/group-wizard.test.tsx` - was extended with 12 additional cases covering every other branch/function - in the file (94.5% statements / 96.25% lines final). - -## Proposed fix -Either: -1. Remove the `!fields || fields.length === 0` special case entirely (the - `form.trigger([])` call with an empty array resolves `true` immediately - in react-hook-form, so simplifying to always call `form.trigger(fields)` - would behave identically and remove the dead branch), or -2. If the branch is meant as future-proofing for a step reachable via some - other entry point, add a code comment explaining that intent so the next - reader (and coverage tooling) doesn't flag it as untested dead code. - -## Impact if not fixed -None functionally — this is a coverage/clarity issue, not a behavior bug. -Low priority; safe to leave as documented, known-dead code. diff --git a/.claude/TODO/2026-09-13-mergetemplate-logs-address-pii-on-error.md b/.claude/TODO/2026-09-13-mergetemplate-logs-address-pii-on-error.md deleted file mode 100644 index dc9d811..0000000 --- a/.claude/TODO/2026-09-13-mergetemplate-logs-address-pii-on-error.md +++ /dev/null @@ -1,86 +0,0 @@ ---- -title: mergeTemplate (and sibling actions) console.error the raw docxtemplater error, which can carry household addresses -severity: high -tags: [security, bug] -area: components -files: [src/components/address-labels/actions.ts] -discovered: 2026-09-13 -discovered_by: coverage-agent-address-labels -status: resolved ---- - -## Problem - -CLAUDE.md rule 14 requires that errors log "identifiers and shape" only — never -record content — "including inside thrown error messages, which travel further -than logs do." `mergeTemplate` (and its siblings `generateLabelPdf` / -`generateLabelDocx`) violate this by passing the entire caught `error` object to -`console.error`, not just `error.message`. - -For `mergeTemplate` this is a concrete PII leak, not a theoretical one: -docxtemplater's scope-parser error path attaches the live merge scope object to -`err.properties.scope` (see `node_modules/docxtemplater/js/errors.js:593-600`, -function that throws `"scopeparser_execution_failed"`). In this feature, the -merge scope is exactly the `addresses` array built in `mergeTemplate` — each -entry carries `Name`, `AddressLine1`, `AddressLine2`, `City`, `State`, -`PostalCode` for a real household. A user-uploaded template with a malformed -tag (e.g. a token that calls a method that throws, or a bad expression) causes -docxtemplater to throw with that scope attached, and -`console.error('mergeTemplate error:', error)` writes the full object — -including the nested `.properties.scope` with every printable household's -name and address — to server logs. - -## Evidence - -- `src/components/address-labels/actions.ts:328` — `console.error('mergeTemplate error:', error);` logs the raw `error`, not `error.message`. -- `src/components/address-labels/actions.ts:189` and `:232` — same pattern for `generateLabelPdf` / `generateLabelDocx` (lower risk here, but same anti-pattern). -- `node_modules/docxtemplater/js/errors.js:593-601` — `err.properties.scope = scope;` attaches the full render scope (the caller's data) to certain template errors. -- `mergeTemplate`'s `addresses` array (`actions.ts:286-296`) contains `Name`, `AddressLine1`, `AddressLine2`, `City`, `State`, `PostalCode` — real household PII — which is exactly what would appear in that scope. - -## Proposed fix - -Log only identifiers/shape, per rule 14: e.g. -```ts -console.error('mergeTemplate error:', error instanceof Error ? error.message : 'non-Error thrown'); -``` -or, if the `docxtemplater` error's `.properties.id`/`.name` is useful for -diagnosing which failure mode occurred, log those specific fields explicitly -rather than the object itself — never `error` or `error.properties` in bulk. -Apply the same change to the `console.error` calls in `generateLabelPdf` and -`generateLabelDocx` for consistency and defense in depth. - -## Impact if not fixed - -Any user who uploads a mail-merge template with a tag that fails docxtemplater's -scope parser causes every printable household's name and mailing address for -that batch to be written to application/server logs (e.g. Vercel function -logs), which are typically retained, more widely readable, and less access -controlled than the MP database itself. - ---- - -## Resolution (2026-09-13) -Added a `describeError()` helper in `src/components/address-labels/actions.ts` -and routed all three `console.error` calls through it — -`generateLabelPdf`, `generateLabelDocx`, and `mergeTemplate`. - -It reduces a caught value to `{ name, message }`, plus docxtemplater's own -`properties.id` and `properties.explanation` when present. Those two are -genuinely useful for telling which failure mode occurred and contain no caller -data. Everything else on `properties` — crucially `scope`, which in this -feature IS the household list — is dropped. A non-`Error` throw is reported as -`{ name: 'NonError', type: typeof error }` rather than by value, since a thrown -string can itself be data-derived. - -The user-facing return value still carries `error.message`. That is the -diagnostic the person fixing their template needs, and docxtemplater's messages -reference tag names from the uploaded template, not merge data. - -### Tests -`src/components/address-labels/actions.test.ts` — 5 cases driving a -docxtemplater-shaped error whose `properties.scope` holds a real-looking name -and address, asserting none of it reaches the log while the identifiers do. -One asserts on the logged object's KEYS rather than substrings: the word -"scope" legitimately appears inside the safe identifier -`scopeparser_execution_failed`, so a naive `not.toContain('scope')` fails for -the wrong reason. diff --git a/.claude/TODO/2026-09-13-page-logs-raw-error-object.md b/.claude/TODO/2026-09-13-page-logs-raw-error-object.md deleted file mode 100644 index 77fac66..0000000 --- a/.claude/TODO/2026-09-13-page-logs-raw-error-object.md +++ /dev/null @@ -1,88 +0,0 @@ ---- -title: "AddEditFamilyPage logs the raw error object instead of an identifier" -severity: medium -tags: [security, drift] -area: components -files: [src/app/(web)/tools/addeditfamily/page.tsx] -discovered: 2026-09-13 -discovered_by: coverage-agent-addeditfamily -status: resolved ---- - -## Problem -`AddEditFamilyPage` swallows a failed `resolveContactIdFromPage` call with: - -```ts -console.warn("Failed to resolve Contact_ID from page record:", error); -``` - -CLAUDE.md rule 14 requires errors to log identifiers and shape only — never -record content, `$filter` strings, or request/response bodies, "including -inside thrown error messages, which travel further than logs do." Logging the -raw `error` object prints its `.message` (and, depending on the runtime, -`.stack`), which can carry the interpolated MP `$filter` string built in -`FamilyService.resolveContactIdFromPage` (`` `${primaryKey} = ${recordId}` ``) -or other request detail if the underlying `getTableRecords` call or -`validateColumnName`/`validatePositiveInt` throws with that detail embedded. - -The sibling file `src/lib/tool-params.server.ts` (same call-swallowing shape, -literally a few lines away in the import graph) already gets this right: - -```ts -// 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. -console.warn('tool_params.page_data_unavailable', { pageID: parsedPageID }); -``` - -`page.tsx` should follow the same pattern instead of passing `error` through. - -## Evidence -- `src/app/(web)/tools/addeditfamily/page.tsx:29` — `console.warn("Failed to resolve Contact_ID from page record:", error)` -- `src/lib/tool-params.server.ts:58-60` — the correct pattern, logging an identifier object with no error content -- `src/services/familyService.ts:140-165` — `resolveContactIdFromPage` builds a `$filter` string from `primaryKey`/`recordId` that could surface in a thrown error's `.message` - -## Proposed fix -Replace the `console.warn` call with an identifier-only log, e.g.: - -```ts -console.warn("addeditfamily.resolve_contact_id_failed", { - tableName: params.pageData.Table_Name, - recordID: params.recordID, -}); -``` - -and drop `error` from the log call entirely (or log only `error instanceof Error ? error.name : typeof error` if a coarse classification is wanted). - -## Impact if not fixed -Low likelihood, low blast radius — this only logs when the page-record -resolution has already failed, and only to server logs. But it is the second -place after `tool-params.server.ts`'s example that this exact swallow-and-warn -shape appears, and it is the one that gets it wrong; left uncorrected, it is -an easy pattern to copy into the next new page. - ---- - -## Resolution (2026-09-13) -Fixed alongside the identical anti-pattern in -`src/components/address-labels/actions.ts` — shipping a redaction fix for one -file while leaving the same bug in the feature next door would have left the -codebase with two standards. - -```ts -console.warn("addeditfamily.resolve_contact_id_failed", { - table: params.pageData.Table_Name, - name: error instanceof Error ? error.name : "NonError", -}); -``` - -The table name is a configuration identifier, not record content, and rule 14 -explicitly allows "table, IDs, HTTP status". The error's `message` is dropped -because `resolveContactIdFromPage` builds an MP `$filter` from the record id -and column path, and MP surfaces that filter back inside its error text. - -### Tests -`src/app/(web)/tools/addeditfamily/page.test.tsx` — the existing swallow test -now asserts the redacted shape, plus two new cases: a rejection whose message -contains a filter string never reaches the log, and a non-`Error` throw is -described as `NonError` rather than by value. diff --git a/.claude/TODO/2026-09-13-removegroup-order-guard-mismatch.md b/.claude/TODO/2026-09-13-removegroup-order-guard-mismatch.md deleted file mode 100644 index 94a32e7..0000000 --- a/.claude/TODO/2026-09-13-removegroup-order-guard-mismatch.md +++ /dev/null @@ -1,129 +0,0 @@ ---- -title: removeGroup drops a non-empty group's fields from the save payload -severity: high -tags: [bug] -area: components -files: [src/components/field-management/use-field-order-state.ts] -discovered: 2026-09-13 -discovered_by: coverage-agent-field-management -status: resolved ---- - -## Problem - -`removeGroup(name)` in `use-field-order-state.ts` guards `groupedFields` against -removing a group that still has fields, but does not apply the same guard to -`groupOrder`. The two state updates run independently: - -```ts -const removeGroup = useCallback((name: string) => { - setGroupedFields((prev) => { - if ((prev[name] || []).length > 0) return prev; // <-- guarded: no-op if non-empty - const { [name]: _, ...rest } = prev; - return rest; - }); - setGroupOrder((prev) => prev.filter((g) => g !== name)); // <-- unconditional - setIsDirty(true); -}, []); -``` - -If a caller invokes `removeGroup` on a group that still has fields (the UI's -trash-can button is only rendered for empty groups, but `removeGroup` is a -plain callback with no such enforcement — any other call site, a future UI -change, or a test/automation calling the hook directly can hit this), the -group's fields survive in `groupedFields` but the group name is removed from -`groupOrder`. - -`buildSavePayload()` iterates `groupOrder` (not `Object.keys(groupedFields)`) -to build the save payload: - -```ts -for (const groupName of groupOrder) { - const fieldIds = groupedFields[groupName] || []; - ... -} -``` - -Because the group name is no longer in `groupOrder`, its fields are silently -skipped — they never appear in the payload sent to -`savePageFieldOrder`/`api_Tools_...` stored proc. Since this feature writes -live Ministry Platform page field configuration, this is a silent -configuration-loss bug: fields simply vanish from the page's field list on -next save, with no error or warning to the user. - -## Evidence - -- `src/components/field-management/use-field-order-state.ts:123-130` (removeGroup) -- `src/components/field-management/use-field-order-state.ts:222-223` (buildSavePayload - iterates `groupOrder`, using `groupedFields[groupName] || []` as a fallback for - names no longer present) -- Reproduced in - `src/components/field-management/use-field-order-state.test.ts`, describe - block `useFieldOrderState > removeGroup`, test `'is a no-op on groupedFields - when the group still has fields, but still removes it from groupOrder'`: - after calling `removeGroup('1 - First')` on a group with one field, the test - asserts `groupedFields['1 - First']` still equals `[1]` but - `buildSavePayload()`'s output has no entry for that field at all. - -## Proposed fix - -Make the two updates consistent — either: -1. Only remove from `groupOrder` when the group was actually empty (mirror the - same guard used for `groupedFields`), or -2. If `removeGroup` is meant to force-delete a non-empty group (moving its - fields elsewhere, e.g. into "99 - Other Fields"), do that explicitly instead - of silently orphaning the fields. - -Given the existing UI only calls `removeGroup` for empty groups (see -`sortable-group.tsx`'s trash button, rendered only when `fieldIds.length === -0`), option 1 (guard `groupOrder` the same way) is the minimal, safe fix and -matches the function's only current call site's expectations. - -## Impact if not fixed - -Any caller of `removeGroup` on a non-empty group — now or in a future UI -change — silently drops that group's fields from the next save, with no error -surfaced to the admin performing the edit. This directly corrupts live -Ministry Platform page field configuration (view order, group assignment, -required/hidden/filter settings all lost for the affected fields) until -someone notices fields missing from the page and manually re-adds them. - ---- - -## Resolution (2026-09-13) -Applied option 1 from the proposed fix: `groupOrder` is now guarded the same -way `groupedFields` was, so a non-empty group is a complete no-op. - -The guard moved OUT of the `setGroupedFields` updater and up to the top of the -callback, reading the rendered `groupedFields` (added to the dependency array, -matching how `addGroup` already reads `isFlat`): - -```ts -const removeGroup = useCallback( - (name: string) => { - if ((groupedFields[name] || []).length > 0) return; - setGroupedFields((prev) => { const { [name]: _, ...rest } = prev; return rest; }); - setGroupOrder((prev) => prev.filter((g) => g !== name)); - setIsDirty(true); - }, - [groupedFields], -); -``` - -### A wrong first attempt, recorded so it is not retried -The obvious fix — set a `let removed = false` flag inside the -`setGroupedFields` updater and check it before calling `setGroupOrder` — does -not work. React runs state updaters during the render phase, not synchronously -at call time, so the flag is still `false` when the second setter is reached. -It would have looked correct, passed a casual read, and silently kept the bug. -The decision has to be made once, up front, from state both setters agree on. - -`isDirty` is no longer set when the removal is refused: nothing changed, so -there is nothing to save. - -### Tests -`src/components/field-management/use-field-order-state.test.ts` — the test that -documented the broken behaviour was replaced with four that pin the fix: both -halves of state left intact, fields still present in `buildSavePayload()`, -`isDirty` untouched on refusal, and a group still removable once emptied (with -its field surviving). diff --git a/.claude/TODO/2026-09-13-search-empty-state-never-renders.md b/.claude/TODO/2026-09-13-search-empty-state-never-renders.md deleted file mode 100644 index 6b6e3d7..0000000 --- a/.claude/TODO/2026-09-13-search-empty-state-never-renders.md +++ /dev/null @@ -1,51 +0,0 @@ ---- -title: "Add/Edit Family search: 'No contacts found' empty state never renders" -severity: low -tags: [bug] -area: components -files: [src/app/(web)/tools/addeditfamily/add-edit-family.tsx] -discovered: 2026-09-13 -discovered_by: coverage-agent-addeditfamily -status: open ---- - -## Problem -`FamilySearchBar` (in `add-edit-family.tsx`) renders a `No contacts -found.` whenever the query is at least 2 characters and -`searchContacts` returned zero results. In practice this text never appears in -the DOM, because `cmdk`'s `CommandEmpty` only renders when the command's -*global* registered-item count (`filtered.count`, tracked across the whole -`Command` tree, not just the group it lives in) is zero. The sibling "Or" -`CommandGroup` always registers a `+ New Family with last name "…"` item as -soon as the query is >= 2 characters, so that count is never zero, and -`CommandEmpty` unconditionally returns `null` regardless of our own -`results.length === 0` check. - -Net effect: a user who searches for a name with no matches sees only the -"+ New Family" suggestion, with no indication that the search itself came up -empty (they may not notice there's a difference between "no matches" and -"you haven't typed enough"). - -## Evidence -- `src/app/(web)/tools/addeditfamily/add-edit-family.tsx:457-459` — the - `CommandEmpty` block, gated on local `results`/`query` state. -- `node_modules/cmdk/dist/index.mjs` — `CommandEmpty` (`Ie`) renders based on - `useCommandState(v => v.filtered.count === 0)`, and `filtered.count` is - computed from `shouldFilter === false` as `u.current.size` (total item - registrations), not scoped to a single `CommandGroup`. -- `src/app/(web)/tools/addeditfamily/add-edit-family.test.tsx` — the test - `"finds no CommandEmpty text when the search returns nothing"` documents and - asserts this actual (broken) behavior instead of the intended message. - -## Proposed fix -Don't rely on `CommandEmpty`'s built-in visibility heuristic here. Replace it -with an explicit conditional `
` (matching the style already used for the -"Type at least 2 characters" and "Searching…" states a few lines above) that -renders whenever `!isSearching && query.trim().length >= 2 && results.length -=== 0`, instead of wrapping the message in ``. - -## Impact if not fixed -Cosmetic/UX only — the "+ New Family" path still works, so no data is lost or -corrupted. A user searching for an existing family that doesn't actually exist -gets a slightly less clear affordance (no explicit "not found" feedback), but -can still proceed by creating a new family. diff --git a/.claude/TODO/2026-09-13-testing-coverage-report-masked-untested-files.md b/.claude/TODO/2026-09-13-testing-coverage-report-masked-untested-files.md deleted file mode 100644 index 6e721d0..0000000 --- a/.claude/TODO/2026-09-13-testing-coverage-report-masked-untested-files.md +++ /dev/null @@ -1,64 +0,0 @@ ---- -title: Coverage config omitted `coverage.include`, hiding every untested file from the report -severity: high -tags: [bug, testing, drift] -area: testing -files: [vitest.config.mts, .github/workflows/test.yml] -discovered: 2026-09-13 -discovered_by: coverage-review-orchestrator -status: resolved ---- - -## Problem -`vitest.config.mts` (then `vitest.config.ts`) configured v8 coverage without a `coverage.include` glob. -The v8 provider only instruments files that were actually loaded during the -test run, so any source file that no test imported was absent from the report -entirely rather than counted as 0%. - -Note for anyone fixing this from memory or from an older guide: the flag that -used to do this was `coverage.all: true`, and it was **removed in Vitest 5**. -Setting it now is a TypeScript error (`No overload matches this call`) and has -no runtime effect — `coverage.include` is the only lever. This was verified -in-session: adding `all: true` alongside `include` changed nothing but the type -check, and removing it changed no coverage number. - -The reported figure was therefore a percentage of *the code tests already -touch*, not of the codebase. It read 83.14% statements while true coverage over -`src/**` was 48.41%. 131 of 176 source files never appeared in the report at -all, including `src/services/familyService.ts` (756 lines, zero tests) and -`src/app/(web)/tools/layout.tsx` (the `/tools` authorization gate, zero tests). - -CI uploads this report to Codecov, so the inflated number was the project's -published quality signal. - -## Evidence -- `vitest.config.ts:15-24` (pre-fix, before the rename to `.mts`) — `coverage` block had `provider`, - `reporter`, `exclude`, but no `all` and no `include`. -- `npx vitest run --coverage` before fix: `Statements : 83.14% ( 1845/2219 )` -- Same suite with `include: ['src/**/*.{ts,tsx}']`: - `Statements : 48.41% ( 1845/3811 )` — identical numerator, denominator up 72%. -- Files absent from the pre-fix report despite existing: `familyService.ts`, - `googlePlacesService.ts`, all of `src/components/template-editor/`, all of - `src/app/(web)/tools/*/page.tsx`. - -## Proposed fix -Applied. `vitest.config.mts` now sets `include: ['src/**/*.{ts,tsx}']`, -plus explicit exclusions for vendored -shadcn/ui primitives, generated MP models, build-time scripts, barrel files, -type-only modules, and declarative `loading.tsx` shells. - -A second, related defect was found while applying this: directory exclusions -were written with a bare trailing slash (`'src/components/ui/'`), which matches -nothing. Those files stayed in the denominator until the patterns were -rewritten as `'src/components/ui/**'`. Any directory exclusion added here must -end in `**`. - -Codecov will show a one-time drop from 83% to ~49% on the first push carrying -this change — before the new tests land on top of it. That is the correction, -not a regression. - -## Impact if not fixed -Every coverage claim in `CLAUDE.md`, `.claude/references/testing/`, and Codecov -was overstating the tested share of the codebase by ~35 points. Untested -files stayed invisible, so no one could see that a 756-line service and a -route authorization gate had no tests at all. diff --git a/.claude/TODO/2026-09-13-testing-no-typecheck-gate-in-ci.md b/.claude/TODO/2026-09-13-testing-no-typecheck-gate-in-ci.md deleted file mode 100644 index 2ea39f5..0000000 --- a/.claude/TODO/2026-09-13-testing-no-typecheck-gate-in-ci.md +++ /dev/null @@ -1,119 +0,0 @@ ---- -title: Add a type-check gate to CI (stale test fixtures had silently broken `npm run build`) -severity: medium -tags: [bug, testing, drift] -area: testing -files: [.github/workflows/test.yml, package.json] -discovered: 2026-09-13 -discovered_by: coverage-review-orchestrator -status: resolved ---- - -## Problem -Two test files that are committed and unmodified on `dev` fail type checking. -`tsconfig.json` includes `**/*.ts` and `**/*.tsx`, `next.config.ts` sets no -`typescript.ignoreBuildErrors`, and `npm run build` runs `next build` — which -type-checks the project. So `npm run build` fails on a clean `dev` checkout. - -This is invisible because, as `CLAUDE.md` already notes, CI runs only -`npm run test:coverage` and never `npm run build`. The tests themselves pass: -the arguments are wrong *types* but the mocked implementations never inspect -the missing fields, so the assertions still hold. Type safety is the only thing -catching it, and nothing automated runs type safety. - -The underlying defect is that these tests assert against object shapes the -production types do not accept — `helper.test.ts` builds `CommunicationInfo` -and `MessageInfo` values out of raw MP column names (`Author_User_ID`, -`From_Contact`, `To_Contact_List`) instead of the camelCase DTO fields the -helper actually requires (`AuthorUserId`, `FromContactId`, `ReplyToContactId`, -`CommunicationType`, ...). A test that type-checks would have caught the drift; -these ones silently encode the wrong contract. - -## Evidence -``` -$ git status --porcelain src/lib/providers/ministry-platform/helper.test.ts \ - src/components/address-labels/word-document.test.ts -(no output — both unmodified) - -$ npx tsc --noEmit -src/components/address-labels/word-document.test.ts(62,46): error TS1355: - A 'const' assertion can only be applied to references to enum members, or - string, number, boolean, array, or object literals. -src/lib/providers/ministry-platform/helper.test.ts(762,57): error TS2345: - ... is missing the following properties from type 'CommunicationInfo': - AuthorUserId, FromContactId, ReplyToContactId, CommunicationType, and 4 more. -src/lib/providers/ministry-platform/helper.test.ts(782,57): error TS2345: (same) -src/lib/providers/ministry-platform/helper.test.ts(822,49): error TS2345: - ... is missing the following properties from type 'MessageInfo': - FromAddress, ToAddresses -src/lib/providers/ministry-platform/helper.test.ts(841,49): error TS2345: (same) -src/lib/providers/ministry-platform/helper.test.ts(923,11): error TS2559: - Type '{ IsDefault: boolean; Description: string; }' has no properties in - common with type 'FileUploadParams'. -``` -- `tsconfig.json` — `"include": ["**/*.ts", "**/*.tsx", ...]`, so tests are in - the program. -- `next.config.ts` — no `typescript` block, so `ignoreBuildErrors` is false. -- `.github/workflows/test.yml` — runs `npm run test:coverage` only. - -## Fixed in this branch -The stale fixtures have been corrected. `helper.test.ts` and `provider.test.ts` -now build real `CommunicationInfo` / `MessageInfo` / `FileUploadParams` / -`FileUpdateParams` values with explicit type annotations, so the compiler -enforces the shape, and the `as const` misuse at `word-document.test.ts:62` is -gone. `npx tsc --noEmit` is clean and all 96 tests in those files still pass. - -## Remaining work -**Add a type-check gate to CI.** Add a `typecheck` script (`tsc --noEmit`) to -`package.json` and run it in `.github/workflows/test.yml` alongside -`test:coverage`. - -`CLAUDE.md` documents "CI gates tests only ... Type-check locally" as the -mitigation. This issue is the evidence that the honour system did not hold: two -committed test files sat on `dev` in a state that broke `npm run build`, and -nothing caught it because the tests themselves passed — the arguments were the -wrong *types*, but the mocks never inspected the missing fields. - -Note that the new `coverage.thresholds` in `vitest.config.mts` are enforced by -the existing `test:coverage` CI job, so coverage regressions now fail the build. -Type regressions still do not. - -## Impact if not fixed -The specific breakage is repaired, but the gap that allowed it is still open. A -type error reaching `dev` breaks `npm run build` and therefore a Vercel -production deploy, while CI stays green — so it is found at deploy time by -whoever is shipping, not at PR time by whoever wrote it. - ---- - -## Resolution (2026-09-13) -Added `"typecheck": "tsc --noEmit"` to `package.json` and wired it into -`.github/workflows/test.yml`, along with two other gaps found while in there. - -**Added as STEPS, not new jobs.** Branch protection on `dev` and `main` -requires the status check named `test`. Lint and typecheck as separate jobs -would have been green-but-unrequired until someone also edited the protection -rules — a gate that does not gate. As steps in the existing `test` job they are -covered by the rule already in place, with no admin change needed. - -Order is install -> lint -> typecheck -> test:coverage, so the cheap checks -fail first. The Codecov step keeps `if: always()` and `fail_ci_if_error: false`, -so an early failure that produces no coverage file does not itself fail the -build. - -### Two other CI issues fixed in the same pass -- **`npm install` -> `npm ci`.** `npm install` resolves fresh versions and - rewrites `package-lock.json` inside CI, so CI could be testing a different - dependency tree than any developer had. `npm ci` installs exactly what the - lockfile pins and fails outright if `package.json` and the lockfile disagree. - Verified with `npm ci --dry-run` before committing. -- **No `concurrency` group.** Rapid pushes to a PR branch left superseded runs - burning minutes. Now cancels in-progress runs for the same ref, except on - `main` and `dev`, whose runs gate merges and releases and must not be killed. - -Also added `permissions: contents: read` — this workflow only reads the repo, -and Codecov authenticates with its own token rather than `GITHUB_TOKEN`. - -`CLAUDE.md` previously documented "CI gates tests only ... Type-check locally" -as the mitigation. That line should now be read alongside this change: the -honour system has been replaced by a gate. diff --git a/.claude/TODO/2026-09-13-testing-vitest-config-loaded-as-cjs.md b/.claude/TODO/2026-09-13-testing-vitest-config-loaded-as-cjs.md deleted file mode 100644 index d2b2562..0000000 --- a/.claude/TODO/2026-09-13-testing-vitest-config-loaded-as-cjs.md +++ /dev/null @@ -1,77 +0,0 @@ ---- -title: `vitest.config.ts` was loaded as CommonJS, warned as unsupported by Vite's future default loader -severity: low -tags: [drift, testing] -area: testing -files: [vitest.config.mts] -discovered: 2026-09-13 -discovered_by: coverage-review-orchestrator -status: resolved ---- - -## Problem -Every Vitest invocation prints: - -``` -(!) Your Vite config uses features that are unsupported by `configLoader: 'native'`, -which is planned to become the default in a future major version of Vite: - - ESM syntax in a file loaded as CommonJS (vitest.config.ts:1:1). - Use a `.mjs` extension or set `"type": "module"` in the closest package.json -``` - -`vitest.config.ts` uses ESM syntax (`import`/`export default`) but `package.json` -has no `"type": "module"`, so Vite loads it through the CommonJS path. That path -still works today, but Vite plans to make `configLoader: 'native'` the default, -at which point the config stops loading and the whole suite fails to start. - -This is noise on every local run and every CI run today, and a hard break on a -future Vite major. - -## Evidence -- `vitest.config.ts:1` — `import { defineConfig } from 'vitest/config';` -- `package.json` — no `"type"` field. -- Warning reproduces on any `npx vitest run`. -- Vite 8 / Vitest 6 are the likely forcing versions; neither is adopted here yet. - -## Proposed fix -Lowest-risk option is to rename `vitest.config.ts` -> `vitest.config.mts`. Vitest -resolves the `.mts` extension automatically, so no script changes are needed, and -it does not touch how Next.js or the rest of the toolchain treat the package. - -Do NOT add `"type": "module"` to `package.json` as the fix — this project also -contains CommonJS tooling and Next.js build config, and flipping the package type -has a much wider blast radius than renaming one file. - -Verify with `npx vitest run` (warning gone, suite still runs) and -`npm run test:coverage` (Codecov path `coverage/coverage-final.json` still -produced). - -## Impact if not fixed -Cosmetic today. On the Vite major that flips `configLoader` to `'native'`, the -config fails to load and the entire test suite — and therefore the CI `test` -gate that protects `dev` and `main` — stops running. Cheap to fix now, and the -dependency-audit process should not have to rediscover it under time pressure. - ---- - -## Resolution (2026-09-13) -Applied the proposed fix: renamed `vitest.config.ts` -> `vitest.config.mts`. -Vitest resolves the `.mts` extension automatically, so no script or CI changes -were needed, and `package.json` was left alone (adding `"type": "module"` would -have had a far wider blast radius, as noted above). - -The rename surfaced a second, hidden instance of the same problem. With the -file loaded as real ESM, Vite immediately flagged: - -``` -- `__dirname` (vitest.config.mts:66:25). Use `import.meta.dirname` instead -``` - -`__dirname` is a CommonJS global and does not exist in ESM; it had been working -only because the file was being loaded as CJS. The `@` path alias is now -resolved with `import.meta.dirname`. Had the rename been done without running -the suite, the alias would have thrown at config load and every test would have -failed to start. - -Verified: `npm run test:run` produces 17 lines of output with zero warnings, -116 files / 1,535 tests passing, and `npx tsc --noEmit` is clean. diff --git a/.claude/TODO/2026-09-13-unvalidated-envelope-donor-ids-in-filter.md b/.claude/TODO/2026-09-13-unvalidated-envelope-donor-ids-in-filter.md deleted file mode 100644 index ae949b0..0000000 --- a/.claude/TODO/2026-09-13-unvalidated-envelope-donor-ids-in-filter.md +++ /dev/null @@ -1,122 +0,0 @@ ---- -title: Unvalidated client-supplied numeric fields interpolated into MP $filter strings in FamilyService -severity: high -tags: [security, bug] -area: services -files: [src/services/familyService.ts, src/app/(web)/tools/addeditfamily/actions.ts, src/lib/dto/family.ts] -discovered: 2026-09-13 -discovered_by: coverage-agent-services -status: resolved ---- - -## Problem -`saveFamily` (the `"use server"` action in `addeditfamily/actions.ts`) accepts a -`Household` object straight from the client and passes it directly to -`FamilyService.saveHousehold()` — it is never parsed through the -`HouseholdSchema`/`FamilyMemberSchema` Zod schemas that already exist in -`src/lib/dto/family.ts` for exactly this purpose. TypeScript's compile-time -`number` type on `FamilyMember.envelopeNo` and `FamilyMember.donorId` is not -enforced at runtime — a server action is a public POST endpoint, and any JSON -body can be sent to it, including e.g. `envelopeNo: "1 OR 1=1"`. - -Inside `FamilyService.saveHousehold` → `upsertDonor` → `resolveUniqueEnvelopeNo`, -those two client-controlled values are interpolated directly into an MP -`$filter` string with no `validatePositiveInt` (or any other) check: - -```ts -private async resolveUniqueEnvelopeNo( - requested: number, - excludeDonorId: number | null, -): Promise<{ envelopeNo: number; bumped: boolean }> { - let candidate = requested; - ... - const filter = - excludeDonorId && excludeDonorId > 0 - ? `Envelope_No = ${candidate} AND Donor_ID <> ${excludeDonorId}` - : `Envelope_No = ${candidate}`; -``` - -Every other filter built in this file first validates its numeric inputs -(`validatePositiveInt(contactId)` in `getHousehold`, `validatePositiveInt(recordId)` -in `resolveContactIdFromPage`), so this is an inconsistency, not the intended -design. This is the same shape of hole CLAUDE.md rule 11 calls out for strings -("escape user input in filters") — here the payload is untyped/unvalidated -numeric input instead of a string, but it reaches a raw `$filter` string the -same way. - -## Evidence -- `src/services/familyService.ts:687-712` (`resolveUniqueEnvelopeNo`) — `candidate` - (from `member.envelopeNo`) and `excludeDonorId` (from `member.donorId`) are - interpolated into the filter with no type/range check. -- `src/app/(web)/tools/addeditfamily/actions.ts:137-154` (`saveFamily`) — takes - `household: Household` straight from the client with no `HouseholdSchema.parse()` - or `.safeParse()` call before it reaches the service. -- Contrast with `src/services/familyService.ts:150,175` where the same file - validates `recordId`/`contactId` via `validatePositiveInt` before use in a - filter. -- Confirmed via test: `src/services/familyService.test.ts` exercises - `resolveUniqueEnvelopeNo` only with well-formed numbers (current behavior is - documented, not exploited, per this task's instructions not to modify source). - -## Proposed fix -1. In `saveFamily` (`actions.ts`), parse the incoming `household` argument with - `HouseholdSchema.safeParse()` before calling `FamilyService.saveHousehold()`, - returning an `ActionError` on failure — mirroring the "Validate at API - boundaries" guidance in CLAUDE.md. -2. Defense in depth: in `resolveUniqueEnvelopeNo` and `upsertDonor`, call - `validatePositiveInt` (or an equivalent nullable-safe check) on `requested`/ - `envelopeNo` and `excludeDonorId`/`existingDonorId` before they are - interpolated into any `$filter` string. - -## Impact if not fixed -An authenticated user with write access to `Households` (the only gate this -action checks) can submit a crafted `envelopeNo` or `donorId` value that is -not a plain positive integer. Depending on how the MP OData-style `$filter` -parser handles the resulting string, this ranges from a confusing 400/500 -error to a filter-injection primitive against the `Donors` table — the same -class of risk rule 11 exists to close off for string fields. - ---- - -## Resolution (2026-09-13) -Both layers of the proposed fix were applied. - -**1. Parse at the action boundary.** `saveFamily` now runs -`HouseholdSchema.safeParse(household)` before the payload reaches -`FamilyService`, and returns an `ActionError` on failure. The error reports the -offending field PATHS only (`members.0.envelopeNo`), never the submitted -values — rule 14 applies to returned error strings, which travel further than -logs. Authorization still runs first, so an unauthorized caller learns nothing -about the schema. - -**2. Validate where the filter is built.** `resolveUniqueEnvelopeNo` calls -`validatePositiveInt` on `requested` and on `excludeDonorId`, and re-validates -`candidate` on every loop iteration so a future change to -`getNextEnvelopeNumber()` cannot reintroduce an unchecked value. `upsertDonor` -validates `existingDonorId` before using it to target the `Donors` update. -`null` and `0` are preserved as the "no donor to exclude" sentinels rather than -being rejected. - -### Correction to the original severity assessment -Re-reading this while fixing it: the classic injection string was already -blocked, but incidentally rather than by design. `upsertDonor` guards with -`envelopeNo > 0`, and JS coerces `"1 OR 1=1"` to `NaN`, making that comparison -false — so the crafted string never reached the filter. What *did* get through -were values that survive numeric coercion but are not positive integers: -`1.5`, `Infinity`, `1e21` (which interpolates as the malformed `1e+21`), and -numeric strings. Those produce malformed filters and confusing 400/500s, not a -filter-injection primitive. - -So the practical severity was lower than "high" as originally filed. The fix is -still correct and worth keeping: relying on an incidental coercion side effect -to block injection is fragile, and a schema change or a new call path that -drops the `> 0` guard would turn it into the real thing with nothing to catch -it. Recorded here so the next reader is not misled by the original framing. - -### Tests -- `src/app/(web)/tools/addeditfamily/actions.test.ts` — 11 cases: injection-shaped - `envelopeNo`/`donorId`, non-integer/array/object/boolean fields, missing - top-level fields, path reporting, no value echoed back, authorize-before-validate. -- `src/services/familyService.test.ts` — 7 cases asserting no malformed value - ever reaches an MP query, that `donorId: 0` is still accepted as "none", and - that well-formed input produces the exact expected filter string. diff --git a/.claude/TODO/INDEX.md b/.claude/TODO/INDEX.md index e6b6dc6..3ab3e42 100644 --- a/.claude/TODO/INDEX.md +++ b/.claude/TODO/INDEX.md @@ -1,20 +1,16 @@ --- title: TODO Index type: index -last_updated: 2026-09-13 +last_updated: 2026-10-10 --- - - - - - - - - # TODO Index -Open TODOs from the context-engineering review (2026-04-17) and later additions. +This folder lists **open work only**. When a TODO is finished, or we decide not +to do it, its file is **deleted**, not marked resolved. The commit that removes +it is the record (`git log --diff-filter=D -- .claude/TODO/`). See +[`SCHEMA.md`](SCHEMA.md) § Lifecycle. + Severity tiers: - **critical**: security hole, data loss, auth bypass @@ -22,19 +18,7 @@ Severity tiers: - **medium**: doc drift, missing test, refactor with real cost - **low**: nits, minor doc fixes, stylistic improvements -Total: **4 open TODOs**. - -> **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 -> tests in 116 files. Most of the items below were opened during that work, -> found by reading code while writing tests for it. A follow-up pass cleaned -> the runner output from 1,507 lines to 17 with zero warnings. -> -> **2026-09-13 (later) — remediation.** All three high-severity items and the -> CI gap are fixed and closed; see each file's Resolution section. CI now runs -> lint and `tsc --noEmit` as required steps, and installs with `npm ci`. -> Everything still open is `medium` or below, and all of it sits in -> `src/components`. +Total: **0 open TODOs**. --- @@ -46,17 +30,11 @@ _none open_ ### High (0) _none open_ -### Medium (2) -| Area | Tags | Title | File | -|---|---|---|---| -| 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) | +### Medium (0) +_none open_ -### Low (2) -| Area | Tags | Title | File | -|---|---|---|---| -| 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) | +### Low (0) +_none open_ --- @@ -65,22 +43,16 @@ _none open_ ### security (0) _none open_ -### bug (3) -- components-template-editor-no-mp-persistence — medium -- components-template-editor-merge-token-resolver — medium -- search-empty-state-never-renders — low - -### drift (1) -- components-template-editor-no-mp-persistence — medium +### bug (0) +_none open_ -### missing-test (1) -- dead-empty-fields-branch-handlenext — low +### drift (0) +_none open_ -### refactor (2) -- components-template-editor-merge-token-resolver — medium -- dead-empty-fields-branch-handlenext — low +### missing-test (0) +_none open_ -### testing (0) +### refactor (0) _none open_ ### doc (0) @@ -95,33 +67,19 @@ _none open_ | Area | Count | |---|---| -| components | 4 | -| testing | 0 | +| components | 0 | +| routing | 0 | | services | 0 | | auth | 0 | +| commands | 0 | | mp-provider | 0 | +| testing | 0 | | utils | 0 | -| routing | 0 | | mp-schema | 0 | | contexts | 0 | | dto-constants | 0 | -| doc (cross-cutting) | 0 | - ---- - -## Recently resolved - -| Date | Title | File | -|---|---|---| -| 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) | -| 2026-09-13 | Radix Select fields switched uncontrolled -> controlled | [→](2026-09-13-components-select-uncontrolled-to-controlled.md) | -| 2026-09-13 | Unvalidated numeric fields reaching MP `$filter` strings | [→](2026-09-13-unvalidated-envelope-donor-ids-in-filter.md) | -| 2026-09-13 | `removeGroup` dropped a non-empty group's fields from the save payload | [→](2026-09-13-removegroup-order-guard-mismatch.md) | -| 2026-09-13 | Raw docxtemplater error logged household addresses | [→](2026-09-13-mergetemplate-logs-address-pii-on-error.md) | -| 2026-09-13 | `AddEditFamilyPage` logged the raw error object | [→](2026-09-13-page-logs-raw-error-object.md) | -| 2026-09-13 | No type-check gate in CI (also `npm ci`, lint, concurrency) | [→](2026-09-13-testing-no-typecheck-gate-in-ci.md) | +| data-flow | 0 | +| security | 0 | --- diff --git a/.claude/TODO/SCHEMA.md b/.claude/TODO/SCHEMA.md index b36c378..89df230 100644 --- a/.claude/TODO/SCHEMA.md +++ b/.claude/TODO/SCHEMA.md @@ -16,7 +16,7 @@ type: meta title: severity: critical | high | medium | low tags: [security | bug | drift | missing-test | refactor | doc | perf] -area: auth | mp-provider | services | components | contexts | routing | data-flow | testing | dto-constants | utils | commands +area: auth | mp-provider | mp-schema | services | components | contexts | routing | data-flow | security | testing | dto-constants | utils | commands files: [src/path/to/file.ts] discovered: YYYY-MM-DD discovered_by: @@ -37,6 +37,23 @@ Concrete, actionable. Specific file:line edits or command to run. Who is affected, how badly, when. ``` +## Lifecycle +This folder holds **open work only**. `status` is always `open`. + +- **Done or won't do → delete the file** in the same PR that finishes the work + or records the decision. Never leave a file marked `resolved`, `closed`, + `won't fix` or `superseded`. The commit message is the record: say what was + done or why it was dropped. Find past items with + `git log --diff-filter=D -- .claude/TODO/`. +- **Before deleting**, salvage what outlives the item: + - leftover follow-up work → a new TODO file; + - a lasting lesson or trap → `.claude/references/GOTCHAS.md`; + - a decision → `.claude/references/DECISIONS.md` (ADR). +- **Remove every inbound link** to the deleted file (`INDEX.md`, references, + code comments, other TODOs). A link to a missing TODO is drift. +- **Update `INDEX.md`** (counts, tables, tags, areas) in the same change. + `INDEX.md` has no "resolved" section. + ## Severity - **critical**: security hole, data loss risk, auth bypass - **high**: broken behavior, test failure, convention violation that causes bugs diff --git a/.claude/commands/newstoredproc.md b/.claude/commands/newstoredproc.md index c675c8c..9c5fb8a 100644 --- a/.claude/commands/newstoredproc.md +++ b/.claude/commands/newstoredproc.md @@ -167,5 +167,5 @@ All INSERT sections use `IF NOT EXISTS` guards so they skip gracefully on re-run - The unified install script at `_INSTALL/ministryplatform-install.sql` is auto-generated by the build — never edit it manually - SQL files in `src/lib/providers/ministry-platform/db/` are combined alphabetically by the build script - Domain_ID is hardcoded to `1` in role grants (matches existing pattern across all deployment scripts) -- If the procedure needs to be callable from the app, a corresponding service method should be added — see `src/services/` and `.claude/references/services.md` +- If the procedure needs to be callable from the app, a corresponding service method should be added (it must gate with `AuthorizationService.requireSecurityRole()` and must NOT pass `@DomainID` in the body) — see `src/services/`, `.claude/references/services/README.md`, and add the proc to `.claude/references/mp-schema/required-procs.md` - **`@DomainID INT` is required by the Ministry Platform API for all `api_*` stored procedures.** The platform passes the current domain ID automatically when calling procedures through the API. If this parameter is missing, the procedure will not be callable via the API even if it is registered in `dp_API_Procedures`. diff --git a/.claude/commands/newtool.md b/.claude/commands/newtool.md index 3ce740d..0819537 100644 --- a/.claude/commands/newtool.md +++ b/.claude/commands/newtool.md @@ -28,13 +28,18 @@ Create `src/app/(web)/tools/{routeName}/page.tsx`: ```tsx import { {componentName} } from "./{fileName}"; -import { parseToolParams } from "@/lib/tool-params"; +import { requireToolAccess } from "@/app/(web)/tools/require-tool-access"; +import { parseToolParams } from "@/lib/tool-params.server"; interface {componentName}PageProps { searchParams: Promise<{ [key: string]: string | string[] | undefined }>; } export default async function {componentName}Page({ searchParams }: {componentName}PageProps) { + // Self-gating, before ANY param parsing or data call — see require-tool-access.ts. + // Pick the MP table/operation this tool actually needs. + await requireToolAccess({ table: "dp_Tools", operation: "read" }); + const params = await parseToolParams(await searchParams); return <{componentName} params={params} />; @@ -54,7 +59,7 @@ Create `src/app/(web)/tools/{routeName}/{fileName}.tsx`: ```tsx "use client"; -import { useState, useEffect } from "react"; +import { useState } from "react"; import { useRouter } from "next/navigation"; import { ToolContainer } from "@/components/tool"; import { Users } from "lucide-react"; @@ -69,19 +74,10 @@ export function {componentName}({ params }: {componentName}Props) { const [isSaving, setIsSaving] = useState(false); const isNew = isNewRecord(params); - useEffect(() => { - console.log("Tool launched with params:", params); - console.log("Mode:", isNew ? "Create New" : "Edit Existing"); - if (params.recordDescription) { - console.log("Editing record:", params.recordDescription); - } - }, [params, isNew]); - const handleSave = async () => { setIsSaving(true); await new Promise((resolve) => setTimeout(resolve, 1000)); setIsSaving(false); - console.log("Saved!", { params }); }; const handleClose = () => { @@ -163,6 +159,8 @@ After creating all files, confirm the new tool by listing the created files and - [ ] Tool component created at `src/app/(web)/tools/{routeName}/{fileName}.tsx` - [ ] Homepage card added to `src/app/(web)/page.tsx` - [ ] All placeholder text replaced with the tool's displayName +- [ ] `page.tsx` calls `requireToolAccess()` before parsing params, and imports `parseToolParams` from `@/lib/tool-params.server` (not `@/lib/tool-params`, which must stay client-safe) +- [ ] No `console.log` (ESLint `no-console` allows only `warn`/`error`) - [ ] Component uses named export (no default export) — except `page.tsx` which requires default export per Next.js ## Multiple Tools @@ -178,5 +176,7 @@ Creates both tools and adds both cards to the homepage. ## Notes - The `ToolContainer` renders a `DevPanel` above the title bar automatically on localhost in development builds — no manual scaffolding needed. +- `tools/layout.tsx` only redirects role-less users for UX; it does **not** protect the page. Each page gates itself with `requireToolAccess` (`src/app/(web)/tools/require-tool-access.ts`), and any server action / service the tool adds must gate again with `AuthorizationService.requireSecurityRole()` (CLAUDE.md practice #12). +- Add co-located tests (`page.test.tsx`, `{fileName}.test.tsx`) — CI enforces coverage thresholds (`vitest.config.mts`). - 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`, `fieldmanagement`, `addeditfamily`) diff --git a/.claude/commands/release.md b/.claude/commands/release.md index 23726d1..54d794b 100644 --- a/.claude/commands/release.md +++ b/.claude/commands/release.md @@ -20,8 +20,8 @@ Create a GitHub release with auto-generated release notes from merged pull reque - Run `git log --oneline` (from last release tag to HEAD, or recent commits if first release) to understand what's new 3. **Identify PRs to include:** - - If this is the first release, run `gh pr list --state merged --limit 20 --json number,title,mergedAt,body,labels` to get recent merged PRs - - If a previous release exists, find PRs merged since that release using `gh pr list --state merged --search "merged:>YYYY-MM-DD" --json number,title,mergedAt,body,labels` + - If this is the first release, run `gh pr list --base dev --state merged --limit 20 --json number,title,mergedAt,body,labels` to get recent merged PRs + - If a previous release exists, find PRs merged since that release using `gh pr list --base dev --state merged --search "merged:>YYYY-MM-DD" --json number,title,mergedAt,body,labels` (feature PRs target `dev`; add `--base main` hotfix PRs if any) - Present the list of PRs to the user and ask which to include (default: all) - Exclude the `dev` → `main` release PR itself — it is a promotion, not a change @@ -105,7 +105,7 @@ gh release list --limit 5 git tag --sort=-version:refname | head -5 # 2. Get merged PRs since last release -gh pr list --state merged --limit 20 --json number,title,mergedAt,body,labels +gh pr list --base dev --state merged --limit 20 --json number,title,mergedAt,body,labels # 3. Get repo info for changelog URL gh repo view --json nameWithOwner --jq .nameWithOwner diff --git a/.claude/commands/update-deps.md b/.claude/commands/update-deps.md index dff63a9..a670a91 100644 --- a/.claude/commands/update-deps.md +++ b/.claude/commands/update-deps.md @@ -45,7 +45,8 @@ Record these numbers — they are the comparison for everything that follows: ```bash npm run test:run # test + file counts npm run lint -npm run build 2>&1 | grep -cE "error TS" # may be non-zero already +npm run typecheck 2>&1 | grep -cE "error TS" # may be non-zero already +npm run build # needs a valid .env.local — the app refuses to boot (even at build) on missing/weak secrets npm audit node -v && npm -v ``` @@ -112,8 +113,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. @@ -123,7 +124,8 @@ Then install that **one** package, and verify: npm install @ npm run test:run npm run lint -npm run build 2>&1 | grep -cE "error TS" # compare to baseline +npm run typecheck 2>&1 | grep -cE "error TS" # compare to baseline +npm run build && npm run check:prerender ``` - Any new type error is a real break even if tests pass — find the migration @@ -170,8 +172,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`). @@ -208,7 +209,8 @@ adds no install scripts. Otherwise pin to the previous version and note why. held with clearing conditions**, and any pre-existing issues found but not fixed. - Add a row to the index table in `.claude/packages/README.md`. -- Add a row to the **Dependency Audit History** table in `CLAUDE.md`. +- Add a row to the **Dependency Audit History** table in `CLAUDE.md`, and update + its **Current holds** table to match the new record. - Commit in the same separable units used above (in-range / removals / each major), then open a PR targeting `dev`. 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/2026-10-10.md b/.claude/packages/2026-10-10.md new file mode 100644 index 0000000..ad7995e --- /dev/null +++ b/.claude/packages/2026-10-10.md @@ -0,0 +1,150 @@ +# Dependency Audit — 2026-10-10 + +**Branch:** `chore/dependency-audit-2026-10` · **Node:** 24.18.0 · **npm:** 11.16.0 + +## Result + +| | Before | After | +|---|---|---| +| `npm audit` | 7 high (3 root causes) | **5 high (1 root cause, no fix published)** | +| Direct dependencies | 57 | 57 | +| Tests | 2076 passing / 116 files | **2076 passing / 116 files** | +| `eslint .` | clean | clean | +| `tsc --noEmit` type errors | 0 | 0 | +| `next build` | passes | passes | +| Coverage (`coverage/coverage-final.json`) | written | written; 98.97% stmts / 99.71% lines | +| OSV.dev sweep | — | 431 packages, 1 hit (`braces`, same as `npm audit`) | + +--- + +## 1. Security + +### Fixed by in-range updates + +**`next` 16.3.7 → 16.4.0** — six high advisories, all `16.0.0 – 16.3.7`: + +| Advisory | Issue | Exposure here | +|---|---|---| +| [GHSA-cjq9-62q9-8jv4](https://github.com/advisories/GHSA-cjq9-62q9-8jv4) | SSRF in Image Optimization | Real on any deployment that uses `next/image` | +| [GHSA-mcj8-r9mp-w47p](https://github.com/advisories/GHSA-mcj8-r9mp-w47p) | SSG/ISR cache poisoning → cross-user content substitution | Low: tool pages are dynamic and auth-gated | +| [GHSA-4jqv-mc3x-m676](https://github.com/advisories/GHSA-4jqv-mc3x-m676) | SSG/ISR cache poisoning, self-hosted | Not applicable on Vercel | +| [GHSA-3w37-wq28-93x7](https://github.com/advisories/GHSA-3w37-wq28-93x7) | `use cache` fill leaks Draft Mode content | Not used | +| [GHSA-f87g-xv8r-7p7x](https://github.com/advisories/GHSA-f87g-xv8r-7p7x) | Metadata image routes, `dynamicParams` bypass | Low | +| [GHSA-39w2-rjm5-chcv](https://github.com/advisories/GHSA-39w2-rjm5-chcv) | Dev-server MCP endpoint info disclosure | Dev only | + +**`source-map-js` 1.2.1** — [GHSA-68fv-2mgg-jv7q](https://github.com/advisories/GHSA-68fv-2mgg-jv7q) +(event-loop DoS from indexed source-map offsets). Cleared transitively by the +same `npm update` (via `postcss`, `next`, `jsdom`, Tailwind, Vitest). + +### Open, no fix exists: `braces` (accepted, lint-time only) + +[GHSA-vfj7-8cjw-p6xm](https://github.com/advisories/GHSA-vfj7-8cjw-p6xm), +published 2026-09-18: stack-exhaustion DoS from deeply nested brace patterns. +OSV range is `introduced 0, last_affected 3.0.3`. That is a real range, not the +malformed `introduced: 0` with no events, and 3.0.3 is the **latest published +`braces`** (last publish 2024-09). So no patched version exists. + +`npm audit` counts it **5 times**, once per node of the chain: + +``` +eslint-config-next@16.4.0 +└─ @next/eslint-plugin-next@16.4.0 (pins fast-glob 3.3.1 exactly) + └─ fast-glob@3.3.1 + └─ micromatch@4.0.8 + └─ braces@3.0.3 +``` + +The fix `npm audit` offers is `eslint-config-next@14.2.35`. That is a downgrade +across two majors, so it is not a fix. An `overrides` entry cannot help either, +because there is no fixed `braces` to override to. + +**Exposure is lint-time only.** The only path is the ESLint plugin, which globs +patterns from the repo's own config. It is not in the app bundle and never sees +request data. Accepted. It clears when `braces` publishes a fix (or +`micromatch`/`fast-glob` drops it) **and** `@next/eslint-plugin-next` moves off +its exact `fast-glob@3.3.1` pin, if the fix doesn't land in 3.3.1's own range: + +```bash +npm view braces version && npm view @next/eslint-plugin-next dependencies +``` + +### Supply-chain checks + +- No deprecated packages in the resolved tree. +- Fresh releases (< 2 days), all accepted: + - `lucide-react@1.55.0` (0.3d): published by GitHub Actions **with + provenance**, as 1.54.0 was. No install scripts. + - `dotenv@18.0.7` (0.9d): no provenance, same as every prior release. The same + maintainer (`motdotla`) publishes manually. Zero dependencies, no install + scripts. Only the codegen scripts use it. + - `@types/node@24.19.2` (0.8d): published by the DefinitelyTyped `types` bot, + which never attaches provenance. Types only. +- Every other upgraded direct package was published by GitHub Actions with + provenance, matching its previous version, and none adds an install script. +- **npm 11 `allowScripts`**: still warns on `esbuild` and `unrs-resolver`, both at + unchanged versions. Still open, see §5. + +--- + +## 2. In-range updates + +| Package | From → To | +|---|---| +| `next`, `eslint-config-next` | 16.3.7 → 16.4.0 | +| `better-auth` | 1.7.6 → 1.7.7 | +| `docx` | 9.8.1 → 9.9.0 | +| `dotenv` | 18.0.4 → 18.0.7 | +| `lucide-react` | 1.49.0 → 1.55.0 | +| `vitest`, `@vitest/coverage-v8` | 5.0.2 → 5.0.3 | +| `jsdom` | 30.1.1 → 30.1.2 | +| `@vitejs/plugin-react` | 6.1.1 → 6.1.2 | +| `postcss` | 8.5.28 → 8.5.29 | +| `@inquirer/prompts` | 8.7.2 → 8.7.3 | +| `@types/node` | 24.19.0 → 24.19.2 (stays on 24) | +| `@radix-ui/*` (12 packages) | patch/minor bumps. `react-dialog`, `react-popover` 1.1.23 → 1.2.0; `react-slot` 1.3.3 → 1.4.0; `react-tooltip` 1.2.16 → 1.3.0 | + +Verified: tests, lint and type-error count are identical to baseline, +`next build` passes, and `coverage/coverage-final.json` is still written. + +--- + +## 3. Removed: none + +The same set as 2026-09-30 has no direct import. Each is still reached another +way: `react-dom` (peer of `next`), `tsx` (package scripts), `@types/node`, +`@types/react`, `@types/react-dom` (ambient types), `@vitest/coverage-v8` +(`provider: 'v8'`), `typescript` (toolchain), `postcss` (deliberate root pin). +`@types/mjml` was on that list and went with #40. + +--- + +## 4. Majors + +### Adopted: none + +The only majors available are the three held packages below. + +### Held: all re-checked, all still blocked + +| Package | Re-check result (2026-10-10) | Clears when | +|---|---|---| +| `typescript` 6.0.3 → 7.0.2 | `typescript-eslint` 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.12.0 | `eslint-plugin-react@7.37.5` is still latest, still peers `eslint ^9.7` at most. `eslint-config-next@16.4.0` still depends on it | `eslint-plugin-react` supports `^10` and `eslint-config-next` picks it up | +| `@types/node` 24 → 26.6.5 | Vercel still lists **24.x (default)**, 22.x, 20.x | Vercel offers a newer default Node major | + +**Retired hold:** `grapesjs` 0.23. The package was removed in #40, so the +`@grapesjs/react` peer blocker no longer applies. Its standing OSV false +positive (GHSA-589f-c66p-hxr4) is also gone from the sweep. + +--- + +## 5. Pre-existing issues: status + +Unchanged from 2026-09-30, NOT fixed here: + +- **npm 11 `allowScripts`.** `esbuild` and `unrs-resolver` `postinstall` + scripts are still unapproved. The fix is a reviewed + `npm approve-scripts esbuild unrs-resolver`. That is a policy decision, not a + version change. +- **Test performance hint.** Vitest reports jsdom created 116 times (~58% of + tracked time) and suggests `pool: 'vmThreads'`. Not attempted. diff --git a/.claude/packages/README.md b/.claude/packages/README.md index b2b2027..25a7c12 100644 --- a/.claude/packages/README.md +++ b/.claude/packages/README.md @@ -18,6 +18,8 @@ later audit can re-check in one command instead of re-deriving the analysis. | Date | Summary | |---|---| +| [2026-10-10](2026-10-10.md) | `next` → 16.4.0 cleared 6 high (image-optimizer SSRF, ISR cache poisoning) plus `source-map-js` DoS. `braces` DoS open with no published fix (lint-time only). TS 7 / ESLint 10 / `@types/node` holds re-checked: still blocked. GrapesJS hold retired (#40). | +| [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/references/DECISIONS.md b/.claude/references/DECISIONS.md index a3ea270..f6a2234 100644 --- a/.claude/references/DECISIONS.md +++ b/.claude/references/DECISIONS.md @@ -1,7 +1,7 @@ --- title: Architectural Decisions type: adr -last_updated: 2026-04-17 +last_updated: 2026-10-10 --- # Architectural Decisions @@ -16,6 +16,7 @@ Architectural decisions captured by the context-engineering review at SHA `971c4 - [ADR-003: `customSession` does name-splitting only; MP profile loaded on client](#adr-003-customsession-does-name-splitting-only-mp-profile-loaded-on-client) - [ADR-004: Dual-layer route protection — edge proxy + server-component validation](#adr-004-dual-layer-route-protection--edge-proxy--server-component-validation) - [ADR-005: Route group `(web)` for authenticated pages; `/signin` and `/api/auth` outside the group](#adr-005-route-group-web-for-authenticated-pages-signin-and-apiauth-outside-the-group) +- [ADR-027: One MP OAuth client for the service account and user sign-in](#adr-027-one-mp-oauth-client-for-the-service-account-and-user-sign-in) ### MP Provider - [ADR-006: Singleton `MinistryPlatformProvider` with a shared `MinistryPlatformClient`](#adr-006-singleton-ministryplatformprovider-with-a-shared-ministryplatformclient) @@ -57,7 +58,7 @@ Architectural decisions captured by the context-engineering review at SHA `971c4 **Date:** 2026-04-17 **Status:** Accepted **Context:** The app authenticates users against Ministry Platform's OIDC provider, which has several non-standard traits (custom `realm` authorization param, MP-specific scope `http://www.thinkministry.com/dataplatform/scopes/all`, dedicated `/oauth/connect/userinfo` endpoint for user lookup). The auth layer must run cleanly on Next.js 16 App Router with Turbopack. `src/lib/auth.ts` still reads `NEXTAUTH_URL` / `NEXTAUTH_SECRET` as fallbacks, evidence that the codebase migrated from NextAuth. -**Decision:** Use Better Auth 1.5 (`better-auth@^1.5.5`) with the `genericOAuth` plugin, a custom `getUserInfo` that calls `/oauth/connect/userinfo`, and `mapProfileToUser` that stores the OIDC `sub` claim as a user `additionalField` named `userGuid`. Wire Next.js integration with `nextCookies()` plugin and mount `toNextJsHandler(auth)` at `/api/auth/[...all]`. +**Decision:** Use Better Auth (adopted at 1.5; now `better-auth@^1.7.7`) with the `genericOAuth` plugin, a custom `getUserInfo` that calls `/oauth/connect/userinfo`, and `mapProfileToUser` that stores the OIDC `sub` claim as a user `additionalField` named `userGuid`. Wire Next.js integration with `nextCookies()` plugin and mount `toNextJsHandler(auth)` at `/api/auth/[...all]`. **Consequences:** Custom `getUserInfo` and `mapProfileToUser` are required for MP's OIDC quirks. `additionalFields` isn't in the `genericOAuth` option types, so a `Record` cast is needed when returning from `mapProfileToUser`. First-class Next.js 16 integration keeps route handlers minimal. **Alternatives considered:** - **NextAuth v5** — heavier, less flexible for MP's custom OIDC details; migration evidence suggests it was the previous choice and was replaced. @@ -71,6 +72,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. @@ -80,8 +82,9 @@ Architectural decisions captured by the context-engineering review at SHA `971c4 **Date:** 2026-04-17 **Status:** Accepted **Context:** A tempting design is to enrich the session object inside `customSession` with the user's full MP profile (roles, user groups, Contact_ID, Image_GUID). Every consumer would then read a single object. The downside is that `customSession` runs on every cache miss, and MP profile lookups require extra MP API calls (`dp_Users` + `dp_User_Roles` + `dp_User_User_Groups`). -**Decision:** `customSession` in `src/lib/auth.ts:398-413` does only `firstName` / `lastName` splitting from `user.name` — no API calls. MP profile loading moves to the client, behind `UserProvider` (`src/contexts/user-context.tsx`), which calls the parameterless `getCurrentUserProfile()` server action on mount (the action re-derives the GUID from the session — a GUID parameter would be an IDOR) and exposes `useUser()`. +**Decision:** `customSession` (callback extracted as `enrichSession` in `src/lib/auth.ts`) does only `firstName` / `lastName` splitting from `user.name` — no API calls. MP profile loading moves to the client, behind `UserProvider` (`src/contexts/user-context.tsx`), which calls the parameterless `getCurrentUserProfile()` server action on mount (the action re-derives the GUID from the session — a GUID parameter would be an IDOR) and exposes `useUser()`. **Consequences:** `getSession()` stays cheap. Sign-in does not break when MP is down. Client components that need roles/groups must mount under `UserProvider`; every page load incurs one extra round-trip. `UserService.getUserProfile()` issues three queries (profile + roles + groups). +**Note (2026-10-10):** `MPUserProfile` is now display-only (`First_Name`, `Nickname`, `Last_Name`, `Email_Address`) and `getUserProfile()` issues **one** `dp_Users` query; roles and user groups are no longer sent to the client. Security-role checks use `AuthorizationService` server-side. See `services/user-service.md`. **Alternatives considered:** - **Enrich in `customSession`** — would hit MP API on every JWT refresh and couple sign-in availability to MP uptime. - **Load profile server-side in layout** — blocks TTFB on every page render. @@ -92,10 +95,10 @@ Architectural decisions captured by the context-engineering review at SHA `971c4 **Status:** Accepted **Context:** Unauthenticated requests should be redirected as early and cheaply as possible, but stale/forged cookies must still be rejected before any MP API call. Better Auth's full `auth.api.getSession()` is Node-only and too heavy for the edge runtime. **Decision:** Two-layer gate: -- **Layer 1 — edge proxy** (`src/proxy.ts`): uses `getSessionCookie()` from `better-auth/cookies` to do a cheap cookie-presence check and redirects to `/signin?callbackUrl=` if absent. Also sets an `x-pathname` header forwarding the original URL. +- **Layer 1 — edge proxy** (`src/proxy.ts`): uses `getSessionCookie()` from `better-auth/cookies` to do a cheap cookie-presence check and redirects to `/signin?callbackUrl=` if absent. Also sets an `x-pathname` header forwarding the original URL (and, since security Step 6, the per-request CSP nonce). - **Layer 2 — server component** (`src/components/layout/auth-wrapper.tsx`): calls `auth.api.getSession()` inside the `(web)` route group and redirects using the forwarded `x-pathname` if the session is missing or invalid. -**Consequences:** Fast happy path at the edge; full validation before any protected render. Auth logic lives in two files that must stay in sync. Public paths (`/api/*`, `/signin`) are whitelisted in the proxy. +**Consequences:** Fast happy path at the edge; full validation before any protected render. Auth logic lives in two files that must stay in sync. Public paths (`/api/auth`, `/api/auth/*`, `/signin`, `/auth-error` — `isPublicPath()` in `src/proxy.ts`) are allowlisted; every other `/api/*` route needs the session cookie. Since 2026-09-13 the real authorization gate is `AuthorizationService` in the services layer (see ADR-014). **Alternatives considered:** - **Single full-validation gate in the proxy** — Better Auth's full session validation isn't cheap enough for edge and has Node-only dependencies. - **Skip edge check entirely** — every request would render a server component just to redirect, slower and more expensive than a cookie peek. @@ -105,12 +108,22 @@ Architectural decisions captured by the context-engineering review at SHA `971c4 **Date:** 2026-04-17 **Status:** Accepted **Context:** All authenticated pages need a shared layout with `AuthWrapper` and the client `Providers` (which mounts `UserProvider`). The sign-in page and Better Auth's catch-all handler must render without a valid session. -**Decision:** Use Next.js App Router route groups. Authenticated pages live under `src/app/(web)/` and inherit its `layout.tsx`. Public routes (`src/app/signin/`, `src/app/api/auth/[...all]/`) sit as siblings outside the group. +**Decision:** Use Next.js App Router route groups. Authenticated pages live under `src/app/(web)/` and inherit its `layout.tsx`. Public routes (`src/app/signin/`, `src/app/api/auth/[...all]/`, `src/app/auth-error/`, `src/app/session-error/`) sit as siblings outside the group. **Consequences:** Clean separation of public vs. authenticated surfaces. Adding a new tool is a one-directory drop under `src/app/(web)/tools/`. Easy to accidentally place a public page inside `(web)/`, so reviewers must watch for this. **Alternatives considered:** - **Per-page auth checks** — copy-paste prone; easy to forget on new pages. - **Single top-level layout with conditional branch** — awkward in the App Router model and wastes a layout slot. +### ADR-027: One MP OAuth client for the service account and user sign-in + +**Date:** 2026-10-10 +**Status:** Accepted (reverses security Step 4's dedicated OIDC client) +**Context:** Step 4 (2026-09-30) added `MP_OIDC_CLIENT_ID` / `MP_OIDC_CLIENT_SECRET` so user sign-in would run as its own MP client, with a fallback to the service account while environments were split. That meant two MP client registrations and two secrets per deployment, plus a fallback path and a warning to maintain. +**Decision:** The app has exactly one MP OAuth client, `MINISTRY_PLATFORM_CLIENT_ID` / `MINISTRY_PLATFORM_CLIENT_SECRET`, read through `getMpClient()` in `src/lib/env.ts`. It is the client-credentials service account and the OIDC sign-in client; its id is the id_token audience and the sign-out `client_id`. The `MP_OIDC_*` variables, `getOidcClient`, `warnIfSharedOidcClient` and the `auth.oidc.shared_client` event were removed. +**Consequences:** One registration and one secret per deployment, and no half-configured state. Accepted risk: that one secret is both full MP data access and user sign-in, and an id_token minted for the service account's client is a valid sign-in token. The MP client record must carry the sign-in redirect URIs (`/api/auth/callback/ministryplatform`, post-logout ``) and nothing broader. +**Alternatives considered:** +- **Dedicated OIDC client (Step 4)** — narrower blast radius per secret, but double the client management for every deployment and customer clone. + --- ## MP Provider @@ -149,11 +162,12 @@ Architectural decisions captured by the context-engineering review at SHA `971c4 **Date:** 2026-04-17 **Status:** Accepted **Context:** MP's OAuth server returns `expires_in: 3600` (1 hour), but tokens have been observed to become invalid earlier — server restarts, credential rotations, and other backend events invalidate tokens without client notice. Trusting `expires_in` causes 401 storms. -**Decision:** Ignore `expires_in`. `TOKEN_LIFE = 5 * 60 * 1000` is hard-coded in `src/lib/providers/ministry-platform/client.ts:6` and used for both `expiresAt` and `devExpiresAt`. `ensureValidToken()` refreshes whenever `expiresAt < Date.now()`, so at most one 5-minute window is stale. -**Consequences:** Predictable refresh cadence; worst-case staleness is 5 minutes. OAuth traffic is ~12× per hour per process vs. once. No automatic recovery for sub-5-minute invalidations (no retry-on-401). +**Decision:** Ignore `expires_in`. `TOKEN_LIFE = 5 * 60 * 1000` is hard-coded in `src/lib/providers/ministry-platform/client.ts:8` and used by each `TokenPipeline` (default and dev). `TokenPipeline.ensureValid()` refreshes whenever `expiresAt < Date.now()`, so at most one 5-minute window is stale. +**Consequences:** Predictable refresh cadence; worst-case staleness is 5 minutes. OAuth traffic is ~12× per hour per process vs. once. +**Amended 2026-09-30 (security Step 5):** sub-5-minute invalidations are now recovered — the `HttpClient` 401 hook invalidates the rejected token, refreshes once (single-flight) and retries the request once; a failed refresh is negative-cached for 5–30 s (jittered). See `mp-provider/client.md`. **Alternatives considered:** - **Use `expires_in` minus 60-second buffer** — fragile against early invalidation by the MP backend. -- **Retry-on-401 with single refresh** — complicates error paths and risks infinite-loop bugs. +- **Retry-on-401 with single refresh** — originally rejected (infinite-loop risk); adopted alongside the 5-minute lifetime in Step 5, bounded to one retry. - **Heartbeat refresh** — extra cost without clear benefit. ### ADR-009: Two isolated token pipelines (default + dev) with `api_dev_*` routing @@ -161,7 +175,7 @@ Architectural decisions captured by the context-engineering review at SHA `971c4 **Date:** 2026-04-17 **Status:** Accepted **Context:** Tools like the Deploy Tool (`DeployToolPanel` → `api_dev_DeployTool`) must run against a non-production MP credential set to avoid accidental prod mutation during local development. Mixing credentials into a single client risks state leakage between requests. -**Decision:** `MinistryPlatformClient` holds two isolated pipelines — `(token, expiresAt, httpClient)` and `(devToken, devExpiresAt, devHttpClient)`. `getClientCredentialsToken(profile: 'default' | 'dev')` selects which env-var pair to use (`MINISTRY_PLATFORM_CLIENT_ID/SECRET` vs `MINISTRY_PLATFORM_DEV_CLIENT_ID/SECRET`). `ProcedureService` routes only procedures whose name starts with `api_dev_` through the dev pipeline. +**Decision:** `MinistryPlatformClient` holds two isolated `TokenPipeline` instances — `main` and `dev` — each with its own token, expiry, single-flight refresh and `HttpClient`. `getClientCredentialsToken(profile: 'default' | 'dev')` selects which env-var pair to use (`MINISTRY_PLATFORM_CLIENT_ID/SECRET` vs `MINISTRY_PLATFORM_DEV_CLIENT_ID/SECRET`). `ProcedureService` routes only procedures whose name starts with `api_dev_` through the dev pipeline. **Consequences:** Clean isolation; dev token refresh cannot touch prod token. Two `HttpClient` instances in memory is negligible. Dev env vars are optional at startup — the dev pipeline throws a clear error only when a caller actually invokes it without the vars set. **Alternatives considered:** - **Single pipeline with a pre-call hook swapping credentials** — easy to miss a swap-back, high blast radius. @@ -194,8 +208,8 @@ Architectural decisions captured by the context-engineering review at SHA `971c4 **Date:** 2026-04-17 **Status:** Accepted **Context:** MP's database has hundreds of tables and stored procedures (current facts baseline: 603 generated model files). Hand-maintaining a catalog would drift constantly. -**Decision:** `npm run mp:generate:models` produces `src/lib/providers/ministry-platform/models/*.ts` (TypeScript + Zod) using `--clean` to wipe the directory first; `npm run mp:generate:storedprocs` produces `.claude/references/ministryplatform.storedprocs.md`. `required-stored-procs.md` is hand-written for the handful of procs the app actually invokes (facts snapshot lists 8). -**Consequences:** Schema reference docs stay in sync with MP after a regen. `required-stored-procs.md` must be manually updated whenever a service method wraps a new proc. +**Decision:** `npm run mp:generate:models` produces `src/lib/providers/ministry-platform/models/*.ts` (TypeScript + Zod) using `--clean` to wipe the directory first; `npm run mp:generate:storedprocs` writes `mp-schema/stored-procs.md`, and the type generator writes `mp-schema/tables.md` (both keep their frontmatter; a `--search` run never overwrites the full catalog). `mp-schema/required-procs.md` is hand-written for the handful of procs the app actually invokes. +**Consequences:** Schema reference docs stay in sync with MP after a regen (move the output into `mp-schema/`). `mp-schema/required-procs.md` must be manually updated whenever a service method wraps a new proc. **Alternatives considered:** - **Hand-maintained catalog** — untenable at MP's scale. - **On-the-fly MP API lookup during agent work** — latency, no offline grep, and no stable reference. @@ -208,8 +222,8 @@ Architectural decisions captured by the context-engineering review at SHA `971c4 **Date:** 2026-04-17 **Status:** Accepted -**Context:** All five app services (`UserService`, `ToolService`, `AddressLabelService`, `GroupService`, `FieldManagementService`) need a single shared `MPHelper` per process. Construction is currently synchronous but `initialize()` may become async (e.g., to warm a cache). Tests must be able to reset state between cases. -**Decision:** Each service has a private constructor, a private static `instance` field, and a public static `async getInstance()` that lazily constructs and awaits `initialize()` (see `src/services/userService.ts:29-35`). Tests reset via `(Service as any).instance = undefined` in `beforeEach`. +**Context:** The MP-backed app services (`UserService`, `ToolService`, `AddressLabelService`, `GroupService`, `FieldManagementService`, `FamilyService`, plus `GooglePlacesService`) need a single shared instance per process. Construction is currently synchronous but `initialize()` may become async (e.g., to warm a cache). Tests must be able to reset state between cases. +**Decision:** Each service has a private constructor, a private static `instance` field, and a public static `async getInstance()` that lazily constructs and awaits `initialize()` (see `src/services/userService.ts:30-36`). Tests reset via `(Service as any).instance = undefined` in `beforeEach`. Exceptions: `AuthorizationService` and `DomainTimezoneService` expose a **synchronous** `getInstance()`. **Consequences:** Single shared instance per process. Callers always `await` `getInstance()` even though today's `initialize()` is synchronous — future async setup won't break callers. Test isolation requires explicit reset. **Alternatives considered:** - **Module-level `const service = new Service()`** — loses test resettability. @@ -220,7 +234,7 @@ Architectural decisions captured by the context-engineering review at SHA `971c4 **Date:** 2026-04-17 **Status:** Accepted **Context:** Components should not know MP URL shapes, `$filter` / `$select` syntax, ambiguous-column prefixing rules, or `$userId` audit wiring. Concentrating these concerns keeps query conventions consistent. -**Decision:** Server actions always follow: `auth.api.getSession()` → `Service.getInstance()` → `service.method()` → `MPHelper`. Services own query strings, batching, Zod schemas, and audit parameters. +**Decision:** Server actions always follow: `Service.getInstance()` → `service.method()` → `MPHelper`. Services own query strings, batching, Zod schemas, and audit parameters. **Amended 2026-09-13:** each MP-touching service method gates itself with `AuthorizationService.requireSecurityRole()` (reads and writes) and takes `$userId` from its return value — a bare `auth.api.getSession()` in the action is not authorization (CLAUDE.md practices #12, #13). **Consequences:** One place to change an ambiguous-column fix or a batching strategy. Clean test seam: per-service mocks of `MPHelper` cover most server-action tests. **Alternatives considered:** - **Actions call `MPHelper` directly** — duplicates query strings across features, drifts on ambiguous-column prefixes, and spreads `$userId` wiring. @@ -233,9 +247,9 @@ Architectural decisions captured by the context-engineering review at SHA `971c4 **Date:** 2026-04-17 **Status:** Accepted -**Context:** The Better Auth session contains identity claims (`id`, `email`, `userGuid`, `firstName`, `lastName`). Downstream features need roles, user groups, `Contact_ID`, and profile image — all of which require MP API calls and should not block the auth critical path. -**Decision:** `useAppSession()` (wraps `authClient.useSession()`) exposes only the lightweight Better Auth session. A separate `UserProvider` (`src/contexts/user-context.tsx`) loads the MP profile lazily by `userGuid` via `getCurrentUserProfile()` and exposes it through `useUser()`. `Providers` (`src/app/providers.tsx`) composes them. -**Consequences:** Auth-only consumers don't pay MP API latency. MP profile loads once per session on the client; refresh is explicit via `refreshUserProfile()`. Context value and callback are memoized (`useMemo`, `useCallback`). +**Context:** The Better Auth session contains identity claims (`id`, `email`, `userGuid`, `firstName`, `lastName`). Downstream UI needs the user's MP display profile, which requires an MP API call and should not block the auth critical path. +**Decision:** `useAppSession()` (wraps `authClient.useSession()`) exposes only the lightweight Better Auth session. A separate `UserProvider` (`src/contexts/user-context.tsx`) loads the MP profile via the parameterless `getCurrentUserProfile()` action and exposes it through `useUser()`. The load is **lazy on demand**: the provider makes no MP call until a component calls `useUser()` (whose effect calls an internal `requestProfile()` from a private context, flipping a `requested` flag), and then only once a `userGuid` is in the session; all consumers share that one load. `Providers` (`src/app/providers.tsx`) mounts `UserProvider`; `useAppSession()` is a standalone hook. Both hooks are kept as a documented **extension point** for tools built on the template — since the header `UserMenu` was removed (#54) nothing outside `src/contexts/` calls them, and with lazy fetch the mounted provider costs no MP request until a tool opts in. +**Consequences:** Auth-only consumers don't pay MP API latency. Pages with no `useUser()` consumer make no profile request. MP profile loads once per provider lifetime on the client, on first use; refresh is explicit via `refreshUserProfile()`. Context value and callback are memoized (`useMemo`, `useCallback`). **Alternatives considered:** - **Single merged provider** — couples auth availability to MP availability. - **Load profile server-side in layout** — blocks TTFB on every page. @@ -256,10 +270,10 @@ Architectural decisions captured by the context-engineering review at SHA `971c4 **Date:** 2026-04-17 **Status:** Accepted **Context:** Service code interpolates user input into MP OData `$filter` strings (e.g., `User_GUID = '${guid}'`). MP's API accepts raw filter strings — there is no parameterized query builder. Something must prevent SQL-like injection without forcing every call site to adopt a heavyweight ORM. -**Decision:** Four throwing helpers in `src/lib/validation.ts` — `validateGuid`, `validatePositiveInt`, `validateColumnName`, `escapeFilterString`. Callers invoke them on input and interpolate the returned value. Violations throw synchronously with a clear message. +**Decision:** Throwing helpers in `src/lib/validation.ts` — `validateGuid`, `validatePositiveInt`, `validateColumnName`, `validateFkPath`, `validateMailerId`, `escapeFilterString`. Callers invoke them on input and interpolate the returned value. Violations throw synchronously with a clear message. **Consequences:** Simple, testable (see `src/lib/validation.test.ts`), grep-friendly. Static types cannot enforce "this string was validated" — reviewers must spot missed validation at call sites. **Alternatives considered:** -- **Branded/opaque types** — over-engineered for a four-helper API. +- **Branded/opaque types** — over-engineered for a handful of helpers. - **Full ORM or query builder** — doesn't integrate with MP's REST surface. --- @@ -324,7 +338,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. @@ -360,7 +374,7 @@ Architectural decisions captured by the context-engineering review at SHA `971c4 **Date:** 2026-04-17 **Status:** Accepted **Context:** React component tests need a DOM that supports `localStorage`, `window.location`, `Headers`, and the full `@testing-library` API surface. -**Decision:** `environment: 'jsdom'` in `vitest.config.mts` with `jsdom@^30.0.1`. +**Decision:** `environment: 'jsdom'` in `vitest.config.mts` with `jsdom@^30.1.2`. **Consequences:** Slightly slower than happy-dom on pure-DOM tests, but compatibility is higher for edge APIs (`Headers`, `Storage`, `fetch` mocks). **Alternatives considered:** - **happy-dom** — faster, but historically has gaps around `Headers`, `fetch`, and `Storage` mocks that affect MP client tests. diff --git a/.claude/references/GLOSSARY.md b/.claude/references/GLOSSARY.md index cfb0817..59decf7 100644 --- a/.claude/references/GLOSSARY.md +++ b/.claude/references/GLOSSARY.md @@ -1,7 +1,7 @@ --- title: Glossary type: glossary -last_updated: 2026-04-17 +last_updated: 2026-10-10 --- # Glossary @@ -15,15 +15,15 @@ Agent-facing domain ontology for MPNext-Tools. Terms are alphabetized. Aliases a SQL-side convention: every `api_*` stored procedure MUST declare `@DomainID INT` as its first parameter; the MP REST API injects the authenticated domain automatically so TypeScript callers never pass it. **Not to be confused with:** `$userId` (app-side audit param) or `Domain_ID` (FK column value). -**Defined in:** `.claude/commands/newstoredproc.md:171` +**Defined in:** `.claude/commands/newstoredproc.md:21` **See:** `mp-schema/required-procs.md` ## $userId (aliases: MP audit user ID) -Option passed to `MPHelper.createTableRecords` / `updateTableRecords` that stamps MP audit columns with an MP `User_ID`; resolve from `session.user.userGuid` via `UserService.getUserIdByGuid`. +Option passed to `MPHelper.createTableRecords` / `updateTableRecords` (and procedure/file calls) that stamps MP audit columns with an MP `User_ID`; taken only from the return value of `AuthorizationService.requireSecurityRole()` inside the service, never from a caller (CLAUDE.md practice #13). **Not to be confused with:** `session.user.id` (Better Auth internal nanoid) or `userGuid` (MP User_GUID). -**Defined in:** `src/services/groupService.ts:232` +**Defined in:** `src/services/authorizationService.ts:200` (`requireSecurityRole`); used e.g. `src/services/groupService.ts:369` **See:** `services/README.md` ## A @@ -33,7 +33,7 @@ Option passed to `MPHelper.createTableRecords` / `updateTableRecords` that stamp MP REST error raised when a column name appears in multiple tables joined by `_TABLE` FK traversal; resolved by prefixing with `TableName.Column` or `FKColumn_TABLE.Column`. **Not to be confused with:** SQL ambiguous-column errors (different error surface). -**Defined in:** `src/services/addressLabelService.ts:28` +**Defined in:** `src/services/addressLabelService.ts:30` **See:** `services/query-patterns.md` ## api_dev_ prefix (aliases: dev procedure, DEV_PROC_PREFIX) @@ -41,12 +41,12 @@ MP REST error raised when a column name appears in multiple tables joined by `_T Procedures whose name starts with `api_dev_` (case-insensitive, trailing underscore required) are routed through an isolated dev-credential HttpClient; `api_developer_*` does NOT match. **Not to be confused with:** regular `api_*` procs (default credential pipeline). -**Defined in:** `src/lib/providers/ministry-platform/services/procedure.service.ts:6` +**Defined in:** `src/lib/providers/ministry-platform/services/procedure.service.ts:7` **See:** `mp-provider/services/procedure.md` ## AuthWrapper -Server component that runs inside the `(web)` route group, calls `auth.api.getSession()`, and redirects unauthenticated users to `/signin?callbackUrl=`; layer-2 session validation complementing the proxy's layer-1 cookie check. +Server component that runs inside the `(web)` route group, calls `auth.api.getSession()`, redirects unauthenticated users to `/signin?callbackUrl=` and sessions without a `userGuid` to `/session-error`; layer-2 session validation complementing the proxy's layer-1 cookie check. **Not to be confused with:** `proxy` (edge-layer cookie check only). **Defined in:** `src/components/layout/auth-wrapper.tsx:5` @@ -67,7 +67,7 @@ Server component that runs inside the `(web)` route group, calls `auth.api.getSe Query-string parameter on `/signin` that captures the originally requested URL; preserved across the OAuth redirect so the user lands back on the exact tool + params after sign-in. **Not to be confused with:** `post_logout_redirect_uri` (endsession flow). -**Defined in:** `src/proxy.ts:29` +**Defined in:** `src/proxy.ts:82` **See:** `routing/proxy.md` ## client credentials flow (aliases: OAuth2 client credentials, machine-to-machine auth) @@ -75,15 +75,15 @@ Query-string parameter on `/signin` that captures the originally requested URL; OAuth2 grant type used by the provider to acquire tokens for server-to-MP calls via `POST {baseUrl}/oauth/connect/token` with `grant_type=client_credentials`; distinct from the Better Auth user-login OAuth flow. **Not to be confused with:** `genericOAuth` (user-facing OIDC authorization code flow). -**Defined in:** `src/lib/providers/ministry-platform/auth/client-credentials.ts:3` +**Defined in:** `src/lib/providers/ministry-platform/auth/client-credentials.ts:27` **See:** `mp-provider/client.md` ## 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` +**Defined in:** `src/lib/auth.ts:1060` **See:** `auth/sessions.md` ## CredentialProfile (aliases: default, dev) @@ -91,7 +91,7 @@ Better Auth stateless session strategy: 1-hour TTL JWT stored in a cookie with n Union type `'default' | 'dev'` selecting which pair of env vars supplies the client id/secret; `'dev'` is used only by `ProcedureService` when executing `api_dev_*` stored procedures. **Not to be confused with:** Better Auth's OAuth `providerId`. -**Defined in:** `src/lib/providers/ministry-platform/auth/client-credentials.ts:1` +**Defined in:** `src/lib/providers/ministry-platform/auth/client-credentials.ts:6` **See:** `mp-provider/client.md` ## customSession (aliases: customSession plugin, customSessionClient) @@ -99,7 +99,7 @@ Union type `'default' | 'dev'` selecting which pair of env vars supplies the cli Better Auth plugin wrapping the server `auth` config that enriches every `getSession()` response; here it performs name-splitting only (no API calls). Client-side `customSessionClient()` provides type inference but does NOT expose `additionalFields`. **Not to be confused with:** `UserProvider` (loads the full MP profile). -**Defined in:** `src/lib/auth.ts:97` +**Defined in:** `src/lib/auth.ts:1116` (callback: `enrichSession`) **See:** `auth/sessions.md` ## D @@ -124,38 +124,30 @@ 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` -**See:** `components/user-menu.md` +**Defined in:** `src/components/shared-actions/sign-out.ts` (`handleSignOut`) +**See:** `auth/oauth-flow.md` § Sign-out flow ## escapeFilterString (aliases: MP filter quote escape) -Helper that escapes `'` -> `''`, `%` -> `[%]`, `_` -> `[_]` before interpolating user input into an MP `$filter` string; used by `GroupService.searchContacts`/`searchGroups`. +Helper that escapes `[` -> `[[]`, `%` -> `[%]`, `_` -> `[_]`, `'` -> `''` (and maps quote look-alikes to `_`; throws on control chars or > `MAX_SEARCH_TERM_LENGTH`) before interpolating user input into an MP `$filter` LIKE string; used by `GroupService`, `FamilyService`, and `ToolService`. **Not to be confused with:** plain quote-doubling (insufficient — misses wildcards). -**Defined in:** `src/lib/validation.ts:25` +**Defined in:** `src/lib/validation.ts:127` **See:** `services/query-patterns.md` ## G ## 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"`, 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 app's single MP client (`MINISTRY_PLATFORM_CLIENT_ID`, shared with the service account), `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` +**Defined in:** `src/lib/auth.ts:699` (`ministryPlatformProviderConfig`) **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 @@ -163,7 +155,7 @@ Client-only drag-and-drop visual editor used for the template editor; wrapped vi Low-level fetch wrapper for the provider stack; injects `Authorization: Bearer ` via closure, serializes JSON or FormData, URL-encodes query params, throws formatted `Error`s on non-2xx. **Not to be confused with:** `MinistryPlatformClient` (token lifecycle owner). -**Defined in:** `src/lib/providers/ministry-platform/utils/http-client.ts:4` +**Defined in:** `src/lib/providers/ministry-platform/utils/http-client.ts:91` **See:** `mp-provider/client.md` ## I @@ -173,7 +165,7 @@ Low-level fetch wrapper for the provider stack; injects `Authorization: Bearer < USPS 65-bar, 4-state (T/D/A/F) mail barcode encoding a 20-digit tracking block + optional 5/9/11-digit routing code; encoded by the hand-rolled `imbEncode()`. **Not to be confused with:** POSTNET (legacy tall/short barcode). -**Defined in:** `src/lib/imb-encoder.ts:14` +**Defined in:** `src/lib/imb-encoder.ts:320` **See:** `utils/barcodes.md` ## L @@ -209,15 +201,15 @@ Secondary tab in `/tools/addresslabels` that lets the user upload a `.docx` temp USPS-assigned 6- or 9-digit identifier embedded in the IMb tracking block; the serial-number length is derived from Mailer ID length (9 vs 6 digits). **Not to be confused with:** Service Type ID (3-digit IMb field). -**Defined in:** `src/lib/barcode-helpers.ts:21` +**Defined in:** `src/lib/barcode-helpers.ts:22` **See:** `utils/barcodes.md` ## MinistryPlatformClient -Token-lifecycle + HttpClient holder; owns two independent pipelines (default + dev) each with its own token cache, `expiresAt`, and `HttpClient`. +Token-lifecycle + HttpClient holder; owns two independent `TokenPipeline`s (default + dev), each with its own token cache, `expiresAt`, single-flight refresh, 401 retry hook, and `HttpClient`. **Not to be confused with:** `HttpClient` (low-level fetch wrapper). -**Defined in:** `src/lib/providers/ministry-platform/client.ts:21` +**Defined in:** `src/lib/providers/ministry-platform/client.ts:93` **See:** `mp-provider/client.md` ## MinistryPlatformProvider (aliases: provider singleton) @@ -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`. @@ -249,15 +233,15 @@ Pattern of mocking a constructable import as a `class { method = mockFn }` liter Public-API facade class for Ministry Platform operations; normalizes friendly param names (`select`, `filter`, `top`) to `$`-prefixed OData params, offers optional Zod validation on create/update, and delegates to the singleton `MinistryPlatformProvider`. **Not to be confused with:** `MinistryPlatformProvider` (singleton internal). -**Defined in:** `src/lib/providers/ministry-platform/helper.ts:34` +**Defined in:** `src/lib/providers/ministry-platform/helper.ts:55` **See:** `mp-provider/README.md` ## MPUserProfile -Enriched client-side user profile combining `dp_Users` row + joined Contact fields + `roles[]` + `userGroups[]`; loaded via `UserService.getUserProfile(userGuid)` and cached in `UserContext`. +Display-only client-side user profile: `First_Name`, `Nickname`, `Last_Name`, `Email_Address` (joined from the `dp_Users` row's Contact) — no IDs, phone, image, roles, or user groups; loaded via the parameterless `getCurrentUserProfile()` → `UserService.getUserProfile(userGuid)` (single query) and cached in `UserContext`. **Not to be confused with:** Better Auth `session.user` (name/email/id only). -**Defined in:** `src/lib/providers/ministry-platform/types/user-profile.types.ts:1` +**Defined in:** `src/lib/providers/ministry-platform/types/user-profile.types.ts:10` **See:** `contexts/user-provider.md` ## P @@ -275,7 +259,7 @@ Row describing how one column is presented on an MP page: order, group, required TS interface describing the first-result-set row returned by `api_Tools_GetPageData`: `Page_ID`, `Display_Name`, `Singular_Name`, `Table_Name`, `Primary_Key`, and optional tool-config fields. **Not to be confused with:** `PageListItem` (list view) or `PageField` (per-field metadata). -**Defined in:** `src/lib/tool-params.ts:3` +**Defined in:** `src/lib/tool-params.ts:14` **See:** `utils/tool-params.md` ## parseToolParams (aliases: ToolParams) @@ -283,7 +267,7 @@ TS interface describing the first-result-set row returned by `api_Tools_GetPageD Single ingestion point for Ministry Platform tool URL query strings (`pageID`, `s`, `sc`, `p`, `q`, `v`, `recordID`, `recordDescription`, `addl`); hydrates `pageData` and returns a typed `ToolParams`. **Not to be confused with:** framework `searchParams` (raw Next.js input). -**Defined in:** `src/lib/tool-params.ts:30` +**Defined in:** `src/lib/tool-params.server.ts:43` (`ToolParams` type: `src/lib/tool-params.ts:28`) **See:** `utils/tool-params.md` ## POSTNET @@ -291,15 +275,15 @@ Single ingestion point for Ministry Platform tool URL query strings (`pageID`, ` Legacy USPS tall/short barcode for 5/9/11-digit ZIPs with Mod-10 check digit; used as IMb fallback when Mailer ID is absent or IMb encoding throws. **Not to be confused with:** IMb (modern 4-state replacement). -**Defined in:** `src/lib/postnet-encoder.ts:11` +**Defined in:** `src/lib/postnet-encoder.ts:31` **See:** `utils/barcodes.md` ## proxy (aliases: proxy.ts, middleware (old name)) -Next.js 16 renamed `middleware.ts`/`middleware()` to `proxy.ts`/`proxy()`; here it performs a session-cookie-presence check via `getSessionCookie()` (no JWT decode) and forwards `pathname + search` as the `x-pathname` header. +Next.js 16 renamed `middleware.ts`/`middleware()` to `proxy.ts`/`proxy()`; here it performs a session-cookie-presence check via `getSessionCookie()` (no JWT decode) forwards `pathname + search` as the `x-pathname` header, and sets the per-request CSP nonce. **Not to be confused with:** `AuthWrapper` (server-component layer-2 check). -**Defined in:** `src/proxy.ts:4` +**Defined in:** `src/proxy.ts:37` **See:** `routing/proxy.md` ## R @@ -319,7 +303,7 @@ Next.js App Router convention: a directory wrapped in parentheses groups routes Ministry Platform user-scoped record set (identified by `Selection_ID` + `User_ID` + `Page_ID`) fetched via `api_Common_GetSelection`; the "selected records" context passed to cloud tools. **Not to be confused with:** browser text selection or client-side state. -**Defined in:** `src/services/toolService.ts:152` +**Defined in:** `src/services/toolService.ts:162` (`getSelectionRecordIds`) **See:** `mp-schema/required-procs.md` ## service singleton (aliases: getInstance pattern, lazy async singleton) @@ -327,7 +311,7 @@ Ministry Platform user-scoped record set (identified by `Selection_ID` + `User_I Application service class with private constructor and static `getInstance(): Promise` that lazily constructs and initializes a single shared instance holding an `MPHelper`; reset in tests via `(ServiceClass as any).instance = undefined`. **Not to be confused with:** `MinistryPlatformProvider` singleton (provider stack internal). -**Defined in:** `src/services/toolService.ts:81` +**Defined in:** `src/services/toolService.ts:102` **See:** `services/README.md` ## shadcn/ui (aliases: shadcn) @@ -343,7 +327,7 @@ Component primitive library installed via the shadcn CLI using `new-york` style, Test pattern: set `(ServiceClass as any).instance = undefined` inside `beforeEach` to clear cached service singletons between tests. **Not to be confused with:** `vi.resetModules()` (full module-cache clear). -**Defined in:** `src/services/toolService.test.ts:22` +**Defined in:** `src/services/toolService.test.ts:49` **See:** `testing/mocks.md` ## SkipRecord @@ -368,8 +352,8 @@ MP REST convention for following a foreign key: suffix the FK column with `_TABL Constant `5 * 60 * 1000` (ms); `MinistryPlatformClient` refreshes after this hard-coded 5-minute window for safety, ignoring MP's `expires_in`. -**Not to be confused with:** `cookieCache.maxAge` (Better Auth 1-hour JWT). -**Defined in:** `src/lib/providers/ministry-platform/client.ts:6` +**Not to be confused with:** `cookieCache.maxAge` (Better Auth 1-hour JWE cookie cache). +**Defined in:** `src/lib/providers/ministry-platform/client.ts:8` **See:** `mp-provider/client.md` ## ToolContainer @@ -387,7 +371,7 @@ Client-side full-screen flex shell that composes `DevPanel` (conditional), `Tool Thin React hook that returns only the `data` portion of Better Auth's `authClient.useSession()`; the preferred client-side session accessor (drops `isPending`/`error`/`refetch`). **Not to be confused with:** `useUser` (loads full MP profile from UserContext). -**Defined in:** `src/contexts/session-context.tsx:7` +**Defined in:** `src/contexts/use-app-session.ts:7` **See:** `contexts/session.md` ## userGuid (aliases: User_GUID, MP User GUID) @@ -395,7 +379,7 @@ Thin React hook that returns only the `data` portion of Better Auth's `authClien MP Ministry Platform `User_GUID` (UUID) stored on `session.user` via Better Auth `additionalFields` and populated from the OIDC `sub` claim by `mapProfileToUser`; REQUIRED for every MP API lookup of the signed-in user — `session.user.id` is a Better Auth internal nanoid and is NOT interchangeable. **Not to be confused with:** `session.user.id` (BA nanoid) or `User_ID` (MP int surrogate). -**Defined in:** `src/lib/auth.ts:24` +**Defined in:** `src/lib/auth.ts:353` (`userAdditionalFields.userGuid`) **See:** `auth/user-identity.md` ## UserProvider @@ -413,7 +397,7 @@ Client React provider mounted in `src/app/providers.tsx` that watches the Better Throws if the input does not match `/^[A-Za-z_][A-Za-z0-9_]*$/`; used before interpolating column names into selects. **Not to be confused with:** `escapeFilterString` (value escaping, not identifier validation). -**Defined in:** `src/lib/validation.ts:18` +**Defined in:** `src/lib/validation.ts:40` **See:** `dto-constants/constants.md` ## validateGuid @@ -421,7 +405,7 @@ Throws if the input does not match `/^[A-Za-z_][A-Za-z0-9_]*$/`; used before int Throws on malformed GUID (accepts upper/lower hex); used before interpolating `User_GUID` into MP filters. **Not to be confused with:** runtime Zod schema validation. -**Defined in:** `src/lib/validation.ts:4` +**Defined in:** `src/lib/validation.ts:20` **See:** `dto-constants/constants.md` ## validatePositiveInt @@ -429,7 +413,7 @@ Throws on malformed GUID (accepts upper/lower hex); used before interpolating `U Throws on non-integer, zero, or negative input; strictly positive (`>= 1`). **Not to be confused with:** non-negative integer checks (this rejects `0`). -**Defined in:** `src/lib/validation.ts:11` +**Defined in:** `src/lib/validation.ts:33` **See:** `dto-constants/constants.md` ## vi.hoisted @@ -457,5 +441,5 @@ Vitest helper that declares variables which must exist before `vi.mock()` factor Request header set by `src/proxy.ts` on passthrough responses; carries `pathname + search` so downstream server components (notably `AuthWrapper`) can build a correct `callbackUrl`. **Not to be confused with:** `NextRequest.nextUrl.pathname` (available only in the proxy layer). -**Defined in:** `src/proxy.ts:13` +**Defined in:** `src/proxy.ts:58` **See:** `routing/proxy.md` diff --git a/.claude/references/GOTCHAS.md b/.claude/references/GOTCHAS.md index 97cbe4b..a378b9b 100644 --- a/.claude/references/GOTCHAS.md +++ b/.claude/references/GOTCHAS.md @@ -1,7 +1,7 @@ --- title: Gotchas type: gotchas -last_updated: 2026-04-17 +last_updated: 2026-10-10 --- # Gotchas @@ -48,6 +48,8 @@ Known pitfalls for agents working on MPNext-Tools. Each entry has a symptom-firs - GOTCHA-027: Next.js errors "sync dynamic API usage" or undefined `params` - GOTCHA-028: New public route redirects to `/signin` in a loop - GOTCHA-029: CI runs `next lint` and fails — command unknown +- GOTCHA-046: `This module cannot be imported from a Client Component module` from `server-only` +- GOTCHA-047: CI `check:prerender` fails — a route was statically prerendered ### Frontend / components - GOTCHA-030: `useUser()` throws "useUser must be used within a UserProvider" @@ -58,8 +60,10 @@ 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)_ +- GOTCHA-044: A flag set inside one `setState` updater is still `false` when the next setter runs +- GOTCHA-045: `` never renders even though the search returned nothing ### Utils / labels - GOTCHA-040: IMb barcode silently becomes POSTNET (or disappears) with no log @@ -76,39 +80,39 @@ Known pitfalls for agents working on MPNext-Tools. Each entry has a symptom-firs **Symptom:** You pass `session.user.id` to an MP query expecting `dp_Users.User_GUID`. MP returns 0 rows, joins fail, or audit columns show the wrong user. **Root cause:** `session.user.id` is Better Auth's internal nanoid-style ID (e.g. `1gYSNMvy6OqAm9q3DdVhtKj3Czkxd0ms`), NOT the MP `User_GUID` (UUID). The MP `sub` claim is stored in a separate `userGuid` additionalField. **Fix:** Always use `session.user.userGuid` for MP API lookups. Needs a cast at the call site: `const userGuid = (session.user as { userGuid?: string }).userGuid`. -**Enforced where:** `src/lib/auth.ts:22-30` (additionalFields definition), `src/contexts/user-context.tsx:29` (cast pattern), `src/auth.test.ts:189-207` (regression test). +**Enforced where:** `src/lib/auth.ts` (`userAdditionalFields`), `src/contexts/user-context.tsx:29` (cast pattern), `src/auth.test.ts:203` (regression test). **Related:** [auth/user-identity.md](auth/user-identity.md), [services/](services/) ### GOTCHA-002: TypeScript error "Property userGuid does not exist on type..." **Symptom:** TS complains about `session.user.userGuid` even though the value exists at runtime. -**Root cause:** Better Auth 1.5's `customSessionClient` does not propagate `additionalFields` into its inferred session type. Runtime is correct, types are not. +**Root cause:** Better Auth's `customSessionClient` does not propagate `additionalFields` into its inferred session type. Runtime is correct, types are not. **Fix:** Cast at the read site: `(session.user as { userGuid?: string }).userGuid`. Do not try to extend Better Auth's types globally. -**Enforced where:** `src/contexts/user-context.tsx:29`, `src/lib/auth.ts:85` (map function also needs a cast). +**Enforced where:** `src/contexts/user-context.tsx:29`, `src/lib/auth.ts` (`mapProfileToUser` returns `as Record`). **Related:** [auth/README.md](auth/README.md) ### GOTCHA-003: Proxy lets forged/expired session cookies through **Symptom:** Proxy does not redirect a broken session to `/signin`; the request reaches a route that then errors or returns empty data. **Root cause:** `src/proxy.ts` uses `getSessionCookie()` from `better-auth/cookies`, which only checks for cookie presence — it does not decode or validate the JWT. Any forged or expired cookie passes the proxy. -**Fix:** Every protected page must render inside `(web)/layout.tsx` so `AuthWrapper` runs `auth.api.getSession()` on the server. Protected API/server actions must also call `auth.api.getSession()` themselves. Never rely on the proxy alone for auth. -**Enforced where:** `src/proxy.ts:24-26` (cookie-presence check), `src/components/layout/auth-wrapper.tsx:5-20` (real session check). +**Fix:** Every protected page must render inside `(web)/layout.tsx` so `AuthWrapper` runs `auth.api.getSession()` on the server. Server actions and services that touch MP data must call `AuthorizationService.requireSecurityRole()` (CLAUDE.md practice #12). Never rely on the proxy alone for auth. +**Enforced where:** `src/proxy.ts:91-95` (cookie-presence check), `src/components/layout/auth-wrapper.tsx:5-27` (real session check). **Related:** [auth/](auth/), [routing/](routing/) ### GOTCHA-004: User lands on tool index instead of specific tool + params after sign-in **Symptom:** After OAuth round-trip the user is dropped at `/` or the tool index rather than the originally requested URL. **Root cause:** `callbackUrl` is dropped somewhere in the round-trip. The proxy must forward `x-pathname` on the pass-through request, and `/signin` redirects must set `callbackUrl` to that original path. `AuthWrapper` must read `x-pathname` to build the redirect. -**Fix:** Preserve both the `x-pathname` header in `src/proxy.ts:13` and the `hdrs.get("x-pathname")` read in `src/components/layout/auth-wrapper.tsx:13`. Do not remove either without updating the other. -**Enforced where:** `src/proxy.ts:12-15,28-30`, `src/components/layout/auth-wrapper.tsx:13-16`. +**Fix:** Preserve both the `x-pathname` header in `src/proxy.ts:58` and the `hdrs.get("x-pathname")` read in `src/components/layout/auth-wrapper.tsx:13`. Do not remove either without updating the other. +**Enforced where:** `src/proxy.ts:57-58` (`forwardedHeaders.set('x-pathname', fullPath)`), `src/components/layout/auth-wrapper.tsx:13-16`. **Related:** [auth/oauth-flow.md](auth/oauth-flow.md) ### GOTCHA-005: Sign-out redirects to `http://localhost:3000` in production **Symptom:** Clicking sign-out in prod lands the user at `http://localhost:3000` (or some other wrong base URL). **Root cause:** `handleSignOut` uses `process.env.BETTER_AUTH_URL || process.env.NEXTAUTH_URL || 'http://localhost:3000'`. If both env vars are unset in prod, the literal fallback fires silently. -**Fix:** Throw instead of silent fallback (mirror the `MINISTRY_PLATFORM_BASE_URL` check on the line above). TODO: `.claude/TODO/2026-04-17-auth-localhost-fallback-signout.md`. -**Enforced where:** `src/components/user-menu/actions.ts:18-23` (not currently enforced — open TODO). +**Fix:** Fixed — `handleSignOut` reads the origin through `getAuthBaseUrl()`, which throws when unset; there is no localhost fallback. +**Enforced where:** `src/components/shared-actions/sign-out.ts` (`getAuthBaseUrl()` / `getMpBaseUrl()`), `src/lib/env.ts`. **Related:** [auth/](auth/) ### GOTCHA-006: User appears to sign out but is silently re-authenticated (logout loop) @@ -116,15 +120,15 @@ Known pitfalls for agents working on MPNext-Tools. Each entry has a symptom-firs **Symptom:** Clicking sign-out clears the cookie, but the OAuth IDP immediately re-signs-in the user because the redirect back bounces into `/signin` which rehydrates the session. **Root cause:** The `post_logout_redirect_uri` value sent by `handleSignOut` is not registered on the MP OAuth client's allow-list, so MP ignores it and issues a fresh session. **Fix:** Register the exact value of `BETTER_AUTH_URL` as a post-logout redirect URI on the MP OAuth client. The value must match byte-for-byte (no trailing slash drift). -**Enforced where:** `src/components/user-menu/actions.ts:18-23`. +**Enforced where:** `src/components/shared-actions/sign-out.ts` (`handleSignOut`). **Related:** [auth/oauth-flow.md](auth/oauth-flow.md) ### GOTCHA-007: MP audit columns show wrong user (or Better Auth nanoid) in created_by/modified_by **Symptom:** `Created_By`, `Modified_By`, `dp_Audit_Log` rows show the test user, an empty user, or the wrong MP user. **Root cause:** Caller passed `session.user.id` (the Better Auth internal) as `$userId`. MP expects the numeric `User_ID` (integer), not a GUID and certainly not a nanoid. -**Fix:** Resolve the numeric MP `User_ID` before the MP call: `const userId = await UserService.getInstance().then(s => s.getUserIdByGuid(session.user.userGuid))`, then pass that integer as `$userId`. -**Enforced where:** `src/services/groupService.ts:232,248` (correct pattern — uses `userId: number`), `src/services/userService.ts:68-79` (`getUserIdByGuid` helper). +**Fix:** Take `$userId` from the return value of `AuthorizationService.getInstance().requireSecurityRole(...)` inside the **service** (it resolves the session's `userGuid` to the numeric `dp_Users.User_ID`). Never accept it from an action argument or caller payload (CLAUDE.md practice #13). +**Enforced where:** `src/services/authorizationService.ts` (`requireSecurityRole` returns the acting `User_ID`); e.g. `src/services/groupService.ts:369,385`, `src/services/toolService.ts` (`deployTool`). **Related:** [auth/user-identity.md](auth/user-identity.md), [services/](services/) --- @@ -134,25 +138,25 @@ 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:49`, `communication.service.ts:28,51`. **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 **Symptom:** Validation error surfaces as a plain `Error` with a message string; calling code cannot inspect `.issues` to build field-level UI feedback. **Root cause:** `MPHelper.createTableRecords` / `updateTableRecords` catch the ZodError and re-throw as `new Error("Validation failed for record N: ...")`, losing the structured issue list. -**Fix:** Subclass `MPValidationError` that carries the original `ZodError`, and throw that instead. TODO: `.claude/TODO/2026-04-17-mp-provider-helper-loses-zod-issue-list.md`. -**Enforced where:** `src/lib/providers/ministry-platform/helper.ts:185-199,269-281` (not enforced). +**Fix:** Fixed — the helper throws `MPValidationError`, which carries the original `ZodError` as `.zodError`. Catch that and read `.zodError.issues`. +**Enforced where:** `src/lib/providers/ministry-platform/helper.ts` (`class MPValidationError`). **Related:** [mp-provider/](mp-provider/) ### GOTCHA-011: Local edits to files under `src/lib/providers/ministry-platform/models/` disappear @@ -160,7 +164,7 @@ Known pitfalls for agents working on MPNext-Tools. Each entry has a symptom-firs **Symptom:** You hand-edit a model file, run `npm run mp:generate:models`, and the edit is gone. **Root cause:** `npm run mp:generate:models` uses the `--clean` flag, which deletes every `.ts` file in the output dir before regeneration. **Fix:** Never hand-edit anything under `models/`. Add runtime types to `src/lib/dto/` or wrap the generated model with a DTO. If the generator output is wrong, fix the generator in `src/lib/providers/ministry-platform/scripts/generate-types.ts`. -**Enforced where:** `src/lib/providers/ministry-platform/scripts/generate-types.ts:570-581`. +**Enforced where:** `src/lib/providers/ministry-platform/scripts/generate-types.ts:577-581` (`options.clean`). **Related:** [mp-provider/](mp-provider/) ### GOTCHA-012: Ambiguous column error when `$filter` joins tables @@ -168,7 +172,7 @@ Known pitfalls for agents working on MPNext-Tools. Each entry has a symptom-firs **Symptom:** MP REST call errors with "ambiguous column name" when querying a table whose columns exist on the base table and a joined table. **Root cause:** MP REST does not auto-qualify columns that appear in both base and joined tables. The `Contact_ID` on `Contacts` is ambiguous with `Contact_ID` on `Contact_Addresses`, for example. **Fix:** Prefix every ambiguous column with its table: `Contacts.Contact_ID` instead of bare `Contact_ID`. For FK traversal use `FKColumn_TABLE.Column` (e.g. `Contact_ID_TABLE.First_Name`). For multi-level FK chains use underscores between `_TABLE_` hops and a single dot before the final field: `Building_ID_TABLE_Location_ID_TABLE.Congregation_ID`. -**Enforced where:** Documented in CLAUDE.md practice #10. Examples: `src/services/userService.ts:85` (FK traversal), `src/services/addressLabelService.ts:83` (multi-level). +**Enforced where:** Documented in CLAUDE.md practice #10. Examples: `src/services/userService.ts:63` (FK traversal), `src/services/addressLabelService.ts:32-38` (multi-level). **Related:** [services/query-patterns.md](services/query-patterns.md) ### GOTCHA-013: Passing `@DomainID` in `executeProcedureWithBody` causes parameter-count mismatch @@ -176,7 +180,7 @@ Known pitfalls for agents working on MPNext-Tools. Each entry has a symptom-firs **Symptom:** Stored procedure call errors with "Procedure expected parameter @DomainID which was not supplied" or "procedure has too many arguments". **Root cause:** The MP REST API injects `@DomainID` automatically from the OAuth token context. Passing it in the body double-binds the parameter. **Fix:** Never include `@DomainID` in the body passed to `executeProcedureWithBody`. DO still declare `@DomainID INT` as the first parameter in the SP DDL — it is required for MP API exposure. See user's persistent memory: "@DomainID Required — All api_* stored procs MUST have @DomainID INT as first parameter for MP API". -**Enforced where:** `src/services/toolService.ts:126-129` (comment + correct pattern), `src/services/toolService.ts:185-187`. +**Enforced where:** `src/services/toolService.ts:133-135` (comment + correct pattern in `getPageData`); every `executeProcedureWithBody` call in `toolService.ts` omits `@DomainID`. **Related:** [mp-schema/](mp-schema/), [services/](services/) ### GOTCHA-014: Deploy-tool call hits production MP instance by accident @@ -184,7 +188,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:266-297` (`deployTool`), `src/lib/providers/ministry-platform/services/procedure.service.ts` (`DEV_PROC_PREFIX`), `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 @@ -192,7 +196,7 @@ Known pitfalls for agents working on MPNext-Tools. Each entry has a symptom-firs **Symptom:** `dp_Files` rows appear after upload but `Modified_By` is null or the system user. **Root cause:** `FileService.uploadFiles` sends `userId` only as a `$userId` query-string parameter, not as a FormData field. If the caller forgets to pass `params.userId` at all, no audit trail is recorded. **Fix:** Always pass `params.userId` to `uploadFiles`. Verify MP records the audit by checking `dp_Audit_Log` rows after upload. -**Enforced where:** `src/lib/providers/ministry-platform/services/file.service.ts:67-77`. +**Enforced where:** `src/lib/providers/ministry-platform/services/file.service.ts:100` (`$userId` query param in `uploadFiles`). **Related:** [mp-provider/services/](mp-provider/services/) ### GOTCHA-016: `createCommunication` with empty `[]` attachments sends JSON not multipart @@ -200,15 +204,15 @@ Known pitfalls for agents working on MPNext-Tools. Each entry has a symptom-firs **Symptom:** `createCommunication(info, [])` does not exercise the multipart path even though an array was passed. **Root cause:** Branch condition is `attachments && attachments.length > 0`. An empty array is truthy for `&&` but `length > 0` fails, so it falls through to the JSON path. This is the intended behavior but a footgun for tests that assume "array passed ⇒ multipart used". **Fix:** Pass `undefined` (or omit the second argument) when there are no attachments. Don't assert `postFormData` was called on an empty-array fixture. -**Enforced where:** `src/lib/providers/ministry-platform/services/communication.service.ts:22,42`. +**Enforced where:** `src/lib/providers/ministry-platform/services/communication.service.ts:32,55`. **Related:** [mp-provider/services/](mp-provider/services/) ### GOTCHA-017: `getTables('Contacts')` returns multiple tables -**Symptom:** Calling `getTables('Contacts')` returns `Contacts`, `Contact_Addresses`, `Contact_Log`, `Contact_Attributes`, etc. — not just the exact match you wanted. -**Root cause:** The MP REST `$search` parameter is a substring match, not an exact match. -**Fix:** Always post-filter: `tables.find((t) => t.Table_Name === exactName)`. Return `tables.find(...) ?? tables[0]` or raise if no exact match found. -**Enforced where:** `src/services/fieldManagementService.ts:70-71`. +**Symptom:** `getTables(term)` can return more than the one table you asked for (other tables matching the search term), in no guaranteed order. +**Root cause:** The MP REST `$search` parameter is a search, not an exact-name lookup. (A live `$search=Contacts` on 2026-10-11 returned a single entry, but callers must not rely on that.) +**Fix:** Always post-filter on the **`Name`** key (a `/tables` entry has no `Table_Name`): `tables.find((t) => t.Name === exactName) ?? null`. Never fall back to `tables[0]` — that silently uses a different table. +**Enforced where:** `src/services/fieldManagementService.ts:87`. **Related:** [services/](services/) --- @@ -219,16 +223,16 @@ Known pitfalls for agents working on MPNext-Tools. Each entry has a symptom-firs **Symptom:** User types `O'Brien` into a search box; MP request fails with 400 or — worse — a SQL-like injection token is embedded verbatim into the filter. **Root cause:** MP `$filter` accepts raw OData-style strings. If user input is interpolated without escaping, single quotes close the literal prematurely. -**Fix:** Always escape before embedding: `term.replace(/'/g, "''")`. For LIKE patterns with wildcards also escape `%` and `_`: use `escapeFilterString` from `src/lib/validation.ts:25-30`. Documented in CLAUDE.md practice #11. -**Enforced where:** `src/lib/validation.ts:25-30` (helper), `src/services/toolService.ts:229` (correct inline), `src/services/groupService.ts` passim. +**Fix:** Always escape before embedding: `term.replace(/'/g, "''")`. For LIKE patterns use `escapeFilterString` from `src/lib/validation.ts:127` (bracket-escapes `[`, `%`, `_`, doubles `'`, rejects control chars and terms over `MAX_SEARCH_TERM_LENGTH`). Documented in CLAUDE.md practice #11. +**Enforced where:** `src/lib/validation.ts:127` (helper), `src/services/toolService.ts:254`, `src/services/groupService.ts` passim. **Related:** [services/query-patterns.md](services/query-patterns.md), [dto-constants/](dto-constants/) ### GOTCHA-019: MP filter breaks on non-numeric record ID (NaN interpolation) **Symptom:** MP returns 400 or empty result because the filter reads `WHERE Record_ID = NaN`. **Root cause:** `parseInt('abc', 10)` returns `NaN`; if the service doesn't validate before interpolating into `` `Record_ID = ${id}` ``, `NaN` slips through. -**Fix:** Call `validatePositiveInt(id)` from `src/lib/validation.ts:11-16` before building any `` `ID = ${id}` `` filter. Services like `addressLabelService` batch-validate in loops; copy that pattern. -**Enforced where:** `src/services/addressLabelService.ts:76` (positive pattern), `src/lib/validation.ts:11-16` (helper). +**Fix:** Call `validatePositiveInt(id)` from `src/lib/validation.ts:33` before building any `` `ID = ${id}` `` filter. Services like `addressLabelService` batch-validate in loops; copy that pattern. +**Enforced where:** `src/services/addressLabelService.ts:83` (positive pattern), `src/lib/validation.ts:33` (helper). **Related:** [services/query-patterns.md](services/query-patterns.md) ### GOTCHA-020: Field-management partial save leaves `dp_Page_Fields` inconsistent @@ -236,7 +240,7 @@ Known pitfalls for agents working on MPNext-Tools. Each entry has a symptom-firs **Symptom:** User edits 20 fields, save fails halfway; some changes are committed and others are not — no rollback. **Root cause:** `FieldManagementService.updatePageFieldOrder` calls `api_MPNextTools_UpdatePageFieldOrder` once per field, 5 at a time, with no transaction wrapper. A failure in batch 3 leaves batches 1-2 committed. **Fix:** Caller should wrap in `try/catch` and surface partial-save state. For true atomicity, rewrite `api_MPNextTools_UpdatePageFieldOrder` to accept a table-valued parameter and commit all rows in a single SP transaction. -**Enforced where:** `src/services/fieldManagementService.ts:74-109`. +**Enforced where:** `src/services/fieldManagementService.ts:105-130` (`updatePageFieldOrder`, `CONCURRENCY = 5`). **Related:** [services/](services/), [components/field-management.md](components/field-management.md) ### GOTCHA-021: Negative `Page_Field_ID` values sent to MP if passed through carelessly @@ -244,7 +248,7 @@ Known pitfalls for agents working on MPNext-Tools. Each entry has a symptom-firs **Symptom:** MP rejects update with "PK violation" or silently creates a bogus row because a negative `Page_Field_ID` was sent over the wire. **Root cause:** `fetchPageFieldData` synthesizes client-only IDs starting at `-1` and decrementing (`nextId--`) for rows that exist in the table metadata but not yet in `dp_Page_Fields`. These negative IDs are sentinel values for "not yet persisted" and must never be sent back to MP. **Fix:** Never send `Page_Field_ID` to MP. `FieldOrderPayload` intentionally omits it. When you add a new payload type, omit `Page_Field_ID` explicitly. -**Enforced where:** `src/components/field-management/actions.ts:34-42` (negative ID synthesis). +**Enforced where:** `src/components/field-management/actions.ts:61-71` (negative ID synthesis). **Related:** [components/field-management.md](components/field-management.md) ### GOTCHA-022: SkipRecord / FetchAddressLabelsResult indexing returns undefined @@ -280,7 +284,7 @@ Known pitfalls for agents working on MPNext-Tools. Each entry has a symptom-firs **Symptom:** `npm test -- src/services/toolService.test.ts` passes, but `npm run test:run` fails that same file with bogus mock return values from a previous test file. **Root cause:** The service singleton (`ServiceClass.instance`) is cached across test files. The previous file's mock `MPHelper` is still attached. **Fix:** Reset the singleton in `beforeEach`: `(ServiceClass as any).instance = undefined`. -**Enforced where:** `src/services/toolService.test.ts:19-23` (canonical pattern). +**Enforced where:** `src/services/toolService.test.ts:46-49` (canonical pattern). **Related:** [testing/mocks.md](testing/mocks.md) ### GOTCHA-026: Server action test crashes with `headers(...).get is not a function` @@ -288,7 +292,7 @@ Known pitfalls for agents working on MPNext-Tools. Each entry has a symptom-firs **Symptom:** Testing a server action: `TypeError: (intermediate value).get is not a function` inside Better Auth. **Root cause:** `next/headers` was mocked with a plain object (`{}`) or `new Map()`. Better Auth internally calls `.get('cookie')` on a `Headers` instance — plain objects don't implement that interface. **Fix:** `vi.mock('next/headers', () => ({ headers: vi.fn().mockResolvedValue(new Headers()) }))`. Use a real `Headers` instance. -**Enforced where:** `src/components/dev-panel/panels/user-tools-actions.test.ts:17-19` (canonical pattern). +**Enforced where:** `src/components/dev-panel/panels/user-tools-actions.test.ts:16-18` (canonical pattern). **Related:** [testing/mocks.md](testing/mocks.md) --- @@ -307,8 +311,8 @@ Known pitfalls for agents working on MPNext-Tools. Each entry has a symptom-firs **Symptom:** A newly added public route (e.g. `/healthz`) bounces to `/signin` which bounces back. **Root cause:** The proxy's early-return whitelist was not updated for the new path. -**Fix:** Add the path to the early-return branch in `src/proxy.ts:18`. The current whitelist is `pathname.startsWith('/api') || pathname === '/signin'`. -**Enforced where:** `src/proxy.ts:18`. +**Fix:** Add the path to `isPublicPath()` in `src/proxy.ts:26-35`. The current allowlist is `/api/auth`, `/api/auth/*`, `/signin` and `/auth-error` (exact-segment matches; every other `/api/*` route needs a session cookie). +**Enforced where:** `src/proxy.ts:26-35` (`isPublicPath`); `src/proxy.test.ts`. **Related:** [routing/](routing/) ### GOTCHA-029: CI runs `next lint` and fails — command unknown @@ -316,11 +320,27 @@ Known pitfalls for agents working on MPNext-Tools. Each entry has a symptom-firs **Symptom:** CI fails with "Unknown command: lint" or similar from a script that invokes `next lint`. **Root cause:** Next.js 16 removed `next lint`. This project uses ESLint flat config directly via `eslint .`. **Fix:** Use `npm run lint`. Config is native flat config in `eslint.config.mjs`. -**Enforced where:** `package.json:9` (`"lint": "eslint ."`). +**Enforced where:** `package.json:13` (`"lint": "eslint ."`). **Related:** CLAUDE.md "Next.js 16 Notes" --- +### GOTCHA-046: `This module cannot be imported from a Client Component module` from `server-only` + +**Symptom:** One of three, depending on where the import runs: `next build` fails with *"You're importing a module that depends on "server-only""* and a `[Client Component Browser]` import trace; `npm run mp:generate*` (or a hand-run `tsx` on the codegen scripts) throws from `node_modules/server-only/index.js`; or a new Vitest file throws the same error. +**Root cause:** Every service, `src/lib/auth.ts`, `src/lib/env.ts`, `src/lib/tool-params.server.ts` and the MP client/credentials modules start with `import 'server-only'`. That package throws unless resolved under the `react-server` export condition, which Next applies to server code only. +**Fix:** In the build case the guard is doing its job — the trace names the client component that reached a server module; use `import type` or call a server action instead. Codegen scripts must run as `tsx --conditions=react-server …` (the `package.json` scripts already do). Vitest aliases `server-only` to the package's `empty.js` in `vitest.config.mts`; do not remove that alias. +**Enforced where:** `next build` (CI `Build` step); `vitest.config.mts` `resolve.alias`; `package.json` `mp:generate*` scripts. +**Related:** [security/README.md](security/README.md), GOTCHA-047 + +### GOTCHA-047: CI `check:prerender` fails — a route was statically prerendered + +**Symptom:** The `Check prerendered routes` CI step lists a route; locally, the build table shows it as `○ (Static)`. +**Root cause:** The CSP nonce is read from the request at render time. A prerendered page has none, so under the enforced CSP it never hydrates — and it also skips server-side session checks. +**Fix:** Give the route a dynamic signal: a `headers()`/session read, or `export const dynamic = "force-dynamic"` in a **server** module (segment config is ignored in a `"use client"` file, the same trap as GOTCHA-032). Only Next's `/_not-found` and `/_global-error` may be static. +**Enforced where:** `scripts/check-prerender.mjs`, run by CI after `npm run build`. +**Related:** [security/README.md](security/README.md) § Nonces force dynamic rendering + ## Frontend / components ### GOTCHA-030: `useUser()` throws "useUser must be used within a UserProvider" @@ -328,15 +348,15 @@ Known pitfalls for agents working on MPNext-Tools. Each entry has a symptom-firs **Symptom:** Client component crashes on render with `useUser must be used within a UserProvider`. **Root cause:** Consumer was rendered outside the `` subtree, or inside a server component that never reaches `providers.tsx`. **Fix:** Ensure the component is a descendant of ``, which is mounted in `src/app/providers.tsx`. Convert the component to a client component (`"use client"`) if it needs `useUser()`. -**Enforced where:** `src/contexts/user-context.tsx:76-82`. +**Enforced where:** `src/contexts/user-context.tsx:76-82` (`useUser`). **Related:** [contexts/](contexts/) ### GOTCHA-031: `userProfile` stays `null` and `isLoading` never resolves when session has no `userGuid` **Symptom:** Loading spinner spins forever for a user whose session does not have a `userGuid` (e.g. edge-case cookie migration). **Root cause:** The effect gating `loadUserProfile` only runs when `userGuid` is present AND session is settled; the "settled but no guid" branch does not set `isLoading = false` in the initial render. -**Fix:** Gate UI on `isLoading` only when a `userGuid` is expected. Handle the missing case explicitly in consumer components. See `user-context.tsx:51-58`. -**Enforced where:** `src/contexts/user-context.tsx:51-58`. +**Fix:** Gate UI on `isLoading` only when a `userGuid` is expected. Handle the missing case explicitly in consumer components. See `user-context.tsx:52-60`. (Inside `(web)` this state is unreachable in practice: `AuthWrapper` redirects a session with no `userGuid` to `/session-error`.) +**Enforced where:** `src/contexts/user-context.tsx:52-60`, `src/components/layout/auth-wrapper.tsx:24-27`. **Related:** [contexts/](contexts/) ### GOTCHA-032: `AuthWrapper` silently stops authenticating after adding `"use client"` @@ -344,7 +364,7 @@ Known pitfalls for agents working on MPNext-Tools. Each entry has a symptom-firs **Symptom:** You add `"use client"` to `auth-wrapper.tsx` for a quick fix; the app compiles but every user is treated as signed in (or the route is never protected). **Root cause:** `AuthWrapper` must be a server component — it calls `await headers()` and `await auth.api.getSession({ headers })`. These are server-only APIs. Adding `"use client"` silently no-ops the redirect. **Fix:** Keep `auth-wrapper.tsx` as a server component. Move any interactive state into a child client component. -**Enforced where:** `src/components/layout/auth-wrapper.tsx:1-20` (no `"use client"`). +**Enforced where:** `src/components/layout/auth-wrapper.tsx` (no `"use client"`). **Related:** [components/layout.md](components/layout.md), [auth/](auth/) ### GOTCHA-033: Dev overlay unexpectedly appears in production @@ -352,15 +372,15 @@ Known pitfalls for agents working on MPNext-Tools. Each entry has a symptom-firs **Symptom:** The amber dev-panel banner shows up in a deployed environment. **Root cause:** The self-gating lives inside `DevPanel` (`process.env.NODE_ENV === "development"` AND hostname === localhost) — not in `ToolContainer`. `ToolContainer` always renders `` when `params` is provided. **Fix:** Trust the DevPanel self-gate. If you need to hide the overlay locally (e.g. for a screenshot), drop the `params` prop from `ToolContainer`. Do NOT remove the self-gate. -**Enforced where:** `src/components/dev-panel/dev-panel.tsx:20-22,56-58`. +**Enforced where:** `src/components/dev-panel/dev-panel.tsx:14-22,59-61` (`isLocalhost`, `isDevBuild`). **Related:** [components/dev-panel.md](components/dev-panel.md) ### GOTCHA-034: Dev-panel server actions callable in production despite UI hidden **Symptom:** Security scan flags that `deployToolAction` / `listPagesAction` are network-addressable in prod even though the UI is hidden. **Root cause:** `"use server"` exports create network-addressable RPC endpoints regardless of where (or whether) the UI imports them. Only `deploy-tool-actions.ts` currently has the `requireDevSession` guard that throws on `NODE_ENV === "production"`. -**Fix:** Every dev-panel server action must call `requireDevSession()` (or an equivalent `NODE_ENV !== "production"` check) before doing any work. Consider extracting a shared helper. TODO: `.claude/TODO/2026-04-17-components-dev-panel-prod-guard-gap.md`. -**Enforced where:** `src/components/dev-panel/panels/deploy-tool-actions.ts:13-21` (correct pattern, not consistently applied elsewhere). +**Fix:** Every dev-panel server action must call `requireDevSession()` (or an equivalent `NODE_ENV !== "production"` check) before doing any work. Fixed — every dev-panel action file now calls the shared helper. +**Enforced where:** `src/components/dev-panel/panels/require-dev-session.ts`, called by `contact-records-actions.ts`, `deploy-tool-actions.ts`, `selection-actions.ts`, `user-tools-actions.ts`. **Related:** [components/dev-panel.md](components/dev-panel.md) ### GOTCHA-035: Dev-panel `UserToolsPanel` always shows "NOT AUTHORIZED" @@ -368,40 +388,48 @@ Known pitfalls for agents working on MPNext-Tools. Each entry has a symptom-firs **Symptom:** The dev panel's user-tools check shows a red "NOT AUTHORIZED" banner even though the user can obviously access the tool in prod. **Root cause:** `NEXT_PUBLIC_PROD_URL` is unset (or has a trailing slash mismatch). The check is `path.includes(prodToolUrl)` where `prodToolUrl = ${prodBase}${pathname}`; if `prodBase` is empty, the match can never succeed. **Fix:** Set `NEXT_PUBLIC_PROD_URL` in `.env.local` to the deployed tool base URL (no trailing slash). The component already renders a helpful amber banner when unset — check for that banner instead of the red one. -**Enforced where:** `src/components/dev-panel/panels/user-tools-panel.tsx:38-42,112-133` (handles the unset case explicitly). +**Enforced where:** `src/components/dev-panel/panels/user-tools-panel.tsx:41-44,115` (handles the unset case explicitly). **Related:** [components/dev-panel.md](components/dev-panel.md) ### GOTCHA-036: Edit-mode Review screen shows "ID: <n>" for Primary Contact / Parent Group **Symptom:** In the group-wizard edit flow, the Review step shows "ID: 12345" instead of the contact's name next to Primary Contact or Parent Group. **Root cause:** `fetchGroupRecord` returns numeric IDs only. The display maps (`contactDisplayMap`, `groupDisplayMap`) are only seeded from user interactions during the wizard flow, never from a loaded record. -**Fix:** Extend `GroupService.getGroup` to return display names alongside IDs and seed the display maps before `form.reset` in the edit-mode effect. TODO: `.claude/TODO/2026-04-17-components-group-wizard-display-map-edit-mode.md`. -**Enforced where:** `src/app/(web)/tools/groupwizard/group-wizard.tsx:69-84` (not currently enforced). +**Fix:** Fixed — `fetchGroupRecord` returns `displayNames`, and the edit-mode effect seeds both display maps before `form.reset`. If you see this again, check that a new ID field is included in `displayNames`. +**Enforced where:** `src/app/(web)/tools/groupwizard/group-wizard.tsx` (edit-mode `useEffect`). **Related:** [components/group-wizard.md](components/group-wizard.md) ### GOTCHA-037: Renaming `Secure_Check-in` breaks compilation in non-obvious places **Symptom:** You rename the field in the Zod schema and the form compiles, but the Review step or form submission silently drops the value. **Root cause:** The field name contains a hyphen and is referenced as a quoted string literal (`'Secure_Check-in'`) across schema, `STEP_FIELDS`, `SwitchRow`, and `step-review`. A refactor tool that only renames symbols will miss the string literals. -**Fix:** Grep-search for `Secure_Check-in` across `src/components/group-wizard` and rename at all five sites. TypeScript will not help you here — the key is a string literal, not a symbol. +**Fix:** Grep-search for `Secure_Check-in` across `src/components/group-wizard` and rename at every site (`schema.ts` declaration, `STEP_FIELDS`, defaults; `step-settings.tsx`; two reads in `step-review.tsx`). TypeScript will not help you here — the key is a string literal, not a symbol. **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_ + +Retired 2026-10-10: applied only to the GrapesJS template editor, which was removed along with its dependencies. -**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) +### GOTCHA-039: _Retired_ + +Retired 2026-10-10: applied only to the template editor's merge tokens; the editor was removed. + +### GOTCHA-044: A flag set inside one `setState` updater is still `false` when the next setter runs + +**Symptom:** Two state updates that must agree (e.g. remove a group from `groupedFields` *and* `groupOrder`) drift apart. A guard that looked correct never fires. +**Root cause:** React runs updater functions (`setX(prev => …)`) during the next render, not when you call the setter. Code like `let removed = false; setA(prev => { …; removed = true; … }); if (removed) setB(…)` reads `removed` before the updater has run. +**Fix:** Make the decision once, up front, from rendered state that both setters agree on (add it to the `useCallback` deps), then call both setters unconditionally. +**Enforced where:** `src/components/field-management/use-field-order-state.ts` (`removeGroup`) and its tests. +**Related:** [components/field-management.md](components/field-management.md) -### GOTCHA-039: Merge tokens like `{{First_Name}}` appear verbatim in sent emails +### GOTCHA-045: `` never renders even though the search returned nothing -**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) +**Symptom:** A cmdk combobox with `shouldFilter={false}` gets zero results back, but its `` message never appears, even when it is wrapped in your own `results.length === 0` condition. +**Root cause:** `CommandEmpty` renders only when the **whole** `Command` has zero registered items (`filtered.count === 0`), not just the group it sits in. A permanent item elsewhere, such as a "+ New …" action, keeps that count above zero. +**Fix:** Don't use `CommandEmpty` alongside a permanent item. Render a plain `
` gated on your own state. Gate it on the term that was actually searched, too, or it flashes during the debounce while `results` still describes the previous term. +**Enforced where:** `src/app/(web)/tools/addeditfamily/add-edit-family.tsx` (`FamilySearchBar`, `searchedTerm`) and its tests. +**Related:** [components/](components/) --- @@ -410,9 +438,9 @@ Known pitfalls for agents working on MPNext-Tools. Each entry has a symptom-firs ### GOTCHA-040: IMb barcode silently becomes POSTNET (or disappears) with no log **Symptom:** User configures IMb with a Mailer ID, prints labels, and sees POSTNET (or no barcode) on the output. -**Root cause:** `preEncodeBarcodes` wraps `imbEncode` in a bare `try {} catch {}` with no logging, falling through to POSTNET. Also, if `config.mailerId` is empty (`''`), the `config.barcodeFormat === 'imb' && config.mailerId` guard skips IMb entirely and falls through silently. -**Fix:** (a) Users: enter a valid 6- or 9-digit USPS Mailer ID. (b) Code: log the caught IMb error and surface a per-label `barcodeError` field so the UI can warn the user. TODO: `.claude/TODO/2026-04-17-utils-imb-fallback-silent.md`. -**Enforced where:** `src/lib/barcode-helpers.ts:41-50` (silent fallback), `src/lib/dto/address-label.dto.ts:40` (mailerId not validated at config level). +**Root cause:** `preEncodeBarcodes` falls back to POSTNET when `imbEncode` throws (originally inside a bare `try {} catch {}` with no logging — now logged, see Fix). Also, if `config.mailerId` is empty (`''`), the `config.barcodeFormat === 'imb' && config.mailerId` guard skips IMb entirely and falls through silently. +**Fix:** (a) Users: enter a valid 6- or 9-digit USPS Mailer ID. (b) Code: fixed — the fallback is logged (`console.warn`) and the Mailer ID is validated. +**Enforced where:** `src/lib/barcode-helpers.ts` (logged fallback). Log payload is `{ labelIndex, error: }` — no label name, address, or routing digits (rule 14) — so locate the row by its index in the request. **Related:** [utils/](utils/), [components/address-labels.md](components/address-labels.md) ### GOTCHA-041: Avery label cells misaligned after adding new stock @@ -426,15 +454,15 @@ Known pitfalls for agents working on MPNext-Tools. Each entry has a symptom-firs ### GOTCHA-042: Tool page crashes when it assumes `params.pageData` is present **Symptom:** Tool route throws "Cannot read properties of undefined" on `params.pageData.Table_Name` or similar. -**Root cause:** `parseToolParams` catches errors from `ToolService.getPageData` and leaves `pageData` as `undefined`. The SP `api_Tools_GetPageData` may not exist yet in the target MP instance (dev/new installs). +**Root cause:** `parseToolParams` (`src/lib/tool-params.server.ts`) catches errors from `ToolService.getPageData` and leaves `pageData` as `undefined`. The SP `api_Tools_GetPageData` may not exist yet in the target MP instance (dev/new installs). **Fix:** Always treat `pageData` as optional at consumption sites: guard with `if (params.pageData) { ... }`. Never destructure it without a null check. -**Enforced where:** `src/lib/tool-params.ts:54-61` (catch + undefined assignment). +**Enforced where:** `src/lib/tool-params.server.ts:65-76` (catch + undefined assignment). **Related:** [utils/](utils/) ### GOTCHA-043: `pageID` / `recordID` values of `NaN` appear in downstream state **Symptom:** Downstream code receives `NaN` as a numeric param and constructs a filter `WHERE Contact_ID = NaN`, producing no rows and a confusing log. **Root cause:** `parseToolParams` uses `x ? parseInt(x, 10) : undefined`. `parseInt('abc', 10)` returns `NaN` (not undefined), so `NaN` leaks through. -**Fix:** Filter with `Number.isFinite` after `parseInt`: `const n = parseInt(x, 10); return Number.isFinite(n) ? n : undefined`. TODO: `.claude/TODO/2026-04-17-utils-tool-params-parseint-nan.md`. -**Enforced where:** `src/lib/tool-params.ts:49,63-70` (not currently enforced). +**Fix:** Fixed — `parseIntOrUndefined` returns `undefined` unless `Number.isFinite(parsed)`. +**Enforced where:** `src/lib/tool-params.server.ts` (`parseIntOrUndefined`). **Related:** [utils/](utils/) diff --git a/.claude/references/INDEX.md b/.claude/references/INDEX.md index 6436a51..a53bcf0 100644 --- a/.claude/references/INDEX.md +++ b/.claude/references/INDEX.md @@ -1,7 +1,7 @@ --- title: Reference Index type: index -last_updated: 2026-04-17 +last_updated: 2026-10-10 --- # Reference Index @@ -13,6 +13,7 @@ Agent-facing navigation map for MPNext-Tools. For each question pattern, find th | If you need to... | Read | |---|---| | Understand OAuth, sessions, or route protection | [auth/README.md](auth/README.md) | +| Check who may use a tool, the endpoint surface, CSP/headers, or security findings | [security/README.md](security/README.md) | | Distinguish `session.user.id` from `userGuid` | [auth/user-identity.md](auth/user-identity.md) | | Trace the MP provider / API client stack | [mp-provider/README.md](mp-provider/README.md) | | Look up an MP service (table, procedure, file, etc.) | [mp-provider/services/](mp-provider/services/) | @@ -20,6 +21,7 @@ Agent-facing navigation map for MPNext-Tools. For each question pattern, find th | See which MP tables/FKs the app actually touches | [mp-schema/data-model-map.md](mp-schema/data-model-map.md) | | Understand an app service's role or query patterns | [services/README.md](services/README.md) | | Avoid MP query pitfalls (ambiguous columns, FK traversal, escaping) | [services/query-patterns.md](services/query-patterns.md) | +| Send or read an MP date/datetime (`DomainTimezoneService`) | [ministryplatform.datetimehandling.md](ministryplatform.datetimehandling.md) | | Find a component's location or role | [components/README.md](components/README.md) | | Understand the Tool framework (ToolContainer, header, footer) | [components/tool-framework.md](components/tool-framework.md) | | Understand a React context (UserProvider, useAppSession) | [contexts/README.md](contexts/README.md) | @@ -30,6 +32,9 @@ Agent-facing navigation map for MPNext-Tools. For each question pattern, find th | Set up Vitest or fix a mock pattern | [testing/setup.md](testing/setup.md), [testing/mocks.md](testing/mocks.md) | | Find a DTO or shared constant | [dto-constants/README.md](dto-constants/README.md) | | Look up barcode, label-stock, or tool-param utilities | [utils/README.md](utils/README.md) | +| Upgrade dependencies / check what is held back | [../packages/README.md](../packages/README.md) (newest dated file), [../commands/update-deps.md](../commands/update-deps.md) | +| Scaffold a tool or stored proc, or cut a release | [../commands/](../commands/) (`newtool`, `newstoredproc`, `release`, `update-deps`) | +| Port a past fix into a derived repo | [../playbooks/](../playbooks/) | | Look up a domain term | [GLOSSARY.md](GLOSSARY.md) | | Understand why a design choice was made | [DECISIONS.md](DECISIONS.md) | | Avoid a known trap | [GOTCHAS.md](GOTCHAS.md) | @@ -42,24 +47,27 @@ Agent-facing navigation map for MPNext-Tools. For each question pattern, find th | **auth** | [auth/README.md](auth/README.md) | oauth-flow, sessions, route-protection, user-identity | | **mp-provider** | [mp-provider/README.md](mp-provider/README.md) | architecture, client, type-generation, error-handling + 6 services/ | | **mp-schema** | [mp-schema/README.md](mp-schema/README.md) | tables, stored-procs, required-procs, data-model-map | -| **services** | [services/README.md](services/README.md) | query-patterns + 5 per-service docs | -| **components** | [components/README.md](components/README.md) | tool-framework, layout, ui + 6 feature docs | +| **services** | [services/README.md](services/README.md) | query-patterns + 6 per-service docs | +| **components** | [components/README.md](components/README.md) | tool-framework, layout, ui + 4 feature docs (address-labels, dev-panel, field-management, group-wizard) | | **contexts** | [contexts/README.md](contexts/README.md) | user-provider, session | | **routing** | [routing/README.md](routing/README.md) | app-router, proxy | | **data-flow** | [data-flow/README.md](data-flow/README.md) | call-graphs, error-catalog | | **testing** | [testing/README.md](testing/README.md) | setup, mocks, cookbook, inventory | | **dto-constants** | [dto-constants/README.md](dto-constants/README.md) | dtos, constants | | **utils** | [utils/README.md](utils/README.md) | barcodes, label-stock, tool-params | +| **security** | [security/README.md](security/README.md) | single file: authorization policy, endpoint surface, headers/CSP, findings | ## Cross-cutting -- [GLOSSARY.md](GLOSSARY.md) — 52 domain terms (alphabetized, one-line definitions) -- [DECISIONS.md](DECISIONS.md) — 26 architectural decisions (ADRs) grouped by domain -- [GOTCHAS.md](GOTCHAS.md) — 43 symptom-first traps with fixes +- [GLOSSARY.md](GLOSSARY.md) — 50 domain terms (alphabetized, one-line definitions) +- [DECISIONS.md](DECISIONS.md) — 27 architectural decisions (ADR-001–027; ADR-023 superseded) grouped by domain +- [GOTCHAS.md](GOTCHAS.md) — 47 symptom-first traps (GOTCHA-001–047; 038/039 retired) with fixes +- [ministryplatform.datetimehandling.md](ministryplatform.datetimehandling.md) — MP date/time boundary rules (`DomainTimezoneService`) +- [../../.github/SECURITY.md](../../.github/SECURITY.md) — human-facing security policy (vulnerability reporting); agent detail lives in [security/README.md](security/README.md) ## Open TODOs -- [`../TODO/INDEX.md`](../TODO/INDEX.md) — 39 open TODOs by severity / tag / area (critical → low) +- [`../TODO/INDEX.md`](../TODO/INDEX.md) — open TODOs by severity / tag / area (open work only; closed items are deleted) - [`../TODO/SCHEMA.md`](../TODO/SCHEMA.md) — required format for new TODO files ## Meta @@ -68,7 +76,7 @@ Agent-facing navigation map for MPNext-Tools. For each question pattern, find th - [`_meta/conventions.md`](_meta/conventions.md) — writing rules for editing these docs - [`_meta/verification-rules.md`](_meta/verification-rules.md) — how to verify claims - [`_meta/schemas/`](_meta/schemas/) — templates for every doc type -- [`_meta/facts/`](_meta/facts/) — frozen facts snapshots per review date (baseline: `2026-04-17.md` at SHA `971c40b`) +- [`_meta/facts/`](_meta/facts/) — frozen facts snapshots per review date (baseline: `2026-04-17.md` at SHA `971c40b`; read the newest file for current counts) ## How to maintain diff --git a/.claude/references/_meta/conventions.md b/.claude/references/_meta/conventions.md index 3d048c6..607cc24 100644 --- a/.claude/references/_meta/conventions.md +++ b/.claude/references/_meta/conventions.md @@ -8,7 +8,7 @@ Agent-only. Human readers use `README.md` at the project root. ## Writing rules - **Terse.** Bullets, tables, frontmatter, code blocks. No narrative prose. No "this document explains..." openings. -- **Verify every factual claim.** Grep/Read the source or use context7 for framework API claims. Never trust training data for Next.js 16 / React 19 / Zod v4 / Better Auth 1.5 / Vitest 4. +- **Verify every factual claim.** Grep/Read the source or use context7 for framework API claims. Never trust training data for Next.js 16 / React 19 / Zod v4 / Better Auth 1.7 / Vitest 5. - **Copy-paste examples from real source.** Do NOT invent code. Pull from actual `src/**` files. - **Link, don't repeat.** If another doc covers it, link. - **One responsibility per file.** If a doc is doing two things, split it. diff --git a/.claude/references/_meta/facts/2026-09-13.md b/.claude/references/_meta/facts/2026-09-13.md index 88b1914..b9cea56 100644 --- a/.claude/references/_meta/facts/2026-09-13.md +++ b/.claude/references/_meta/facts/2026-09-13.md @@ -30,8 +30,9 @@ context-engineering review and should not be edited). | Functions | 38.35% | **98.81%** (915/926) | The "before" figures are measured on the same denominator as "after". The -number the config *reported* before this work was 83.14%, which was wrong — see -`.claude/TODO/2026-09-13-testing-coverage-report-masked-untested-files.md`. +number the config *reported* before this work was 83.14%, which was wrong: with no +`coverage.include`, files no test imported were left out of the denominator (see +CLAUDE.md § Testing). ### Test files by area diff --git a/.claude/references/_meta/facts/2026-10-10.md b/.claude/references/_meta/facts/2026-10-10.md new file mode 100644 index 0000000..b71ce0a --- /dev/null +++ b/.claude/references/_meta/facts/2026-10-10.md @@ -0,0 +1,76 @@ +--- +title: Facts Snapshot 2026-10-10 +type: meta +git_sha: 61566687ed38c3dd8011a2db47f26b9ab496ad40 +--- + +## Purpose +Authoritative counts for the full documentation review on branch +`docs/full-doc-review`. Supersedes `2026-09-13.md` (kept as history; do not +edit). All figures below were measured, not carried forward. + +## Git baseline +- **SHA:** `61566687ed38c3dd8011a2db47f26b9ab496ad40` (`feat(no-access): sign-out form for users with no MP security role (#55)`) +- **Branch:** `docs/full-doc-review` (off `dev`) +- **Created:** 2026-10-10 +- Changes since `2026-09-13.md`: GrapesJS `template-editor` removed (#40), header `UserMenu` removed and `handleSignOut` moved to `src/components/shared-actions/sign-out.ts` (#54), `/no-access` sign-out form (#55). + +## Counts (authoritative) + +- **Test files:** 118 +- **Test cases:** 2,185 (from `npx vitest run`, all passing) +- **Addendum 2026-10-11** (after #57–#60): 120 files / 2,237 cases; the per-area table in `testing/inventory.md` is current. +- **Vitest:** 5.0.3 installed (`vitest` / `@vitest/coverage-v8` `^5.0.0` in `package.json`); jsdom 30.1.2 +- **Source files in the coverage denominator:** 118 (3,910 statements) + +### Coverage (from `vitest run --coverage`) + +| Metric | Result | +|---|---| +| Statements | **99.13%** (3,876/3,910) | +| Lines | **99.80%** (3,581/3,588) | +| Branches | 93.32% (2,490/2,668) | +| Functions | 99.56% (916/920) | + +Enforced thresholds (`vitest.config.mts`): statements 97, lines 98. Branches +and functions are deliberately unenforced. + +### Test files by area + +| Area | Files | Cases | +|---|---|---| +| `src/app/**` | 22 | 406 | +| `src/` root (`auth.*` ×10, `proxy`) | 11 | 250 | +| `src/components/address-labels` | 11 | 126 | +| `src/components/dev-panel` | 11 | 102 | +| `src/components/field-management` | 7 | 113 | +| `src/components/group-wizard` | 12 | 159 | +| `src/components/{layout,shared-actions,tool}` | 5 | 44 | +| `src/contexts` | 2 | 13 | +| `src/lib/providers/ministry-platform` | 14 | 481 | +| `src/lib/**` (excl. MP provider) | 14 | 255 | +| `src/services` | 9 | 236 | +| **Total** | **118** | **2,185** | + +Per-file list: `../../testing/inventory.md`. + +## Components (`src/components/`) + +- **Feature folders (4):** `address-labels` (24 files), `dev-panel` (3 + `panels/` 20), `field-management` (16), `group-wizard` (26) +- **Framework folders (4):** `layout` (3), `shared-actions` (7 incl. `README.md`), `tool` (5), `ui` (22) +- **shadcn/ui primitives (22):** `alert`, `alert-dialog`, `avatar`, `badge`, `breadcrumb`, `button`, `card`, `checkbox`, `command`, `dialog`, `drawer`, `dropdown-menu`, `form`, `input`, `label`, `popover`, `radio-group`, `select`, `skeleton`, `switch`, `textarea`, `tooltip` +- **Shared actions (3):** `getCurrentUserProfile` (`user.ts`), `getMpTimezone` (`domain.ts`), `handleSignOut` (`sign-out.ts`) +- No `user-menu/` or `template-editor/` folder exists any more. + +## Tool routes (`src/app/(web)/tools/`) +`addeditfamily`, `addresslabels`, `fieldmanagement`, `groupwizard`, `template` + +## Contexts (`src/contexts/`) +`user-context.tsx` (`UserProvider`, `useUser`), `use-app-session.ts` +(`useAppSession`, `SessionData`), `index.ts` barrel. Neither hook has a +consumer outside `src/contexts/` at this SHA. + +## Services (9) +`addressLabelService`, `authorizationService`, `domainTimezoneService`, +`familyService`, `fieldManagementService`, `googlePlacesService`, +`groupService`, `toolService`, `userService` diff --git a/.claude/references/_meta/schemas/adr-entry.md b/.claude/references/_meta/schemas/adr-entry.md index 969a95e..c17a0c0 100644 --- a/.claude/references/_meta/schemas/adr-entry.md +++ b/.claude/references/_meta/schemas/adr-entry.md @@ -3,13 +3,13 @@ title: ADR Entry Template type: meta --- -Copy this block into `DECISIONS.md`; one section per decision. +Copy this block into `DECISIONS.md` under the matching `## ` heading (and add a TOC line); one section per decision. Never renumber or delete an ADR — supersede it, or append an `**Amended YYYY-MM-DD:**` line. ```markdown -## ADR-NNN: +### ADR-NNN: <title> **Date:** YYYY-MM-DD -**Status:** Accepted | Superseded by ADR-XXX +**Status:** Accepted | Superseded by ADR-XXX | Superseded (YYYY-MM-DD) — <reason> **Context:** why the question came up **Decision:** what we chose **Consequences:** tradeoffs / downstream constraints diff --git a/.claude/references/_meta/schemas/gotcha-entry.md b/.claude/references/_meta/schemas/gotcha-entry.md index 4865463..9176f6b 100644 --- a/.claude/references/_meta/schemas/gotcha-entry.md +++ b/.claude/references/_meta/schemas/gotcha-entry.md @@ -3,10 +3,10 @@ title: GOTCHA Entry Template type: meta --- -Copy this block into `GOTCHAS.md`; one section per trap. +Copy this block into `GOTCHAS.md` under the matching `## <Area>` heading (and add a TOC line); one section per trap. IDs are never reused — a dead entry becomes `### GOTCHA-NNN: _Retired_` with a one-line reason. ```markdown -## GOTCHA-NNN: <symptom-focused title> +### GOTCHA-NNN: <symptom-focused title> **Symptom:** what goes wrong **Root cause:** underlying reason diff --git a/.claude/references/_meta/schemas/reference-doc.md b/.claude/references/_meta/schemas/reference-doc.md index 765531e..ec4adee 100644 --- a/.claude/references/_meta/schemas/reference-doc.md +++ b/.claude/references/_meta/schemas/reference-doc.md @@ -8,7 +8,7 @@ Copy this schema when writing any reference doc under a domain subfolder (e.g., ```markdown --- title: <concise title> -domain: auth | mp-provider | services | components | contexts | routing | data-flow | testing | dto-constants | utils +domain: auth | mp-provider | mp-schema | services | components | contexts | routing | data-flow | security | testing | dto-constants | utils type: reference applies_to: [src/path/to/file.ts] symbols: [exportName1, exportName2] diff --git a/.claude/references/_meta/schemas/todo.md b/.claude/references/_meta/schemas/todo.md index b07492d..d861e53 100644 --- a/.claude/references/_meta/schemas/todo.md +++ b/.claude/references/_meta/schemas/todo.md @@ -10,7 +10,7 @@ Used for files under `.claude/TODO/YYYY-MM-DD-<slug>.md`. See `.claude/TODO/SCHE title: <actionable title> severity: critical | high | medium | low tags: [security | bug | drift | missing-test | refactor | doc | perf] -area: auth | mp-provider | services | components | contexts | routing | data-flow | testing | dto-constants | utils | commands +area: auth | mp-provider | mp-schema | services | components | contexts | routing | data-flow | security | testing | dto-constants | utils | commands files: [src/path/to/file.ts] discovered: YYYY-MM-DD discovered_by: <shard-name> diff --git a/.claude/references/_meta/verification-rules.md b/.claude/references/_meta/verification-rules.md index 12feee6..cf680a4 100644 --- a/.claude/references/_meta/verification-rules.md +++ b/.claude/references/_meta/verification-rules.md @@ -14,7 +14,7 @@ Applies to every doc under `.claude/references/**/*.md`. | Line number ref | `Read` with offset — content matches | | Count (e.g., "22 components", "507 tests") | Generate current count via `Glob`/`Grep` — fix inline if drift; drop TODO if structural | | Version (package version) | `Read` `package.json` — match | -| Framework API claim (Next.js 16, React 19, Zod v4, Better Auth 1.5, Vitest 4) | **context7 `query-docs`** — do not trust training | +| Framework API claim (Next.js 16, React 19, Zod v4, Better Auth 1.7, Vitest 5) | **context7 `query-docs`** — do not trust training | | Behavior claim | `Read` referenced source — confirm the claim holds | | Cross-ref to another doc | Target doc exists at that path | diff --git a/.claude/references/auth/README.md b/.claude/references/auth/README.md index 6ebb577..8832add 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.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, one MP client for both the service account and sign-in (`MINISTRY_PLATFORM_CLIENT_ID`), 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 | -| `sessions.md` | Stateless JWT cookie strategy, `cookieCache`, `customSession`, session shape, access patterns | Reading/modifying session data, any `getSession()` call | +| `oauth-flow.md` | `genericOAuth` config, lazy id_token verification, `getUserInfo` + sub binding, the single MP 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,18 +19,24 @@ 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/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/lib/auth-client.ts` | Client `authClient` (`customSessionClient`) | +| `src/lib/env.ts` | `getMpBaseUrl()`, `getAuthBaseUrl()`, `getMpClient()` — validated env readers | +| `src/app/api/auth/[...all]/route.ts` | Route handler — deny-by-default allowlist + `/sign-in/social` body filter in front of `toNextJsHandler(auth)` | +| `src/app/signin/page.tsx` + `sign-in-content.tsx` | Sign-in page (server shell, `force-dynamic`) — the client body auto-redirects via `authClient.signIn.social({ provider: "ministryplatform", callbackURL })` | | `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/shared-actions/sign-out.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/contexts/use-app-session.ts` | `useAppSession()` — thin wrapper around `authClient.useSession()` (returns `data`) | +| `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/auth.secret-guard.test.ts`, `src/auth.session-config.test.ts`, `src/auth.session-lifetime.test.ts`, `src/auth.user-oauth-tokens.test.ts`, `src/auth.ip-address.test.ts` | Boot refusals, cookie-cache config, 12 h cap, token stripping, client-IP headers | +| `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 | +| `src/components/shared-actions/sign-out.test.ts` | `handleSignOut` tests | ## Related domains - `../contexts/README.md` — `UserProvider` consumes `userGuid` from the session diff --git a/.claude/references/auth/oauth-flow.md b/.claude/references/auth/oauth-flow.md index e7af464..d426be3 100644 --- a/.claude/references/auth/oauth-flow.md +++ b/.claude/references/auth/oauth-flow.md @@ -1,195 +1,111 @@ --- -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/sign-in-content.tsx, src/app/api/auth/[...all]/route.ts, src/components/shared-actions/sign-out.ts, src/app/session-error/page.tsx] +symbols: [auth, ministryPlatformProviderConfig, lazyIdTokenVerifier, sharedInstance, getMpClient, handleSignOut] +related: [sessions.md, user-identity.md, route-protection.md, ../security/README.md] +last_verified: 2026-10-10 --- ## 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()`, `getMpClient()` (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/shared-actions/sign-out.ts` — `handleSignOut()` server action (the `<form action>` on `/no-access` and `/session-error`) +- 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/shared-actions/sign-out.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: <synthetic>, mpEmail, name, image: undefined, emailVerified: <from claim> }`. 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** `<sub>@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", "offline_access", "http://www.thinkministry.com/dataplatform/scopes/all"]` | `auth.ts:39-43` | -| `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<string, unknown>; -}, -``` - -- The `Record<string, unknown>` 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 MP 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: <sub>@mp.invalid, mpEmail, name, image: undefined, emailVerified: <claim> }`. +- **One MP client (owner decision 2026-10-10, ADR-027).** Sign-in runs as `MINISTRY_PLATFORM_CLIENT_ID` / `MINISTRY_PLATFORM_CLIENT_SECRET` — the same client the service account uses — read through `getMpClient()`, which refuses to start when either is unset. The earlier dedicated `MP_OIDC_CLIENT_ID` / `_SECRET` pair and its `auth.oidc.shared_client` warning were removed; the variables are ignored if still set. +- **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 `<sub>@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=<original> -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: <sub>@mp.invalid, mpEmail, name, emailVerified: <claim> } - b2. accountSubject({ profile }) → sub (the provider account key) - c. mapProfileToUser(profile) → { userGuid: sub, email: <sub>@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) -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=<original> +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=<MINISTRY_PLATFORM_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 | + +**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/shared-actions/sign-out.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=<BETTER_AUTH_URL>&client_id=<MINISTRY_PLATFORM_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. Invoke it as a `<form action>` (as + `/no-access` and `/session-error` do) so Next handles the redirect. A future client caller + that `await`s it in try/catch must call `unstable_rethrow(err)` **first**, or + it swallows the navigation as an "error". + +**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 | -| `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 | - -`NEXTAUTH_*` fallbacks exist for gradual migration from NextAuth (`src/lib/auth.ts:9-10`, `src/components/user-menu/actions.ts:20`). - -## MP OAuth client setup +| `MINISTRY_PLATFORM_BASE_URL` | yes | Root of the OIDC endpoints (authorize, token, userinfo, endsession, lazy discovery). Validated by `getMpBaseUrl()` | +| `MINISTRY_PLATFORM_CLIENT_ID` / `_SECRET` | yes | The app's one MP client: service account for MP data **and** the sign-in OAuth client, id_token audience, sign-out `client_id`. Read by `getMpClient()` | +| `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 (`MINISTRY_PLATFORM_CLIENT_ID`) - **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 `<guid>@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/route-protection.md b/.claude/references/auth/route-protection.md index 248e8ca..d042d24 100644 --- a/.claude/references/auth/route-protection.md +++ b/.claude/references/auth/route-protection.md @@ -3,13 +3,13 @@ title: Route Protection (proxy + AuthWrapper) domain: auth type: reference applies_to: [src/proxy.ts, src/components/layout/auth-wrapper.tsx, src/app/(web)/layout.tsx] -symbols: [proxy, config, AuthWrapper] -related: [sessions.md, oauth-flow.md] -last_verified: 2026-07-09 +symbols: [proxy, config, isPublicPath, AuthWrapper] +related: [sessions.md, oauth-flow.md, ../routing/proxy.md] +last_verified: 2026-10-10 --- ## Purpose -Dual-layer route protection: edge-level cookie presence check in `src/proxy.ts` (Next.js 16 proxy, formerly middleware), plus a server-component `AuthWrapper` that verifies the decoded session and preserves the original URL as `callbackUrl` through the OAuth round-trip. +Dual-layer route protection: a cookie presence check in `src/proxy.ts` (Next.js 16 proxy, formerly middleware), plus a server-component `AuthWrapper` that verifies the decoded session and preserves the original URL as `callbackUrl` through the OAuth round-trip. ## Files - `src/proxy.ts` — proxy function (exported as `proxy`, not `middleware`) + matcher @@ -18,61 +18,40 @@ Dual-layer route protection: edge-level cookie presence check in `src/proxy.ts` - `src/components/layout/auth-wrapper.test.tsx` — `AuthWrapper` redirect/callbackUrl tests - `src/app/(web)/layout.tsx` — mounts `<AuthWrapper>` around authenticated pages - `src/app/session-error/page.tsx` — recovery page for broken (no-`userGuid`) sessions; **outside** `(web)` +- `src/app/(web)/no-access/page.tsx` — where `ToolsLayout` sends signed-in users with no MP security role; has a sign-out form ## Key concepts - **Next.js 16 renamed `middleware.ts` → `proxy.ts`** and `middleware()` → `proxy()`. The matcher is still exported as `config`. -- **Layer 1 (proxy):** `getSessionCookie()` from `better-auth/cookies` — **cookie-presence check only**, no JWT decode, no DB (there's no DB). Fast path gate at the edge. +- **Layer 1 (proxy):** `getSessionCookie()` from `better-auth/cookies` — **cookie-presence check only**, no cookie decryption, no DB (there's no DB). Runs on the Node.js runtime (Next 16 `proxy` cannot run on edge). - **Layer 2 (`AuthWrapper`):** `auth.api.getSession({ headers })` in a server component — decodes the cookie, validates session, redirects to `/signin` with `callbackUrl` if invalid/missing. It **also** redirects a session that exists but has no `userGuid` to `/session-error` (see "Broken-session recovery" below). - **`x-pathname` forwarding:** proxy copies `pathname + search` into a request header. `AuthWrapper` reads it via `headers()` to build an accurate `callbackUrl` even when the proxy itself lets the request through (valid cookie, decoded session invalid). -- **Public paths (proxy bypass):** `/api/*` (Better Auth endpoints) and `/signin`. Static (`_next/static`, `_next/image`, `favicon.ico`, `assets/`) are excluded via the matcher. +- **Public paths (proxy bypass):** `/api/auth` + `/api/auth/*` (Better Auth endpoints — every other `/api/*` is gated), `/signin` and `/auth-error`. Static (`_next/static/`, `_next/image`, `favicon.ico`, `assets/`) are excluded via the anchored matcher. -## Proxy function (verbatim, `src/proxy.ts`) +## Proxy function (`src/proxy.ts`) -```typescript -// src/proxy.ts:4-42 -export async function proxy(request: NextRequest) { - const { pathname } = request.nextUrl; - const fullPath = pathname + request.nextUrl.search; - - // Forward the original URL (pathname + search) to downstream server - // components via a request header, so AuthWrapper (and anything else - // that needs to redirect to /signin) can build a correct callbackUrl - // even when the proxy itself lets the request through. - const forwardedHeaders = new Headers(request.headers); - forwardedHeaders.set('x-pathname', fullPath); - const passThrough = () => - NextResponse.next({ request: { headers: forwardedHeaders } }); - - // Early returns for public paths - if (pathname.startsWith('/api') || pathname === '/signin') { - console.log(`Proxy: Allowing public path ${pathname}`); - return passThrough(); - } - - try { - const sessionCookie = getSessionCookie(request); - - if (!sessionCookie) { - console.log("Proxy: Redirecting to signin - no session cookie"); - const signinUrl = new URL('/signin', request.url); - signinUrl.searchParams.set('callbackUrl', fullPath); - return NextResponse.redirect(signinUrl); - } +The full annotated source (abridged, CSP construction elided) lives in +[`../routing/proxy.md`](../routing/proxy.md#api--interface). The gate itself: - console.log(`Proxy: Allowing request to ${pathname}`); - return passThrough(); - - } catch (error) { - console.error('Proxy: Error checking session:', error); - const signinUrl = new URL('/signin', request.url); - signinUrl.searchParams.set('callbackUrl', fullPath); - return NextResponse.redirect(signinUrl); - } +```typescript +// src/proxy.ts +function isPublicPath(pathname: string): boolean { + return ( + pathname === '/api/auth' || + pathname.startsWith('/api/auth/') || + pathname === '/signin' || + pathname === '/auth-error' + ); } +// inside proxy(): +if (isPublicPath(pathname)) return passThrough(); // x-pathname + CSP forwarded +const sessionCookie = getSessionCookie(request); // try/catch → redirectToSignin() +if (!sessionCookie) return redirectToSignin(); // /signin?callbackUrl=<path+search> +return passThrough(); + export const config = { matcher: [ - '/((?!_next/static|_next/image|favicon.ico|assets/).*)', + '/((?!_next/static/|_next/image$|favicon\\.ico$|assets/).*)', ], }; ``` @@ -95,9 +74,8 @@ export async function AuthWrapper({ children }: { children: React.ReactNode }) { redirect(`${signinUrl.pathname}${signinUrl.search}`); } - // A session without a userGuid is unusable: every MP lookup keys off userGuid, - // and without it the header avatar/menu never renders — leaving the user with - // no way to sign out. Route these broken sessions to a recovery page instead. + // A session without a userGuid is unusable: every MP lookup keys off userGuid. + // Route these broken sessions to a recovery page that CAN sign them out. const userGuid = (session.user as { userGuid?: string | null }).userGuid; if (!userGuid) { redirect("/session-error"); @@ -110,7 +88,7 @@ export async function AuthWrapper({ children }: { children: React.ReactNode }) { Mounted via: ```typescript -// src/app/(web)/layout.tsx:34-42 +// src/app/(web)/layout.tsx:34-42 (inside WebLayout) <AuthWrapper> <Providers> <div className={...}> @@ -126,11 +104,12 @@ Mounted via: | Path pattern | Proxy behavior | AuthWrapper behavior | |---|---|---| -| `/api/*` | pass through (public) | not mounted (no `(web)` layout) | -| `/signin` | pass through (public) | not mounted | +| `/api/auth`, `/api/auth/*` | pass through (public; route itself is allowlisted) | not mounted (no `(web)` layout) | +| other `/api/*` | redirect to `/signin` if no cookie | not mounted | +| `/signin`, `/auth-error` | pass through (public) | not mounted | | `/session-error` | pass through if cookie present; else → `/signin` | not mounted (outside `(web)`) — no redirect loop | -| `/_next/static/*`, `/_next/image/*`, `/favicon.ico`, `/assets/*` | excluded by matcher | not mounted | -| `/tools/...`, `/home`, etc. | redirect to `/signin?callbackUrl=<path>` if no cookie | `/signin` if session missing; `/session-error` if session present but no `userGuid` | +| `/_next/static/*`, `/_next/image`, `/favicon.ico`, `/assets/*` | excluded by matcher | not mounted | +| `/`, `/no-access`, `/tools/...` | redirect to `/signin?callbackUrl=<path>` if no cookie | `/signin` if session missing; `/session-error` if session present but no `userGuid` | ## `callbackUrl` preservation end-to-end @@ -142,17 +121,17 @@ Mounted via: 6. `AuthWrapper` confirms decoded session — if ever absent, falls back to `/signin?callbackUrl=<x-pathname value or "/">`. Tested: -- `src/proxy.test.ts:98-107` — proxy attaches `callbackUrl` with query string preserved -- `src/components/layout/auth-wrapper.test.tsx:51-94` — `AuthWrapper` reads `x-pathname` and falls back to `/` +- `src/proxy.test.ts:150-159` — proxy attaches `callbackUrl` with query string preserved +- `src/components/layout/auth-wrapper.test.tsx:51-94` — `AuthWrapper` reads `x-pathname`, falls back to `/`, URL-encodes the query ## Broken-session recovery (`/session-error`) A session can be authenticated (cookie present, `getSession()` returns a user) yet have **no `userGuid`** — e.g. after a better-auth upgrade drops the field (see `user-identity.md`), or a user row created before the field existed. Such a -session is a trap: every MP lookup keys off `userGuid`, so the header -avatar/menu never render — and the normal sign-out control lives *in* that menu, -so the user is stuck with no way out. +session is a trap: every MP lookup keys off `userGuid`, so nothing works — and +the app has no sign-out control in its shell (the only ones are the forms on +`/session-error` and `/no-access`), so the user would be stuck. `AuthWrapper` defends against this independent of root cause: a session with no `userGuid` → `redirect("/session-error")`. The page (`src/app/session-error/page.tsx`) @@ -160,8 +139,8 @@ renders a recovery screen with a `handleSignOut` form button, giving the user an unconditional exit. Why this placement: -- **Not the proxy:** it does a cookie-*presence* check and never decodes the - JWT, so it can't cheaply see `userGuid`. +- **Not the proxy:** it does a cookie-*presence* check and never decrypts the + JWE `session_data` cookie, so it can't cheaply see `userGuid`. - **Not cookie deletion in the wrapper:** Next.js forbids cookie mutation during a Server Component render (only actions/route handlers) — which is exactly why recovery uses a server-action form button, not an auto-clear. @@ -175,9 +154,9 @@ healthy session → renders children. ## Gotchas - **Next.js 16 rename:** anything that refers to `middleware.ts` or `middleware()` is stale. This project uses `proxy.ts` / `proxy()`. -- **Proxy is a cookie-presence check only.** A forged/expired/invalid cookie will pass the proxy and only be caught by `AuthWrapper`. This is by design (edge speed), but it means route guards **must** rely on `AuthWrapper` (or equivalent `auth.api.getSession()` check) for correctness — never the proxy alone. -- **`/api/auth/*` is public at the proxy layer.** Better Auth's handler validates its own auth; protected app APIs should check `auth.api.getSession()` in the route handler. -- **`x-pathname` forwarding:** proxy only forwards it via `NextResponse.next({ request: { headers: forwardedHeaders } })`. When the proxy redirects, the header is not used — `AuthWrapper` only sees it on pass-through requests (`src/proxy.test.ts:178-186`). +- **Proxy is a cookie-presence check only.** A forged/expired/invalid cookie will pass the proxy and only be caught by `AuthWrapper`. This is by design (it runs on every matched request), but it means route guards **must** rely on `AuthWrapper` (or equivalent `auth.api.getSession()` check) for correctness — never the proxy alone. +- **`/api/auth/*` is public at the proxy layer.** The route is a deny-by-default allowlist in front of Better Auth's handler. Any other `/api/*` route is cookie-gated by the proxy, but its handler must still authorize (`AuthorizationService`) — see `../security/README.md`. +- **`x-pathname` forwarding:** proxy only forwards it via `NextResponse.next({ request: { headers: forwardedHeaders } })`. When the proxy redirects, the header is not used — `AuthWrapper` only sees it on pass-through requests (`src/proxy.test.ts:230-238`). - **Trailing-slash fallback:** if `x-pathname` is missing (e.g., request didn't route through the proxy), `AuthWrapper` falls back to `"/"` (`src/components/layout/auth-wrapper.tsx:13`). ## Related docs diff --git a/.claude/references/auth/sessions.md b/.claude/references/auth/sessions.md index d1364d5..b16068b 100644 --- a/.claude/references/auth/sessions.md +++ b/.claude/references/auth/sessions.md @@ -1,137 +1,166 @@ --- -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/use-app-session.ts] +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()`, `getMpClient()` - `src/lib/auth-client.ts` — client `authClient` with `customSessionClient<typeof auth>()` -- `src/contexts/session-context.tsx` — `useAppSession()` hook -- `src/auth.test.ts` — `customSession` logic tests (name splitting + session shape) +- `src/contexts/use-app-session.ts` — `useAppSession()` hook (returns `authClient.useSession().data`) + `SessionData` type +- 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<typeof auth>(), - ], -}); +}, +advanced: { + disableOriginCheck: false, // a truthy TEST env var must not switch it off + ipAddress: parseIpAddressOptions(process.env), +}, ``` -`customSessionClient<typeof auth>()` 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`), + 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 + 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 <guid>@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 +168,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/auth/user-identity.md b/.claude/references/auth/user-identity.md index 32469d4..69195a6 100644 --- a/.claude/references/auth/user-identity.md +++ b/.claude/references/auth/user-identity.md @@ -3,20 +3,21 @@ title: User Identity — user.id vs userGuid (CRITICAL) domain: auth type: reference applies_to: [src/lib/auth.ts, src/contexts/user-context.tsx] -symbols: [userGuid, user.id, additionalFields] -related: [sessions.md, oauth-flow.md, ../services/user-service.md] -last_verified: 2026-07-09 +symbols: [userGuid, mpEmail, user.id, userAdditionalFields, mapProfileToUser, accountSubject] +related: [sessions.md, oauth-flow.md, ../services/user-service.md, ../contexts/user-provider.md] +last_verified: 2026-10-10 --- ## Purpose Explain the single most consequential gotcha in this codebase: `session.user.id` is **Better Auth's internal nanoid**, not the Ministry Platform `User_GUID`. The MP `User_GUID` lives in `session.user.userGuid` (populated from the OIDC `sub` claim via `additionalFields` + `mapProfileToUser`). Every MP API lookup keyed by the signed-in user **must** use `userGuid`. ## Files -- `src/lib/auth.ts:22-30` — `user.additionalFields.userGuid` declaration -- `src/lib/auth.ts:82-86` — `mapProfileToUser` writes `userGuid: profile.id` -- `src/lib/auth.ts:69-75` — `getUserInfo` returns `id: profile.sub` +- `src/lib/auth.ts:352-363` — `userAdditionalFields` (`userGuid`, `mpEmail`) +- `src/lib/auth.ts:777` — `accountSubject` reads `profile.sub` (the account key) +- `src/lib/auth.ts:778-892` — `getUserInfo` returns `{ sub, email: <sub>@mp.invalid, mpEmail, ... }` +- `src/lib/auth.ts:899-917` — `mapProfileToUser` writes `userGuid: sub` (validated by `extractUserGuid`) - `src/contexts/user-context.tsx:29` — canonical consumer cast -- `src/auth.test.ts:189-207` — test asserting the distinction +- `src/auth.test.ts:194-222` — field-persistence guard + test asserting the distinction ## Identity table @@ -24,17 +25,23 @@ Explain the single most consequential gotcha in this codebase: `session.user.id` |---|---|---|---| | `session.user.id` | Better Auth generates internally on user creation | `1gYSNMvy6OqAm9q3DdVhtKj3Czkxd0ms` (nanoid-style) | Auth guards only (`if (!session?.user?.id) throw ...`) | | `session.user.userGuid` | MP OIDC `sub` claim → stored via `additionalFields` | `ab12cd34-ef56-7890-abcd-ef1234567890` (UUID) | **All MP API lookups** (`dp_Users.User_GUID`, profile fetching, `$userId` audit) | -| Account row `accountId` | `getUserInfo` returns `id: profile.sub`, Better Auth stores it on the account, **not** on the user | `ab12cd34-ef56-7890-abcd-ef1234567890` | Better Auth internal (account-to-user link) | +| `session.user.email` | `syntheticEmailForSub(sub)` — `<sub>@mp.invalid`, **not** a real address | `ab12cd34-...@mp.invalid` | Better Auth identity keying only; display the real address from `session.user.mpEmail` | +| Account row `accountId` | `accountSubject` reads `profile.sub` (returned by `getUserInfo`); Better Auth stores it on the account, **not** on the user | `ab12cd34-ef56-7890-abcd-ef1234567890` | Better Auth internal (account-to-user link) | ## Why `user.id` ≠ `sub` -Better Auth explicitly strips the incoming `id` when creating the user row, using the value only as the `accountId` on the linked account record. The user's `id` is a fresh server-generated token. This is why `mapProfileToUser` must write `userGuid` separately as an `additionalField`. +Better Auth uses the provider subject (`accountSubject` → `profile.sub`) only as the `accountId` on the linked account record. The user's `id` is a fresh server-generated token. This is why `mapProfileToUser` must write `userGuid` separately as an `additionalField`. -## `additionalFields.userGuid` declaration (verbatim) +## `userAdditionalFields` declaration (verbatim) ```typescript -// src/lib/auth.ts — extracted to an exported const so a test can assert against it +// src/lib/auth.ts:352-363 — extracted to an exported const so a test can assert against it export const userAdditionalFields = { userGuid: { + type: "string" as const, + required: true, + input: true, + }, + mpEmail: { type: "string" as const, required: false, input: true, @@ -47,21 +54,23 @@ user: { }, ``` -> ⚠️ **`userGuid` MUST keep `input: true`.** It is populated server-side from the +> ⚠️ **`userGuid` (and `mpEmail`) MUST keep `input: true`.** It is populated server-side from the > OAuth profile via `mapProfileToUser`, not by user input. As of better-auth > 1.6, the provider-profile field parser (`parseAdditionalUserInput` in > `node_modules/better-auth/dist/db/schema.mjs`) no longer lets an > `input: false` additionalField through when a value is supplied — in 1.6.11 it > throws `"userGuid is not allowed to be set"`, so the OAuth-created user never -> gets a `userGuid`. Result: `session.user.userGuid` is `undefined` → blank -> avatar, dead user menu, broken MP profile lookups. There is no user-facing -> form that sets this field (genericOAuth only; no email/password signup or -> update-user endpoint), so `input: true` carries no practical risk here. +> gets a `userGuid`. Result: `session.user.userGuid` is `undefined` → every +> tool redirects to `/session-error`, and MP profile lookups break. `input: true` +> also makes the field writable through Better Auth's `/update-user` endpoint; +> that endpoint is closed at the route layer (`disabledAuthPaths` in +> `src/lib/auth.ts` and the allowlist in `src/app/api/auth/[...all]/route.ts`) — +> that is the control, not this flag. > Guarded by `src/auth.test.ts` (runs the real better-auth parser against the > real `userAdditionalFields` config). - `input: true` — see the warning above. The field is only ever set server-side by `mapProfileToUser`. -- `required: false` — the field is optional in the TS type, so consumers must null-check. +- `required: true` — better-auth refuses to create a user record with no MP identity (fail closed). Sessions minted before this, or by a future regression, can still lack it — `AuthWrapper` routes those to `/session-error`, and consumers still null-check. ## Better Auth upgrade checklist @@ -76,7 +85,7 @@ After **any** better-auth version change: 3. **Manual smoke test (required — nothing else catches this):** `npm run dev`, **sign out and sign in fresh** (stale sessions predate any user-record change), open `/api/auth/get-session`, and confirm `user.userGuid` is present. Confirm - the header avatar renders and the user menu opens with a working sign-out. + a tool loads (not `/session-error`). 4. If `userGuid` is missing, inspect `parseAdditionalUserInput` (formerly `parseAdditionalUserInputFromProviderProfile`) in `node_modules/better-auth/dist/db/schema.mjs`. @@ -84,10 +93,18 @@ After **any** better-auth version change: ## `mapProfileToUser` (verbatim) ```typescript -// src/lib/auth.ts:82-86 +// src/lib/auth.ts:899-917 (comments trimmed) mapProfileToUser: (profile) => { + const sub = extractUserGuid(profile); + if (!sub) { + throw new Error("mapProfileToUser: profile has no usable sub"); + } return { - userGuid: profile.id, + userGuid: sub, + email: syntheticEmailForSub(sub), + mpEmail: + (profile as unknown as { mpEmail?: string | null }).mpEmail ?? + null, } as Record<string, unknown>; }, ``` @@ -104,6 +121,8 @@ const session = await auth.api.getSession({ headers: await headers() }); if (!session?.user?.id) { throw new Error("Authentication required"); // guard on any identity } +// Feature actions/services must authorize via AuthorizationService, not just +// check a session — see ../security/README.md. // For MP API lookups, use userGuid (NOT user.id): const userGuid = (session.user as { userGuid?: string }).userGuid; @@ -141,12 +160,12 @@ const loadUserProfile = useCallback(async () => { - Server: `(session.user as Record<string, unknown>).userGuid as string` **or** `(session.user as { userGuid?: string }).userGuid` - Client: `(session?.user as { userGuid?: string } | undefined)?.userGuid` -This is a Better Auth type-inference limitation as of `better-auth@^1.5.5`. Runtime behavior is correct. +This is a Better Auth type-inference limitation (still present on `better-auth@^1.7.7`; the casts remain in `user-context.tsx` and `auth-wrapper.tsx`). Runtime behavior is correct. ## Test evidence ```typescript -// src/auth.test.ts:189-207 +// src/auth.test.ts:203-222 it('should distinguish user.id (Better Auth internal) from userGuid (MP User_GUID)', () => { const mpUserGuid = 'ab12cd34-ef56-7890-abcd-ef1234567890'; const betterAuthId = '1gYSNMvy6OqAm9q3DdVhtKj3Czkxd0ms'; @@ -169,7 +188,7 @@ it('should distinguish user.id (Better Auth internal) from userGuid (MP User_GUI - **Most common bug:** calling an MP service with `session.user.id` instead of `session.user.userGuid` — returns empty results or throws "user not found" because `user.id` is a nanoid, not a UUID in `dp_Users.User_GUID`. - **Don't rename `userGuid` to `mpUserGuid` / `userId`.** Call sites grep on exactly `userGuid`. -- **`userGuid` can be `undefined`** if the user row was created before `additionalFields.userGuid` was added, or if `mapProfileToUser` ran before a schema change. Always null-check. +- **`userGuid` can be `undefined`** on a session minted before the field existed or by a better-auth regression (the 1.6 incident). Always null-check. - **`user.id` is still required** — use it only as a "session exists" sentinel (`if (!session?.user?.id)`), never for DB joins. ## Glossary candidates @@ -178,5 +197,5 @@ it('should distinguish user.id (Better Auth internal) from userGuid (MP User_GUI ## Related docs - `sessions.md` — session shape and why the cast is necessary - `oauth-flow.md` — how `sub` flows into `userGuid` -- `../services/user-service.md` — `UserService.getUserProfile(userGuid)` consumer (if authored) -- `../contexts/user-provider.md` — canonical client-side consumer (if authored) +- `../services/user-service.md` — `UserService.getUserProfile(userGuid)` consumer +- `../contexts/user-provider.md` — canonical client-side consumer diff --git a/.claude/references/components/README.md b/.claude/references/components/README.md index a49a6c3..92b8b41 100644 --- a/.claude/references/components/README.md +++ b/.claude/references/components/README.md @@ -16,23 +16,19 @@ 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 | ## Code surfaces | Path | Role | |------|------| -| `src/components/tool/` | `ToolContainer`, `ToolHeader`, `ToolFooter` — 4 files | +| `src/components/tool/` | `ToolContainer`, `ToolHeader`, `ToolFooter` + barrel + `tool-framework.test.tsx` — 5 files | | `src/components/layout/` | `AuthWrapper` server component + test — 3 files | | `src/components/ui/` | 22 shadcn/ui primitives | -| `src/components/shared-actions/` | Cross-feature server actions — 2 files (+ local `README.md`) | -| `src/components/address-labels/` | Address-label feature — 13 files | -| `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 | +| `src/components/shared-actions/` | Cross-feature server actions — `user.ts`, `domain.ts`, `sign-out.ts` + tests (+ local `README.md`) | +| `src/components/address-labels/` | Address-label feature — 24 files (incl. 11 tests) | +| `src/components/dev-panel/` | Dev overlay — 3 files + `panels/` subdir (20 files) | +| `src/components/field-management/` | Field-order editor — 16 files (incl. 7 tests) | +| `src/components/group-wizard/` | Group wizard — 26 files (incl. 12 tests) | ## Conventions (domain-wide) - File names: `kebab-case.tsx` (e.g., `tool-container.tsx`) diff --git a/.claude/references/components/address-labels.md b/.claude/references/components/address-labels.md index 1efaf8a..c9d1aac 100644 --- a/.claude/references/components/address-labels.md +++ b/.claude/references/components/address-labels.md @@ -24,13 +24,15 @@ related: - ../utils/label-stock.md - ../services/address-label-service.md - tool-framework.md -last_verified: 2026-04-17 +last_verified: 2026-10-10 --- ## Purpose Print USPS-compliant mailing labels (PDF + Word) from MP Contacts/Selection data, with IMb/POSTNET barcodes, and merge addresses into a user-uploaded `.docx` template (one page per address with embedded barcode image) for window-envelope mailings. -## Files (13) +## Files + +Per-component `*.test.tsx` files are co-located and not listed. | File | Role | |------|------| @@ -44,9 +46,14 @@ Print USPS-compliant mailing labels (PDF + Word) from MP Contacts/Selection data | `src/components/address-labels/mail-merge-tab.tsx` | Client mail-merge UI: sample download, template upload, merge + download | | `src/components/address-labels/sample-template.ts` | `'use server'` — `generateSampleTemplate()` produces the sample `.docx` with merge tokens | | `src/components/address-labels/word-document.ts` | `buildWordDocument()` — Avery-style `.docx` via `docx` package with BMP barcodes | -| `src/components/address-labels/actions.ts` | Server actions (see API below) | +| `src/components/address-labels/actions.ts` | Server actions (see API below); `requireAccess` (`AuthorizationService` gate) + shared `prepareLabels` validation helper | +| `src/components/address-labels/limits.ts` | Shared size caps: `MAX_TEMPLATE_BYTES` (2 MB) + `MAX_TEMPLATE_LABEL` (`'2MB'`), `MAX_LABELS` (10,000), `TEMPLATE_ZIP_LIMITS` (`maxEntries: 1_000`, `maxTotalBytes: 64 MB`). Imported by both client (`mail-merge-tab.tsx`) and server (`actions.ts`, `schemas.ts`) | +| `src/components/address-labels/schemas.ts` | Zod schemas + `parseLabelInput(labels, config)` — server-side validation of client-sent labels/config | +| `src/lib/zip-guard.ts` | `assertZipWithinLimits` / `ZipLimitError` — zip-bomb pre-flight run before PizZip | | `src/components/address-labels/actions.test.ts` | Vitest suite covering all four server actions | | `src/components/address-labels/word-document.test.ts` | Vitest suite for `buildWordDocument` geometry paths | +| `src/components/address-labels/sample-template.test.ts` | `generateSampleTemplate` | +| `src/components/address-labels/merge-template.integration.test.ts` | Real (unmocked) `mergeTemplate` + PizZip run, incl. a forged-size zip bomb | Tool route: `src/app/(web)/tools/addresslabels/page.tsx` → `address-labels.tsx` (orchestrates form, fetch, summary, tabs, PDF/Word download). @@ -118,24 +125,26 @@ export async function fetchAddressLabels( config: LabelConfig, ): Promise<FetchAddressLabelsResult>; +type ActionResult = { success: true; data: string } | { success: false; error: string }; + // Renders LabelDocument to a PDF via @react-pdf/renderer; returns base64 or { success: false, error }. export async function generateLabelPdf( - labels: LabelData[], - config: LabelConfig, -): Promise<{ success: true; data: string } | { success: false; error: string }>; + rawLabels: LabelData[], + rawConfig: LabelConfig, +): Promise<ActionResult>; // Builds an Avery-layout .docx via `docx` + BMP barcodes; returns base64 or error. export async function generateLabelDocx( - labels: LabelData[], - config: LabelConfig, -): Promise<{ success: true; data: string } | { success: false; error: string }>; + rawLabels: LabelData[], + rawConfig: LabelConfig, +): Promise<ActionResult>; -// Merges user-uploaded .docx template (base64, ≤5MB) with address rows + embedded barcode images. +// Merges user-uploaded .docx template (base64, ≤ MAX_TEMPLATE_BYTES = 2MB) with address rows + embedded barcode images. export async function mergeTemplate( templateBase64: string, - labels: LabelData[], - config: LabelConfig, -): Promise<{ success: true; data: string } | { success: false; error: string }>; + rawLabels: LabelData[], + rawConfig: LabelConfig, +): Promise<ActionResult>; ``` Other exported helpers: @@ -158,13 +167,25 @@ const SERVICE_TYPES = [{id:'040',…}, {id:'300',…}, {id:'044',…}, {id:'700' ## How it works ### fetchAddressLabels (selection vs single-record) -- `actions.ts:28` `getSession()` asserts `session.user.id` or throws `Unauthorized`. -- Selection mode (`params.s && params.pageID`): resolves MP `User_ID` from `session.user.userGuid` via `UserService.getUserIdByGuid`, then `ToolService.getSelectionRecordIds(s, userId, pageID)` → contact IDs → `AddressLabelService.getAddressesForContacts()`. -- Single-record mode (`params.recordID !== -1`): `AddressLabelService.getAddressForContact(recordID)`. -- `filterAndTransform()` (`actions.ts:42`) iterates rows, applies skip rules in fixed order (opted_out → no_address → no_postal_code → no_barcode), dedups by `Household_ID` in household mode, sorts by `postalCode` ascending. +- `requireAccess('Contacts', 'read')` (`actions.ts:43`) → `AuthorizationService.requireSecurityRole`; throws on no session / no qualifying MP security role. The services gate again (defence in depth). +- Selection mode (`params.s && params.pageID`): `ToolService.getSelectionRecordIds(s, pageID)` → contact IDs → `AddressLabelService.getAddressesForContacts()`. The acting MP `User_ID` for the selection SP comes from the gate inside the service, never from the action. +- Single-record mode (`params.recordID` set and `!== -1`): `AddressLabelService.getAddressForContact(recordID)`. +- `filterAndTransform()` (`actions.ts:51`) iterates rows, applies skip rules in fixed order (opted_out → no_address → no_postal_code → no_barcode → no_household), dedups by `Household_ID` in household mode, sorts by `postalCode` ascending. + +### prepareLabels (shared input validation) +Called by `generateLabelPdf`, `generateLabelDocx`, `mergeTemplate` after `requireAccess('Contacts', 'read')`. Labels/config are caller-shaped input (they round-trip through the client after `fetchAddressLabels`), so every render action re-validates them. Returns `{ ok: true, labels, config, stock }` or `{ ok: false, result }` (the action returns `result` as-is). Order: +1. `parseLabelInput(rawLabels, rawConfig)` (`schemas.ts`): + - array length > `MAX_LABELS` → `Too many labels (N); the limit is 10,000 per request` (checked before Zod) + - `labelsSchema` — bounded strings (`name`/`addressLine1`/`addressLine2` ≤256, `city` ≤128, `state` ≤64, `postalCode` ≤32, `barCode` ≤64, `deliveryPointCode` ≤16); `barStates`/`barType` are **stripped** (Zod `z.object` drops unknown keys) — only `preEncodeBarcodes` may set them. Failure → `Invalid label data` + - `labelConfigSchema` — `stockId` ≤64, `addressMode`/`barcodeFormat` enums, `startPosition` int ≥1, `mailerId` ≤9, `serviceType` ∈ `SERVICE_TYPES` ids. Failure → `Invalid label settings` + - Errors name the problem, never the payload. +2. `getLabelStock(config.stockId)` → `Unknown label stock: ${stockId}`. +3. `config.startPosition > stock.columns * stock.rows` → `Start position is beyond the end of the sheet` (it is a count of blank cells to emit, so unbounded = unbounded pages). +4. Empty labels → per-action message (`No labels to print` / `No labels to export` / `No addresses to merge`). +5. IMb with a Mailer ID → `validateMailerId`; failure returns its message (fallback `Invalid Mailer ID`). ### generateLabelPdf -- Calls `preEncodeBarcodes(labels, config)` then renders `<LabelDocument>` through `pdf()` from `@react-pdf/renderer` → `toBlob()` → base64. Unknown `stockId` or empty labels return `{ success: false }`. +- `prepareLabels(..., 'No labels to print')`, then `preEncodeBarcodes(labels, config)`, then renders `<LabelDocument>` through `pdf()` from `@react-pdf/renderer` → `toBlob()` → base64. ### LabelDocument layout - `skipCount = startPosition - 1`; `labelsPerPage = stock.columns * stock.rows`; `pageCount = ceil((skipCount + labels.length) / labelsPerPage)`. @@ -175,19 +196,21 @@ const SERVICE_TYPES = [{id:'040',…}, {id:'300',…}, {id:'044',…}, {id:'700' - Barcodes embedded via `ImageRun({ type: 'bmp' })` using `imbBarcodeToBmp` / `postnetBarcodeToBmp` from `src/lib/barcode-image.ts`. ### mergeTemplate (.docx mail merge) -- Rejects `templateBase64` if `Math.ceil(length * 0.75) > 5 * 1024 * 1024`. +- Size check first, on the encoded string, before any decoding: non-string or `Math.ceil(length * 0.75) > MAX_TEMPLATE_BYTES` → `Template file exceeds 2MB limit`. +- `prepareLabels(..., 'No addresses to merge')`. +- Zip-bomb guard: `assertZipWithinLimits(Buffer.from(templateBase64, 'base64'), TEMPLATE_ZIP_LIMITS)` (`src/lib/zip-guard.ts`) **before** PizZip sees the archive. Walks the central directory, caps entry count (1,000) and summed declared uncompressed size (64 MB), rejects ZIP64 / encrypted / non-STORE-DEFLATE entries, then inflates each entry with `maxOutputLength` = declared size so a lying header fails at that size. `ZipLimitError` message is returned; any other throw → `Not a valid .docx file`. See `../data-flow/error-catalog.md` for the full message list. - `preEncodeBarcodes()` then builds `addresses[]` rows with `{ Name, AddressLine1, AddressLine2, City, State, PostalCode, Barcode: 'barcode_N', isNotLast }` plus a `Map<string,Buffer>` of BMP barcodes keyed by `barcode_N`. - `Docxtemplater` with `docxtemplater-image` — `getImage` resolves key strings to buffers; `getSize` returns `[200, 25]` for the `Barcode` tag. - Render errors containing "tag" are wrapped as `Template error: …`. ### Mail merge tab upload flow -- Only `.docx`, `file.size <= 5MB`; base64-encoded client-side via `FileReader`. +- Only `.docx`, `file.size <= MAX_TEMPLATE_BYTES` (2 MB, from `limits.ts` — same constant the server enforces; error `Template file must be under 2MB`); base64-encoded client-side via `FileReader`. Label text shows `max {MAX_TEMPLATE_LABEL}`. - Re-validates 6/9-digit Mailer ID before calling `mergeTemplate`. - Downloads result blob as `merged-letters.docx`. ## Usage -Dashboard card entry (from `src/app/(web)/page.tsx:41`): +Dashboard card entry (from `src/app/(web)/page.tsx:27`): ```tsx <Card className="flex flex-col"> <CardHeader> @@ -204,7 +227,7 @@ Dashboard card entry (from `src/app/(web)/page.tsx:41`): </Card> ``` -Tool page wiring (from `src/app/(web)/tools/addresslabels/address-labels.tsx:180`): +Tool page wiring (abridged, from `src/app/(web)/tools/addresslabels/address-labels.tsx:183`): ```tsx <ToolContainer params={params} title="Address Labels" onClose={handleClose} hideFooter> <div className="px-6 py-4 space-y-6 max-w-2xl"> @@ -229,11 +252,12 @@ Tool page wiring (from `src/app/(web)/tools/addresslabels/address-labels.tsx:180 ## Gotchas -- **Mailer ID is user-supplied** (not an env var). `LabelConfig.mailerId` persists in `localStorage`; both the PDF (`address-labels.tsx:98`) and `mergeTemplate` (`mail-merge-tab.tsx:111`) reject IMb generation unless the length is exactly 6 or 9. `preEncodeBarcodes` silently falls back from IMb → POSTNET when `mailerId` is empty (`src/lib/barcode-helpers.ts:41`). +- **Mailer ID is user-supplied** (not an env var). `LabelConfig.mailerId` persists in `localStorage`; both the PDF/Word handlers (`address-labels.tsx:100,139`) and `mergeTemplate` (`mail-merge-tab.tsx:111`) reject IMb generation unless the length is exactly 6 or 9. `preEncodeBarcodes` falls back from IMb → POSTNET when `mailerId` is empty (silently) or IMb encoding throws (`console.warn`) (`src/lib/barcode-helpers.ts:53-66`). - **Label geometry is in PDF points** (72pt = 1in). `buildWordDocument` converts via `convertInchesToTwip(ptToIn(...))`. Adding a new `LABEL_STOCKS` entry in the wrong unit silently produces misaligned pages. -- **React-pdf type cast**: `pdf(doc as any)` in `actions.ts:156` is a runtime-safe cast because `LabelDocument` returns `<Document>` at its root; `@react-pdf/renderer` types don't permit a wrapper with its own props. -- **PizZip/docxtemplater expect strings, not Buffers**, as image tag values — `mergeTemplate` keys the buffer map with `barcode_${i}` strings; passing the buffer directly makes the image module misinterpret it as a cached result (`actions.ts:227`). -- **Selection mode requires MP `User_ID`** (not Better Auth `user.id`). `getMPUserId()` does a GUID→ID lookup per call — no memoization. +- **React-pdf type cast**: `pdf(doc as any)` in `generateLabelPdf` (`actions.ts`) is a runtime-safe cast because `LabelDocument` returns `<Document>` at its root; `@react-pdf/renderer` types don't permit a wrapper with its own props. +- **PizZip/docxtemplater expect strings, not Buffers**, as image tag values — `mergeTemplate` keys the buffer map with `barcode_${i}` strings; passing the buffer directly makes the image module misinterpret it as a cached result (`mergeTemplate` in `actions.ts`). +- **Size limits are coupled to the request-body ceiling.** `next.config.ts` sets `experimental.serverActions.bodySizeLimit: "4mb"` (Next default 1 MB made the template limit unreachable) to stay under Vercel's 4.5 MB function request cap. A 2 MB template is ~2.7 MB as base64, leaving ~1.3 MB for the label list on a merge. Change `limits.ts` and `next.config.ts` together. +- **Selection mode requires MP `User_ID`** (not Better Auth `user.id`). It is resolved by the `AuthorizationService` gate inside `ToolService.getSelectionRecordIds` (memoized per request), not by the action. ## Related docs diff --git a/.claude/references/components/dev-panel.md b/.claude/references/components/dev-panel.md index 13279da..7454a31 100644 --- a/.claude/references/components/dev-panel.md +++ b/.claude/references/components/dev-panel.md @@ -14,6 +14,7 @@ applies_to: - src/components/dev-panel/panels/user-tools-actions.ts - src/components/dev-panel/panels/deploy-tool-panel.tsx - src/components/dev-panel/panels/deploy-tool-actions.ts + - src/components/dev-panel/panels/require-dev-session.ts symbols: - DevPanel - ParamsPanel @@ -28,12 +29,13 @@ symbols: - listPagesAction - listRolesAction - getDeployToolEnvStatusAction + - requireDevSession related: - tool-framework.md - ../services/tool-service.md - - ../routing/tool-params.md - - ../required-stored-procs.md -last_verified: 2026-04-17 + - ../utils/tool-params.md + - ../mp-schema/stored-procs.md +last_verified: 2026-10-10 --- ## Purpose @@ -47,21 +49,23 @@ Localhost-only developer overlay rendered by `ToolContainer` when `params` is pr | `src/components/dev-panel/dev-panel.test.tsx` | Shell tests (gating, localStorage persistence) — sub-panels fully mocked | | `src/components/dev-panel/panels/params-panel.tsx` | Renders `ToolParams` as a labelled grid + raw JSON (pure client, no server action) | | `src/components/dev-panel/panels/selection-panel.tsx` | Resolves `params.s` → record IDs via `resolveSelection`, bubbles IDs up via `onRecordIdsResolved` | -| `src/components/dev-panel/panels/selection-actions.ts` | `resolveSelection(selectionId, pageId)` → `{ recordIds, count }` (auth-gated) | -| `src/components/dev-panel/panels/selection-actions.test.ts` | Tests for auth/userGuid guards and delegation | +| `src/components/dev-panel/panels/selection-actions.ts` | `resolveSelection(selectionId, pageId)` → `{ recordIds, count }` (`requireDevSession`) | +| `src/components/dev-panel/panels/selection-actions.test.ts` | Tests for the dev-session guard and delegation | | `src/components/dev-panel/panels/contact-records-panel.tsx` | Maps record IDs → Contact IDs via `resolveContactRecords`, driven by `params.recordID` or upstream selection | -| `src/components/dev-panel/panels/contact-records-actions.ts` | `resolveContactRecords(table, pk, contactIdField, recordIds)` → `ContactRecordResult` | +| `src/components/dev-panel/panels/contact-records-actions.ts` | `resolveContactRecords(pageId, recordIds)` → `ContactRecordResult \| null` (page metadata resolved server-side) | | `src/components/dev-panel/panels/user-tools-panel.tsx` | Lists the user's authorized tool paths, computes `isAuthorized` against `NEXT_PUBLIC_PROD_URL + pathname`, reports status up via `onAuthorizationChange` | -| `src/components/dev-panel/panels/user-tools-actions.ts` | `getUserTools()` → `string[]` (paths, auth-gated, resolves `userGuid` → `User_ID`) | -| `src/components/dev-panel/panels/user-tools-actions.test.ts` | Tests for auth/userGuid guards and delegation | +| `src/components/dev-panel/panels/user-tools-actions.ts` | `getUserTools()` → `string[]` (paths; `requireDevSession`; acting `User_ID` resolved by the gate inside `ToolService.getUserTools`) | +| `src/components/dev-panel/panels/user-tools-actions.test.ts` | Tests for the dev-session guard, refusal propagation, delegation | | `src/components/dev-panel/panels/deploy-tool-panel.tsx` | Form: tool-name/launch-page/description/flags/pages/roles + server-side env-status badge; rendered only when user is NOT authorized for the current tool path | -| `src/components/dev-panel/panels/deploy-tool-actions.ts` | `listPagesAction`, `listRolesAction`, `deployToolAction`, `getDeployToolEnvStatusAction` — all gated on `NODE_ENV !== 'production'` + session | +| `src/components/dev-panel/panels/deploy-tool-actions.ts` | `listPagesAction`, `listRolesAction`, `deployToolAction`, `getDeployToolEnvStatusAction` — all via `requireDevSession("Deploy Tool")` | | `src/components/dev-panel/panels/deploy-tool-actions.test.ts` | Tests for prod guard, session guard, delegation, env-status reporting | +| `src/components/dev-panel/panels/require-dev-session.ts` | `requireDevSession(featureLabel = "Dev panel")` — shared guard for **every** dev-panel action: throws when `NODE_ENV === "production"`, then requires a session with `user.id`; returns the session. CLAUDE.md rule-12 carve-out (the services it calls gate again) | +| `src/components/dev-panel/panels/*-panel.test.tsx`, `require-dev-session.test.ts` | Per-panel and guard tests (11 test files in the folder tree in total) | ## Key concepts ### Triple-gate rendering -`DevPanel` returns `null` unless **all three** hold (`src/components/dev-panel/dev-panel.tsx:56-58`): +`DevPanel` returns `null` unless **all three** hold (`src/components/dev-panel/dev-panel.tsx:59-61`): 1. `mounted === true` — avoids SSR/CSR mismatch (value flipped inside `useEffect`) 2. `process.env.NODE_ENV === "development"` (`isDevBuild()`) 3. `window.location.hostname === "localhost" || "127.0.0.1"` (`isLocalhost()`) @@ -74,7 +78,7 @@ Rendered from `ToolContainer` (`src/components/tool/tool-container.tsx:37`): `{p - Restored on mount via `setIsOpen(readOpenState())` in the same `useEffect` that sets `mounted` ### Sub-panel composition -Inside the expanded body (`src/components/dev-panel/dev-panel.tsx:94-109`): +Inside the expanded body (`src/components/dev-panel/dev-panel.tsx:102-111`): 1. `ParamsPanel` — always 2. `SelectionPanel` — bubbles `selectionRecordIds` up via `onRecordIdsResolved` 3. `ContactRecordsPanel` — consumes `params.recordID` OR `selectionRecordIds` from (2) @@ -87,7 +91,7 @@ Inside the expanded body (`src/components/dev-panel/dev-panel.tsx:94-109`): - If `NEXT_PUBLIC_PROD_URL` is unset, renders an amber warning and sets `isAuthorized = false` (so Deploy Tool stays visible) ### Deploy Tool env status -`getDeployToolEnvStatusAction` server-side checks two env vars (`src/components/dev-panel/panels/deploy-tool-actions.ts:46-55`): +`getDeployToolEnvStatusAction` server-side checks two env vars (`src/components/dev-panel/panels/deploy-tool-actions.ts:37-46`): - `MINISTRY_PLATFORM_DEV_CLIENT_ID` - `MINISTRY_PLATFORM_DEV_CLIENT_SECRET` @@ -96,12 +100,12 @@ When missing, the form disables the submit button and surfaces an amber notice n ### Stored procedures called from the dev-panel | SP | Called via | From file | |----|-----------|-----------| -| `api_Common_GetSelection` | `ToolService.getSelectionRecordIds` | `src/services/toolService.ts:154` (via `selection-actions.ts`) | -| `api_Tools_GetUserTools` | `ToolService.getUserTools` | `src/services/toolService.ts:185` (via `user-tools-actions.ts`) | -| `api_dev_DeployTool` | `ToolService.deployTool` | `src/services/toolService.ts:268` (via `deploy-tool-actions.ts`) | -| `api_MPNextTools_GetPages` | `ToolService.listPages` | `src/services/toolService.ts:207` (via `deploy-tool-actions.ts`) | +| `api_Common_GetSelection` | `ToolService.getSelectionRecordIds` | `src/services/toolService.ts:162` (via `selection-actions.ts`) | +| `api_Tools_GetUserTools` | `ToolService.getUserTools` | `src/services/toolService.ts:196` (via `user-tools-actions.ts`) | +| `api_dev_DeployTool` | `ToolService.deployTool` | `src/services/toolService.ts:272` (via `deploy-tool-actions.ts`) | +| `api_MPNextTools_GetPages` | `ToolService.listPages` | `src/services/toolService.ts:223` (via `deploy-tool-actions.ts`) | -`ContactRecordsPanel` does **not** call a stored procedure — it uses `getTableRecords` in `ToolService.resolveContactIds` (`src/services/toolService.ts:335`). +`ContactRecordsPanel` does **not** call a stored procedure — it uses `getTableRecords` in `ToolService.resolveContactIds` (`src/services/toolService.ts`). ### Glossary candidates - **DevPanel** — localhost-only overlay defined at `src/components/dev-panel/dev-panel.tsx:44` @@ -134,11 +138,9 @@ export async function resolveSelection( // src/components/dev-panel/panels/contact-records-actions.ts export async function resolveContactRecords( - tableName: string, - primaryKey: string, - contactIdField: string, + pageId: number, recordIds: number[] -): Promise<ContactRecordResult>; +): Promise<ContactRecordResult | null>; // src/components/dev-panel/panels/user-tools-actions.ts export async function getUserTools(): Promise<string[]>; @@ -176,8 +178,8 @@ interface DeployToolPanelProps { - `DevPanel` returns `null` on first render (pre-`useEffect`), then re-evaluates the three gates — so the bar appears only after hydration on a qualifying localhost dev build - Toggle button writes `"1"`/`"0"` to `localStorage["mp-dev-panel:open"]`; reads in `useEffect` prevent SSR mismatch - Sub-panels are arranged so state flows downward from `SelectionPanel` → `ContactRecordsPanel` and upward from `UserToolsPanel` → `DevPanel` (`isAuthorized`) → `DeployToolPanel` (via conditional render). Successful deploy bumps `userToolsRefreshKey`, which re-triggers `UserToolsPanel`'s fetch, which flips `isAuthorized` and unmounts `DeployToolPanel`. -- Every server action begins with `auth.api.getSession({ headers: await headers() })` and extracts `userGuid` (not `session.user.id`) for MP lookups — see `../auth/sessions.md` -- `deploy-tool-actions.ts` adds `process.env.NODE_ENV === "production"` → throw before any session check (belt-and-suspenders against accidental exposure) +- Every dev-panel server action begins with `await requireDevSession(...)` (`panels/require-dev-session.ts`): `NODE_ENV === "production"` → throw, then a Better Auth session with `user.id` is required +- The actions pass no user identity to the services; `ToolService` resolves the acting MP `User_ID` from `session.user.userGuid` through `AuthorizationService.requireSecurityRole` (see `../auth/sessions.md`) ## Usage @@ -196,30 +198,33 @@ if (!isDevBuild()) return null; if (!isLocalhost()) return null; ``` -Server-action guard pattern, from `src/components/dev-panel/panels/deploy-tool-actions.ts:13-21`: +Server-action guard, from `src/components/dev-panel/panels/require-dev-session.ts:26-37` (docstring elided): ```typescript -async function requireDevSession(): Promise<void> { +export async function requireDevSession( + featureLabel: string = "Dev panel" +): Promise<NonNullable<Awaited<ReturnType<typeof auth.api.getSession>>>> { if (process.env.NODE_ENV === "production") { - throw new Error("Deploy Tool is not available in production."); + throw new Error(`${featureLabel} is not available in production.`); } const session = await auth.api.getSession({ headers: await headers() }); if (!session?.user?.id) { throw new Error("Unauthorized - Missing user session data"); } + return session; } ``` ## Gotchas -- **Don't ship DevPanel to production.** The `DevPanel` component gates on `NODE_ENV === "development"` AND `hostname === "localhost" | "127.0.0.1"`. Removing either check (or setting `NODE_ENV=development` on a deployed build) would leak deploy-tool and session internals. Defense-in-depth: `deploy-tool-actions.ts` also throws in production before any session check. -- **Server actions in a dev-only code path are still reachable.** `/* "use server" */` exports are addressable over the network regardless of where they're imported. `requireDevSession` in `deploy-tool-actions.ts` enforces the `NODE_ENV` check server-side; `selection-actions.ts`, `contact-records-actions.ts`, and `user-tools-actions.ts` only check for a valid session — they do NOT gate on `NODE_ENV`. This is by design (those same services back real features), but it means the "dev-only" label applies to the **UI** only, not the actions. (See TODO `2026-04-17-components-dev-panel-prod-guard-gap.md`.) +- **Don't ship DevPanel to production.** The `DevPanel` component gates on `NODE_ENV === "development"` AND `hostname === "localhost" | "127.0.0.1"`. Removing either check (or setting `NODE_ENV=development` on a deployed build) would leak deploy-tool and session internals. Defense-in-depth: every dev-panel action calls `requireDevSession`, which throws in production before any session check. +- **Server actions in a dev-only code path are still reachable.** `"use server"` exports are addressable over the network regardless of where they're imported. That is why all four action files (`selection-`, `contact-records-`, `user-tools-`, `deploy-tool-actions.ts`) call `requireDevSession`, which enforces the `NODE_ENV` check server-side. Any new dev-panel action must do the same. - **Missing `NEXT_PUBLIC_PROD_URL` makes the authorization check non-functional.** When unset, `UserToolsPanel` cannot match production URLs and always treats the user as unauthorized, which keeps `DeployToolPanel` visible even for already-registered tools. -- **`session.user.id` is NOT `User_GUID`.** Every action extracts `userGuid` from `session.user` (see `../auth/sessions.md`) before calling `UserService.getUserIdByGuid`. +- **`session.user.id` is NOT `User_GUID`.** `requireDevSession` only checks that `user.id` exists; the MP `User_ID` is resolved from `session.user.userGuid` by `AuthorizationService` inside the service (see `../auth/sessions.md`). ## Related docs - `tool-framework.md` — `ToolContainer` that mounts this overlay - `../services/tool-service.md` — `ToolService.getSelectionRecordIds`, `getUserTools`, `deployTool`, `resolveContactIds`, `listPages`, `listRoles` -- `../routing/tool-params.md` — `ToolParams` shape + `parseToolParams` -- `../required-stored-procs.md` — `api_Common_GetSelection`, `api_Tools_GetUserTools` call contracts +- `../utils/tool-params.md` — `ToolParams` shape + parsing +- `../mp-schema/stored-procs.md` — `api_Common_GetSelection`, `api_Tools_GetUserTools` signatures - `../auth/sessions.md` — why `userGuid` (not `user.id`) is used for MP lookups diff --git a/.claude/references/components/field-management.md b/.claude/references/components/field-management.md index 3e18a0a..caebeb8 100644 --- a/.claude/references/components/field-management.md +++ b/.claude/references/components/field-management.md @@ -32,7 +32,7 @@ related: - ../services/field-management-service.md - ../mp-schema/required-procs.md - tool-framework.md -last_verified: 2026-04-17 +last_verified: 2026-10-10 --- ## Purpose @@ -44,10 +44,10 @@ Drag-and-drop editor for Ministry Platform page field layout: pick a page, reord | `src/components/field-management/index.ts` | Barrel export — re-exports `PageSearch`, `FieldOrderEditor`, and shared types | | `src/components/field-management/types.ts` | `PageListItem`, `PageField`, `PageFieldData`, `FieldOrderPayload`, `FieldOrderEditorHandle` interfaces | | `src/components/field-management/actions.ts` | Server actions: `fetchPages`, `fetchPageFieldData`, `savePageFieldOrder` | -| `src/components/field-management/actions.test.ts` | Vitest tests for the three server actions | +| `src/components/field-management/actions.test.ts` | Vitest tests for the three server actions (7 test files in the folder: one per source file except `index.ts`/`types.ts`) | | `src/components/field-management/page-search.tsx` | Combobox (Popover + cmdk) for picking a MP page; loads list via `fetchPages` | -| `src/components/field-management/field-order-editor.tsx` | `forwardRef` editor shell — renders flat or grouped mode, wires `DragDropProvider`, exposes `getSavePayload` / `moveHiddenToOther` via `useImperativeHandle` | -| `src/components/field-management/use-field-order-state.ts` | Hook owning groupedFields map, groupOrder list, fieldLookup, `isDirty`, DnD handlers, `addGroup`/`removeGroup`/`moveHiddenToOther`/`buildSavePayload` | +| `src/components/field-management/field-order-editor.tsx` | `forwardRef` editor shell — renders flat or grouped mode, wires `DragDropProvider`, exposes `getSavePayload` / `moveHiddenToOther` / `hideAllSeparators` via `useImperativeHandle` (`field-order-editor.tsx:42-46`) | +| `src/components/field-management/use-field-order-state.ts` | Hook owning groupedFields map, groupOrder list, fieldLookup, `isDirty`, DnD handlers, `addGroup`/`removeGroup`/`moveHiddenToOther`/`hideAllSeparators`/`updateField`/`buildSavePayload` | | `src/components/field-management/sortable-group.tsx` | `@dnd-kit/react` sortable group container (header, collapse, delete-empty) | | `src/components/field-management/sortable-field-item.tsx` | Sortable field row with expandable detail panel (label / default / filter / depends-on / switches) | | `src/components/field-management/new-group-dialog.tsx` | Dialog for creating a new group (validates name, rejects duplicates case-insensitively) | @@ -58,27 +58,28 @@ Tool page consumer: `src/app/(web)/tools/fieldmanagement/field-management.tsx`. - **Page field**: a row in `dp_Page_Fields` describing how one column is presented on a given MP page (order, group, required, hidden, label, default, filter, depends-on, writing-assistant flag). - **Flat vs grouped mode**: if every incoming field has `Group_Name === null` the hook stores them under the sentinel key `"__flat__"` (constant `FLAT_GROUP`, `use-field-order-state.ts:8`) and renders a single list. Adding a group transitions to grouped mode — flat fields move into `"99 - Other Fields"`. - **`"99 - Other Fields"` pinned bucket**: constant `OTHER_FIELDS_GROUP` (`field-order-editor.tsx:14`, `use-field-order-state.ts:9`). Always rendered last, cannot be dragged (`isPinned` disables its sortable handle), cannot be removed, and catches fields the user marks Hidden when they click **Move Hidden to Other**. -- **Negative `Page_Field_ID`**: `fetchPageFieldData` merges table columns that aren't in `dp_Page_Fields` yet and assigns them synthetic IDs starting at `-1` and decrementing (`actions.ts:34,42`). These IDs only exist client-side as React keys — the save payload is keyed by `Page_ID + Field_Name`, not ID. -- **Group renumbering on save**: `buildSavePayload` strips any `^\d+\s*-\s*` prefix and re-emits groups as `"1 - …"`, `"2 - …"`, … in current display order; `"99 - Other Fields"` keeps its literal name and `"__flat__"` becomes `Group_Name: null` (`use-field-order-state.ts:176-215`). -- **Schema-required lock**: when `tableMetadata.Columns[n].IsRequired` is true, the field's `Required` switch is forced on and disabled (`sortable-field-item.tsx:126-134`). The badge also shows "Required" based on `schemaRequired || field.Required`. -- **Imperative handle**: parent tool page keeps an editor `ref` and calls `ref.current.getSavePayload()` before invoking `savePageFieldOrder`; `ref.current.moveHiddenToOther()` powers the footer button. +- **Separators**: `PageField.isSeparator` is derived server-side from `tableMetadata` (`DataType === 'Separator'`, `actions.ts:51-55,68`); it is client-only and not part of `FieldOrderPayload`. Separator rows render differently (`sortable-field-item.tsx:30,54-85`), never show the Required badge, and `hideAllSeparators()` marks every separator `Hidden` and moves them into `"99 - Other Fields"` (`use-field-order-state.ts:187-225`). +- **Negative `Page_Field_ID`**: `fetchPageFieldData` merges table columns that aren't in `dp_Page_Fields` yet and assigns them synthetic IDs starting at `-1` and decrementing (`actions.ts:61,71`). These IDs only exist client-side as React keys — the save payload is keyed by `Page_ID + Field_Name`, not ID. +- **Group renumbering on save**: `buildSavePayload` strips any `^\d+\s*-\s*` prefix and re-emits groups as `"1 - …"`, `"2 - …"`, … in current display order; `"99 - Other Fields"` keeps its literal name and `"__flat__"` becomes `Group_Name: null` (`use-field-order-state.ts:238-277`). +- **Schema-required lock**: when `tableMetadata.Columns[n].IsRequired` is true, the field's `Required` switch is forced on and disabled (`sortable-field-item.tsx:171-177`). The badge also shows "Required" based on `schemaRequired || field.Required` (non-separators only, `sortable-field-item.tsx:71`). +- **Imperative handle**: parent tool page keeps an editor `ref` and calls `ref.current.getSavePayload()` before invoking `savePageFieldOrder`; `ref.current.moveHiddenToOther()` and `ref.current.hideAllSeparators()` power the toolbar buttons (`field-management.tsx:72-78`). ## API / Interface ### Server actions — `actions.ts` ```typescript -// src/components/field-management/actions.ts:14 +// src/components/field-management/actions.ts:29 export async function fetchPages(): Promise<PageListItem[]> -// src/components/field-management/actions.ts:20 +// src/components/field-management/actions.ts:35 export async function fetchPageFieldData(pageId: number, tableName: string): Promise<PageFieldData> -// src/components/field-management/actions.ts:61 +// src/components/field-management/actions.ts:91 export async function savePageFieldOrder( fields: FieldOrderPayload[] ): Promise<{ success: boolean; error?: string }> ``` -All three call the internal `getSession()` guard (`actions.ts:8-12`), which throws `Error('Unauthorized')` when `session.user.id` is missing. `savePageFieldOrder` catches errors and returns `{ success: false, error }`; the other two let `Unauthorized` propagate. +All three call the internal `requireAccess(table, operation)` gate (`actions.ts:21-26`) → `AuthorizationService.requireSecurityRole`: `fetchPages` → `('dp_Pages', 'read')`, `fetchPageFieldData` → `('dp_Page_Fields', 'read')`, `savePageFieldOrder` → `('dp_Page_Fields', 'update')`. `savePageFieldOrder` catches errors (including a refusal) and returns `{ success: false, error }`; the other two let the refusal propagate. `FieldManagementService` gates again and takes `$userId` from its own gate. ### Types — `types.ts` ```typescript @@ -103,21 +104,23 @@ export interface PageField { Depends_On_Field: string | null; Field_Label: string | null; Writing_Assistant_Enabled: boolean; + isSeparator: boolean; // client-only; derived from table metadata } -// src/components/field-management/types.ts:24 +// src/components/field-management/types.ts:25 export interface PageFieldData { fields: PageField[]; tableMetadata: TableMetadata | null; } -// src/components/field-management/types.ts:29 -export interface FieldOrderPayload { /* same shape as PageField minus Page_Field_ID */ } +// src/components/field-management/types.ts:30 +export interface FieldOrderPayload { /* same shape as PageField minus Page_Field_ID and isSeparator */ } -// src/components/field-management/types.ts:43 +// src/components/field-management/types.ts:44 export interface FieldOrderEditorHandle { getSavePayload: () => FieldOrderPayload[]; moveHiddenToOther: () => void; + hideAllSeparators: () => void; } ``` @@ -129,7 +132,7 @@ export interface FieldOrderEditorHandle { | `NewGroupDialog` | `new-group-dialog.tsx:23` | `{ open, onOpenChange, onCreateGroup: (name) => void, existingGroupNames: string[] }` | | `SortableGroup` | `sortable-group.tsx:22` | `{ groupName, index, fieldIds, fieldLookup, isPinned, onRemove, onUpdateField, schemaRequiredFields }` | | `SortableFieldItem` | `sortable-field-item.tsx:19` | `{ field, index, groupName, onUpdateField, schemaRequired }` | -| `useFieldOrderState` | `use-field-order-state.ts:30` | `(fields: PageField[]) => FieldOrderState` — see the interface at `use-field-order-state.ts:14-28` | +| `useFieldOrderState` | `use-field-order-state.ts:31` | `(fields: PageField[]) => FieldOrderState` — see the interface at `use-field-order-state.ts:14-29` | ### Service delegation `actions.ts` calls `FieldManagementService.getInstance()` for all three operations: @@ -144,14 +147,14 @@ See [`../services/field-management-service.md`](../services/field-management-ser ## How it works - **Step 1 — page select**: tool page renders `PageSearch`; `fetchPages` runs once (guarded by `fetchedRef`, `page-search.tsx:32,36-42`) and caches the full page list in component state. Selection triggers `fetchPageFieldData` and advances to step 2. -- **Merge pass**: `fetchPageFieldData` (`actions.ts:30-56`) fetches existing `dp_Page_Fields` rows and the table metadata in parallel, then appends any `tableMetadata.Columns` that (a) aren't `IsPrimaryKey` and (b) aren't already in the returned fields. New entries get negative IDs, `View_Order = maxViewOrder + 1 + n`, `Required = col.IsRequired`, and all other props defaulted. -- **Hook initialization**: `initializeGroupedFields` (`use-field-order-state.ts:234-259`) checks whether any field has a non-null `Group_Name`; if not → flat mode, otherwise bucket by group and sort each bucket by `View_Order`. `initializeGroupOrder` (`use-field-order-state.ts:261-283`) sorts group names by the minimum `View_Order` of their members, and pins `"99 - Other Fields"` last. -- **Drag lifecycle**: `handleDragStart` snapshots both structures (`use-field-order-state.ts:54-59`), `handleDragOver` applies `@dnd-kit/helpers`' `move()` to `groupedFields` on every item drag and no-ops for group drags (`use-field-order-state.ts:62-66`), `handleDragEnd` either restores the snapshot on cancel or — for group drags — re-runs `move()` on `groupOrder` and re-pins `"99 - Other Fields"` last (`use-field-order-state.ts:69-92`). -- **Dirty tracking**: every state mutator (`addGroup`, `removeGroup`, `moveHiddenToOther`, `updateField`, and any non-canceled drag) sets `isDirty = true`. `FieldOrderEditor` forwards it through `onDirtyChange`; the tool page shows an "Unsaved changes" pill and clears it after a successful save + re-fetch. +- **Merge pass**: `fetchPageFieldData` (`actions.ts:35-89`) fetches existing `dp_Page_Fields` rows and the table metadata in parallel, tags each existing row with `isSeparator`, then appends any `tableMetadata.Columns` that (a) aren't `IsPrimaryKey` and (b) aren't already in the returned fields. New entries get negative IDs, `View_Order = maxViewOrder + 1 + n`, `Required = col.IsRequired` (always `false` for separators), and all other props defaulted. +- **Hook initialization**: `initializeGroupedFields` (`use-field-order-state.ts:297-322`) checks whether any field has a non-null `Group_Name`; if not → flat mode, otherwise bucket by group and sort each bucket by `View_Order`. `initializeGroupOrder` (`use-field-order-state.ts:324-341`) sorts group names by the minimum `View_Order` of their members, and pins `"99 - Other Fields"` last. +- **Drag lifecycle**: `handleDragStart` snapshots both structures (`use-field-order-state.ts:55-60`), `handleDragOver` applies `@dnd-kit/helpers`' `move()` to `groupedFields` on every item drag and no-ops for group drags (`use-field-order-state.ts:63-67`), `handleDragEnd` either restores the snapshot on cancel or — for group drags — re-runs `move()` on `groupOrder` and re-pins `"99 - Other Fields"` last (`use-field-order-state.ts:70-93`). +- **Dirty tracking**: every state mutator (`addGroup`, `removeGroup`, `moveHiddenToOther`, `hideAllSeparators`, `updateField`, and any non-canceled drag) sets `isDirty = true`. `FieldOrderEditor` forwards it through `onDirtyChange`; the tool page shows an "Unsaved changes" pill and clears it after a successful save + re-fetch. - **Save flow**: tool page calls `editorRef.current.getSavePayload()` → `savePageFieldOrder(payload)`. On success it toasts, re-fetches via `fetchPageFieldData` so synthetic negative IDs are replaced with real persisted IDs, and resets `isDirty`. ## Usage -From the tool page (`src/app/(web)/tools/fieldmanagement/field-management.tsx:27-64`): +From the tool page (`src/app/(web)/tools/fieldmanagement/field-management.tsx:27-64`, one comment elided): ```typescript const editorRef = useRef<FieldOrderEditorHandle>(null); @@ -196,7 +199,7 @@ const handleSave = async () => { Mounting the editor: ```typescript -// src/app/(web)/tools/fieldmanagement/field-management.tsx:174-179 +// src/app/(web)/tools/fieldmanagement/field-management.tsx:189-194 <FieldOrderEditor ref={editorRef} fields={fieldData.fields} @@ -207,14 +210,14 @@ Mounting the editor: ## Gotchas - **Synthetic `Page_Field_ID < 0`** — any ID below zero was invented by `fetchPageFieldData` and is only a React key. Do **not** send it to MP. The save payload schema (`FieldOrderPayload`) intentionally omits `Page_Field_ID`; the stored proc matches by `@PageID + @FieldName`. -- **Group numbers renumber on every save** — the user-visible "1 - …", "2 - …" prefixes are stripped and reassigned by `buildSavePayload` from current display order (`use-field-order-state.ts:188-193`). The `GROUP_PREFIX_REGEX` is `/^\d+\s*-\s*/` (`use-field-order-state.ts:10`); renaming a group to e.g. `"Contact Details"` without a prefix works and will become `"1 - Contact Details"` on save. +- **Group numbers renumber on every save** — the user-visible "1 - …", "2 - …" prefixes are stripped and reassigned by `buildSavePayload` from current display order (`use-field-order-state.ts:251-254`). The `GROUP_PREFIX_REGEX` is `/^\d+\s*-\s*/` (`use-field-order-state.ts:10`); renaming a group to e.g. `"Contact Details"` without a prefix works and will become `"1 - Contact Details"` on save. - **`"99 - Other Fields"` is a string literal, not a number** — it's compared by exact string match. Do not change its casing or spacing anywhere (`field-order-editor.tsx:14`, `use-field-order-state.ts:9`). -- **Deleting a group only works when empty** — `removeGroup` silently no-ops if the group still holds fields (`use-field-order-state.ts:123-124`); the UI enforces this by only rendering the trash icon when `fieldIds.length === 0`. -- **Primary key columns are skipped during merge** — any `IsPrimaryKey` column from `tableMetadata` is excluded (`actions.ts:38`), so PK fields that already exist in `dp_Page_Fields` stay but new ones won't be synthesized. -- **Schema-required overrides UI `Required`** — switch is disabled when `schemaRequired` is true (`sortable-field-item.tsx:127`). Flipping the underlying DB `Required` column for such a field is impossible from this UI; only `tableMetadata` can clear it. +- **Deleting a group only works when empty** — `removeGroup` silently no-ops if the group still holds fields (`use-field-order-state.ts:142`); the UI enforces this by only rendering the trash icon when `fieldIds.length === 0`. +- **Primary key columns are skipped during merge** — any `IsPrimaryKey` column from `tableMetadata` is excluded (`actions.ts:65`), so PK fields that already exist in `dp_Page_Fields` stay but new ones won't be synthesized. +- **Schema-required overrides UI `Required`** — switch is disabled when `schemaRequired` is true (`sortable-field-item.tsx:172`). Flipping the underlying DB `Required` column for such a field is impossible from this UI; only `tableMetadata` can clear it. - **`fetchPages` is fired once per mount** — `PageSearch` uses `fetchedRef` to block re-fetches; remounting the component (e.g., after a tool refresh) is the only way to refresh the page list. -- **`savePageFieldOrder` batches 5 procs at a time** — the service loops `fields` in chunks of `CONCURRENCY = 5` and awaits each batch (`src/services/fieldManagementService.ts:74,91-107`). A partial failure in batch N will short-circuit later batches, leaving `dp_Page_Fields` in a mixed state. -- **`session.user.id` check, not `userGuid`** — `getSession()` rejects on missing `user.id`, which is Better Auth's internal ID. The stored procs don't actually need the MP User_GUID, so this is intentional, but stays inconsistent with the rule in `CLAUDE.md` about preferring `userGuid` for MP lookups. +- **`savePageFieldOrder` batches 5 procs at a time** — the service loops `fields` in chunks of `CONCURRENCY = 5` and awaits each batch (`src/services/fieldManagementService.ts:88,115-117`). A partial failure in batch N will short-circuit later batches, leaving `dp_Page_Fields` in a mixed state. +- **Page-layout writes are domain-wide** — `savePageFieldOrder` changes `dp_Page_Fields` for every user in the MP domain, which is why both the action and `FieldManagementService.updatePageFieldOrder` require the `dp_Page_Fields` `update` role rather than a bare session. ## Related docs - [`../services/field-management-service.md`](../services/field-management-service.md) — `FieldManagementService` singleton, `executeProcedureWithBody` calls, batching diff --git a/.claude/references/components/group-wizard.md b/.claude/references/components/group-wizard.md index 5c822d3..8b3365a 100644 --- a/.claude/references/components/group-wizard.md +++ b/.claude/references/components/group-wizard.md @@ -18,6 +18,7 @@ symbols: - GroupSearch - groupWizardSchema - GROUP_WIZARD_DEFAULTS + - buildGroupWizardDefaults - STEP_FIELDS - WIZARD_STEPS - GroupWizardFormData @@ -32,7 +33,7 @@ related: - ../services/group-service.md - ../components/tool-framework.md - ../auth/sessions.md -last_verified: 2026-04-17 +last_verified: 2026-10-10 --- ## Purpose @@ -44,8 +45,8 @@ Six-step create/edit wizard for Ministry Platform `Groups` records. Client-side |---|---| | `src/components/group-wizard/index.ts` | Barrel export (components, schema, types, constants) | | `src/components/group-wizard/types.ts` | `GroupWizardLookups`, `WIZARD_STEPS`, result/error types | -| `src/components/group-wizard/schema.ts` | Zod `groupWizardSchema`, `STEP_FIELDS`, `GROUP_WIZARD_DEFAULTS` | -| `src/components/group-wizard/actions.ts` | Server actions: lookups, contact/group search, fetch/create/update | +| `src/components/group-wizard/schema.ts` | Zod `groupWizardSchema`, `STEP_FIELDS`, `GROUP_WIZARD_DEFAULTS`, `buildGroupWizardDefaults(mpTimezone)` | +| `src/components/group-wizard/actions.ts` | Server actions: lookups, contact/group search, fetch/create/update; `requireAccess` gate + server-side `parseGroupPayload` | | `src/components/group-wizard/wizard-stepper.tsx` | Desktop ordered-list stepper + mobile progress bar | | `src/components/group-wizard/wizard-navigation.tsx` | Back / Next / Cancel / Submit footer | | `src/components/group-wizard/step-identity.tsx` | Step 0 — `Group_Name`, `Group_Type_ID`, dates, `Reason_Ended`, description | @@ -61,11 +62,11 @@ Route host (outside the component folder): | File | Role | |---|---| -| `src/app/(web)/tools/groupwizard/page.tsx` | Server entry; awaits `searchParams`, calls `parseToolParams` | -| `src/app/(web)/tools/groupwizard/group-wizard.tsx` | Client shell: `useForm`, step gating, submit dispatch, ToolContainer | +| `src/app/(web)/tools/groupwizard/page.tsx` | Server entry; `requireToolAccess({ table: "Groups", operation: "read" })` first, then `parseToolParams` (`@/lib/tool-params.server`) + `getMpTimezone()` in parallel | +| `src/app/(web)/tools/groupwizard/group-wizard.tsx` | Client shell (`{ params, mpTimezone }`): `useForm`, step gating, submit dispatch, ToolContainer | | `src/app/(web)/tools/groupwizard/loading.tsx` | Route loading skeleton | -No `*.test.*` files exist under `src/components/group-wizard/` — see `../../TODO/2026-04-17-components-group-wizard-missing-tests.md`. +Every component under `src/components/group-wizard/` has a co-located `*.test.tsx`; `actions.ts` and `schema.ts` have `*.test.ts` (12 test files). ## Key concepts @@ -86,37 +87,39 @@ Source: `src/components/group-wizard/types.ts:51-58` ### Create vs edit mode -- Mode is derived in the client shell (`src/app/(web)/tools/groupwizard/group-wizard.tsx:41-42`): +- Mode is derived in the client shell (`src/app/(web)/tools/groupwizard/group-wizard.tsx:42-43`): - `isNew = isNewRecord(params)` (from `@/lib/tool-params`) - `isEditMode = !isNew && !!params.recordID && params.recordID > 0` -- Edit mode effect (`group-wizard.tsx:69-84`) calls `fetchGroupRecord(recordID)` and `form.reset(result.data)`. -- `handleSubmit` (`group-wizard.tsx:121-136`) dispatches to `updateGroup` when editing, `createGroup` otherwise. +- Edit mode effect (`group-wizard.tsx:74-108`) calls `fetchGroupRecord(recordID)` and `form.reset(result.data)`. +- `handleSubmit` (`group-wizard.tsx:140-155`) dispatches to `updateGroup` when editing, `createGroup` otherwise. - Submit success → `submitResult` state → `StepReview` renders a success screen (`step-review.tsx:81-108`) with "Create Another" (new only) + "Close". -### Form state (React Hook Form 7.71.1 + Zod 4.3.6) +### Form state (`react-hook-form` ^7.89.0 + `zod` ^4.6.5) -`src/app/(web)/tools/groupwizard/group-wizard.tsx:54-58`: +`src/app/(web)/tools/groupwizard/group-wizard.tsx:57-63`: ```typescript +const initialDefaults = useMemo(() => buildGroupWizardDefaults(mpTimezone), [mpTimezone]); + const form = useForm<GroupWizardFormData>({ resolver: zodResolver(groupWizardSchema), - defaultValues: GROUP_WIZARD_DEFAULTS, + defaultValues: initialDefaults, mode: "onTouched", }); ``` - Steps consume `useFormContext<GroupWizardFormData>()` rather than prop drilling (`step-identity.tsx:29`, `step-organization.tsx:39`, etc.). -- `<Form {...form}>` wraps the entire flow in `group-wizard.tsx:188`. -- Per-step validation on Next: `form.trigger(STEP_FIELDS[currentStep])` (`group-wizard.tsx:96-109`). -- Completed steps tracked in `Set<number>` local state; clicking a past/completed step is allowed, future ones are gated (`wizard-stepper.tsx:22-41`, `group-wizard.tsx:115-119`). +- `<Form {...form}>` wraps the entire flow in `group-wizard.tsx:207`. +- Per-step validation on Next: `form.trigger(STEP_FIELDS[currentStep])` (`group-wizard.tsx:120-128`). +- Completed steps tracked in `Set<number>` local state; clicking a past/completed step is allowed, future ones are gated (`wizard-stepper.tsx:22-39`, `group-wizard.tsx:134-138`). ### Contact / group display maps IDs round-trip through the form; human-readable names are held outside RHF: -- `contactDisplayMap: Map<number, string>` and `groupDisplayMap: Map<number, string>` in the shell (`group-wizard.tsx:51-52`). -- Populated by `onContactSelect` / `onGroupSelect` callbacks from the combobox widgets (`group-wizard.tsx:86-94`). -- **Edit-mode gap:** `fetchGroupRecord` hydrates form values only; it does **not** seed display maps, so selected `Primary_Contact` / `Parent_Group` / `Promote_to_Group` / `Descended_From` render as `ID: <n>` until the user re-selects. See `../../TODO/2026-04-17-components-group-wizard-display-map-edit-mode.md`. +- `contactDisplayMap: Map<number, string>` and `groupDisplayMap: Map<number, string>` in the shell (`group-wizard.tsx:52-53`). +- Populated by `onContactSelect` / `onGroupSelect` callbacks from the combobox widgets (`group-wizard.tsx:110-118`). +- **Edit mode:** `fetchGroupRecord` returns `displayNames`, and the edit-mode effect seeds both maps before `form.reset`, so loaded `Primary_Contact` / `Parent_Group` / `Promote_to_Group` / `Descended_From` show names, not `ID: <n>`. ### Debounced typeahead (ContactSearch / GroupSearch) @@ -132,7 +135,7 @@ IDs round-trip through the form; human-readable names are held outside RHF: ```typescript export { StepIdentity, StepOrganization, StepMeeting, StepAttributes, StepSettings, StepReview }; export { WizardStepper, WizardNavigation, ContactSearch, GroupSearch }; -export { groupWizardSchema, GROUP_WIZARD_DEFAULTS, STEP_FIELDS }; +export { groupWizardSchema, GROUP_WIZARD_DEFAULTS, buildGroupWizardDefaults, STEP_FIELDS }; export type { GroupWizardFormData }; export { WIZARD_STEPS }; export type { @@ -149,13 +152,16 @@ fetchGroupWizardLookups(): Promise<GroupWizardLookups> searchContacts(term: string): Promise<ContactSearchResult[]> // returns [] when term.length < 2 searchGroups(term: string): Promise<GroupSearchResult[]> // returns [] when term.length < 2 fetchGroupRecord(groupId: number): - Promise<{ success: true; data: GroupWizardFormData } | ActionError> + Promise<{ success: true; data: GroupWizardFormData; + displayNames: { contacts: Record<number, string>; groups: Record<number, string> } } | ActionError> createGroup(data: GroupWizardFormData): Promise<CreateGroupResult | ActionError> updateGroup(groupId: number, data: GroupWizardFormData): Promise<UpdateGroupResult | ActionError> ``` -- All actions gate on `auth.api.getSession({ headers: await headers() })` and throw `'Unauthorized'` if no session (`actions.ts:17-21`). -- `createGroup` / `updateGroup` additionally resolve the MP `$userId` from `session.user.userGuid` via `UserService.getUserIdByGuid` (`actions.ts:23-29, 69, 84`). +- All actions call `requireAccess(table, operation)` (`actions.ts:28-33`) → `AuthorizationService.requireSecurityRole`: `Groups`/`read` for lookups, `searchGroups`, `fetchGroupRecord`; `Contacts`/`read` for `searchContacts`; `Groups`/`create` and `Groups`/`update` for the writes. The gate runs **before** the short-query `[]` return. +- `fetchGroupRecord` / `createGroup` / `updateGroup` catch errors (including a refusal) and return `ActionError`; the lookup and search actions let the refusal propagate. +- `createGroup` / `updateGroup` re-validate the payload server-side with `parseGroupPayload` (`actions.ts:44-51`, `groupWizardSchema.safeParse`); failure → `Invalid group data: <field names>` (names only, never values). +- No action passes `$userId`: `GroupService` takes write attribution from its own `requireSecurityRole` call (CLAUDE.md rule 13). - All delegate to `GroupService.getInstance()` from `src/services/groupService.ts`. ### Step component prop contracts @@ -183,16 +189,16 @@ updateGroup(groupId: number, data: GroupWizardFormData): Promise<UpdateGroupResu ## How it works -1. Route `/tools/groupwizard` resolves `searchParams` via `parseToolParams` (`page.tsx:9`). -2. Client shell initialises RHF with `GROUP_WIZARD_DEFAULTS`, mode `onTouched`. +1. Route `/tools/groupwizard` gates with `requireToolAccess` (`page.tsx:12`), then resolves `searchParams` via `parseToolParams` and the MP time zone via `getMpTimezone()` (`page.tsx:14-17`). +2. Client shell initialises RHF with `buildGroupWizardDefaults(mpTimezone)` (`Start_Date` = today in the MP domain zone), mode `onTouched`. 3. Mount effects load `fetchGroupWizardLookups()`; in edit mode, also `fetchGroupRecord(recordID)` → `form.reset(data)`. 4. `<WizardStepper>` renders steps; non-current step clicks are only allowed if `completedSteps.has(index) || index < currentStep`. 5. `<WizardNavigation>` "Next" calls `handleNext`: runs `form.trigger(STEP_FIELDS[currentStep])`; on success, marks current step complete and advances. -6. On final step, the primary button switches to Create/Save (`wizard-navigation.tsx:55-69`). Clicking calls `handleSubmit`: +6. On final step, the primary button switches to Create/Save (`wizard-navigation.tsx:55-68`). Clicking calls `handleSubmit`: - `createGroup(data)` or `updateGroup(recordID, data)` → service → MP API. - Success → `submitResult` state → `StepReview` shows success screen; stepper + nav are hidden. - - Failure → `loadError` is set, which replaces the form body with an `Alert` (`group-wizard.tsx:180-186`). -7. "Create Another" resets the form + step state; "Close" calls `router.back()`. + - Failure → `loadError` is set, which replaces the form body with an `Alert` (`group-wizard.tsx:199-205`). +7. "Create Another" resets the form to `buildGroupWizardDefaults(mpTimezone)` + step state; "Close" calls `router.back()`. ## Usage @@ -200,19 +206,28 @@ Route-level mount (`src/app/(web)/tools/groupwizard/page.tsx`): ```typescript import { GroupWizard } from "./group-wizard"; -import { parseToolParams } from "@/lib/tool-params"; +import { requireToolAccess } from "@/app/(web)/tools/require-tool-access"; +import { parseToolParams } from "@/lib/tool-params.server"; +import { getMpTimezone } from "@/components/shared-actions/domain"; interface GroupWizardPageProps { searchParams: Promise<{ [key: string]: string | string[] | undefined }>; } export default async function GroupWizardPage({ searchParams }: GroupWizardPageProps) { - const params = await parseToolParams(await searchParams); - return <GroupWizard params={params} />; + // 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(), + ]); + + return <GroupWizard params={params} mpTimezone={mpTimezone} />; } ``` -Client shell dispatch (`src/app/(web)/tools/groupwizard/group-wizard.tsx:121-136`): +Client shell dispatch (`src/app/(web)/tools/groupwizard/group-wizard.tsx:140-155`): ```typescript const handleSubmit = useCallback(async () => { @@ -236,17 +251,16 @@ const handleSubmit = useCallback(async () => { ## Gotchas - **Quoted special-character field name.** `Secure_Check-in` contains a hyphen — it is referenced with bracket/string syntax throughout: `schema.ts:43` (`'Secure_Check-in': z.boolean()`), `STEP_FIELDS` uses `'Secure_Check-in' as keyof GroupWizardFormData` (`schema.ts:63`), `step-settings.tsx:115`, `step-review.tsx:200, 206`. Do not rename without also updating all string literals. -- **`Start_Date` default is `new Date().toISOString().split('T')[0]`** (`schema.ts:71`). This is evaluated at module load, which in a Next.js App Router client bundle means it is fixed at the first render of the client component (fine), but tests importing the module will capture the test-run date. -- **Display maps are not seeded in edit mode.** `fetchGroupRecord` returns only numeric IDs for `Primary_Contact`, `Parent_Group`, `Promote_to_Group`, `Descended_From` — those fields render as `ID: <n>` on the Review step until the user opens the combobox and re-selects. TODO: `../../TODO/2026-04-17-components-group-wizard-display-map-edit-mode.md`. -- **Per-step validation gates Next, not form submit.** `form.trigger([fields])` is called in `handleNext`; `handleSubmit` (`group-wizard.tsx:121`) does **not** re-run Zod validation before calling the server action. Server action relies on RHF+Zod having already kept the form valid. `GROUP_WIZARD_DEFAULTS` uses `undefined as unknown as number` casts for four required ID fields (`Group_Type_ID`, `Congregation_ID`, `Ministry_ID`, `Primary_Contact`, `schema.ts:69-76`) — those will fail Zod on `trigger` before the user can reach Review, which is the intended safety net. -- **No unsaved-changes guard.** Navigating away (closing the tool, clicking Cancel, or hitting browser back) discards form state silently — no confirmation dialog. Multi-step progress is lost. TODO: `../../TODO/2026-04-17-components-group-wizard-missing-tests.md` (covers both missing tests and this UX gap). -- **Search actions silent-return `[]` for short queries.** `searchContacts` / `searchGroups` return `[]` if `term.length < 2` (`actions.ts:39, 46`). The combobox also enforces this client-side (`contact-search.tsx:38-41`, `group-search.tsx:45-48`). Do not rely on server validation alone. +- **`Start_Date` default comes from `buildGroupWizardDefaults(mpTimezone)`** (`schema.ts:136-140`), i.e. today's date in the MP domain time zone. The static `GROUP_WIZARD_DEFAULTS` leaves `Start_Date: ''` (`schema.ts:90`) — use the builder, never `new Date().toISOString()` (CLAUDE.md rule 17). +- **Per-step validation gates Next, not form submit.** `form.trigger([fields])` is called in `handleNext`; the client `handleSubmit` (`group-wizard.tsx:140`) does **not** re-run Zod. The server does: `createGroup`/`updateGroup` run `parseGroupPayload` before any MP call. `GROUP_WIZARD_DEFAULTS` uses `undefined as unknown as number` casts for four required ID fields (`Group_Type_ID`, `Congregation_ID`, `Ministry_ID`, `Primary_Contact`, `schema.ts:88-95`) — those fail Zod on `trigger` before the user can reach Review. +- **No unsaved-changes guard.** Navigating away (closing the tool, clicking Cancel, or hitting browser back) discards form state silently — no confirmation dialog. Multi-step progress is lost. Known UX gap; no TODO filed. +- **Search actions silent-return `[]` for short queries.** `searchContacts` / `searchGroups` return `[]` if `term.length < 2` (`actions.ts:61, 68`), after the authorization gate. The combobox also enforces this client-side (`contact-search.tsx:38-41`, `group-search.tsx:45-48`). Do not rely on server validation alone. - **`ContactSearch` has no "clear" affordance.** Unlike `GroupSearch` (which shows a "Clear selection" row when `value` is set, `group-search.tsx:108-122`), `ContactSearch` cannot be cleared — users must pick a different contact. `Primary_Contact` is required so this is intentional, but it means stray test selections can't be undone without a form reset. -- **Step click gating can orphan steps after edit-and-jump.** Clicking Edit on a Review card jumps to that step; if the user then goes Back/Next through, later steps remain in `completedSteps` even if their now-dependent values changed — Next does re-validate, but already-advanced steps are not re-validated until the user explicitly revisits them (`group-wizard.tsx:115-119`). +- **Step click gating can orphan steps after edit-and-jump.** Clicking Edit on a Review card jumps to that step; if the user then goes Back/Next through, later steps remain in `completedSteps` even if their now-dependent values changed — Next does re-validate, but already-advanced steps are not re-validated until the user explicitly revisits them (`group-wizard.tsx:134-138`). ## Related docs - `../services/group-service.md` — `GroupService` (`createGroup`, `updateGroup`, `getGroup`, `fetchAllLookups`, `searchContacts`, `searchGroups`) - `../components/tool-framework.md` — `ToolContainer` wrapper used by the route shell -- `../auth/sessions.md` — `session.user.userGuid` → `UserService.getUserIdByGuid` chain used by `createGroup`/`updateGroup` -- `../routing/tool-params.md` — `ToolParams`, `isNewRecord`, `parseToolParams` contract consumed by the page +- `../auth/sessions.md` — `session.user.userGuid` (the gate resolves the MP `User_ID` from it) +- `../utils/tool-params.md` — `ToolParams`, `isNewRecord`, `parseToolParams` contract consumed by the page diff --git a/.claude/references/components/layout.md b/.claude/references/components/layout.md index 8618984..8505690 100644 --- a/.claude/references/components/layout.md +++ b/.claude/references/components/layout.md @@ -8,18 +8,22 @@ applies_to: - src/components/layout/index.ts - src/components/shared-actions/user.ts - src/components/shared-actions/user.test.ts + - src/components/shared-actions/sign-out.ts + - src/components/shared-actions/sign-out.test.ts + - src/components/shared-actions/domain.ts + - src/components/shared-actions/domain.test.ts - src/components/shared-actions/README.md -symbols: [AuthWrapper, getCurrentUserProfile] +symbols: [AuthWrapper, getCurrentUserProfile, getMpTimezone, handleSignOut] related: - ../auth/README.md - ../routing/README.md - ../services/README.md - tool-framework.md -last_verified: 2026-09-13 +last_verified: 2026-10-10 --- ## Purpose -`AuthWrapper` is the server-side session gate used at the app-shell level; it redirects unauthenticated requests to `/signin` while preserving the original path+query as `callbackUrl`. `shared-actions/` holds server actions used across multiple features (currently just `getCurrentUserProfile`). +`AuthWrapper` is the server-side session gate used at the app-shell level; it redirects unauthenticated requests to `/signin` while preserving the original path+query as `callbackUrl`. `shared-actions/` holds server actions used across multiple features (`getCurrentUserProfile`, `getMpTimezone` in `domain.ts`, and `handleSignOut`). ## Files - `src/components/layout/auth-wrapper.tsx` — server component, 31 lines @@ -27,6 +31,10 @@ last_verified: 2026-09-13 - `src/components/layout/index.ts` — barrel: `AuthWrapper` - `src/components/shared-actions/user.ts` — `getCurrentUserProfile` server action - `src/components/shared-actions/user.test.ts` — unit tests for the action +- `src/components/shared-actions/sign-out.ts` — `handleSignOut` server action (see `../auth/oauth-flow.md` § Sign-out flow); bound to the sign-out `<form action>` on `/no-access` and `/session-error`, the app's only sign-out controls +- `src/components/shared-actions/sign-out.test.ts` — end-session URL / `id_token_hint` / env-guard tests +- `src/components/shared-actions/domain.ts` — `getMpTimezone` server action (session-only carve-out; see source docstring) +- `src/components/shared-actions/domain.test.ts` — unit tests for the action - `src/components/shared-actions/README.md` — when-to-shard-vs-colocate guidelines ## Key concepts @@ -34,7 +42,7 @@ last_verified: 2026-09-13 - Session validation is stateless: `auth.api.getSession({ headers })` reads the Better Auth JWT cookie (see `../auth/README.md`). - Redirect uses a synthetic `http://placeholder` base so we can use `URL` + `searchParams` to build the querystring safely without string concat. - The `x-pathname` header is set upstream by the proxy (`src/proxy.ts`) so the server component can see the original requested URL; it falls back to `/` when absent. -- `shared-actions/user.ts` is marked `'use server'` at the top of the file — all exports are server actions. +- `user.ts`, `domain.ts` and `sign-out.ts` are each marked `'use server'` at the top of the file — all exports are server actions. - Shared actions re-validate auth inside each action (`auth.api.getSession(...)`) — they do not trust the caller. - `getCurrentUserProfile` takes **no parameters**. The MP `User_GUID` is derived from the session inside the action. A caller-supplied GUID would be a live IDOR: server actions are caller-shaped POST endpoints, so any authenticated MP user could have read another user's contact details, roles, and user groups. - The guard keys on a non-empty-string `session.user.userGuid` (declared `required: true` in `src/lib/auth.ts`), not `session.user.id` — the latter is Better Auth's internal ID and its presence does not prove an MP identity exists. @@ -123,7 +131,9 @@ export async function getCurrentUserProfile(): Promise<MPUserProfile | undefined ## Shared Actions catalog | Export | File | Purpose | |---|---|---| -| `getCurrentUserProfile()` | `src/components/shared-actions/user.ts:25` | Fetch the **calling** user's MP profile (`MPUserProfile`); `User_GUID` comes from the session, never from a parameter. Throws `Unauthorized` if the session has no `userGuid`. Backed by `UserService.getUserProfile`. | +| `getCurrentUserProfile()` | `src/components/shared-actions/user.ts:25` | Fetch the **calling** user's MP profile (`MPUserProfile`); `User_GUID` comes from the session, never from a parameter. Throws `Unauthorized` if the session has no `userGuid`. Backed by `UserService.getUserProfile`. Consumer: `src/contexts/user-context.tsx`. | +| `getMpTimezone()` | `src/components/shared-actions/domain.ts:29` | IANA time zone of the MP domain. Requires a session (`session.user.id`), throws `Authentication required` otherwise; deliberately not role-gated (rule-12 carve-out). Backed by `DomainTimezoneService`. Consumer: `src/app/(web)/tools/groupwizard/page.tsx`. | +| `handleSignOut()` | `src/components/shared-actions/sign-out.ts:50` | `auth.api.signOut` first, then `redirect()` to MP `/oauth/connect/endsession` with `post_logout_redirect_uri` + `client_id` (+ `id_token_hint` only from an MP-origin logout URL). Consumers: `src/app/(web)/no-access/page.tsx`, `src/app/session-error/page.tsx`. | Guidelines (verbatim from `src/components/shared-actions/README.md`): - Place actions here when they are **used by multiple components across different features**, provide **shared utility**, or handle **cross-cutting concerns**. @@ -144,14 +154,16 @@ Guidelines (verbatim from `src/components/shared-actions/README.md`): - throws `Unauthorized` when `userGuid` is an empty string - throws `Unauthorized` when `auth.api.getSession()` returns `null` - propagates service-layer errors +- `src/components/shared-actions/domain.test.ts` — 6 cases (session required, service delegation, error propagation) +- `src/components/shared-actions/sign-out.test.ts` — 13 cases (9 `it` + one 4-row `it.each`): session cleared first, end-session URL shape, MP-origin-only `id_token_hint`, env guards (missing/non-https MP URL, missing auth URL, missing client secret), `NEXTAUTH_URL` fallback, trailing-slash normalization -Both test files use `vi.hoisted()` to share mock references (required pattern — see `../testing/README.md`). +All four test files use `vi.hoisted()` to share mock references (required pattern — see `../testing/README.md`). ## Gotchas - **Must stay a server component.** Adding `"use client"` to `auth-wrapper.tsx` silently breaks auth — client components cannot call `auth.api.getSession()` with server headers. Put any interactive state into a child that you render from inside `<AuthWrapper>`. - **`redirect()` throws.** `next/navigation` `redirect()` aborts rendering by throwing a magic error. Do not wrap in try/catch; do not add code after the redirect call expecting it to run on the unauthenticated branch. - **`callbackUrl` relies on `x-pathname`.** If a route bypasses the proxy (or a future proxy matcher excludes it), `x-pathname` will be missing and unauthenticated users land on `/` after sign-in. Verify proxy matcher coverage in `src/proxy.ts` when adding new protected routes. -- **Shared actions must re-validate auth.** `getCurrentUserProfile` calls `auth.api.getSession()` itself rather than trusting caller context. Any new action added here must do the same (see `../auth/README.md` for session access patterns). +- **Shared actions must re-validate auth.** `getCurrentUserProfile` and `getMpTimezone` call `auth.api.getSession()` themselves rather than trusting caller context. Any new action added here must do the same (see `../auth/README.md` for session access patterns). - **Never re-add an identity parameter.** `getCurrentUserProfile` is a CLAUDE.md rule-12 carve-out from the `AuthorizationService` gate on the grounds that it returns only the caller's own profile. That justification holds only because there is no GUID argument to forge; adding one re-opens the IDOR. ## Related docs 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<MjmlCompileResult>; -``` - -## 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 `<WithEditor>`) 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 ( - <ToolContainer - params={params} - title="Template Editor" - infoContent={/* ... */} - hideFooter - > - <TemplateEditorForm onClose={handleClose} /> - </ToolContainer> - ); -} -``` - -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/<slug>`) 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/components/tool-framework.md b/.claude/references/components/tool-framework.md index bfd13e4..a5dfbeb 100644 --- a/.claude/references/components/tool-framework.md +++ b/.claude/references/components/tool-framework.md @@ -7,12 +7,13 @@ applies_to: - src/components/tool/tool-header.tsx - src/components/tool/tool-footer.tsx - src/components/tool/index.ts + - src/components/tool/tool-framework.test.tsx symbols: [ToolContainer, ToolHeader, ToolFooter] related: - dev-panel.md - layout.md - ui.md -last_verified: 2026-04-17 +last_verified: 2026-10-10 --- ## Purpose @@ -23,11 +24,12 @@ Reusable layout shell used by every tool page: a dark slate title bar, a scrolla - `src/components/tool/tool-header.tsx` — dark slate (`bg-slate-700`) title bar with optional info tooltip - `src/components/tool/tool-footer.tsx` — white footer with Close + Save buttons and loading state - `src/components/tool/index.ts` — barrel: `ToolContainer`, `ToolFooter`, `ToolHeader` +- `src/components/tool/tool-framework.test.tsx` — 13 cases covering all three (`DevPanel` mocked; `params`/`infoContent`/`hideFooter` branches) ## Key concepts - Composition via `children`; no internal router or data fetching - `"use client"` only on `ToolContainer` (it imports the client-only `DevPanel`); `ToolHeader` and `ToolFooter` are plain React (no `"use client"` directive, but contain no async server work — safe to import from either) -- Dev overlay gating lives inside `DevPanel`, not `ToolContainer`: `ToolContainer` always renders `<DevPanel />` when `params` is passed, and `DevPanel` itself early-returns `null` when `NODE_ENV !== "development"` or hostname is not `localhost`/`127.0.0.1` (`src/components/dev-panel/dev-panel.tsx:20,56-58`) +- Dev overlay gating lives inside `DevPanel`, not `ToolContainer`: `ToolContainer` always renders `<DevPanel />` when `params` is passed, and `DevPanel` itself early-returns `null` when `NODE_ENV !== "development"` or hostname is not `localhost`/`127.0.0.1` (`src/components/dev-panel/dev-panel.tsx:14-22,59-61`) - Footer buttons: Close is an `outline` variant; Save uses `bg-cyan-600 hover:bg-cyan-700` and swaps label to `"Saving..."` while `isSaving` - Header info tooltip uses shadcn `Tooltip` + `Info` icon from lucide, anchored to `side="left"` with `max-w-xs` @@ -50,7 +52,7 @@ interface ToolContainerProps { children: React.ReactNode; } ``` -`ToolParams` comes from `src/lib/tool-params.ts:17-28`. +`ToolParams` comes from `src/lib/tool-params.ts:28-39`. ### `ToolHeader` Source: `src/components/tool/tool-header.tsx:9-12` diff --git a/.claude/references/components/ui.md b/.claude/references/components/ui.md index 515283b..9aafab9 100644 --- a/.claude/references/components/ui.md +++ b/.claude/references/components/ui.md @@ -9,7 +9,7 @@ symbols: [Button, Dialog, Tooltip, Form, Select, DropdownMenu, Command, Drawer, related: - tool-framework.md - ../testing/README.md -last_verified: 2026-04-17 +last_verified: 2026-10-10 --- ## Purpose @@ -49,16 +49,16 @@ From `components.json`: | `badge.tsx` | `Badge`, `badgeVariants` — small status pill | | `breadcrumb.tsx` | `Breadcrumb` + parts — navigation trail | | `button.tsx` | `Button`, `buttonVariants` — variants: `default | destructive | outline | secondary | ghost | link`; sizes: `default | sm | lg | icon` | -| `card.tsx` | `Card`, `CardHeader`, `CardTitle`, `CardDescription`, `CardContent`, `CardFooter` | +| `card.tsx` | `Card`, `CardHeader`, `CardTitle`, `CardAction`, `CardDescription`, `CardContent`, `CardFooter` | | `checkbox.tsx` | `Checkbox` — Radix checkbox primitive | | `command.tsx` | `Command` + parts — cmdk-based searchable list (used inside popovers) | | `dialog.tsx` | `Dialog` + parts — generic modal | | `drawer.tsx` | `Drawer` + parts — vaul bottom sheet | | `dropdown-menu.tsx` | `DropdownMenu` + parts — Radix menu | -| `form.tsx` | `Form`, `FormField`, `FormItem`, `FormLabel`, `FormControl`, `FormDescription`, `FormMessage` — react-hook-form bindings | +| `form.tsx` | `Form`, `FormField`, `FormItem`, `FormLabel`, `FormControl`, `FormDescription`, `FormMessage`, `useFormField` — react-hook-form bindings | | `input.tsx` | `Input` — text input | | `label.tsx` | `Label` — Radix label | -| `popover.tsx` | `Popover`, `PopoverTrigger`, `PopoverContent` — Radix popover | +| `popover.tsx` | `Popover`, `PopoverTrigger`, `PopoverContent`, `PopoverAnchor` — Radix popover | | `radio-group.tsx` | `RadioGroup`, `RadioGroupItem` | | `select.tsx` | `Select` + parts — Radix select (non-searchable) | | `skeleton.tsx` | `Skeleton` — shimmer placeholder | @@ -66,24 +66,24 @@ From `components.json`: | `textarea.tsx` | `Textarea` — multiline input | | `tooltip.tsx` | `Tooltip`, `TooltipTrigger`, `TooltipContent`, `TooltipProvider` — auto-wraps with `TooltipProvider` internally | -(22 files — matches the facts snapshot for 2026-04-17.) +(22 files — re-counted 2026-10-10; see `../_meta/facts/2026-10-10.md`.) ## Key concepts - **new-york style** — tighter spacing, subtler borders than the `default` style - **slate base color with CSS variables** — theme tokens live in `src/app/globals.css` (e.g., `--primary`, `--background`). Components reference `bg-primary`, `text-foreground`, etc., not raw slate shades. - **lucide icons** — all iconography is `lucide-react` (e.g., `Info` in `tool-header.tsx`, `ChevronDown`/`Terminal` in `dev-panel.tsx`). Do not introduce another icon set. - **cva variants** — primitives with variants (`Button`, `Badge`) use `class-variance-authority`; `buttonVariants` / `badgeVariants` are exported so consumers can apply button styling to a `<Link>` via the shadcn `asChild` pattern. -- **Radix primitives under the hood** — most files re-export a Radix package with Tailwind classes applied (`@radix-ui/react-tooltip`, `@radix-ui/react-dialog`, etc.). +- **Radix primitives under the hood** — most files wrap an individual `@radix-ui/react-*` package (`@radix-ui/react-tooltip`, `@radix-ui/react-dialog`, etc.; `Slot` from `@radix-ui/react-slot`) with Tailwind classes applied. +- **Plain function components with `data-slot`** — no `forwardRef` anywhere in `src/components/ui/` (React 19 passes `ref` as a prop); each part sets a `data-slot` attribute. - **`cn()` utility** — every primitive uses `cn()` from `@/lib/utils` for class merging. ## Conventions when editing / adding - Run `npx shadcn add <name>` to pull a new primitive — it will respect `components.json` (style + aliases + icon lib). - Do not refactor shadcn primitives into default exports; the project enforces **named exports only** across `src/**`. -- Keep `"use client"` at the top of each primitive file — these always render interactively. +- Keep `"use client"` where shadcn put it: the 14 Radix/stateful primitives have it; `alert`, `badge`, `breadcrumb`, `button`, `card`, `input`, `skeleton`, `textarea` do not (server-renderable). - New primitives should re-use `cn()` and CSS-variable theme tokens; avoid hardcoding `slate-*` colors. ## Gotchas -- **`dialog.tsx` mixes patterns.** It uses both direct assignment and `forwardRef` in places — works, but inconsistent with the rest. Avoid copy-pasting its style as the blueprint; prefer `tooltip.tsx` or `popover.tsx`. - **`Tooltip` auto-wraps with `TooltipProvider`.** You don't need to add a provider at the app root when using `<Tooltip>` directly (see `src/components/ui/tooltip.tsx:21-29`). But if you mount many tooltips, wrapping a subtree in a single `<TooltipProvider>` manually can still be a perf win. - **Save button in `ToolFooter` is hardcoded cyan**, not `Button`'s `default` variant. See `tool-framework.md`. diff --git a/.claude/references/components/user-menu.md b/.claude/references/components/user-menu.md deleted file mode 100644 index 51789a8..0000000 --- a/.claude/references/components/user-menu.md +++ /dev/null @@ -1,147 +0,0 @@ ---- -title: User Menu + OIDC Sign-Out -domain: components -type: reference -applies_to: - - src/components/user-menu/user-menu.tsx - - src/components/user-menu/actions.ts - - src/components/user-menu/index.ts - - src/components/user-menu/actions.test.ts -symbols: [UserMenu, handleSignOut] -related: - - ../auth/sessions.md - - ../auth/oauth-flow.md - - ../contexts/user-provider.md -last_verified: 2026-04-17 ---- - -## Purpose -Dropdown menu that displays the signed-in MP user's name + email and triggers RP-initiated OIDC logout (Better Auth `signOut` → MP `/oauth/connect/endsession` → `post_logout_redirect_uri`). - -## Files -- `src/components/user-menu/user-menu.tsx` — `"use client"` `UserMenu` component (Radix `DropdownMenu`) -- `src/components/user-menu/actions.ts` — `"use server"` `handleSignOut` server action -- `src/components/user-menu/index.ts` — barrel: `export { UserMenu } from './user-menu'` -- `src/components/user-menu/actions.test.ts` — 3 test cases for `handleSignOut` - -## Key concepts -- `UserMenu` is a **client component** (`"use client"`) — wraps a caller-supplied trigger via `<DropdownMenuTrigger asChild>{children}</DropdownMenuTrigger>`. -- `userProfile` prop is typed `MPUserProfile` (`src/lib/providers/ministry-platform/types/user-profile.types.ts:1-13`) — expects the enriched MP profile, typically supplied by `UserProvider` / `useUser()`. -- Display name uses `Nickname || First_Name`, then `Last_Name` (`user-menu.tsx:49-50`). -- Sign-out is the only menu item currently (`userMenuItems` array, `user-menu.tsx:21-27`). Icon is `@heroicons/react/24/outline`'s `ArrowRightOnRectangleIcon`. -- `onClose?` optional callback fires before the action — used by callers that render the trigger inside a larger popover/sidebar to close the outer surface. -- `handleSignOut` is a **server action** that (1) clears the Better Auth cookie via `auth.api.signOut`, (2) `redirect(...)`s the browser to MP's `endsession`. No `id_token_hint` is sent. -- `post_logout_redirect_uri` is taken from `BETTER_AUTH_URL || NEXTAUTH_URL || 'http://localhost:3000'` — must be pre-registered on the MP OAuth client. - -## API / Interface - -```typescript -// src/components/user-menu/user-menu.tsx:15-19 -interface UserMenuProps { - onClose?: () => void; - userProfile: MPUserProfile; - children: React.ReactNode; -} - -export function UserMenu({ onClose, userProfile, children }: UserMenuProps); -``` - -```typescript -// src/components/user-menu/actions.ts:7 -export async function handleSignOut(): Promise<void>; -``` - -- `handleSignOut` throws `Error('MINISTRY_PLATFORM_BASE_URL is not configured')` when the env var is unset (`actions.ts:14-16`). -- It never returns normally — either throws or calls `redirect(...)` which throws a `NEXT_REDIRECT`. - -## How it works - -Sign-out call chain: - -``` -UserMenu.handleItemClick("signout") // user-menu.tsx:30-37 (client) - → onClose?.() // optional caller close-hook - → handleSignOut() // actions.ts:7 (server action) - → auth.api.signOut({ headers: await headers() }) // actions.ts:9-11 - → check process.env.MINISTRY_PLATFORM_BASE_URL // actions.ts:13-16 - → redirect( - `${baseUrl}/oauth/connect/endsession` + - `?post_logout_redirect_uri=${BETTER_AUTH_URL || NEXTAUTH_URL || localhost}` - ) // actions.ts:18-23 - → Browser follows 307 → MP endsession - → MP ends its SSO session, 302s back to post_logout_redirect_uri -``` - -- `await headers()` — required in Next.js 16 (dynamic APIs are async). -- `redirect(...)` uses `next/navigation` and throws internally; do not wrap in `try/catch` that swallows errors. - -## Usage - -Nothing in `src/` currently imports `UserMenu` (verified via `Grep` for `UserMenu` in `*.tsx`; only self-references). It is a library-style component exposed via the barrel for future composition. A typical call site would be: - -```tsx -// hypothetical call site -import { UserMenu } from '@/components/user-menu'; -import { useUser } from '@/contexts'; - -function AppHeader() { - const { userProfile } = useUser(); - if (!userProfile) return null; - return ( - <UserMenu userProfile={userProfile}> - <button>{userProfile.Nickname || userProfile.First_Name}</button> - </UserMenu> - ); -} -``` - -Real verbatim sign-out action: - -```typescript -// src/components/user-menu/actions.ts:7-24 -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()}`); -} -``` - -## Tests - -`src/components/user-menu/actions.test.ts` — 3 cases: - -| Case | Asserts | -|------|---------| -| `should call auth.api.signOut` | `mockSignOut` called with `{ headers: <Headers> }` | -| `should redirect to MP end session URL` | `redirect(...)` called with URL containing `https://mp.example.com/oauth/connect/endsession` and URL-encoded `post_logout_redirect_uri=https%3A%2F%2Fmyapp.example.com` | -| `should throw when MINISTRY_PLATFORM_BASE_URL is missing` | `handleSignOut()` rejects with `'MINISTRY_PLATFORM_BASE_URL is not configured'` | - -Mocks use `vi.hoisted()` for `mockSignOut` / `mockRedirect` (required pattern — see `../testing/setup.md`). - -No test exists for the `UserMenu` client component itself (rendering / `handleItemClick` wiring). See TODO. - -## Gotchas -- **Post-logout redirect URI must be pre-registered on the MP OAuth client.** If `BETTER_AUTH_URL` is not in MP's allowed post-logout URIs, MP will ignore the param and the user lands on MP's generic post-logout page — subsequent navigation to the app sees a stale SSO session and silently re-authenticates, producing a perceived logout loop. Enforced nowhere in code; configuration-only. (`src/components/user-menu/actions.ts:19-21`) -- **Localhost fallback ships to production.** If neither `BETTER_AUTH_URL` nor `NEXTAUTH_URL` is set, sign-out redirects to `http://localhost:3000` regardless of environment. (`src/components/user-menu/actions.ts:20`) -- **No `id_token_hint` is sent on `endsession`** (optional in OIDC spec). Pre-registration of `post_logout_redirect_uri` is the only allowlist mechanism. (`src/components/user-menu/actions.ts:18-23`, cross-ref `../auth/oauth-flow.md`) -- **`UserMenu` is unused in the current tree.** Shipping a new layout needs to wire it up; it will not appear by virtue of the barrel alone. -- **`Email_Address` can be `null`** per `MPUserProfile`, but `user-menu.tsx:52` renders `{userProfile.Email_Address}` directly — React renders `null` as empty, so there's no crash, but the email line collapses silently. (`src/components/user-menu/user-menu.tsx:52`) - -## Related docs -- `../auth/oauth-flow.md` — full OIDC sign-in + `endsession` logout flow (this action is one half) -- `../auth/sessions.md` — what `auth.api.signOut` clears (JWT cookie, `cookieCache`) -- `../contexts/user-provider.md` — source of the `MPUserProfile` prop passed to `UserMenu` -- `README.md` — components domain index diff --git a/.claude/references/contexts/README.md b/.claude/references/contexts/README.md index 9730be6..d12e971 100644 --- a/.claude/references/contexts/README.md +++ b/.claude/references/contexts/README.md @@ -5,7 +5,7 @@ domain: contexts --- ## What's in this domain -Two React client contexts composed at the app shell: `UserProvider` (enriched MP profile, fetched from `userGuid`) and `useAppSession` (thin wrapper around Better Auth's `authClient.useSession()`). `session-context.tsx` is a hook module, not a `Context` — naming is legacy. +Two React client contexts composed at the app shell: `UserProvider` (enriched MP profile, fetched from `userGuid`) and `useAppSession` (thin wrapper around Better Auth's `authClient.useSession()`). `use-app-session.ts` is a hook module, not a `Context`. Both hooks are kept as a documented **extension point** for tools built on the template — nothing outside `src/contexts/` calls them today (the header `UserMenu` was removed in #54). `UserProvider` is mounted by `src/app/providers.tsx` but fetches **lazily**: it makes no MP call until some component calls `useUser()`, and all consumers share that one load. ## File map | File | Purpose | When to read | @@ -16,10 +16,10 @@ Two React client contexts composed at the app shell: `UserProvider` (enriched MP ## Code surfaces | Path | Role | |------|------| -| `src/contexts/user-context.tsx` | `UserProvider` + `useUser` hook (loads `MPUserProfile` from `userGuid`) | -| `src/contexts/user-context.test.tsx` | 6 test cases: throw-outside-provider, load-with-guid, null-session, missing-guid, error path, refresh | -| `src/contexts/session-context.tsx` | `useAppSession()` + `SessionData` type alias | -| `src/contexts/session-context.test.tsx` | 2 test cases: data passthrough, null session | +| `src/contexts/user-context.tsx` | `UserProvider` + `useUser` hook (lazily loads `MPUserProfile` from `userGuid` on first `useUser()`) | +| `src/contexts/user-context.test.tsx` | 14 test cases: throw-outside-provider, load-with-guid, null-session, missing-guid, error path, refresh, plus refresh-without-guid, pending state, non-Error rejection, undefined profile, error cleared on refresh, plus lazy fetch (no consumer → no call, two consumers → one call, late consumer triggers the call) | +| `src/contexts/use-app-session.ts` | `useAppSession()` + `SessionData` type alias | +| `src/contexts/use-app-session.test.ts` | 2 test cases: data passthrough, null session | | `src/contexts/index.ts` | Barrel: `UserProvider`, `useUser`, `useAppSession`, `SessionData` | | `src/app/providers.tsx` | Client `Providers` — mounts `<UserProvider>` + Sonner `<Toaster>` | diff --git a/.claude/references/contexts/session.md b/.claude/references/contexts/session.md index 10e006c..ad03412 100644 --- a/.claude/references/contexts/session.md +++ b/.claude/references/contexts/session.md @@ -2,30 +2,31 @@ title: useAppSession wrapper domain: contexts type: reference -applies_to: [src/contexts/session-context.tsx, src/contexts/session-context.test.tsx] +applies_to: [src/contexts/use-app-session.ts, src/contexts/use-app-session.test.ts] symbols: [useAppSession, SessionData] related: [user-provider.md, ../auth/README.md] -last_verified: 2026-04-17 +last_verified: 2026-10-10 --- ## Purpose Thin hook that returns only the `data` portion of Better Auth's `authClient.useSession()`. Encapsulates the session-access pattern so callers don't destructure `{ data, isPending, error }` inline at every consumer. ## Files -- `src/contexts/session-context.tsx` — `useAppSession()` + `SessionData` type alias -- `src/contexts/session-context.test.tsx` — 2 test cases (passthrough + null) -- `src/lib/auth-client.ts` — `authClient = createAuthClient({ plugins: [genericOAuthClient(), customSessionClient<typeof auth>()] })` +- `src/contexts/use-app-session.ts` — `useAppSession()` + `SessionData` type alias +- `src/contexts/use-app-session.test.ts` — 2 test cases (passthrough + null) +- `src/lib/auth-client.ts` — `authClient = createAuthClient({ plugins: [customSessionClient<typeof auth>()] })` (no `genericOAuthClient()` — removed in Better Auth 1.7; see the note in that file) ## Key concepts -- **Not a React Context** — `session-context.tsx` defines no `createContext`. The filename is legacy; it is purely a hook module. +- **Not a React Context** — `use-app-session.ts` defines no `createContext`; it is purely a hook module. +- **Extension point, no current consumers** — nothing outside `src/contexts/` imports `useAppSession` (the header `UserMenu` that used it was removed in #54); it is kept, with `useUser()`, as the template's documented client-side session/profile surface for new tools. It makes no MP call (session data only). - **`authClient.useSession()` is a Better Auth nanostore hook** — provided by `better-auth/react`; refetches on visibility / network events. - **`SessionData` is inferred from `authClient.$Infer.Session`** — the `customSessionClient<typeof auth>()` plugin flows server-side `customSession` return types to the client. -- **`userGuid`** lives on `session.user` because of the server-side `customSession` plugin (see `../auth/`), but its type is not part of `SessionData` by default — consumers assert via `(session?.user as { userGuid?: string })` (example: `src/contexts/user-context.tsx:29`). +- **`userGuid`** lives on `session.user` because of the server-side `customSession` plugin (see `../auth/`), but its type is not part of `SessionData` by default — consumers assert via `(session?.user as { userGuid?: string })` (example: `src/contexts/user-context.tsx:42`). ## API / Interface ```typescript -// src/contexts/session-context.tsx +// src/contexts/use-app-session.ts "use client"; import { authClient } from "@/lib/auth-client"; @@ -41,8 +42,8 @@ export function useAppSession() { Barrel re-export (`src/contexts/index.ts`): ```typescript -export { useAppSession } from "./session-context"; -export type { SessionData } from "./session-context"; +export { useAppSession } from "./use-app-session"; +export type { SessionData } from "./use-app-session"; ``` ## How it works @@ -54,10 +55,10 @@ export type { SessionData } from "./session-context"; - **Single import path for consumers** — `import { useAppSession } from '@/contexts'` instead of `import { authClient } from '@/lib/auth-client'` every time. - **Stable API surface** — if Better Auth's hook signature changes, only this module updates. - **Intent signaling** — "app session" name documents that this is the authenticated-user session, not a cookie/session-storage primitive. -- **Limitation**: drops `isPending`. Components that must distinguish "loading" from "signed-out" (like `UserProvider`) bypass this hook and call `authClient.useSession()` directly (`src/contexts/user-context.tsx:22`). +- **Limitation**: drops `isPending`. Components that must distinguish "loading" from "signed-out" (like `UserProvider`) bypass this hook and call `authClient.useSession()` directly (`src/contexts/user-context.tsx:35`). ## Usage -Consumer pattern (test file `session-context.test.tsx:17-26`): +Consumer pattern (test file `use-app-session.test.ts:17-26`): ```typescript mockUseSession.mockReturnValue({ data: mockSessionData }); @@ -68,11 +69,11 @@ const result = useAppSession(); When a component needs `isPending` too, import `authClient` directly: ```typescript -// src/contexts/user-context.tsx:22 +// src/contexts/user-context.tsx:35 const { data: session, isPending } = authClient.useSession(); ``` -## Test coverage (`session-context.test.tsx`) +## Test coverage (`use-app-session.test.ts`) | Test | Line | Asserts | |---|---|---| @@ -80,8 +81,8 @@ const { data: session, isPending } = authClient.useSession(); | returns null when no session | 28 | `{ data: null }` → hook returns `null` | ## Gotchas -- Use the wrapper for read-only session access; drop to `authClient.useSession()` when you need `isPending` / `error` / `refetch` (`src/contexts/user-context.tsx:22`). -- The `customSession` plugin only runs server-side; `SessionData.user.userGuid` is present at runtime but may require a type assertion (see pattern in `user-context.tsx:29`). +- Use the wrapper for read-only session access; drop to `authClient.useSession()` when you need `isPending` / `error` / `refetch` (`src/contexts/user-context.tsx:35`). +- The `customSession` plugin only runs server-side; `SessionData.user.userGuid` is present at runtime but may require a type assertion (see pattern in `user-context.tsx:42`). - Do not import from `@/lib/auth-client` in a feature component purely to read the session — prefer `useAppSession` from `@/contexts` for consistency. ## Related docs diff --git a/.claude/references/contexts/user-provider.md b/.claude/references/contexts/user-provider.md index 204692d..54086a8 100644 --- a/.claude/references/contexts/user-provider.md +++ b/.claude/references/contexts/user-provider.md @@ -5,26 +5,29 @@ type: reference applies_to: [src/contexts/user-context.tsx, src/contexts/user-context.test.tsx, src/app/providers.tsx] symbols: [UserProvider, useUser, MPUserProfile] related: [session.md, ../auth/README.md, ../services/README.md] -last_verified: 2026-09-13 +last_verified: 2026-10-10 --- ## Purpose -Client context that watches the Better Auth session, extracts `userGuid` (OIDC `sub` → MP `User_GUID`), and loads the enriched `MPUserProfile` (roles + groups) via the `getCurrentUserProfile` server action. `userGuid` gates *whether* the load fires; the action re-derives it server-side and takes no argument. +Client context that watches the Better Auth session, extracts `userGuid` (OIDC `sub` → MP `User_GUID`), and loads the display-only `MPUserProfile` (names + email; no IDs, roles, or groups) via the `getCurrentUserProfile` server action. The load is **lazy**: nothing is fetched until a component calls `useUser()`. `userGuid` gates *whether* the load fires; the action re-derives it server-side and takes no argument. + +Kept as an **extension point** for tools built on the template: no component outside `src/contexts/` calls `useUser()` today (the header `UserMenu` was removed in #54), so the mounted provider costs nothing until a tool opts in. ## Files - `src/contexts/user-context.tsx` — provider + hook + error handling -- `src/contexts/user-context.test.tsx` — 6 test cases covering load/error/refresh/missing-guid +- `src/contexts/user-context.test.tsx` — 14 test cases covering load/error/refresh/missing-guid, edge branches, and lazy fetch - `src/app/providers.tsx` — mounts `<UserProvider>` at the app shell - `src/components/shared-actions/user.ts` — `getCurrentUserProfile()` server action (no parameters; derives the GUID from the session) -- `src/services/userService.ts:81` — `UserService.getUserProfile(guid)` downstream lookup +- `src/services/userService.ts` — `UserService.getUserProfile(guid)` downstream lookup - `src/lib/providers/ministry-platform/types/user-profile.types.ts` — `MPUserProfile` shape ## Key concepts -- **Client-side profile load** — MP profile is fetched after mount, not injected by the server. Page renders before `userProfile` is available (`isLoading` starts `true`). -- **`userGuid` is the key** — `session.user.id` is Better Auth's internal ID; `session.user.userGuid` is the MP `User_GUID`. Only `userGuid` is used for MP lookups (`user-context.tsx:27-29`). -- **Effect gating** — the effect only fires load when `!isPending && userGuid`; the else-branch (`!isPending && !session`) clears state. If the session exists but has no `userGuid`, **neither branch runs** (`user-context.tsx:51-59`; test at `user-context.test.tsx:88-99`). -- **Error state is local** — `getCurrentUserProfile` rejections are caught; `error` is exposed on context, `userProfile` is reset to `null`, `isLoading` ends `false` (`user-context.tsx:43-48`). -- **`useUser` throws outside a provider** — guard at `user-context.tsx:79-81`. +- **Lazy, client-side profile load** — MP profile is fetched after the first `useUser()` consumer mounts, not on provider mount and not injected by the server. Page renders before `userProfile` is available (`isLoading` starts `true`). +- **One load, shared** — `useUser()` runs an effect that calls an internal `requestProfile()` (from a private `UserProfileRequestContext`), which sets the provider's `requested` state to `true`. Setting it again is a React bail-out, so N consumers cause one fetch. The private context keeps `useUser()`'s return shape unchanged. +- **`userGuid` is the key** — `session.user.id` is Better Auth's internal ID; `session.user.userGuid` is the MP `User_GUID`. Only `userGuid` is used for MP lookups (`user-context.tsx:40-42`). +- **Effect gating** — the provider effect does nothing until `requested`; then it fires load when `!isPending && userGuid`, and the else-branch (`!isPending && !session`) clears state. If the session exists but has no `userGuid`, **neither branch runs** (test: "should not fetch profile when session has no userGuid"). +- **Error state is local** — `getCurrentUserProfile` rejections are caught; `error` is exposed on context, `userProfile` is reset to `null`, `isLoading` ends `false`. +- **`useUser` throws outside a provider** — guard at the end of `useUser()`. ## API / Interface @@ -44,24 +47,18 @@ export function useUser(): UserContextValue; ```typescript // src/lib/providers/ministry-platform/types/user-profile.types.ts export interface MPUserProfile { - User_ID: number; - User_GUID: string; - Contact_ID: number; First_Name: string; Nickname: string; Last_Name: string; Email_Address: string | null; - Mobile_Phone: string | null; - Image_GUID: string | null; - roles: string[]; - userGroups: string[]; } ``` ## How it works -- On mount: `const { data: session, isPending } = authClient.useSession()`. +- On mount: `const { data: session, isPending } = authClient.useSession()`; `requested` starts `false` — **no fetch yet**. - `userGuid = (session?.user as { userGuid?: string } | undefined)?.userGuid`. -- `useEffect` fires `loadUserProfile()` when `!isPending && userGuid` truthy. +- A consumer's `useUser()` effect calls `requestProfile()` → `setRequested(true)` (idempotent). +- Provider `useEffect` fires `loadUserProfile()` when `requested && !isPending && userGuid`. - `loadUserProfile`: - Sets `isLoading=true`, `error=null`. - Calls `getCurrentUserProfile()` — no argument; the action re-derives the GUID from the server-side session. @@ -82,6 +79,10 @@ import { UserProvider } from "@/contexts/user-context"; import { Toaster } from "sonner"; import { ReactNode } from "react"; +interface ProvidersProps { + children: ReactNode; +} + export function Providers({ children }: ProvidersProps) { return ( <UserProvider> @@ -92,11 +93,18 @@ export function Providers({ children }: ProvidersProps) { } ``` -Consumer hook (from `src/contexts/user-context.tsx:77-83`): +Consumer hook (from `src/contexts/user-context.tsx`): ```typescript export function useUser() { const context = useContext(UserContext); + const requestProfile = useContext(UserProfileRequestContext); + + // The first mounted consumer triggers the provider's one profile load. + useEffect(() => { + requestProfile?.(); + }, [requestProfile]); + if (context === undefined) { throw new Error("useUser must be used within a UserProvider"); } @@ -107,18 +115,18 @@ export function useUser() { ## Load lifecycle (call graph) ``` -UserProvider mount +UserProvider mount (no fetch; requested = false) └─ authClient.useSession() → { data: session, isPending } └─ userGuid = session.user.userGuid - └─ useEffect [!isPending && userGuid] +first useUser() consumer mounts + └─ useEffect → requestProfile() → setRequested(true) (later consumers: no-op) + └─ provider useEffect [requested && !isPending && userGuid] └─ loadUserProfile() └─ getCurrentUserProfile() (server action: src/components/shared-actions/user.ts:25) └─ auth.api.getSession({ headers }) (re-verifies session; throws Unauthorized unless user.userGuid is a non-empty string) └─ UserService.getInstance() - └─ UserService.getUserProfile(userGuid) (src/services/userService.ts:81 — GUID from the session, never the caller) - ├─ MP getTableRecords dp_Users (filter: User_GUID = '...') - ├─ MP getTableRecords dp_User_Roles (filter: User_ID = ...) - └─ MP getTableRecords dp_User_User_Groups (filter: User_ID = ...) + └─ UserService.getUserProfile(userGuid) (src/services/userService.ts — GUID from the session, never the caller) + └─ MP getTableRecords dp_Users (filter: User_GUID = '...'; single query, 4 Contact_ID_TABLE fields) └─ setUserProfile | setError └─ setIsLoading(false) ``` @@ -133,12 +141,21 @@ UserProvider mount | no fetch when session lacks userGuid | 88 | `data: { user: { id } }` (no `userGuid`) → action not called | | handles profile load error | 101 | rejected promise → `error` set, `userProfile=null` | | `refreshUserProfile` re-fetches | 119 | second call returns updated profile; action called twice | +| refresh with no `userGuid` clears profile | 160 | guard inside `loadUserProfile`; action not called | +| stays loading while session pending | 177 | `isPending: true` → `isLoading` stays `true` | +| non-Error rejection wrapped | 186 | `error` is a real `Error` ("Failed to load user profile") | +| undefined profile → no profile | 202 | `profile ?? null` | +| error cleared on successful refresh | 217 | `setError(null)` before reload | +| no call when nothing calls `useUser()` | 246 | provider mounted with a `userGuid` session but no consumer → action not called | +| two consumers share one call | 264 | both consumers see the profile; action called once | +| late consumer triggers the call | 288 | no call until a consumer is rendered; then exactly one | ## Gotchas -- Reading `useUser().userProfile` before load returns `null` — gate UI on `isLoading` (inline; `user-context.tsx:24,51-59`). -- Consumer must be a client component and wrapped by `<UserProvider>` — the hook throws otherwise (`user-context.tsx:79-81`). -- Session without `userGuid` leaves `isLoading` at its previous value — if the session switches to "missing guid" mid-lifecycle, neither branch of the effect clears it (`user-context.tsx:51-59`). Prefer `useAppSession` + explicit checks in components that need to distinguish "loading" vs "guid missing". -- Direct import from `@/lib/auth-client` bypasses the `useAppSession` wrapper — `UserProvider` itself does this (`user-context.tsx:22`) because it needs `isPending` which `useAppSession` drops. +- Reading `useUser().userProfile` before load returns `null` — gate UI on `isLoading`. +- Consumer must be a client component and wrapped by `<UserProvider>` — the hook throws otherwise. +- Once requested, the profile stays loaded for the provider's lifetime even if every consumer unmounts — `requested` never resets; a session/`userGuid` change re-runs the load. +- Session without `userGuid` leaves `isLoading` at its previous value — if the session switches to "missing guid" mid-lifecycle, neither branch of the effect clears it. Prefer `useAppSession` + explicit checks in components that need to distinguish "loading" vs "guid missing". +- Direct import from `@/lib/auth-client` bypasses the `useAppSession` wrapper — `UserProvider` itself does this because it needs `isPending` which `useAppSession` drops. ## Related docs - `session.md` — why `useAppSession` is separate diff --git a/.claude/references/data-flow/README.md b/.claude/references/data-flow/README.md index 0873549..a7d2cbc 100644 --- a/.claude/references/data-flow/README.md +++ b/.claude/references/data-flow/README.md @@ -11,11 +11,12 @@ Call graphs for key end-to-end flows plus an error catalog grouped by origin lay | File | Purpose | When to read | |------|---------|--------------| | `call-graphs.md` | 7 key flows with entry points, stack traces, side effects, error paths | Tracing a user action end-to-end | -| `error-catalog.md` | Error types by origin layer (auth, proxy, service, MP provider, HTTP, Zod) | Debugging errors or designing error handling | +| `error-catalog.md` | Error types by origin layer (OAuth/session, MP HTTP client, authorization, validation, service, action envelopes) | Debugging errors or designing error handling | ## Related domains - `../auth/README.md` — auth flows touch many call graphs - `../mp-provider/README.md` — MP stack behind many flows - `../services/README.md` — service layer is the middle tier +- `../security/README.md` — `AuthorizationService` gate that every flow passes through - `../routing/README.md` — tool page convention referenced by flow 2 - `../components/README.md` — feature components that originate each flow diff --git a/.claude/references/data-flow/call-graphs.md b/.claude/references/data-flow/call-graphs.md index 630f3ea..3618fa7 100644 --- a/.claude/references/data-flow/call-graphs.md +++ b/.claude/references/data-flow/call-graphs.md @@ -4,8 +4,8 @@ domain: data-flow type: reference applies_to: [src/proxy.ts, src/components/layout/auth-wrapper.tsx, src/lib/auth.ts, src/app/(web)/tools/**, src/services/**, src/contexts/user-context.tsx] symbols: [] -related: [error-catalog.md, ../auth/README.md, ../mp-provider/README.md, ../services/README.md] -last_verified: 2026-04-17 +related: [error-catalog.md, ../auth/README.md, ../mp-provider/README.md, ../services/README.md, ../security/README.md] +last_verified: 2026-10-10 --- ## Flow 1: Auth — protected route hit @@ -14,32 +14,33 @@ last_verified: 2026-04-17 **Entry point:** `src/proxy.ts:proxy` **Stack trace:** -1. `src/proxy.ts:4` — `proxy(request)` sets `x-pathname` header on forwarded request headers (`src/proxy.ts:12-13`). -2. `src/proxy.ts:18` — early return `passThrough()` if path is `/api/*` or `/signin`. -3. `src/proxy.ts:24` — `getSessionCookie(request)` from `better-auth/cookies`. -4. `src/proxy.ts:26-31` — if no cookie: build `/signin?callbackUrl=<fullPath>` and `NextResponse.redirect(signinUrl)`; stop. -5. If cookie present: `src/proxy.ts:34` — `passThrough()`; request reaches `src/app/(web)/layout.tsx:28` (`WebLayout`). +1. `src/proxy.ts:37` — `proxy(request)` builds the CSP + nonce, then sets `x-pathname` (`src/proxy.ts:58`), `x-nonce` and the CSP header on the forwarded request headers. +2. `src/proxy.ts:86-88` — early `passThrough()` if `isPublicPath(pathname)` (`/api/auth`, `/api/auth/*`, `/signin`, `/auth-error` — `src/proxy.ts:26-35`). +3. `src/proxy.ts:91` — `getSessionCookie(request)` from `better-auth/cookies`. +4. `src/proxy.ts:93-95` — if no cookie: `redirectToSignin()` (`src/proxy.ts:80-84`) → `NextResponse.redirect('/signin?callbackUrl=<fullPath>')`; stop. +5. If cookie present: `src/proxy.ts:97` — `passThrough()`; request reaches `src/app/(web)/layout.tsx:28` (`WebLayout`). 6. `src/app/(web)/layout.tsx:34` — layout wraps children in `<AuthWrapper>`. 7. `src/components/layout/auth-wrapper.tsx:6-7` — `await headers()` then `auth.api.getSession({ headers })`. 8. `src/components/layout/auth-wrapper.tsx:9-17` — on no session: read `x-pathname`, build `/signin?callbackUrl=...`, `redirect(...)`. -9. Browser lands on `src/app/signin/page.tsx:7` (`SignInContent`). -10. `src/app/signin/sign-in-content.tsx` — `authClient.signIn.social({ provider: "ministryplatform", callbackURL })` kicks off OAuth. See `../auth/oauth-flow.md`. +9. `src/components/layout/auth-wrapper.tsx:25-28` — session without `userGuid` → `redirect("/session-error")`. +10. Browser lands on `src/app/signin/page.tsx:25` (`SignIn`, `force-dynamic`), which renders `SignInContent` inside `<Suspense>`. +11. `src/app/signin/sign-in-content.tsx:179` — `authClient.signIn.social({ provider: "ministryplatform", callbackURL })` kicks off OAuth. See `../auth/oauth-flow.md`. **Side effects:** -- Request header set (internal): `x-pathname = <pathname + search>` (`src/proxy.ts:13`). -- HTTP 302 to `/signin?callbackUrl=...` from proxy when cookie absent (`src/proxy.ts:30`). +- Request headers set (internal): `x-pathname = <pathname + search>`, `x-nonce`, CSP header (`src/proxy.ts:57-67`). +- Response headers: CSP (+ `Reporting-Endpoints` when configured) on every proxied response (`withCsp`, `src/proxy.ts:69-75`). +- HTTP 307 redirect to `/signin?callbackUrl=...` from proxy when cookie absent (`NextResponse.redirect` default status). - Server-side `redirect(...)` (Next.js exception) from AuthWrapper when session absent (`src/components/layout/auth-wrapper.tsx:16`). -- Log: `Proxy: Redirecting to signin - no session cookie` (`src/proxy.ts:27`). **Error paths:** -- Cookie-read throws: caught at `src/proxy.ts:36-41`, logs and redirects to `/signin` with the same callbackUrl. -- `auth.api.getSession` throws: **not** wrapped in `AuthWrapper` — propagates to Next.js error boundary (`src/app/(web)/error.tsx`). +- Cookie-read throws: caught at `src/proxy.ts:99-105`, logs `proxy.session_check_failed` (message only, never the path) and redirects to `/signin` with the same callbackUrl. +- `auth.api.getSession` throws: **not** wrapped in `AuthWrapper` — propagates to the nearest error boundary (`src/app/(web)/error.tsx`). **Return shape:** `NextResponse` (redirect or pass-through) from proxy; rendered React tree from `WebLayout` when authed. **Related:** -- `../auth/oauth-flow.md` — full OAuth round-trip (continues from step 10) -- `../auth/sessions.md` — JWT cookie, `cookieCache`, `customSession` +- `../auth/oauth-flow.md` — full OAuth round-trip (continues from step 11) +- `../auth/sessions.md` — JWE cookie cache, `customSession` - `../routing/proxy.md` — proxy matcher and header forwarding --- @@ -51,28 +52,32 @@ last_verified: 2026-04-17 **Stack trace:** 1. `src/app/(web)/layout.tsx:34` — `AuthWrapper` gate (see Flow 1). -2. `src/app/(web)/tools/addresslabels/page.tsx:8` — `AddressLabelsPage({ searchParams })` awaits `searchParams` (Next.js 16 async dynamic API). -3. `src/app/(web)/tools/addresslabels/page.tsx:9` — `parseToolParams(await searchParams)`. -4. `src/lib/tool-params.ts:30` — `parseToolParams` reads `pageID`, `s`, `sc`, `p`, `q`, `v`, `recordID`, `recordDescription`, `addl`. -5. `src/lib/tool-params.ts:53-60` — if `pageID` present: `ToolService.getInstance()` → `toolService.getPageData(parsedPageID)`. On throw: `console.warn` and set `pageData = undefined` (`src/lib/tool-params.ts:57-60`). -6. `src/services/toolService.ts:123` — `getPageData(pageID)`. -7. `src/services/toolService.ts:127` — `this.mp!.executeProcedureWithBody('api_Tools_GetPageData', { '@PageID': pageID })`. -8. `src/lib/providers/ministry-platform/helper.ts:544` — `MPHelper.executeProcedureWithBody` → `provider.executeProcedureWithBody` → `ProcedureService` → `HttpClient.post('/procs/api_Tools_GetPageData', ...)`. -9. `src/services/toolService.ts:132-137` — unwraps `result[0][0]` as `PageData`. -10. `src/app/(web)/tools/addresslabels/page.tsx:11` — renders `<AddressLabels params={params} />` (client subtree). +2. `src/app/(web)/tools/layout.tsx:31-34` — `AuthorizationService.getInstance().hasSecurityRole()`; `redirect("/no-access")` if false. UX only — **not** enforcement for the page (Next 16 renders the page segment independently of the layout). +3. `src/app/(web)/tools/addresslabels/page.tsx:9` — `AddressLabelsPage({ searchParams })`. +4. `src/app/(web)/tools/addresslabels/page.tsx:11` — `requireToolAccess({ table: 'Contacts', operation: 'read' })` (`src/app/(web)/tools/require-tool-access.ts:29-36`) → `requireSecurityRole`; `UnauthorizedError` → `redirect("/no-access")`, any other error rethrows. +5. `src/app/(web)/tools/addresslabels/page.tsx:13` — `parseToolParams(await searchParams)` (Next.js 16 async dynamic API). +6. `src/lib/tool-params.server.ts:43` — `parseToolParams` reads `pageID`, `s`, `sc`, `p`, `q`, `v`, `recordID`, `recordDescription`, `addl`; integers via `parseIntOrUndefined` (non-numeric → `undefined`), `recordDescription` via `safeDecode`. +7. `src/lib/tool-params.server.ts:66-77` — if `pageID` parsed: dynamic `import("@/services/toolService")` → `ToolService.getInstance()` → `toolService.getPageData(parsedPageID)`. On throw: `console.warn('tool_params.page_data_unavailable', { pageID })` and `pageData = undefined`. +8. `src/services/toolService.ts:126-130` — `getPageData(pageID)` gates `dp_Pages`/`read` (memoized — same request). +9. `src/services/toolService.ts:134` — `this.mp!.executeProcedureWithBody('api_Tools_GetPageData', { '@PageID': pageID })`. +10. `src/lib/providers/ministry-platform/helper.ts:578` — `MPHelper.executeProcedureWithBody` → `provider.executeProcedureWithBody` → `ProcedureService` → `HttpClient.post('/procs/api_Tools_GetPageData', ...)`. +11. `src/services/toolService.ts:139-142` — unwraps `result[0][0]` as `PageData`. +12. `src/app/(web)/tools/addresslabels/page.tsx:15` — renders `<AddressLabels params={params} />` (client subtree). **Side effects:** -- MP API call: `POST /procs/api_Tools_GetPageData` (first tool page hit within token TTL triggers token refresh — see `../mp-provider/client.md`). +- MP API calls: `GET /tables/dp_Users` + `GET /tables/dp_User_Roles` (authorization decision, once per request via React `cache()`), then `POST /procs/api_Tools_GetPageData`. First call within token TTL triggers a token fetch — see `../mp-provider/client.md`. - Cookie set: none (read-only flow). **Error paths:** -- `api_Tools_GetPageData` not found / SP errors → `HttpClient.post` throws `Error('POST /procs/... failed: ...')` at `src/lib/providers/ministry-platform/utils/http-client.ts:59` → rethrown by `ToolService.getPageData` at `src/services/toolService.ts:138-140` → caught in `parseToolParams` at `src/lib/tool-params.ts:57-60` (swallowed to `pageData = undefined`). -- All other `parseToolParams` field parsing: synchronous — invalid `Number` produces `NaN`, no throw. +- No security role → `redirect("/no-access")` (layout, and again at `requireToolAccess`). +- MP unreachable during the gate → error propagates to `src/app/(web)/error.tsx` (not converted to "no access"). +- `api_Tools_GetPageData` failure → `HttpClient` throws `Error('POST /procs/... failed: <status>')` at `src/lib/providers/ministry-platform/utils/http-client.ts:226` → rethrown by `ToolService.getPageData` → swallowed in `parseToolParams` (`pageData = undefined`). -**Return shape:** `ToolParams` per `src/lib/tool-params.ts:17-28` (fields + optional `pageData: PageData`). +**Return shape:** `ToolParams` per `src/lib/tool-params.ts:28-39` (fields + optional `pageData: PageData`). **Related:** - `../services/tool-service.md` — `getPageData` contract +- `../utils/tool-params.md` — parser details - `../mp-schema/required-procs.md` — `api_Tools_GetPageData` shape - `../routing/app-router.md` — tool page convention @@ -84,37 +89,33 @@ last_verified: 2026-04-17 **Entry point:** `src/components/dev-panel/panels/selection-actions.ts:resolveSelection` (dev-panel path) **or** `src/components/address-labels/actions.ts:fetchAddressLabels` (tool path). **Stack trace (dev-panel variant):** -1. `src/components/dev-panel/panels/selection-actions.ts:13` — `resolveSelection(selectionId, pageId)` (server action). -2. `src/components/dev-panel/panels/selection-actions.ts:17` — `auth.api.getSession({ headers: await headers() })`; throws `'Unauthorized'` at line 18 if no user. -3. `src/components/dev-panel/panels/selection-actions.ts:20-21` — reads `(session.user as {userGuid}).userGuid`; throws `'User GUID not found in session'` if absent. -4. `src/components/dev-panel/panels/selection-actions.ts:23-24` — `UserService.getInstance()` → `userService.getUserIdByGuid(userGuid)`. -5. `src/services/userService.ts:68-79` — `getUserIdByGuid` runs `mp.getTableRecords('dp_Users', { select: 'User_ID', filter: "User_GUID = '<guid>'", top: 1 })`; throws `'User not found'` at line 76 if empty. -6. `src/components/dev-panel/panels/selection-actions.ts:26-27` — `ToolService.getInstance()` → `toolService.getSelectionRecordIds(selectionId, userId, pageId)`. -7. `src/services/toolService.ts:152-174` — `getSelectionRecordIds` calls `mp.executeProcedureWithBody('api_Common_GetSelection', { '@SelectionID', '@UserID', '@PageID' })` (line 154) and finds the result set containing `Record_ID` (line 163). -8. Returns `{ recordIds, count }` to client. +1. `src/components/dev-panel/panels/selection-actions.ts:11` — `resolveSelection(selectionId, pageId)` (server action). +2. `src/components/dev-panel/panels/selection-actions.ts:15` — `requireDevSession('Dev panel')` (`src/components/dev-panel/panels/require-dev-session.ts`): throws in production, or if no session `user.id`. +3. `src/components/dev-panel/panels/selection-actions.ts:17-20` — `ToolService.getInstance()` → `toolService.getSelectionRecordIds(selectionId, pageId)`. +4. `src/services/toolService.ts:163-166` — `requireSecurityRole({ table: 'dp_Selections', operation: 'read' })` returns the acting MP `User_ID` (resolved from `session.user.userGuid` → `dp_Users.User_ID` inside `AuthorizationService`). +5. `src/services/toolService.ts:168-172` — `mp.executeProcedureWithBody('api_Common_GetSelection', { '@SelectionID', '@UserID': userId, '@PageID' })`. +6. `src/services/toolService.ts:175-182` — finds the result set containing `Record_ID` and maps it. +7. Returns `{ recordIds, count }` to client. -**Stack trace (address-labels variant):** same, but step 1 is `src/components/address-labels/actions.ts:99` `fetchAddressLabels(params, config)` and `getMPUserId` is the helper at `src/components/address-labels/actions.ts:34-40`. +**Stack trace (address-labels variant):** step 1 is `src/components/address-labels/actions.ts:116` `fetchAddressLabels(params, config)`, step 2 is `requireAccess('Contacts', 'read')` (line 120), then the same `getSelectionRecordIds(params.s, params.pageID)` call (line 130). **Side effects:** -- MP API call: `POST /procs/api_Common_GetSelection` (`src/services/toolService.ts:154`). -- MP API call: `GET /tables/dp_Users?$select=User_ID&$filter=User_GUID='<guid>'` (`src/services/userService.ts:69-74`). -- The MP procedure is **user-scoped** — `@UserID` must be the correct MP numeric ID or an empty set is returned. +- MP API calls: `GET /tables/dp_Users` + `GET /tables/dp_User_Roles` (gate, memoized per request), `POST /procs/api_Common_GetSelection`. +- The MP procedure is **user-scoped** — `@UserID` comes only from the gate, so a caller cannot read another user's selection. **Error paths:** -- No session → `Error('Unauthorized')` thrown at `src/components/dev-panel/panels/selection-actions.ts:18`. -- Missing `userGuid` on session → `Error('User GUID not found in session')` at `src/components/dev-panel/panels/selection-actions.ts:21`. -- User GUID not in MP → `Error('User not found')` at `src/services/userService.ts:76`. -- MP procedure failure → `HttpClient.post` throws at `src/lib/providers/ministry-platform/utils/http-client.ts:59`; rethrown by `ToolService.getSelectionRecordIds` at `src/services/toolService.ts:171-173`. +- Production build → `Error('Dev panel is not available in production.')` from `requireDevSession`. +- No session → `Error('Unauthorized - Missing user session data')` from `requireDevSession`. +- No MP user / no role / roles unconfigured → `UnauthorizedError` from `requireSecurityRole` (logged as `mp.read.unauthorized`). +- MP procedure failure → `HttpClient` throws at `src/lib/providers/ministry-platform/utils/http-client.ts:226`; rethrown by `ToolService.getSelectionRecordIds`. -**Return shape:** `{ recordIds: number[], count: number }` (dev-panel variant — `SelectionResult` at `src/components/dev-panel/panels/selection-actions.ts:8-11`). +**Return shape:** `{ recordIds: number[], count: number }` (dev-panel variant — `SelectionResult` at `src/components/dev-panel/panels/selection-actions.ts:6-9`). **Related:** - `../services/tool-service.md` — `getSelectionRecordIds` contract - `../mp-schema/required-procs.md` — `api_Common_GetSelection` - `../components/dev-panel.md` — selection panel UI -> **Note:** JSDoc at `src/services/toolService.ts:145` says `api_CloudTools_GetSelection` but the call at line 154 uses `api_Common_GetSelection`. Tracked in TODO `2026-04-17-services-stale-cloudtools-comment.md`. - --- ## Flow 4: Address label fetch @@ -123,23 +124,23 @@ last_verified: 2026-04-17 **Entry point:** `src/components/address-labels/actions.ts:fetchAddressLabels` **Stack trace:** -1. `src/components/address-labels/actions.ts:99` — `fetchAddressLabels(params, config)`. -2. `src/components/address-labels/actions.ts:103` — `getSession()` (local helper at line 28); throws `'Unauthorized'` if no `session.user.id`. -3. `src/components/address-labels/actions.ts:105` — `AddressLabelService.getInstance()`. -4. **Selection mode** (`params.s && params.pageID`, line 107): `getMPUserId(session)` (line 109, helper at lines 34-40) → `ToolService.getInstance().getSelectionRecordIds(...)` (line 111) — see Flow 3 steps 6-7. -5. `src/components/address-labels/actions.ts:117` — `addressService.getAddressesForContacts(contactIds)`. -6. `src/services/addressLabelService.ts:69-90` — batches in groups of 100 (`BATCH_SIZE` at line 21); for each batch validates every ID via `validatePositiveInt`, calls `mp.getTableRecords({ table: 'Contacts', select: SELECT_FIELDS, filter: 'Contact_ID IN (...)', orderBy: 'Household_ID_TABLE_Address_ID_TABLE.Postal_Code' })`. -7. **Single-record mode** (`params.recordID && params.recordID !== -1`, line 119): `addressService.getAddressForContact(params.recordID)` (line 121) → `src/services/addressLabelService.ts:95-104`. -8. `src/components/address-labels/actions.ts:42-97` — `filterAndTransform(rows, config)`: applies skip rules (opt-out, no address, no postal code, no barcode), deduplicates households when `addressMode === 'household'`, sorts by `postalCode`. +1. `src/components/address-labels/actions.ts:116` — `fetchAddressLabels(params, config)`. +2. `src/components/address-labels/actions.ts:120` — `requireAccess('Contacts', 'read')` (helper at lines 44-49 → `requireSecurityRole`). +3. `src/components/address-labels/actions.ts:122` — `AddressLabelService.getInstance()`. +4. **Selection mode** (`params.s && params.pageID`, line 124): `ToolService.getInstance().getSelectionRecordIds(params.s, params.pageID)` (lines 129-130) — see Flow 3 steps 4-6. Empty result → `{ printable: [], skipped: [] }`. +5. `src/components/address-labels/actions.ts:136` — `addressService.getAddressesForContacts(contactIds)`. +6. `src/services/addressLabelService.ts:70-97` — gates `Contacts`/`read`, then batches in groups of `MP_FETCH_BATCH_SIZE` (100, `src/lib/constants.ts`); for each batch validates every ID via `validatePositiveInt`, calls `mp.getTableRecords({ table: 'Contacts', select: SELECT_FIELDS, filter: 'Contact_ID IN (...)', orderBy: 'Household_ID_TABLE_Address_ID_TABLE.Postal_Code' })`. +7. **Single-record mode** (`params.recordID && params.recordID !== -1`, line 138): `addressService.getAddressForContact(params.recordID)` (line 140) → `src/services/addressLabelService.ts:102-117` (gate, `validatePositiveInt`, `top: 1`). +8. `src/components/address-labels/actions.ts:52-114` — `filterAndTransform(rows, config)`: applies skip rules (opt-out, no address, no postal code, no barcode unless `includeMissingBarcodes`, no household in household mode), deduplicates households when `addressMode === 'household'`, sorts by `postalCode`. **Side effects:** -- MP API calls: one `POST /procs/api_Common_GetSelection` per selection fetch; `ceil(contactIds.length / 100)` calls to `GET /tables/Contacts` with FK-joined select. +- MP API calls: gate reads (once per request); one `POST /procs/api_Common_GetSelection` per selection fetch; `ceil(contactIds.length / 100)` calls to `GET /tables/Contacts` with FK-joined select. - No DB writes. **Error paths:** -- No session → `Error('Unauthorized')` at `src/components/address-labels/actions.ts:30`. -- Invalid `Contact_ID` in batch → `validatePositiveInt` throws at `src/services/addressLabelService.ts:76` (selection mode only; single-record mode at line 95 does not validate). -- MP failure → `HttpClient.get` throws at `src/lib/providers/ministry-platform/utils/http-client.ts:32`; propagates through service and action; unhandled on server (becomes `Error` in client action response). +- Not authorized → `UnauthorizedError` from `requireAccess` (action) or the service gate. +- Invalid `Contact_ID` → `validatePositiveInt` throws `'Expected positive integer'` (both modes). +- MP failure → `HttpClient` throws at `src/lib/providers/ministry-platform/utils/http-client.ts:226`; propagates through service and action (no try/catch in `fetchAddressLabels`). **Return shape:** `FetchAddressLabelsResult` (`{ printable: LabelData[], skipped: SkipRecord[] }`) from `@/lib/dto`. @@ -157,65 +158,60 @@ last_verified: 2026-04-17 **Stack trace:** 1. `src/contexts/user-context.tsx:21` — `UserProvider({ children })` renders. -2. `src/contexts/user-context.tsx:22` — `authClient.useSession()` (reactive subscription to JWT cookie cache). +2. `src/contexts/user-context.tsx:22` — `authClient.useSession()` (reactive subscription to the session cookie cache). 3. `src/contexts/user-context.tsx:29` — derive `userGuid = (session?.user as { userGuid?: string } | undefined)?.userGuid`. -4. `src/contexts/user-context.tsx:51-58` — `useEffect` fires when `!isPending && userGuid`: calls `loadUserProfile()` (line 53). +4. `src/contexts/user-context.tsx:51-58` — `useEffect` fires when `!isPending && userGuid`: calls `loadUserProfile()` (line 54). 5. `src/contexts/user-context.tsx:31-49` — `loadUserProfile` sets `isLoading=true`, calls `getCurrentUserProfile()` (line 41) with no arguments. 6. `src/components/shared-actions/user.ts:25` — server action `getCurrentUserProfile()`; takes no parameters, so a caller cannot name another user (IDOR). 7. `src/components/shared-actions/user.ts:26-28` — `auth.api.getSession({ headers: await headers() })`, then derive `userGuid` from `session.user`; throws `'Unauthorized'` unless it is a non-empty string. -8. `src/components/shared-actions/user.ts:30-31` — `UserService.getInstance()` → `userService.getUserProfile(userGuid)`. -9. `src/services/userService.ts:81-110` — runs 3 MP queries: - - `mp.getTableRecords('dp_Users', { filter: "User_GUID = '<guid>'", select: "User_ID, User_GUID, Contact_ID_TABLE.First_Name, ..., Contact_ID_TABLE.dp_fileUniqueId AS Image_GUID", top: 1 })` at lines 86-91. - - `Promise.all([dp_User_Roles fetch, dp_User_User_Groups fetch])` at lines 92-103, keyed by the numeric `User_ID` from the first query. -10. `src/services/userService.ts:105-109` — returns `{ ...profile, roles: string[], userGroups: string[] }`. +8. `src/components/shared-actions/user.ts:30-31` — `UserService.getInstance()` → `userService.getUserProfile(userGuid)`. No `AuthorizationService` gate — documented rule-12 carve-out (own profile only). +9. `src/services/userService.ts` `getUserProfile` — runs 1 MP query: `mp.getTableRecords('dp_Users', { filter: "User_GUID = '<guid>'", select: "Contact_ID_TABLE.First_Name,Contact_ID_TABLE.Nickname,Contact_ID_TABLE.Last_Name,Contact_ID_TABLE.Email_Address", top: 1 })`. +10. Same method — maps the row explicitly to `{ First_Name, Nickname, Last_Name, Email_Address: ?? null }` (no spread, so nothing else MP returns reaches the client). 11. `src/contexts/user-context.tsx:42` — `setUserProfile(profile ?? null)`. **Side effects:** -- MP API calls: 1 + 2 (parallel) `GET /tables/...` per mount (first run per fresh process triggers token fetch). -- No cookie changes (session read from JWT cache). +- MP API calls: 1 `GET /tables/dp_Users` per mount (first run per fresh process triggers token fetch). +- No cookie changes (session read from the cookie cache). **Error paths:** -- Server action throws → caught at `src/contexts/user-context.tsx:43-46` → sets `error` state, `userProfile = null`. +- Server action throws → caught at `src/contexts/user-context.tsx:43-45` → sets `error` state, `userProfile = null`. - Session `userGuid` missing or empty → `Error('Unauthorized')` at `src/components/shared-actions/user.ts:28`. (Not keyed on `session.user.id` — that is Better Auth's internal ID and does not prove an MP identity.) -- Profile row missing → `userProfile = undefined` returned at `src/services/userService.ts:90`; normalized to `null` in context (line 42). +- Profile row missing → `userProfile = undefined` returned by `getUserProfile` (`if (!row) return undefined`); normalized to `null` in context (line 42). -**Return shape:** `MPUserProfile | null` in context state (with `roles: string[]`, `userGroups: string[]`). +**Return shape:** `MPUserProfile | null` in context state (`First_Name`, `Nickname`, `Last_Name`, `Email_Address` only). **Related:** - `../contexts/user-provider.md` — hook usage, lifecycle -- `../services/user-service.md` — `getUserProfile` and `getUserIdByGuid` contracts +- `../services/user-service.md` — `getUserProfile` contract - `../auth/sessions.md` — why `userGuid` (additionalField) is needed instead of `user.id` --- ## Flow 6: Sign-out (OIDC logout) -**Trigger:** user clicks "Sign out" in user menu. -**Entry point:** `src/components/user-menu/user-menu.tsx:35` calls `handleSignOut()` server action. +**Trigger:** user submits a sign-out form — on `/no-access` (signed in, no MP security role) or `/session-error` (session without `userGuid`). These are the app's only sign-out controls (the dropdown `UserMenu` was removed 2026-10-10). +**Entry points:** `src/app/(web)/no-access/page.tsx` and `src/app/session-error/page.tsx` — `<form action={handleSignOut}>`. **Stack trace:** -1. `src/components/user-menu/user-menu.tsx:30-37` — `handleItemClick('signout')` → `await handleSignOut()`. -2. `src/components/user-menu/actions.ts:7` — `handleSignOut` (`'use server'`). -3. `src/components/user-menu/actions.ts:9-11` — `auth.api.signOut({ headers: await headers() })` — clears Better Auth JWT cookie (via `nextCookies()` plugin at `src/lib/auth.ts:113`). -4. `src/components/user-menu/actions.ts:13-15` — read `MINISTRY_PLATFORM_BASE_URL`; throw `'MINISTRY_PLATFORM_BASE_URL is not configured'` if missing. -5. `src/components/user-menu/actions.ts:18-22` — build `${baseUrl}/oauth/connect/endsession?post_logout_redirect_uri=${APP_URL}`. -6. `src/components/user-menu/actions.ts:23` — `redirect(...)` (Next.js server redirect). -7. Browser follows to MP endsession URL; MP clears its session and 302s back to `APP_URL`. -8. Subsequent request → proxy (`src/proxy.ts:24`) sees no cookie → redirects to `/signin` (see Flow 1). +1. `src/components/shared-actions/sign-out.ts:50` — `handleSignOut` (`'use server'`). +2. `auth.api.signOut({ headers, body: { disableRedirect: true } })` — clears the Better Auth session cookie **first** (via the `nextCookies()` plugin), and returns better-auth's provider logout URL. +3. `idTokenHintFrom(...)` — takes `id_token_hint` from that URL only if its origin is the MP origin. +4. Rebuilds `${getMpBaseUrl()}/oauth/connect/endsession?post_logout_redirect_uri=<getAuthBaseUrl()>&client_id=<getMpClient().clientId>[&id_token_hint=…]`. +5. `redirect(...)` — a `NEXT_REDIRECT` throw that Next turns into the navigation. +6. Browser follows to MP endsession; MP clears its session and 302s back to `BETTER_AUTH_URL`. +7. Next request → proxy sees no cookie → `/signin` (see Flow 1). **Side effects:** -- Cookie cleared: Better Auth session cookie (`better-auth.session_token`, set via `nextCookies()` plugin). -- HTTP 302 to MP `/oauth/connect/endsession?post_logout_redirect_uri=<APP_URL>`. -- **Note:** no `id_token_hint` is sent (verified by reading action — `post_logout_redirect_uri` is the only query param, `src/components/user-menu/actions.ts:19-21`). +- Cookie cleared: Better Auth session cookie. +- HTTP redirect to MP `/oauth/connect/endsession` with `post_logout_redirect_uri` and `client_id`, plus `id_token_hint` when this process still holds the id_token (see `../auth/oauth-flow.md` § Limitation). **Error paths:** -- `MINISTRY_PLATFORM_BASE_URL` unset → `Error('MINISTRY_PLATFORM_BASE_URL is not configured')` at `src/components/user-menu/actions.ts:15`; Better Auth cookie is already cleared at this point. -- `auth.api.signOut` failure → propagates out of action; cookie state depends on Better Auth implementation. +- Bad/missing `MINISTRY_PLATFORM_BASE_URL`, `BETTER_AUTH_URL` or MP client env → `src/lib/env.ts` readers throw (values never echoed). The app session is already cleared by then. +- `auth.api.signOut` failure → propagates out of the action. **Return shape:** `never` (always redirects or throws). **Related:** -- `../components/user-menu.md` — user-menu component - `../auth/oauth-flow.md` — sign-out section - `../auth/sessions.md` — what `auth.api.signOut` clears @@ -224,39 +220,41 @@ last_verified: 2026-04-17 ## Flow 7: Record create (group create) **Trigger:** user completes the Group Wizard and clicks "Submit" on the final step. -**Entry point:** `src/app/(web)/tools/groupwizard/group-wizard.tsx:121` (`handleSubmit`) → server action `createGroup`. +**Entry point:** `src/app/(web)/tools/groupwizard/group-wizard.tsx:140` (`handleSubmit`) → server action `createGroup`. **Stack trace:** -1. `src/app/(web)/tools/groupwizard/group-wizard.tsx:121-129` — `handleSubmit` calls `updateGroup(params.recordID, data)` in edit mode (line 126) or `createGroup(data)` in create mode (line 128). -2. `src/components/group-wizard/actions.ts:65` — `createGroup(data)` server action. -3. `src/components/group-wizard/actions.ts:69` — `getSession()` (helper at lines 17-21); throws `'Unauthorized'` if no `session.user.id`. -4. `src/components/group-wizard/actions.ts:70` — `getMPUserId(session)` (helper at lines 23-29) → reads `userGuid` → `UserService.getUserIdByGuid(userGuid)` → throws `'User not found'` if missing (`src/services/userService.ts:76`). -5. `src/components/group-wizard/actions.ts:71-72` — `GroupService.getInstance()` → `service.createGroup(data, userId)`. -6. `src/services/groupService.ts:227-237` — `createGroup`: - - `prepareForApi(data)` (line 231, defined at line 18) — coerces dates, maps form fields to MP columns. - - `mp.createTableRecords('Groups', [apiData], { $select: 'Group_ID, Group_Name', $userId: userId })` (line 232). -7. `src/lib/providers/ministry-platform/helper.ts:171-213` — `MPHelper.createTableRecords`: - - If `schema` provided (not in this flow), runs `schema.parse(record)` per record (lines 185-197); throws `'Validation failed for record <i>: ...'` on failure. - - `provider.createTableRecords(table, validatedRecords, providerParams)` (line 202). +1. `src/app/(web)/tools/groupwizard/group-wizard.tsx:140-155` — `handleSubmit` calls `updateGroup(params.recordID, data)` in edit mode (line 145) or `createGroup(data)` in create mode (line 147). +2. `src/components/group-wizard/actions.ts:94` — `createGroup(data)` server action. +3. `src/components/group-wizard/actions.ts:98` — `requireAccess('Groups', 'create')` → `requireSecurityRole`. +4. `src/components/group-wizard/actions.ts:99-100` — `parseGroupPayload(data)` (`groupWizardSchema.safeParse`, strips unknown keys); invalid → `{ success: false, error: 'Invalid group data: <field paths>' }`. +5. `src/components/group-wizard/actions.ts:101-102` — `GroupService.getInstance()` → `service.createGroup(parsed.data)` (no user ID passed). +6. `src/services/groupService.ts:365-379` — `createGroup`: + - `requireSecurityRole({ table: 'Groups', operation: 'create' })` → `$userId` (memoized decision from step 3). + - `prepareForApi(data)` (defined at line 95) — keeps only `GROUP_WRITABLE_FIELDS`, converts `Start_Date` / `End_Date` / `Promotion_Date` via `DomainTimezoneService.toMpSqlDatetime`. + - `mp.createTableRecords('Groups', [apiData], { $select: 'Group_ID, Group_Name', $userId })`. +7. `src/lib/providers/ministry-platform/helper.ts:197-243` — `MPHelper.createTableRecords`: + - If `schema` provided (not in this flow), runs `schema.parse(record)` per record (lines 209-228); throws `MPValidationError` (`'Validation failed for record <i>: ...'`) on failure. + - `provider.createTableRecords(table, validatedRecords, providerParams)` (line 232). 8. `MinistryPlatformProvider` → `TableService.createTableRecords` → `HttpClient.post('/tables/Groups', [apiData], { $select: ..., $userId: ... })`. -9. Returns array of created records; action unwraps `result[0]` at `src/services/groupService.ts:236`. -10. `src/components/group-wizard/actions.ts:73` — returns `{ success: true, groupId, groupName }`. -11. Back on client (`src/app/(web)/tools/groupwizard/group-wizard.tsx:131-134`): on success, sets `submitResult`; on failure, `setLoadError(result.error)`. +9. Returns array of created records; service unwraps `result[0]` at `src/services/groupService.ts:378`. +10. `src/components/group-wizard/actions.ts:103` — returns `{ success: true, groupId, groupName }`. +11. Back on client (`src/app/(web)/tools/groupwizard/group-wizard.tsx:150-154`): on success, `setSubmitResult(...)`; on failure, `setLoadError(result.error)`. **Side effects:** - MP API call: `POST /tables/Groups` with `$userId=<mp user id>` → MP writes one row to `Groups` table (`Group_ID` auto-generated). - DB write: `Groups` row (and any FK constraints MP enforces, e.g. `Group_Type_ID`). -- **No Zod validation** is currently wired for `createGroup` (the `GroupService.createGroup` call does not pass a `schema`) — schema validation only runs when callers opt in per helper contract at `src/lib/providers/ministry-platform/helper.ts:183`. +- Input validation happens in the action (`groupWizardSchema`) and the service allowlist; `MPHelper`'s optional `schema` parameter is **not** used here. **Error paths:** -- Unauthorized / missing userGuid / user not found → thrown at `src/components/group-wizard/actions.ts:19`, `:25`, or `src/services/userService.ts:76`; caught at `src/components/group-wizard/actions.ts:74-76` and returned as `{ success: false, error }`. -- MP HTTP failure → `HttpClient.post` throws at `src/lib/providers/ministry-platform/utils/http-client.ts:59`; propagates to action catch block → `{ success: false, error: <message> }`. -- Zod validation (when enabled by caller) → `Error('Validation failed for record <i>: ...')` at `src/lib/providers/ministry-platform/helper.ts:189-196`, prior to any HTTP call. +- Not authorized → `UnauthorizedError` from `requireAccess` or the service gate; caught at `src/components/group-wizard/actions.ts:104-106` and returned as `{ success: false, error }`. +- Payload fails `groupWizardSchema` → `{ success: false, error: 'Invalid group data: ...' }` (field names only, never values). +- `toMpSqlDatetime` on an unparseable date → `Error('toMpSqlDatetime: value could not be parsed as a date')`. +- MP HTTP failure → `HttpClient` throws at `src/lib/providers/ministry-platform/utils/http-client.ts:226`; propagates to action catch block → `{ success: false, error: <message> }`. -**Return shape:** `CreateGroupResult | ActionError` from `./types` — on success `{ success: true, groupId: number, groupName: string }`. +**Return shape:** `CreateGroupResult | ActionError` — on success `{ success: true, groupId: number, groupName: string }`. **Related:** - `../services/group-service.md` — `GroupService.createGroup` + `prepareForApi` - `../components/group-wizard.md` — wizard steps and form - `../mp-provider/services/table.md` — `createTableRecords` path -- `../services/query-patterns.md` — Zod schema opt-in pattern +- `../ministryplatform.datetimehandling.md` — date conversion at the MP boundary diff --git a/.claude/references/data-flow/error-catalog.md b/.claude/references/data-flow/error-catalog.md index 04719fe..cba6977 100644 --- a/.claude/references/data-flow/error-catalog.md +++ b/.claude/references/data-flow/error-catalog.md @@ -3,82 +3,103 @@ title: Error catalog domain: data-flow type: reference applies_to: [src/proxy.ts, src/lib/auth.ts, src/lib/providers/ministry-platform/**, src/services/**, src/components/**/actions.ts, src/lib/validation.ts] -symbols: [Error] -related: [call-graphs.md, ../auth/README.md, ../mp-provider/error-handling.md] -last_verified: 2026-04-17 +symbols: [Error, UnauthorizedError, MPValidationError, PartialSaveError, ZipLimitError] +related: [call-graphs.md, ../auth/README.md, ../mp-provider/error-handling.md, ../security/README.md] +last_verified: 2026-10-10 --- ## Purpose Every error thrown in `src/**` categorized by origin layer. Useful for debugging a stack trace, designing a new error flow, or deciding where to insert a try/catch. +All server-side logs and thrown messages follow CLAUDE.md rule 14: **identifiers and shape only** — never record content, `$filter` strings, request or response bodies. + ## By layer ### OAuth / session | Type | Thrown at | Caught at | User-facing | Logged | |---|---|---|---|---| -| `getUserInfo` userinfo fetch non-OK → returns `null` (no throw) | `src/lib/auth.ts:59-65` | Better Auth internals abort user creation on `null` | sign-in silently fails, OAuth loop repeats | yes, `console.error("getUserInfo - Failed to fetch user info:", status)` | -| `Error('MINISTRY_PLATFORM_BASE_URL is not configured')` | `src/components/user-menu/actions.ts:15` | propagates (uncaught) | server-action error surfaces to caller | no | -| `authClient.getSession()` rejection on /signin | `src/app/signin/page.tsx:16` (promise) | `src/app/signin/page.tsx:30-40` | falls through to `signIn.oauth2()` redirect | yes, `console.error("Failed to check session:", err)` | -| Proxy `getSessionCookie` throw (cookie parse error) | `src/proxy.ts:24` | `src/proxy.ts:36-41` | redirect to `/signin?callbackUrl=...` | yes, `console.error('Proxy: Error checking session:', error)` | -| No session cookie (control-flow, not throw) | `src/proxy.ts:26` check | `src/proxy.ts:26-31` | redirect to `/signin?callbackUrl=...` | yes, `console.log("Proxy: Redirecting to signin - no session cookie")` | +| `getUserInfo` refusal → returns `null` (never throws) — missing/invalid id_token, claim failure, userinfo non-OK / non-JSON / network error, no usable `sub`, `sub` mismatch | `src/lib/auth.ts:778-870` | Better Auth callback treats `null` as failure; `onAPIError.errorURL` (`src/lib/auth.ts:1053-1055`) | redirect to `/auth-error?error=...` (public path), no session | yes — one JSON line on `console.error` via `logAuthEvent` (e.g. `auth.userinfo.fetch_failed` with `reason`/`status`, `auth.userinfo.invalid_sub`) | +| `src/lib/env.ts` reader throws (`getMpBaseUrl` / `getAuthBaseUrl` / `getMpClient`) — unset/malformed value, never echoed | `src/lib/env.ts` (e.g. from `handleSignOut` in `src/components/shared-actions/sign-out.ts`) | propagates (uncaught) | server-action error surfaces to caller | no | +| `authClient.getSession()` rejection on /signin | `src/app/signin/sign-in-content.tsx:229` (promise) | same file, `.catch` at line 240 | falls through to `startOAuth()` → `signIn.social` | yes, `console.error("SignIn: Failed to check session:", err)` | +| `signIn.social` throw / rejection | `src/app/signin/sign-in-content.tsx:179` | same file, lines 185-205 | inline "Failed to start sign-in" + retry button | yes, `console.error("SignIn: authClient.signIn.social ...")` | +| Proxy `getSessionCookie` throw (cookie parse error) | `src/proxy.ts:91` | `src/proxy.ts:99-105` | redirect to `/signin?callbackUrl=...` | yes, `console.error('proxy.session_check_failed', { message })` | +| No session cookie (control-flow, not throw) | `src/proxy.ts:93` check | `src/proxy.ts:94` `redirectToSignin()` | redirect to `/signin?callbackUrl=...` | no | | No session at `AuthWrapper` (control-flow) | `src/components/layout/auth-wrapper.tsx:9` check | `src/components/layout/auth-wrapper.tsx:16` `redirect()` | redirect to `/signin?callbackUrl=...` via Next `redirect()` (throws `NEXT_REDIRECT`) | no | -| `Error('Failed to get client credentials token: ${statusText}')` | `src/lib/providers/ministry-platform/auth/client-credentials.ts:25` | `src/lib/providers/ministry-platform/client.ts:63` logs, re-throws | server-side 500; tool pages fail to render | yes, `logger.error("Failed to refresh token:", error)` | -| `Error('Dev client credentials are not configured…')` | `src/lib/providers/ministry-platform/auth/client-credentials.ts:36-38` | `src/lib/providers/ministry-platform/client.ts:89` re-throws; ultimately surfaced in deploy-tool UI | Deploy Tool panel surfaces error to user via `setError` | yes, `logger.error("Failed to refresh dev token:", error)` | -| `fetch` network `TypeError` (token POST) | `src/lib/providers/ministry-platform/auth/client-credentials.ts:16` | `src/lib/providers/ministry-platform/client.ts:63,89` logs, re-throws | same as above | yes | +| Session without `userGuid` (control-flow) | `src/components/layout/auth-wrapper.tsx:25-26` | `redirect("/session-error")` (line 27) | recovery page with a sign-out form | no | +| `Error('Failed to get client credentials token: <status> <statusText>')` / `': <error name>'` / `': invalid token response'` | `src/lib/providers/ministry-platform/auth/client-credentials.ts:59,64,82` | `src/lib/providers/ministry-platform/client.ts:59-64` sets a negative-cache window, logs, re-throws | server-side 500; tool pages fail to render | yes, `logger.error("mp.token.refresh_failed", { profile, error: <name> })` | +| `Error('MP access token unavailable: recent refresh failed')` | `src/lib/providers/ministry-platform/client.ts:42` — within the negative-cache window after a failed refresh | propagates | as above | no (the original failure was logged) | +| `Error('Dev client credentials are not configured…')` | `src/lib/providers/ministry-platform/auth/client-credentials.ts:93-95` | `client.ts` refresh path logs + re-throws; surfaced in deploy-tool UI | Deploy Tool panel shows the error | yes, `mp.token.refresh_failed` with `profile: 'dev'` | ### MP API (HTTP client) -All MP REST errors originate in `src/lib/providers/ministry-platform/utils/http-client.ts` as `Error` with message shape `${METHOD} ${endpoint} failed: ${status} ${statusText}[ - ${body}]`. They propagate back to the service that called them (`table.service`, `procedure.service`, `file.service`, etc.), which `logger.error`-logs and re-throws unmodified via `throw error`. +All MP REST errors originate in `HttpClient.request` / `HttpClient.send` (`src/lib/providers/ministry-platform/utils/http-client.ts`). Endpoints in messages are **redacted** (`redactEndpoint` — path only, GUIDs replaced); bodies and query strings never appear. Provider services (`table.service`, `procedure.service`, `file.service`, …) log `mp.<area>.<op>_failed` with the error **name** and re-throw unmodified. | Type | Thrown at | Caught at | User-facing | Logged | |---|---|---|---|---| -| `GET ${endpoint} failed: ${status} ${statusText} - ${body}` | `src/lib/providers/ministry-platform/utils/http-client.ts:32` | service catch (e.g., `table.service.ts:29`) logs + re-throws; bubbles to action | server action throws → component catch sets error state | yes, `logger.error("GET Request failed:", {status, statusText, url, responseBody})` | -| `POST ${endpoint} failed: ${status} ${statusText} - ${body}` (JSON) | `src/lib/providers/ministry-platform/utils/http-client.ts:59` | service catch (e.g., `table.service.ts:49`, `procedure.service.ts:78`) re-throws | as above | yes | -| `POST ${endpoint} failed: ${status} ${statusText}` (FormData; **no body** included) | `src/lib/providers/ministry-platform/utils/http-client.ts:79` | `file.service.ts:80` re-throws | upload error surfaces to component | no — FormData path does NOT log the response body (gotcha) | -| `PUT ${endpoint} failed: ${status} ${statusText}` (JSON) | `src/lib/providers/ministry-platform/utils/http-client.ts:112` | `table.service.ts:68`, `file.service.ts:129` re-throw | as above | yes, `logger.error("PUT Request failed:", {...})` | -| `PUT ${endpoint} failed: ${status} ${statusText}` (FormData; no body logging) | `src/lib/providers/ministry-platform/utils/http-client.ts:132` | `file.service.ts:129` re-throws | as above | no body logged | -| `DELETE ${endpoint} failed: ${status} ${statusText}` | `src/lib/providers/ministry-platform/utils/http-client.ts:150` | `table.service.ts:148`, `file.service.ts:151` re-throw | as above | no body logged | -| `GET /files/${uniqueId} failed: ${status} ${statusText}` (unauthenticated file content) | `src/lib/providers/ministry-platform/services/file.service.ts:177` | `file.service.ts:183` logs + re-throws | as above | yes, `logger.error("Error getting file content by unique ID:", error)` | -| Empty/missing result set (not a throw) from stored procs | n/a | `src/services/toolService.ts:132-137,161-170,189-194`; `src/services/fieldManagementService.ts:46-50,58-62` length checks | returns `null` or `[]` — silently treated as "no data" | no | +| `${METHOD} ${endpoint} failed: ${status} ${statusText}` (any method, JSON or FormData) | `http-client.ts:226` | provider service catch (e.g. `services/table.service.ts:28-30`, `services/procedure.service.ts:48-50,76-78`) logs + re-throws; bubbles to app service → action | action throws or returns `{ success: false, error }` | yes, `mp.request.failed` `{ method, endpoint, status, statusText }` + `mp.<area>.<op>_failed` | +| `${METHOD} ${endpoint} failed: ${errorName}` — network error, timeout (`AbortSignal.timeout`), or refused redirect (`redirect: 'error'`) | `http-client.ts:264` | as above | as above | yes, `mp.request.failed` `{ method, endpoint, error }` | +| `${METHOD} ${endpoint} failed: unexpected content-type` / `: invalid JSON response` | `http-client.ts:52,58` (`readJsonResponse`) | as above | as above | provider service log only | +| Unsafe endpoint (`..`, encoded separators, etc.) / `Error('Invalid MP API base URL')` | `http-client.ts:161,170,174` (`assertSafeEndpoint`) | as above | as above | provider service log only | +| 401 → one token refresh + retry; refresh failure falls through to the normal status error | `http-client.ts:202-216` | — | — | yes, `mp.token.refresh_after_401_failed` | +| `GET /files/{uniqueId} failed: ${status} ${statusText}` (unauthenticated file content) | `services/file.service.ts:213` | `file.service.ts:217-219` logs + re-throws | as above | yes, `mp.files.get_content_failed` | +| `Error('Invalid record IDs')` (delete) | `services/table.service.ts:138` | `table.service.ts:147-149` | as above | yes, `mp.table.delete_failed` | +| Empty/missing result set (not a throw) from stored procs | n/a | `src/services/toolService.ts:139-144,175-184,206-211`; `src/services/fieldManagementService.ts:52-56,68-72` length checks | returns `null` or `[]` — silently treated as "no data" | no | -**HTTP status → source note.** Message carries only `status`/`statusText` verbatim from the fetch response. 400 / 403 / 404 / 409 / 413 / 500 (e.g., "Ambiguous column name") all surface through the same string template — the layer does **not** branch on status code. Callers must pattern-match on the message if they need to differentiate. +**HTTP status → source note.** Message carries only `status`/`statusText`. 400 / 403 / 404 / 409 / 413 / 500 (e.g., "Ambiguous column name") all surface through the same template — the layer does **not** branch on status (except the 401 retry). The MP response body is never included, so diagnose a 500 from MP's own logs, not the app's. -### Validation +### Authorization | Type | Thrown at | Caught at | User-facing | Logged | |---|---|---|---|---| -| `Error('Validation failed for record ${i}: ${zod message}')` (create) | `src/lib/providers/ministry-platform/helper.ts:189` | `helper.ts:209-212` re-throws via wrapper | server action bubbles to component | no (helper strips Zod `.issues` — only the `message` survives; structured errors lost — gotcha) | -| `Error('Validation failed for record ${i}: ${zod message}')` (update) | `src/lib/providers/ministry-platform/helper.ts:273` | `helper.ts:291-293` re-throws | as above | no — same Zod `.issues` stripping | -| `Error('Invalid GUID format: ${value}')` | `src/lib/validation.ts:6` (`validateGuid`) | server-action catch (e.g., `userService` caller) | component catches → `setError` | no | -| `Error('Expected positive integer, got: ${value}')` | `src/lib/validation.ts:13` (`validatePositiveInt`) | service callsite try/catch propagates to action | component catches → `setError` | no | -| `Error('Invalid column name: ${value}')` | `src/lib/validation.ts:20` (`validateColumnName`) | `toolService.resolveContactIds` callsite → action | component catches → `setError` | no | -| `Error('Unauthorized')` | `src/components/shared-actions/user.ts:28` — session has no non-empty `userGuid` | `src/contexts/user-context.tsx:43-46` try/catch → `error` state | silent (consumers of `useUser()` inspect `error`) | no | -| `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 | +| `UnauthorizedError('Not authorized')` (`code: "UNAUTHORIZED"`) — no session, no `userGuid`, no `dp_Users` row, `MP_SECURITY_ROLES` unconfigured, or no matching role | `src/services/authorizationService.ts:224` (`requireSecurityRole`) — the gate every feature action and MP-touching service method calls | tool pages: `requireToolAccess` → `redirect("/no-access")`; actions: component `try/catch` or `{ success: false, error }` envelope | `/no-access` page, or inline error | yes — `console.warn('mp.read.unauthorized' \| 'mp.write.unauthorized', { table, operation, reason, userId })`, plus `mp.write.non_user` when no MP user was resolved | +| MP failure while resolving `User_ID` / roles | `src/services/authorizationService.ts:162` (re-throw) | **never** converted to "not authorized"; propagates to error boundary / action catch | error page or inline error | MP-layer logs only | +| `hasSecurityRole()` — never throws; returns `false` on any error | `src/services/authorizationService.ts:235-244` | `src/app/(web)/tools/layout.tsx:31` → `redirect("/no-access")` | `/no-access` | no | +| `Error('Unauthorized')` | `src/components/shared-actions/user.ts:28` — session has no non-empty `userGuid` | `src/contexts/user-context.tsx:43-45` try/catch → `error` state | silent (consumers of `useUser()` inspect `error`) | no | +| `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` | inline | 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 | -| `Error('Invalid routing code length: N')` | `src/lib/imb-encoder.ts:249` | `src/lib/barcode-helpers.ts:48` silent fallthrough | as above | no | -| `Error('Codeword out of range: N')` | `src/lib/imb-encoder.ts:364` | `src/lib/barcode-helpers.ts:48` silent fallthrough | as above | no | -| `Error('POSTNET input must contain only digits')` | `src/lib/postnet-encoder.ts:35` | `src/lib/barcode-helpers.ts:58` silent catch → retry with 5-digit substring at `:62`, then final silent catch | label prints without barcode | no (silent) | -| `Error('POSTNET input must be 5, 9, or 11 digits (got N)')` | `src/lib/postnet-encoder.ts:39` | `src/lib/barcode-helpers.ts:58,62` silent catches | as above | no (silent) | + +### Validation + +Validator messages name the field, never the value (`src/lib/validation.ts` header). + +| Type | Thrown at | Caught at | User-facing | Logged | +|---|---|---|---|---| +| `MPValidationError` — `'Validation failed for record ${i}: ${zod message}'`, keeps `recordIndex` + `zodError` (create) | `src/lib/providers/ministry-platform/helper.ts:217` (class at `:33-41`) | re-thrown out of `createTableRecords` | server action bubbles to component | no | +| `MPValidationError` (update; `partial` → `schema.partial()`) | `src/lib/providers/ministry-platform/helper.ts:305` | re-thrown out of `updateTableRecords` | as above | no | +| `Error('Invalid GUID format')` | `src/lib/validation.ts:22` (`validateGuid`) | caller propagates | inline error | no | +| `Error('Expected positive integer')` / `'Expected positive integer for ${field}'` | `src/lib/validation.ts:35` (`validatePositiveInt`) | caller propagates / action envelope | inline error | no | +| `Error('Invalid column name')` | `src/lib/validation.ts:42` (`validateColumnName`) | `ToolService.resolveContactIds` / `FamilyService.resolveContactIdFromPage` → action | inline error | no | +| `Error('Invalid ${field}')` (e.g. `Invalid Contact_ID_Field`) | `src/lib/validation.ts:66,75` (`validateFkPath`); `src/services/familyService.ts:171` | as above | inline error | no | +| `Error('Invalid search value: ...')` — non-string, > 100 chars, control characters | `src/lib/validation.ts:129-137` (`escapeFilterString`) | search action → component | search shows error / no results | no | +| `'Mailer ID must be exactly 6 or 9 digits, got: N'` | `src/lib/validation.ts:87` (`validateMailerId`) | `prepareLabels` → envelope | inline | no | +| `'Invalid group data: <field paths>'` (envelope, not throw) | `parseGroupPayload` in `src/components/group-wizard/actions.ts` (`groupWizardSchema.safeParse`) | `createGroup` / `updateGroup` actions | inline | no | +| `'Invalid family data. Check these fields: <paths>'` (envelope) | `saveFamily` in `src/app/(web)/tools/addeditfamily/actions.ts` (`HouseholdSchema.safeParse`) | same action | inline | 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:123` | in-form field error messages | no | +| `Error('Input must contain only digits')` (IMb) | `src/lib/imb-encoder.ts:323` | `src/lib/barcode-helpers.ts:60` catch — falls through to POSTNET | no user-facing error; label prints without IMb | yes, `console.warn('[preEncodeBarcodes] IMb encode failed, ...', { labelIndex, error: <name> })` | +| `Error('Input must be 20, 25, 29, or 31 digits (got N)')` | `src/lib/imb-encoder.ts:328` | `src/lib/barcode-helpers.ts:60` | as above | as above | +| `Error('Invalid routing code length: N')` | `src/lib/imb-encoder.ts:249` | `src/lib/barcode-helpers.ts:60` | as above | as above | +| `Error('Codeword out of range: N')` | `src/lib/imb-encoder.ts:364` | `src/lib/barcode-helpers.ts:60` | as above | as above | +| `Error('POSTNET input must contain only digits')` | `src/lib/postnet-encoder.ts:35` | `src/lib/barcode-helpers.ts:71` catch → retry with 5-digit substring, then final catch at `:79` | label prints without barcode | yes, `console.warn` per fallback step | +| `Error('POSTNET input must be 5, 9, or 11 digits (got N)')` | `src/lib/postnet-encoder.ts:39` | `src/lib/barcode-helpers.ts:71,79` | as above | as above | ### Service | Type | Thrown at | Caught at | User-facing | Logged | |---|---|---|---|---| -| `Error('User not found')` | `src/services/userService.ts:76` (`getUserIdByGuid`) | caller propagates → component catch | inline error / toast | no | -| `Error('Tool Name is required')` | `src/services/toolService.ts:248` (`deployTool` guard) | deploy-tool UI catch → `setError` | inline error | no | -| `Error('Launch Page is required')` | `src/services/toolService.ts:249` | deploy-tool UI | inline | no | -| `Error('Tool Name must be 30 characters or fewer')` | `src/services/toolService.ts:250` | deploy-tool UI | inline | no | -| `Error('Description must be 100 characters or fewer')` | `src/services/toolService.ts:251` | deploy-tool UI | inline | no | -| `Error('Launch Page must be 1024 characters or fewer')` | `src/services/toolService.ts:252` | deploy-tool UI | inline | no | -| `Error('Additional Data must be 65 characters or fewer')` | `src/services/toolService.ts:253` | deploy-tool UI | inline | no | -| `Error('Deploy did not return a tool row — check stored procedure permissions and dev credentials.')` | `src/services/toolService.ts:273` | `src/components/dev-panel/panels/deploy-tool-actions.ts` → panel catch | inline error | no | -| Procedure invocation failure from `api_MPNextTools_UpdatePageFieldOrder` (propagates from http-client) | thrown at `http-client.ts:59`, bubbles up via `src/services/fieldManagementService.ts:93-107` | `src/components/field-management/actions.ts:69` → `{ success: false, error }` | inline error | yes (http-client layer) | +| `Error('Tool Name is required')` | `src/services/toolService.ts:277` (`deployTool` guard) | deploy-tool UI catch → `setError` | inline error | no | +| `Error('Launch Page is required')` | `src/services/toolService.ts:278` | deploy-tool UI | inline | no | +| `Error('Tool Name must be 30 characters or fewer')` | `src/services/toolService.ts:279` | deploy-tool UI | inline | no | +| `Error('Description must be 100 characters or fewer')` | `src/services/toolService.ts:280` | deploy-tool UI | inline | no | +| `Error('Launch Page must be 1024 characters or fewer')` | `src/services/toolService.ts:281` | deploy-tool UI | inline | no | +| `Error('Additional Data must be 65 characters or fewer')` | `src/services/toolService.ts:282` | deploy-tool UI | inline | no | +| `Error('Deploy did not return a tool row — check stored procedure permissions and dev credentials.')` | `src/services/toolService.ts:302` | `src/components/dev-panel/panels/deploy-tool-actions.ts` → panel catch | inline error | no | +| `PartialSaveError(progress, underlying)` — any failure mid-`saveHousehold` | `src/services/familyService.ts:24-29` (wrapped by `wrap()` in `saveHousehold`) | `saveFamily` action → `{ success: false, error, progress }` | inline error; client keeps IDs already created | MP-layer logs only | +| `Error('Could not find an available envelope number after 5 attempts ...')` | `src/services/familyService.ts:749` (`resolveUniqueEnvelopeNo`) | wrapped as `PartialSaveError` → `saveFamily` envelope | inline | no | +| `Error('Google Places API key is not configured. ...')` | `src/services/googlePlacesService.ts:86` | `placeDetails` action envelope; `placeAutocomplete` short-circuits on `isEnabled()` | inline | no | +| `Error('toMpSqlDatetime: value must be a non-empty string or Date')` / `'... could not be parsed as a date'` | `src/services/domainTimezoneService.ts:286,297` | write path (`GroupService`, `FamilyService`) → action envelope | inline | no | +| `Error('parseMpDatetime: value could not be parsed as a date')` | `src/services/domainTimezoneService.ts:314` | caller | inline | no | +| Procedure failure from `api_MPNextTools_UpdatePageFieldOrder` (propagates from http-client) | `http-client.ts:226`, via `src/services/fieldManagementService.ts:115-132` | `src/components/field-management/actions.ts:99-100` → `{ success: false, error }` | inline error | yes (http-client + procedure service) | ### Client-fetch (actions returning `{success, error}` envelope) @@ -86,47 +107,54 @@ These do NOT throw to the component; server action returns `{ success: false, er | Type | Thrown at (internal) | Caught at | User-facing | Logged | |---|---|---|---|---| -| `Unknown label stock: ${id}` (control-flow branch, not throw) | check at `src/components/address-labels/actions.ts:136,178` | same function returns `{success:false}` at `:137,:179` | mail-merge / PDF tab `setError` | no | -| `'No labels to print' / 'No labels to export' / 'No addresses to merge'` | `src/components/address-labels/actions.ts:141,183,213` (returns envelope) | tab components `setError` | inline | no | -| `'Template file exceeds 5MB limit'` | `src/components/address-labels/actions.ts:219` (returns envelope) | `mail-merge-tab.tsx:123` `setError` | inline | no | -| PDF render failure (`@react-pdf/renderer` `toBlob`) | throws inside try at `src/components/address-labels/actions.ts:157` | `:162-168` wraps as `{success:false, error: message}` | inline error | yes, `console.error('generateLabelPdf error:', error)` | -| Docx render failure (`Packer.toBuffer`) | throws inside try at `src/components/address-labels/actions.ts:190` | `:194-200` wraps as envelope | inline error | yes, `console.error('generateLabelDocx error:', error)` | -| Docxtemplater render error (tag mismatch) | throws inside `doc.render` at `src/components/address-labels/actions.ts:273` | `:279-286` wraps; if message includes `'tag'` returns prettier error | inline error | yes, `console.error('mergeTemplate error:', error)` | -| 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 | +| `'Too many labels (N); the limit is 10,000 per request'` / `'Invalid label data'` / `'Invalid label settings'` | `parseLabelInput` in `src/components/address-labels/schemas.ts` (Zod `safeParse`; count cap = `MAX_LABELS` in `limits.ts`), called from `prepareLabels` in `actions.ts` | `generateLabelPdf` / `generateLabelDocx` / `mergeTemplate` return the envelope | tab components `setError` | no (errors name the problem, never the payload) | +| `Unknown label stock: ${id}` (control-flow branch, not throw) | `prepareLabels` in `src/components/address-labels/actions.ts` | all three render actions return `{success:false}` | mail-merge / PDF tab `setError` | no | +| `'Start position is beyond the end of the sheet'` | `prepareLabels` — `config.startPosition > stock.columns * stock.rows` | all three render actions | inline | no | +| `'No labels to print' / 'No labels to export' / 'No addresses to merge'` | `prepareLabels` (`emptyMessage` arg, per action) | tab components `setError` | inline | no | +| Mailer ID validation message (`validateMailerId`) / fallback `'Invalid Mailer ID'` | `prepareLabels`, when `barcodeFormat === 'imb' && mailerId` | all three render actions | inline | no | +| `'Template file exceeds 2MB limit'` (`MAX_TEMPLATE_LABEL`) | `mergeTemplate` in `src/components/address-labels/actions.ts` — base64 length × 0.75 > `MAX_TEMPLATE_BYTES`, checked before decoding or `prepareLabels` | `mail-merge-tab.tsx` `handleMerge` → `setError(result.error)` | inline | no | +| `'Template file must be under 2MB'` (client pre-check) | `src/components/address-labels/mail-merge-tab.tsx` `handleFileChange`, `file.size > MAX_TEMPLATE_BYTES` | same function → `setError` early return | inline | no | +| `ZipLimitError`: `'Not a valid .docx file'` / `'ZIP64 templates are not supported'` / `'Template contains too many files'` / `'Encrypted templates are not supported'` / `'Template uses an unsupported compression method'` / `'Template is too large when uncompressed'` | `assertZipWithinLimits` in `src/lib/zip-guard.ts` (limits = `TEMPLATE_ZIP_LIMITS`), run by `mergeTemplate` before PizZip | `mergeTemplate` try/catch → envelope with `error.message`; any non-`ZipLimitError` → `'Not a valid .docx file'` | inline | no | +| Server-action body over `4mb` | Next.js (`experimental.serverActions.bodySizeLimit` in `next.config.ts`) rejects the request before the action body runs | client `await` rejects → `mail-merge-tab.tsx` `handleMerge` catch → `setError(err.message)` (fallback `'Merge failed'`) | inline | framework-side only | +| PDF render failure (`@react-pdf/renderer` `toBlob`) | throws inside try in `generateLabelPdf` (`src/components/address-labels/actions.ts`) | same function wraps as `{success:false, error: message}` | inline error | yes, `console.error('generateLabelPdf error:', describeError(error))` | +| Docx render failure (`Packer.toBuffer`) | throws inside try in `generateLabelDocx` | same function wraps as envelope | inline error | yes, `console.error('generateLabelDocx error:', describeError(error))` | +| Docxtemplater render error (tag mismatch) | throws inside `doc.render` in `mergeTemplate` | same function wraps; if message includes `'tag'` returns `Template error: ... Check that merge tokens are correctly formatted.` | inline error | yes, `console.error('mergeTemplate error:', describeError(error))` | +| 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-45` 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:80` | uncaught — surfaces to nearest React error boundary | error boundary UI | no | | `Error("useFormField should be used within <FormField>")` | `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 | +| "IMb requires a 6 or 9 digit USPS Mailer ID" (control-flow) | `src/components/address-labels/mail-merge-tab.tsx:112` | 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`. -- **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. +- **Throw-then-catch:** action throws (including `UnauthorizedError` from `requireAccess()`); calling component wraps call in `try/catch` and calls `setError(err instanceof Error ? err.message : 'fallback')`. Examples: `selection-actions.ts`, `user-tools-actions.ts`, `contact-records-actions.ts`, `fetchAddressLabels`, `field-management/actions.ts` (`fetchPages`, `fetchPageFieldData`), `group-wizard/actions.ts` (`fetchGroupWizardLookups`, `searchContacts`, `searchGroups`). +- **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` (`savePageFieldOrder`), `addeditfamily/actions.ts` (`fetchHousehold`, `resolveContactIdFromPage`, `placeDetails`, `saveFamily` — the last adds `progress`). +- **Mixed in same file** is common — no central policy; see `address-labels/actions.ts` for both shapes in one file. ### HTTP error shape (MP API) -All errors from `http-client.ts` follow: `${METHOD} ${endpoint} failed: ${status} ${statusText}${body ? ` - ${body}` : ''}`. GET / JSON-POST / JSON-PUT / GET /files/{uniqueId} include the body; FormData POST / FormData PUT / DELETE do **NOT** (inconsistent — gotcha). +All errors from `http-client.ts` follow `${METHOD} ${redactedEndpoint} failed: ${status} ${statusText}` (or `: ${errorName}` for transport failures). No body is ever included — for any method or content type. ### Zod validation errors (helper.ts) -`MPHelper.createTableRecords` and `MPHelper.updateTableRecords` catch `schema.parse` throws and re-wrap them as plain `Error` with message `Validation failed for record ${i}: ${validationError.message}`. The original `ZodError.issues` array is **discarded** — only `validationError.message` (Zod's default `issues` string) survives. Callers cannot programmatically inspect field-level issues. See `src/lib/providers/ministry-platform/helper.ts:189` and `:273`. +`MPHelper.createTableRecords` and `MPHelper.updateTableRecords` re-wrap a `ZodError` from `schema.parse` as `MPValidationError` (`src/lib/providers/ministry-platform/helper.ts:33-41`), which subclasses `Error` and keeps `recordIndex` and the original `zodError` — callers can check `instanceof MPValidationError` for field-level issues. Non-Zod throws are wrapped in a plain `Error` with the same message prefix. -### Silent catches (deliberate fallbacks) -- **Barcode encoding cascade.** `src/lib/barcode-helpers.ts:48,58,62` — on IMb failure, silently fall through to POSTNET; on POSTNET failure, silently retry with first 5 digits; on second POSTNET failure, silently return label with no barcode. No `console.error`. Label prints with best-available barcode or none. Test coverage: `src/lib/barcode-helpers.test.ts`. -- **`ToolService.getPageData` / `getUserTools` / `getSelectionRecordIds` length checks.** `src/services/toolService.ts:132,161,189` — when the stored procedure returns empty/missing result sets, the services return `null` / `[]` rather than throwing. Callers must differentiate "no data" from "error" by checking the return value. -- **`parseToolParams` failures** (if added later) — see `src/lib/tool-params.ts`; existing conventions prefer returning defaults over throwing. +### Silent / quiet fallbacks (deliberate) +- **Barcode encoding cascade.** `src/lib/barcode-helpers.ts:60,71,79` — on IMb failure, fall through to POSTNET; on POSTNET failure, retry with first 5 digits; on second failure, return the label with no barcode. Each step logs a `console.warn` with the label index and error name only. Test coverage: `src/lib/barcode-helpers.test.ts`. +- **`ToolService.getPageData` / `getUserTools` / `getSelectionRecordIds` length checks** — when the stored procedure returns empty/missing result sets, the services return `null` / `[]` rather than throwing. Callers must differentiate "no data" from "error" by checking the return value. +- **`parseToolParams`** (`src/lib/tool-params.server.ts`) — a `getPageData` failure is swallowed to `pageData = undefined` with `console.warn('tool_params.page_data_unavailable', { pageID })`; bad integers become `undefined`, a malformed `recordDescription` escape falls back to the raw value. +- **`GooglePlacesService.resolveApiKey`** — an MP lookup failure is swallowed and falls through to `GOOGLE_PLACES_API_KEY`. The `dp_Configuration_Settings`/`read` gate runs before this, outside the try/catch, so an `UnauthorizedError` is never swallowed. ### Uncaught errors → React error boundary -- `useUser` outside `UserProvider` — `src/contexts/user-context.tsx:79`. +- Boundaries: `src/app/(web)/error.tsx` (inside the app shell), `src/app/error.tsx`, `src/app/global-error.tsx` (root layout failures). +- `useUser` outside `UserProvider` — `src/contexts/user-context.tsx:80`. - `useFormField` outside `<FormField>` — `src/components/ui/form.tsx:53`. -No app-level error boundary exists as of baseline SHA `971c40b`. These crashes surface to Next.js's default error page. ### Unlogged failures -Validation errors (`src/lib/validation.ts`), user-facing session errors (`'Unauthorized'`, `'User GUID not found in session'`), and all envelope-shaped action errors (`{success:false, error}`) are NOT logged server-side. Only HTTP-level failures go through `logger.error`. Server logs will not show Zod validation problems or auth rejections unless the error is re-thrown into the http path. +Validation errors (`src/lib/validation.ts`), session errors (`'Unauthorized'`, `'Unauthorized - Missing user session data'`), and envelope-shaped action errors (`{success:false, error}`) are NOT logged server-side. Authorization denials **are** logged (`mp.read.unauthorized` / `mp.write.unauthorized`), as are HTTP-level failures (`mp.request.failed`). ## Related docs - [call-graphs.md](call-graphs.md) — error paths in the context of full request flows - [../mp-provider/error-handling.md](../mp-provider/error-handling.md) — MP provider-specific error flow -- [../GOTCHAS.md](../GOTCHAS.md) — symptom-first index of known traps (populated in parallel by gotchas consolidator) +- [../security/README.md](../security/README.md) — authorization gate and denial logging +- [../GOTCHAS.md](../GOTCHAS.md) — symptom-first index of known traps - [../auth/README.md](../auth/README.md) — session/OAuth error surface diff --git a/.claude/references/dto-constants/README.md b/.claude/references/dto-constants/README.md index 1af0469..d3477f0 100644 --- a/.claude/references/dto-constants/README.md +++ b/.claude/references/dto-constants/README.md @@ -5,25 +5,29 @@ domain: dto-constants --- ## What's in this domain -Hand-written application-level DTOs/ViewModels (`src/lib/dto/`), shared runtime constants (`SERVICE_TYPES`, `LABEL_STOCKS`, `WIZARD_STEPS`), and the SQL-safety validation helpers in `src/lib/validation.ts`. +Hand-written application-level DTOs/ViewModels (`src/lib/dto/`), shared runtime constants (`MP_FETCH_BATCH_SIZE`, `SERVICE_TYPES`, `LABEL_STOCKS`, `WIZARD_STEPS`), and the SQL-safety validation helpers in `src/lib/validation.ts`. ## File map | File | Purpose | When to read | |------|---------|--------------| -| `dtos.md` | Catalog of hand-written DTOs (`LabelData`, `LabelConfig`, `SkipRecord`, etc.), fields, consumers | Adding a new DTO or wiring up a label/export feature | -| `constants.md` | Shared `as const`/module-level constants, magic-number audit, validation helpers (`validateGuid`, `escapeFilterString`) | Before hardcoding a new ID, enum, or SQL filter fragment | +| `dtos.md` | Catalog of hand-written DTOs (`LabelData`, `LabelConfig`, `SkipRecord`, family `Household` / `HouseholdSchema`, etc.), fields, consumers | Adding a new DTO or wiring up a label/export/family feature | +| `constants.md` | Shared `as const`/module-level constants, magic-number audit, validation helpers (`validateGuid`, `validateFkPath`, `escapeFilterString`, …) | Before hardcoding a new ID, enum, or SQL filter fragment | ## Code surfaces | Path | Role | |------|------| -| `src/lib/dto/address-label.dto.ts` | All current DTOs + `SERVICE_TYPES` constant | +| `src/lib/dto/address-label.dto.ts` | Address-label DTOs + `SERVICE_TYPES` constant | +| `src/lib/dto/family.ts` | Add/Edit Family DTOs (Zod schemas + types, empty-record factories) | | `src/lib/dto/index.ts` | Barrel re-export for `@/lib/dto` | -| `src/lib/validation.ts` | SQL-safety helpers (GUID, positive int, column name, LIKE escape) | -| `src/lib/validation.test.ts` | 19 tests across the 4 helpers | +| `src/lib/constants.ts` | `MP_FETCH_BATCH_SIZE` | +| `src/lib/validation.ts` | SQL-safety helpers (GUID, positive int, column name, FK path, LIKE escape) + `validateMailerId` | +| `src/lib/validation.test.ts` | 33 tests across the 6 helpers + a rule-14 message suite | | `src/lib/label-stock.ts` | `LABEL_STOCKS` constant (Avery sheet geometries) | | `src/lib/barcode-helpers.ts` | Pre-encodes `LabelData[]` using `LabelConfig` | +| `src/components/address-labels/limits.ts` | Template / label-count / zip limits | +| `src/components/address-labels/schemas.ts` | Server-side Zod validation for `LabelData[]` + `LabelConfig` | | `src/lib/tool-params.ts` | `PageData`, `ToolParams` URL-param view models | | `src/components/group-wizard/types.ts` | `WIZARD_STEPS` constant + wizard view models | diff --git a/.claude/references/dto-constants/constants.md b/.claude/references/dto-constants/constants.md index 81c9dec..694e0b9 100644 --- a/.claude/references/dto-constants/constants.md +++ b/.claude/references/dto-constants/constants.md @@ -2,10 +2,10 @@ 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] -related: [dtos.md, ../services/README.md, ../utils/README.md] -last_verified: 2026-04-17 +applies_to: [src/lib/constants.ts, src/lib/dto/address-label.dto.ts, src/lib/label-stock.ts, src/lib/validation.ts, src/components/group-wizard/types.ts, src/services/familyService.ts, src/components/address-labels/limits.ts] +symbols: [MP_FETCH_BATCH_SIZE, SERVICE_TYPES, LABEL_STOCKS, LabelStockConfig, LabelPosition, WIZARD_STEPS, WizardStepIndex, validateGuid, validatePositiveInt, validateColumnName, validateFkPath, validateMailerId, escapeFilterString, MAX_SEARCH_TERM_LENGTH, MAX_TEMPLATE_BYTES, MAX_TEMPLATE_LABEL, MAX_LABELS, TEMPLATE_ZIP_LIMITS] +related: [dtos.md, ../services/README.md, ../services/query-patterns.md, ../utils/README.md] +last_verified: 2026-10-10 --- ## Purpose @@ -13,25 +13,32 @@ Inventory of module-level runtime constants and SQL-safety validation helpers. A ## Files +- `src/lib/constants.ts` — `MP_FETCH_BATCH_SIZE = 100` (shared IN-clause batch size) - `src/lib/dto/address-label.dto.ts:26-32` — `SERVICE_TYPES` - `src/lib/label-stock.ts` — `LABEL_STOCKS` (Avery sheet dimensions) -- `src/lib/validation.ts` — 4 SQL-safety helpers + 2 regexes -- `src/lib/validation.test.ts` — 19 test cases +- `src/lib/validation.ts` — 5 filter/select guards + `validateMailerId`, `MAX_SEARCH_TERM_LENGTH` +- `src/lib/validation.test.ts` — 33 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) +- `src/components/address-labels/limits.ts` — `MAX_TEMPLATE_BYTES`, `MAX_TEMPLATE_LABEL`, `MAX_LABELS`, `TEMPLATE_ZIP_LIMITS` ## Key concepts -- Constants are **not centralized** — each lives next to its consumer (`src/lib/dto/*`, `src/lib/label-stock.ts`, `src/components/*/types.ts`). -- `validation.ts` is the single SQL-safety entry point; all services route user input through one of its four helpers before interpolating into filter strings. -- Two independent `BATCH_SIZE = 100` literals exist (services shard TODO candidate). +- Constants are **not centralized** — each lives next to its consumer (`src/lib/dto/*`, `src/lib/label-stock.ts`, `src/components/*/types.ts`, `src/components/address-labels/limits.ts`). `src/lib/constants.ts` holds only the cross-service `MP_FETCH_BATCH_SIZE`. +- `validation.ts` is the single SQL-safety entry point; services route every interpolated value through one of its helpers before building a `$filter` / `$select`. +- Validator errors name the field, never the value (CLAUDE.md rule 14), and every validator checks the runtime type (server-action payloads are caller-shaped). ## API / Interface ### Runtime enum-like constants +```typescript +// src/lib/constants.ts +/** Max contact/record IDs to bundle in a single MP $filter `IN (...)` clause. */ +export const MP_FETCH_BATCH_SIZE = 100; +``` + +Used by `AddressLabelService.getAddressesForContacts` and `ToolService.resolveContactIds`. + ```typescript // src/lib/dto/address-label.dto.ts:26-32 export const SERVICE_TYPES = [ @@ -43,7 +50,7 @@ export const SERVICE_TYPES = [ ] as const; ``` -USPS service type codes drive `LabelConfig.serviceType`. Default selected in UI is `'040'` (`src/app/(web)/tools/addresslabels/address-labels.tsx:53`). +USPS service type codes drive `LabelConfig.serviceType`; `labelConfigSchema` (`src/components/address-labels/schemas.ts`) restricts it to these ids server-side. Default selected in UI is `'040'` (`src/app/(web)/tools/addresslabels/address-labels.tsx:53`). ```typescript // src/lib/label-stock.ts:23 @@ -55,7 +62,7 @@ export const LABEL_STOCKS: LabelStockConfig[] = [ ]; ``` -Dimensions in PDF points (72pt = 1in). `getLabelStock(id)` lookup + `getLabelPosition(stock, index)` iteration at `src/lib/label-stock.ts:82-98`. +Dimensions in PDF points (72pt = 1in). `getLabelStock(id)` lookup + `getLabelPosition(stock, index)` at `src/lib/label-stock.ts:82-98`. ```typescript // src/components/group-wizard/types.ts:51-60 @@ -76,44 +83,54 @@ export type WizardStepIndex = 0 | 1 | 2 | 3 | 4 | 5; ```typescript 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; -export function validatePositiveInt(value: number): number; +export function validatePositiveInt(value: number, field?: string): number; // positive SAFE integer export function validateColumnName(value: string): string; +export function validateFkPath(value: unknown, field = 'column path'): string; // `A_TABLE.Col`, ≤4 `_TABLE` segments, ≤256 chars +export function validateMailerId(value: string): string; // exactly 6 or 9 digits +export const MAX_SEARCH_TERM_LENGTH = 100; export function escapeFilterString(value: string): string; ``` -`escapeFilterString` handles three characters (MP filter uses SQL LIKE semantics): +`escapeFilterString` (MP filter uses SQL `LIKE` semantics): ```typescript -// src/lib/validation.ts:25-30 +// src/lib/validation.ts:127-144 (abridged) export function escapeFilterString(value: string): string { + // throws on non-string, length > MAX_SEARCH_TERM_LENGTH, or ASCII control chars return value - .replace(/'/g, "''") + .replace(/\[/g, '[[]') .replace(/%/g, '[%]') - .replace(/_/g, '[_]'); + .replace(/_/g, '[_]') + .replace(/'/g, "''") + .replace(QUOTE_LOOKALIKES, '_'); } ``` ## Consumer map -| Symbol | Consumers | +| Symbol | Consumers (non-test) | |--------|-----------| -| `SERVICE_TYPES` | `src/components/address-labels/address-labels-form.tsx:9,128` | -| `LABEL_STOCKS` | `src/lib/label-stock.ts` (internal), `src/components/address-labels/address-labels-form.tsx`, `src/app/(web)/tools/addresslabels/address-labels.tsx` | -| `getLabelStock` | `src/components/address-labels/actions.ts:21`, `src/app/(web)/tools/addresslabels/address-labels.tsx` | +| `MP_FETCH_BATCH_SIZE` | `src/services/addressLabelService.ts`, `src/services/toolService.ts` | +| `SERVICE_TYPES` | `src/components/address-labels/address-labels-form.tsx:9,128`, `src/components/address-labels/schemas.ts` | +| `LABEL_STOCKS` | `src/lib/label-stock.ts` (internal), `src/components/address-labels/address-labels-form.tsx` | +| `getLabelStock` | `src/components/address-labels/actions.ts`, `src/app/(web)/tools/addresslabels/address-labels.tsx` | | `WIZARD_STEPS` | `src/components/group-wizard/*` | -| `validateGuid` | `src/services/userService.ts:72,84` | -| `validatePositiveInt` | `src/services/toolService.ts:334`, `src/services/addressLabelService.ts:76`, `src/services/groupService.ts:176` | -| `validateColumnName` | `src/services/toolService.ts:307,308,310,312` | -| `escapeFilterString` | `src/services/groupService.ts:152,163` | +| `validateGuid` | `src/services/userService.ts`, `src/services/authorizationService.ts`, `src/lib/auth.ts`, `src/lib/providers/ministry-platform/services/guards.ts` | +| `validatePositiveInt` | `src/services/{toolService,addressLabelService,groupService,familyService}.ts`, `src/app/(web)/tools/addeditfamily/actions.ts`, `src/lib/providers/ministry-platform/services/guards.ts` | +| `validateColumnName` | `src/services/toolService.ts`, `src/services/familyService.ts` | +| `validateFkPath` | `src/services/toolService.ts`, `src/services/familyService.ts` | +| `validateMailerId` | `src/lib/barcode-helpers.ts`, `src/components/address-labels/actions.ts` | +| `escapeFilterString` | `src/services/groupService.ts`, `src/services/familyService.ts`, `src/services/toolService.ts` (`listRoles`) | ## How it works -- `as const` tuples give literal-typed ids to `LabelConfig.serviceType`, `WIZARD_STEPS`, etc. +- `as const` tuples give literal-typed ids to `SERVICE_TYPES`, `WIZARD_STEPS`, etc. - `LABEL_STOCKS` is a mutable-typed array (`LabelStockConfig[]`) — NOT `as const`. If mutation-safety is desired this is a refactor candidate. -- `validateGuid` / `validatePositiveInt` / `validateColumnName` throw on bad input; callers rely on the throw (no return-based error). -- `escapeFilterString` returns the escaped string; callers inline-interpolate it into the OData `$filter`. +- The validators throw on bad input; callers rely on the throw (no return-based error). +- `escapeFilterString` returns the escaped string; callers interpolate it into the `$filter`. ## Magic-number / hardcoded-ID audit @@ -121,49 +138,53 @@ Items found in `src/` outside `src/lib/dto/` and `src/lib/validation.ts`: | Literal | Location | Meaning | Extraction candidate? | |---------|----------|---------|-----------------------| -| `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 | +| `MP_FETCH_BATCH_SIZE = 100` | `src/lib/constants.ts` | MP record fetch batch size | Done — single shared export | +| `MAX_TEMPLATE_BYTES = 2 * 1024 * 1024` / `MAX_TEMPLATE_LABEL = '2MB'` | `src/components/address-labels/limits.ts` | Mail-merge `.docx` template cap; one export used by client `mail-merge-tab.tsx` and server `actions.ts` (`mergeTemplate`) | Done — single shared constant | +| `MAX_LABELS = 10_000` | `src/components/address-labels/limits.ts` | Labels per render request (`parseLabelInput` in `schemas.ts`) | Done | +| `TEMPLATE_ZIP_LIMITS = { maxEntries: 1_000, maxTotalBytes: 64 MB }` | `src/components/address-labels/limits.ts` | Zip-bomb bounds passed to `assertZipWithinLimits` (`src/lib/zip-guard.ts`) | Done | +| `bodySizeLimit: "4mb"` | `next.config.ts` (`experimental.serverActions`) | Server-action request cap; sized with `limits.ts` under Vercel's 4.5 MB body cap | Coupled config — change with `limits.ts` | | `'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) | +| `serialLength = mailerId.length === 6 ? 9 : 6` | `src/lib/barcode-helpers.ts:25` | 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:24` | IMb header byte | OK (domain constant, inline) | +| `DEFAULT_FAMILY_DEFAULTS` — `congregationId: 1`, `sourceId: 18`, `householdPositionId: 1`, `participantTypeId: 4` | `src/services/familyService.ts:73-81` | Add/Edit Family form defaults (MP lookup IDs) | Yes — domain-specific MP IDs; candidates for `dp_Configuration_Settings` | +| `DONOR_DEFAULTS` — `Statement_Frequency_ID: 1`, `Statement_Type_ID: 1`, `Statement_Method_ID: 1` | `src/services/familyService.ts:31-36` | New donor statement settings (Quarterly / Individual / Postal Mail) | Yes — same reason | +| `Contact_Status_ID = 1` | `src/services/familyService.ts:131` (`searchContacts` filter); `|| 1` fallback in `saveMember` | "Active" contact status | Yes | -**No hardcoded MP table-IDs, Event_Type_IDs, Contact_Status_IDs, Page_IDs, or Household_Position_IDs appear in `src/` business code.** (Verified via `Grep` for `Page_ID\s*=\s*\d+`, `Event_Type_ID\s*=\s*\d+`, `Group_Type_ID\s*=\s*\d+`, `Contact_Status_ID\s*=\s*\d+`, `Household_Position_ID\s*=\s*\d+` — zero hits in `src/` outside generated `models/` and doc-comment examples.) +Group wizard, address labels, field management and tool code contain no hardcoded MP lookup IDs. The family tool does (above) — the MP IDs it assumes must exist in the target domain. ## Usage -Service-side SQL-safety pattern (from `src/services/groupService.ts:150-177`): +Service-side SQL-safety pattern (from `src/services/groupService.ts:257-291`): ```typescript import { escapeFilterString, validatePositiveInt } from '@/lib/validation'; const escaped = escapeFilterString(term); -// ... filter: `Group_Name LIKE '%${escaped}%'` +// ... filter: `Group_Name LIKE '${escaped}%' AND End_Date IS NULL` // Numeric path safety -filter: `Group_ID = ${validatePositiveInt(groupId)}` +filter: `Groups.Group_ID = ${validatePositiveInt(groupId)}` ``` ```typescript -// src/services/userService.ts:70-85 -return this.mp!.getTableRecords<User>({ - table: 'dp_Users', - filter: `User_GUID = '${validateGuid(guid)}'`, +// src/services/userService.ts — UserService.getUserProfile +const records = await this.mp!.getTableRecords<MPUserProfile>({ + table: "dp_Users", + filter: `User_GUID = '${validateGuid(id)}'`, // ... }); ``` ## Gotchas -- **Two `BATCH_SIZE = 100` constants.** Module-local in `addressLabelService.ts:21`; class-static in `toolService.ts:283`. Drift risk if MP API limit changes. See TODO `2026-04-17-dto-constants-batchsize-duplication.md`. -- **`MAX_TEMPLATE_SIZE` and `MAX_FILE_SIZE` are both 5MB** and mirror each other across server (`actions.ts:203`) and client (`mail-merge-tab.tsx:13`). Drift risk. -- **`escapeFilterString` escapes `%` + `_` + `'`** — not generic SQL escaping, specific to MP OData/LIKE. Do NOT use it as a universal SQL sanitizer. -- **`validatePositiveInt` rejects 0** (strictly positive). If you need non-negative int accept `>= 0`, write a new helper. -- **`validateGuid` accepts upper- or lower-case hex** but NOT braces (no `{xxx-xxx}` form). Confirmed by `src/lib/validation.test.ts:11-13`. +- **Template size cap is one constant.** The former duplicated 5 MB literals were replaced by `MAX_TEMPLATE_BYTES` (2 MB) in `src/components/address-labels/limits.ts`. Raising it means re-checking `next.config.ts` `bodySizeLimit` and Vercel's 4.5 MB cap together. +- **`escapeFilterString` is LIKE-specific** (`[`, `%`, `_`, `'`, quote look-alikes) and throws on >100 chars or control characters — not a universal SQL sanitizer, and not for identifiers (use `validateColumnName` / `validateFkPath`). +- **`validatePositiveInt` rejects 0** (strictly positive) and anything beyond `Number.MAX_SAFE_INTEGER`. If you need non-negative ints, write a new helper. +- **`validateGuid` accepts upper- or lower-case hex** (`/i`) but NOT braces (no `{xxx-xxx}` form) — the regex is anchored. ## Related docs - `dtos.md` — DTO shapes that USE these constants (e.g., `LabelConfig.serviceType: string` maps to `SERVICE_TYPES[i].id`) -- `../services/README.md` — all four validation helpers are consumed here +- `../services/query-patterns.md` — how services apply the validators +- `../services/README.md` — the services that consume them - `../utils/README.md` — barcode/label-stock utilities (separate domain; this doc only inventories the constants) diff --git a/.claude/references/dto-constants/dtos.md b/.claude/references/dto-constants/dtos.md index 895e3c0..51da197 100644 --- a/.claude/references/dto-constants/dtos.md +++ b/.claude/references/dto-constants/dtos.md @@ -2,28 +2,31 @@ title: Hand-written DTOs / ViewModels domain: dto-constants type: reference -applies_to: [src/lib/dto/address-label.dto.ts, src/lib/dto/index.ts, src/lib/tool-params.ts, src/components/group-wizard/types.ts, src/services/addressLabelService.ts] -symbols: [LabelData, SkipReason, SkipRecord, AddressMode, BarcodeFormat, LabelConfig, FetchAddressLabelsResult, ContactAddressRow, PageData, ToolParams, LookupItem, GroupWizardLookups, ContactSearchResult, GroupSearchResult, CreateGroupResult, UpdateGroupResult, ActionError] -related: [constants.md, ../services/README.md, ../mp-provider/README.md] -last_verified: 2026-04-17 +applies_to: [src/lib/dto/address-label.dto.ts, src/lib/dto/family.ts, src/lib/dto/index.ts, src/lib/tool-params.ts, src/components/group-wizard/types.ts, src/components/address-labels/schemas.ts, src/services/addressLabelService.ts] +symbols: [LabelData, SkipReason, SkipRecord, AddressMode, BarcodeFormat, LabelConfig, FetchAddressLabelsResult, Household, FamilyMember, FamilyAddress, FamilyMemberParticipant, FamilyLookups, FamilyDefaults, LookupOption, StateOption, CountryOption, SaveProgress, SavedMemberId, HouseholdSchema, ContactAddressRow, PageData, ToolParams, LookupItem, GroupWizardLookups, ContactSearchResult, GroupSearchResult, CreateGroupResult, UpdateGroupResult, ActionError] +related: [constants.md, ../services/README.md, ../services/family-service.md, ../mp-provider/README.md] +last_verified: 2026-10-10 --- ## Purpose -Hand-written application-layer view models, separate from the 603 auto-generated MP table/view models in `src/lib/providers/ministry-platform/models/`. DTOs here shape data for UI + server actions; they are not 1:1 with DB rows. +Hand-written application-layer view models, separate from the auto-generated MP models in `src/lib/providers/ministry-platform/models/` (301 table interfaces + 301 `*Schema.ts` Zod schemas + `index.ts`). DTOs here shape data for UI + server actions; they are not 1:1 with DB rows. ## Files -- `src/lib/dto/address-label.dto.ts` — all current `@/lib/dto` exports (one file) -- `src/lib/dto/index.ts` — barrel re-export +- `src/lib/dto/address-label.dto.ts` — address-label DTOs + `SERVICE_TYPES` +- `src/lib/dto/family.ts` — Add/Edit Family DTOs: Zod schemas (`HouseholdSchema` etc.) with inferred types, plain interfaces, and `emptyAddress` / `emptyMember` / `emptyHousehold` factories +- `src/lib/dto/index.ts` — barrel re-export of both files - `src/lib/tool-params.ts` — `PageData`, `ToolParams` (URL-param view models, not re-exported from `@/lib/dto`) - `src/components/group-wizard/types.ts` — wizard view models co-located with the feature -- `src/services/addressLabelService.ts:4-19` — `ContactAddressRow` (MP-row DTO co-located with service) +- `src/components/address-labels/schemas.ts` — server-side Zod schemas for `LabelData[]` / `LabelConfig` (`labelDataSchema`, `labelsSchema`, `labelConfigSchema`, `parseLabelInput`) +- `src/services/addressLabelService.ts:7-22` — `ContactAddressRow` (MP-row DTO co-located with service) ## Key concepts - **DTO (Data Transfer Object) / ViewModel** — hand-written TypeScript type shaping data between layers (service → action → component, or URL → server). Distinct from generated MP schemas which mirror DB columns. -- **Barrel import** — consumers use `import type { ... } from '@/lib/dto'`; internal file is `address-label.dto.ts` but all access goes through `index.ts`. -- **Co-location** — feature-specific view models (e.g. `GroupSearchResult`, `WIZARD_STEPS`) live next to the feature, not in `src/lib/dto/`. Only cross-feature DTOs live in `src/lib/dto/`. +- **Barrel import** — address-label consumers use `import type { ... } from '@/lib/dto'`. Family consumers (`addeditfamily/actions.ts`, `add-edit-family.tsx`, `familyService.ts`) import `@/lib/dto/family` directly; both paths resolve to the same module. +- **Co-location** — feature-specific view models (e.g. `GroupSearchResult`, `WIZARD_STEPS`) live next to the feature, not in `src/lib/dto/`. +- **Two `ContactSearchResult` types** — `@/lib/dto/family` (`{ contactId, displayName, detail }`) and `src/components/group-wizard/types.ts` (`{ Contact_ID, Display_Name, Email_Address }`). Same name, different shapes; import the one for your feature. ## API / Interface @@ -71,7 +74,33 @@ export interface FetchAddressLabelsResult { } ``` -### `src/services/addressLabelService.ts:4-19` +### `src/lib/dto/family.ts` (abridged) + +```typescript +export const FamilyAddressSchema = z.object({ addressId, addressLine1, addressLine2, city, state, region, postalCode, countryCode }); +export const FamilyMemberParticipantSchema = z.object({ participantId, participantTypeId, notes }); +export const FamilyMemberSchema = z.object({ contactId, firstName, lastName, /* ...names, IDs, contact info */, envelopeNo, participant, donorId, isDonor }); +export const HouseholdSchema = z.object({ + householdId, householdName, householdPhone, congregationId, sourceId, + address: FamilyAddressSchema, alternateMailingAddress: FamilyAddressSchema, + seasonStart, seasonEnd, repeatsAnnually, areHeadsMarried, + members: z.array(FamilyMemberSchema), +}); +export type Household = z.infer<typeof HouseholdSchema>; // likewise FamilyAddress, FamilyMember, FamilyMemberParticipant + +export interface FamilyLookups { congregations, sources, householdPositions, participantTypes, maritalStatuses, + prefixes, suffixes, genders, contactStatuses, primaryLanguages, faithBackgrounds: LookupOption[]; + states: StateOption[]; countries: CountryOption[]; } +export interface FamilyDefaults { congregationId; sourceId; countryCode; state; householdPositionId; participantTypeId; showEnvelopeNumbers; } +export interface ContactSearchResult { contactId: number; displayName: string; detail: string; } +export interface SavedMemberId { tempContactId; contactId; participantId; donorId; envelopeNo; envelopeBumped; } +export interface SaveProgress { mainAddressId; altAddressId; householdId; members: SavedMemberId[]; } +``` + +- `HouseholdSchema` is the runtime guard for the `saveFamily` action (`safeParse` before `FamilyService.saveHousehold`). +- `SaveProgress` is returned on success **and** on `PartialSaveError`, so the client can retry without duplicating records. + +### `src/services/addressLabelService.ts:7-22` ```typescript export interface ContactAddressRow { @@ -92,7 +121,7 @@ export interface ContactAddressRow { } ``` -### `src/lib/tool-params.ts:3-28` +### `src/lib/tool-params.ts:14-39` ```typescript export interface PageData { @@ -163,34 +192,33 @@ export interface ActionError { success: false; error: string; } ## DTO consumer map -| DTO | Defined | Imported by | +| DTO | Defined | Imported by (non-test) | |-----|---------|-------------| -| `LabelData` | `src/lib/dto/address-label.dto.ts:1` | `src/lib/barcode-helpers.ts:3`, `src/lib/barcode-helpers.test.ts:3`, `src/components/address-labels/actions.ts:14`, `src/components/address-labels/actions.test.ts:91`, `src/components/address-labels/address-label.tsx:4`, `src/components/address-labels/label-document.tsx:5`, `src/components/address-labels/mail-merge-tab.tsx:11`, `src/components/address-labels/word-document.ts:17`, `src/components/address-labels/word-document.test.ts:5`, `src/app/(web)/tools/addresslabels/address-labels.tsx:13` | +| `LabelData` | `src/lib/dto/address-label.dto.ts:1` | `src/lib/barcode-helpers.ts`, `src/components/address-labels/{actions,schemas,word-document}.ts`, `src/components/address-labels/{address-label,label-document,mail-merge-tab}.tsx`, `src/app/(web)/tools/addresslabels/address-labels.tsx` | | `SkipReason` | `src/lib/dto/address-label.dto.ts:15` | (indirect, through `SkipRecord`) | -| `SkipRecord` | `src/lib/dto/address-label.dto.ts:17` | `src/components/address-labels/actions.ts:15`, `src/components/address-labels/mail-merge-tab.tsx:11`, `src/components/address-labels/address-labels-summary.tsx:6`, `src/app/(web)/tools/addresslabels/address-labels.tsx:13` | -| `AddressMode` | `src/lib/dto/address-label.dto.ts:23` | `src/components/address-labels/address-labels-form.tsx:10` | -| `BarcodeFormat` | `src/lib/dto/address-label.dto.ts:24` | `src/components/address-labels/address-labels-form.tsx:10` | -| `LabelConfig` | `src/lib/dto/address-label.dto.ts:34` | `src/lib/barcode-helpers.ts:3`, `src/components/address-labels/actions.ts:16`, `src/components/address-labels/address-labels-form.tsx:10`, `src/components/address-labels/mail-merge-tab.tsx:11`, `src/app/(web)/tools/addresslabels/address-labels.tsx:13`, plus tests | -| `FetchAddressLabelsResult` | `src/lib/dto/address-label.dto.ts:44` | `src/components/address-labels/actions.ts:17` | -| `ContactAddressRow` | `src/services/addressLabelService.ts:4` | `src/components/address-labels/actions.ts:11` | -| `PageData` | `src/lib/tool-params.ts:3` | Tools routing layer (URL → page metadata) | -| `ToolParams` | `src/lib/tool-params.ts:17` | Tools routing layer | -| Group-wizard view models | `src/components/group-wizard/types.ts` | Group-wizard components + actions | +| `SkipRecord` | `src/lib/dto/address-label.dto.ts:17` | `src/components/address-labels/actions.ts`, `mail-merge-tab.tsx`, `address-labels-summary.tsx`, `src/app/(web)/tools/addresslabels/address-labels.tsx` | +| `AddressMode` / `BarcodeFormat` | `src/lib/dto/address-label.dto.ts:23-24` | `src/components/address-labels/address-labels-form.tsx` | +| `LabelConfig` | `src/lib/dto/address-label.dto.ts:34` | `src/lib/barcode-helpers.ts`, `src/components/address-labels/{actions,schemas}.ts`, `address-labels-form.tsx`, `mail-merge-tab.tsx`, `src/app/(web)/tools/addresslabels/address-labels.tsx` | +| `FetchAddressLabelsResult` | `src/lib/dto/address-label.dto.ts:44` | `src/components/address-labels/actions.ts` | +| Family DTOs / `HouseholdSchema` | `src/lib/dto/family.ts` | `src/services/familyService.ts`, `src/app/(web)/tools/addeditfamily/actions.ts`, `src/app/(web)/tools/addeditfamily/add-edit-family.tsx` | +| `ContactAddressRow` | `src/services/addressLabelService.ts:7` | `src/components/address-labels/actions.ts` | +| `PageData` / `ToolParams` | `src/lib/tool-params.ts:14`, `:28` | `src/lib/tool-params.server.ts`, `src/services/toolService.ts`, tool pages/components, dev-panel | +| Group-wizard view models | `src/components/group-wizard/types.ts` | Group-wizard components + actions, `src/services/groupService.ts` | ## How it works -- `@/lib/dto` is the only centralized DTO module. Address-label feature is the single "cross-concern" set because it is consumed by services, actions, components, and a PDF document renderer. +- `@/lib/dto` holds the cross-concern DTO sets: address labels (consumed by services, actions, components, PDF/Word renderers) and Add/Edit Family (service, actions, client component). - Feature-local DTOs (group wizard, tool-params) are **not** re-exported through `@/lib/dto` — they live beside their consumers. -- Service-layer row shapes (e.g. `ContactAddressRow`) are DTOs of the **MP-response shape** (matches `$select` field list at `src/services/addressLabelService.ts:23-38`), not generated from schema. -- `LabelData` is transformed from `ContactAddressRow` in `filterAndTransform()` at `src/components/address-labels/actions.ts:42-96`. -- `preEncodeBarcodes(labels, config)` at `src/lib/barcode-helpers.ts:33` mutates `LabelData[]` by adding `barStates` + `barType` based on `LabelConfig`. +- Service-layer row shapes (e.g. `ContactAddressRow`) are DTOs of the **MP-response shape** (matches the `SELECT_FIELDS` list at `src/services/addressLabelService.ts:24-39`), not generated from schema. +- `LabelData` is transformed from `ContactAddressRow` in `filterAndTransform()` at `src/components/address-labels/actions.ts:52-114`. +- `preEncodeBarcodes(labels, config)` at `src/lib/barcode-helpers.ts:45` returns a new `LabelData[]` with `barStates` + `barType` added according to `LabelConfig`. ## Usage Consumer example (form + server action): ```typescript -// src/components/address-labels/actions.ts:13-18 +// src/components/address-labels/actions.ts:11-16 import type { LabelData, SkipRecord, @@ -207,13 +235,14 @@ import type { AddressMode, BarcodeFormat, LabelConfig } from '@/lib/dto'; ## Gotchas -- **Naming drift between DTO and service row.** `LabelData` uses camelCase (`addressLine1`, `postalCode`) while `ContactAddressRow` uses MP field names (`Address_Line_1`, `Postal_Code`). Transform happens at `src/components/address-labels/actions.ts:42-96`. Do NOT propagate snake_case MP names into UI-layer DTOs. -- **`SkipRecord.name` is the computed display label**, not `Display_Name`. It is either `Household_Name` (household mode) or `Display_Name` (individual mode), fallback chain at `src/components/address-labels/actions.ts:51-53`. +- **Naming drift between DTO and service row.** `LabelData` uses camelCase (`addressLine1`, `postalCode`) while `ContactAddressRow` uses MP field names (`Address_Line_1`, `Postal_Code`). Transform happens in `filterAndTransform` (`src/components/address-labels/actions.ts:52-114`). Do NOT propagate snake_case MP names into UI-layer DTOs. +- **`SkipRecord.name` is the computed display label**, not `Display_Name`. It is either `Household_Name` (household mode, falling back to `Display_Name`) or `Display_Name` (individual mode) — `src/components/address-labels/actions.ts:61-63`. - **`FetchAddressLabelsResult` has fields `printable` + `skipped`** — NOT `labels` / `totalFetched`. -- **DTOs are not validated at runtime.** No Zod schema wraps `LabelConfig` etc. User input in `mailerId` is only stripped to 9 digits at `src/components/address-labels/address-labels-form.tsx:109`. +- **Runtime validation lives beside the action, not on the DTO.** `LabelData` / `LabelConfig` are plain interfaces; the render actions validate caller input with `parseLabelInput` (`src/components/address-labels/schemas.ts`), which bounds string lengths, caps the count at `MAX_LABELS`, restricts `serviceType` to `SERVICE_TYPES` ids, and **drops** `barStates` / `barType` (computed server-side). The form also strips `mailerId` to ≤9 digits (`src/components/address-labels/address-labels-form.tsx:109`). Family DTOs are the exception: they *are* Zod schemas. ## Related docs - `constants.md` — `SERVICE_TYPES`, `LABEL_STOCKS`, `WIZARD_STEPS`, SQL validation helpers - `../services/README.md` — services using `validatePositiveInt` / `escapeFilterString` / `validateGuid` + returning these DTOs +- `../services/family-service.md` — consumer of the family DTOs - `../mp-provider/README.md` — generated Zod schemas (application-level DTOs exist alongside those, by design) diff --git a/.claude/references/ministryplatform.datetimehandling.md b/.claude/references/ministryplatform.datetimehandling.md index ae88778..93ef870 100644 --- a/.claude/references/ministryplatform.datetimehandling.md +++ b/.claude/references/ministryplatform.datetimehandling.md @@ -24,7 +24,7 @@ await tz.toMpSqlDatetime(new Date()); // → MP-TZ wall-clock for "now" await tz.parseMpDatetime('2026-05-17 12:00:00'); // → Date instant ``` -For client-side rendering, expose the IANA zone through `getMpTimezone()` in `src/components/shared-actions/domain.ts` and thread it as a prop into the component that needs to format MP datetimes. +For client-side rendering, expose the IANA zone through the `getMpTimezone()` server action in `src/components/shared-actions/domain.ts` (session-checked, not role-gated — a documented rule-12 carve-out) and thread it as a prop into the component that needs to format MP datetimes. ### `toMpSqlDatetime(value)` — write path @@ -41,6 +41,8 @@ Returns the SQL datetime string MP's table API expects (`YYYY-MM-DD HH:MM:SS`). The rule: **strings with no zone marker are wall-clock**, strings/Dates with explicit zone info are instants that get converted. +**Zod validation:** generated `*Schema.ts` files validate `DateTime`/`Timestamp` columns with a regex that accepts this `YYYY-MM-DD HH:MM:SS` output as well as ISO forms — `^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$` (space or `T`, optional seconds/fraction, optional `Z`/offset). `Date` columns accept a bare `YYYY-MM-DD` or the same date with a time part. So passing a generated `schema` to `createTableRecords`/`updateTableRecords` is compatible with `toMpSqlDatetime`. The regex checks shape only — it does not stop an ISO-`Z` value from being sent; routing through `toMpSqlDatetime` is still what keeps the stored time right. + ### `parseMpDatetime(value)` — read path arithmetic Use when you need a `Date` instant to do real arithmetic on a value MP returned. For pure display, prefer `Intl.DateTimeFormat({ timeZone })` against the raw string. @@ -120,13 +122,13 @@ The shared signature of these bugs: a `Date` object that crosses a zone boundary ## Windows ↔ IANA zone names -MP's `/domain` endpoint returns `TimeZoneName` as a **Windows** zone (e.g. `"Eastern Standard Time"`). `Intl.DateTimeFormat` requires **IANA** (e.g. `"America/New_York"`). `DomainTimezoneService` maps between them. If a new MP deployment surfaces an unmapped zone, `resolveIanaTimezone` throws with the unmapped name — extend the table rather than silently falling back to the server's local zone. +MP's `/domain` endpoint returns `TimeZoneName` as a **Windows** zone (e.g. `"Eastern Standard Time"`). `Intl.DateTimeFormat` requires **IANA** (e.g. `"America/New_York"`). `DomainTimezoneService` maps between them (`WINDOWS_TO_IANA`). `resolveIanaTimezone` also passes through values that already look like IANA names (contain `/`) and maps `UTC`/`Etc/UTC` to `Etc/UTC`. If a new MP deployment surfaces an unmapped Windows zone, it throws with the unmapped name — extend the table rather than silently falling back to the server's local zone. ## Testing When a test exercises code that goes through `DomainTimezoneService`: -1. **Mock `MPHelper.getDomainInfo`** to return a known `TimeZoneName` — use `vi.hoisted()` because the service's `MPHelper` is constructed at module-load time. -2. **Reset the singleton** between tests: `(DomainTimezoneService as any).instance = null` in `beforeEach`. +1. **Mock `MPHelper.getDomainInfo`** to return a known `TimeZoneName` — declare the mock with `vi.hoisted()` so the `vi.mock('@/lib/providers/ministry-platform', ...)` factory can reference it (the service constructs its `MPHelper` when the singleton is first created). +2. **Reset the singleton** between tests: `(DomainTimezoneService as any).instance = null` in `beforeEach` (or call `clearCache()` to drop only the cached zone). 3. **Use `mockReset()` (not `clearAllMocks()`)** on the `getDomainInfo` mock when you queue per-test responses with `mockResolvedValueOnce`. `clearAllMocks` doesn't drain those queues, and tests that don't hit `getMpTimezone()` leave queue entries behind that leak forward. 4. **Run under multiple `TZ` env vars** — at minimum `TZ=UTC` and `TZ=America/Los_Angeles`. The original bug was invisible when developer machines and the server happened to be in the same zone as the MP domain. diff --git a/.claude/references/mp-provider/README.md b/.claude/references/mp-provider/README.md index c9836eb..15ea94f 100644 --- a/.claude/references/mp-provider/README.md +++ b/.claude/references/mp-provider/README.md @@ -38,7 +38,7 @@ Consumers (services in src/services/, server actions) src/lib/providers/ministry-platform/utils/http-client.ts │ fetch() ▼ - Ministry Platform REST API (https://<domain>.ministryplatformapi.com) + Ministry Platform REST API (MINISTRY_PLATFORM_BASE_URL, e.g. https://mpi.ministryplatform.com/ministryplatformapi) ``` ## File map @@ -47,7 +47,7 @@ Consumers (services in src/services/, server actions) |------|---------|--------------| | `architecture.md` | Stack layers, singleton rationale, entry-point semantics | First — to place other docs | | `client.md` | `MinistryPlatformClient`, OAuth2 client credentials, token refresh, dev-token pipeline | When debugging auth or adding a new token-consuming surface | -| `type-generation.md` | `generate-types.ts` CLI, `--clean`, `--zod`, `--detailed`, model output (603 files) | When running `npm run mp:generate:models` or changing the generator | +| `type-generation.md` | `generate-types.ts` CLI, `--clean`, `--zod`, `--detailed`, model output (615 files) | When running `npm run mp:generate:models` or changing the generator | | `error-handling.md` | How MP API errors (401/404/409/500), token-refresh errors, Zod errors surface | When diagnosing a failure at any layer | Services (each MP service wrapping one area of the API) live in `services/` and are documented separately. @@ -62,9 +62,9 @@ Services (each MP service wrapping one area of the API) live in `services/` and | `src/lib/providers/ministry-platform/index.ts` | Barrel: `MinistryPlatformProvider`, `MPHelper`, `MinistryPlatformClient`, types, models, services, auth | | `src/lib/providers/ministry-platform/auth/client-credentials.ts` | `getClientCredentialsToken(profile)` | | `src/lib/providers/ministry-platform/utils/http-client.ts` | `HttpClient` — fetch wrapper | -| `src/lib/providers/ministry-platform/utils/logger.ts` | `logger` — console wrapper gated on `NODE_ENV` | +| `src/lib/providers/ministry-platform/utils/logger.ts` | `logger` — `[MP]`-prefixed `console.error` wrapper (no debug channel); `errorName` | | `src/lib/providers/ministry-platform/scripts/generate-types.ts` | Type-generator CLI | -| `src/lib/providers/ministry-platform/models/` | 603 generated model + Zod schema files | +| `src/lib/providers/ministry-platform/models/` | 615 generated model + Zod schema files (307 tables) | ## Related domains - `../services/README.md` — app-level service singletons that instantiate `MPHelper` diff --git a/.claude/references/mp-provider/architecture.md b/.claude/references/mp-provider/architecture.md index 210aec0..735b208 100644 --- a/.claude/references/mp-provider/architecture.md +++ b/.claude/references/mp-provider/architecture.md @@ -15,14 +15,14 @@ related: - client.md - error-handling.md - ../services/README.md -last_verified: 2026-04-17 +last_verified: 2026-10-10 --- ## Purpose Four-layer SDK: facade (`MPHelper`) → singleton orchestrator (`MinistryPlatformProvider`) → per-domain services → HTTP/auth client (`MinistryPlatformClient` + `HttpClient`). Consumers instantiate `MPHelper` freely; state (token, services) lives on the singleton. ## Files -- `src/lib/providers/ministry-platform/helper.ts` — `MPHelper` class, param-mapping + Zod validation +- `src/lib/providers/ministry-platform/helper.ts` — `MPHelper` class, param-mapping + Zod validation; `MPValidationError` - `src/lib/providers/ministry-platform/provider.ts` — `MinistryPlatformProvider` singleton, holds `MinistryPlatformClient` + six services - `src/lib/providers/ministry-platform/client.ts` — OAuth2 token lifecycle + HttpClient factory - `src/lib/providers/ministry-platform/index.ts` — barrel export @@ -33,7 +33,7 @@ Four-layer SDK: facade (`MPHelper`) → singleton orchestrator (`MinistryPlatfor - **Facade (`MPHelper`)** — friendly param names (`select`, `filter`, `top`) mapped to `$`-prefixed OData params (`$select`, `$filter`, `$top`); optional Zod validation on create/update - **Singleton (`MinistryPlatformProvider`)** — private constructor + `getInstance()`; six services share one `MinistryPlatformClient` so they share one token cache -- **Client (`MinistryPlatformClient`)** — holds cached token, `expiresAt`, and an `HttpClient` bound to `() => this.token` so services always see the latest token without being re-wired +- **Client (`MinistryPlatformClient`)** — holds two internal `TokenPipeline`s (`default`, `dev`), each with its cached token, `expiresAt`, and an `HttpClient` bound to `() => this.token` so services always see the latest token without being re-wired (see `client.md`) - **HTTP (`HttpClient`)** — one instance per pipeline (default + dev), methods: `get`, `post`, `postFormData`, `put`, `putFormData`, `delete` - **Services** — six domain services instantiated once in the provider constructor (`TableService`, `ProcedureService`, `CommunicationService`, `MetadataService`, `DomainService`, `FileService`) @@ -44,7 +44,7 @@ Four-layer SDK: facade (`MPHelper`) → singleton orchestrator (`MinistryPlatfor Every `new MPHelper()` instance resolves to the same singleton under the hood: ```typescript -// src/lib/providers/ministry-platform/helper.ts:41-43 +// src/lib/providers/ministry-platform/helper.ts:62-64 constructor() { this.provider = MinistryPlatformProvider.getInstance(); } @@ -58,8 +58,8 @@ Consequence: creating many `MPHelper` instances is cheap — they all share toke |-------|---------| | Table CRUD | `getTableRecords<T>(params)`, `createTableRecords<T>(table, records, params?)`, `updateTableRecords<T>(table, records, params?)`, `deleteTableRecords<T>(table, ids, params?)` | | Copy / recurrence | `copyRecord<T>(table, recordId, pattern, params?)`, `copyRecordWithSubpages<T>(table, recordId, copyParams, params?)`, `generateSequence(pattern)` | -| Stored procedures | `getProcedures(search?)`, `executeProcedure(procedure, params?)`, `executeProcedureWithBody(procedure, parameters)` | -| Communications | `createCommunication(communication, attachments?)`, `sendMessage(message, attachments?)` | +| Stored procedures | `getProcedures(search?)`, `executeProcedure(procedure, params?)`, `executeProcedureWithBody(procedure, parameters, queryParams?)` | +| Communications | `createCommunication(communication, attachments?, params?)`, `sendMessage(message, attachments?, params?)` (`params` = `{ $userId }`) | | Files | `getFilesByRecord`, `uploadFiles`, `updateFile`, `deleteFile`, `getFileContentByUniqueId`, `getFileMetadata`, `getFileMetadataByUniqueId` | | Domain | `getDomainInfo()`, `getGlobalFilters(params?)` | | Metadata | `getTables(search?)`, `refreshMetadata()` | @@ -94,7 +94,7 @@ private constructor() { Most methods are thin pass-throughs to one of the six services. The exception is `generateSequence`, which calls the client's HttpClient directly: ```typescript -// src/lib/providers/ministry-platform/provider.ts:228-244 +// src/lib/providers/ministry-platform/provider.ts:228-245 public async generateSequence(pattern: RecurrencePattern): Promise<string[]> { await this.client.ensureValidToken(); const queryParams: QueryParams = { /* ...pattern fields... */ }; @@ -118,9 +118,9 @@ export * from './auth'; ## How it works - **Instantiation flow:** `new MPHelper()` → `MinistryPlatformProvider.getInstance()` → on first call: `new MinistryPlatformClient()` + six `new XService(this.client)` — all share the same client reference -- **Token flow:** each service calls `this.client.ensureValidToken()` before making a request; the token value is read lazily by `HttpClient` via the getter `() => this.token` closed over at client construction time +- **Token flow:** each service calls `this.client.ensureValidToken()` before making a request; the token value is read lazily by `HttpClient` via the getter `() => this.token` closed over at `TokenPipeline` construction time - **Param normalization:** `MPHelper.getTableRecords` rewrites friendly names to `$`-prefixed params and forwards to `provider.getTableRecords(table, params)` -- **Validation:** optional `schema?: ZodObject<ZodRawShape>` in create/update params. If present, `MPHelper` calls `schema.parse(record)` (or `schema.partial().parse(record)` for updates when `partial !== false`) and rewraps the error as `Validation failed for record ${index}: ...` +- **Validation:** optional `schema?: ZodObject<ZodRawShape>` in create/update params. If present, `MPHelper` calls `schema.parse(record)` (or `schema.partial().parse(record)` for updates when `partial !== false`); a `ZodError` is rethrown as `MPValidationError` (`helper.ts:33`, extends `Error`, carries `recordIndex` + `zodError`, message `Validation failed for record ${index}: ...`). `MPValidationError` is exported from `helper.ts` only, not the barrel - **Service isolation:** `TableService` / `ProcedureService` / etc. each manage their own endpoint URLs + request shapes; the provider only delegates ## Usage @@ -140,8 +140,9 @@ const contacts = await mp.getTableRecords<Contact>({ ``` ```typescript -// Validated create (from helper.ts JSDoc examples) -import { ContactLogSchema } from '@/lib/providers/ministry-platform/models'; +// Validated create (adapted from helper.ts JSDoc). Schemas are NOT in the models barrel +// (models/index.ts exports interfaces only) — import the *Schema file directly. +import { ContactLogSchema } from '@/lib/providers/ministry-platform/models/ContactLogSchema'; await mp.createTableRecords('Contact_Log', [{ Contact_ID: 12345, @@ -154,9 +155,11 @@ await mp.createTableRecords('Contact_Log', [{ }); ``` +Generated Zod schemas validate `DateTime`/`Timestamp` columns with a regex that accepts the `YYYY-MM-DD HH:MM:SS` string `toMpSqlDatetime` returns as well as ISO forms (`T` separator, optional `Z`/offset), so `schema` and CLAUDE.md rule 17 combine as shown. See `type-generation.md` § Zod type mapping and `../ministryplatform.datetimehandling.md`. + ## Gotchas -- **Singleton state is process-global.** In tests, reset with a mocked `getInstance` (see `helper.test.ts:50-82`) or mock the client + services directly (see `provider.test.ts:60-103`). The provider itself has no `reset()` method. +- **Singleton state is process-global.** In tests, reset with a mocked `getInstance` (see `helper.test.ts:56-88`) or mock the client + services directly (see `provider.test.ts:60-102`). The provider itself has no `reset()` method. - **`MPHelper` constructor returns before any API call.** Token acquisition is deferred to the first method call that hits `ensureValidToken()`; construction is cheap. - **Do not cache an `HttpClient` reference across tokens.** Services should always go through `this.client.getHttpClient()` (not capture the returned value) — only the instance is stable, token value inside is not. - **Do not call MPHelper directly from components.** Wrap it in a service class (`src/services/*.ts`) per project convention. diff --git a/.claude/references/mp-provider/client.md b/.claude/references/mp-provider/client.md index a2a4f38..2c55eaa 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-10-10 --- ## 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: <errorName>`. +- Non-2xx → `Failed to get client credentials token: <status> <statusText>` (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<T>(endpoint, queryParams?)` | GET | no body | -| `post<T>(endpoint, body?, queryParams?)` | POST + JSON | `Content-Type: application/json` | -| `postFormData<T>(endpoint, formData, queryParams?)` | POST + FormData | no `Content-Type` (browser sets it with boundary) | -| `put<T>(endpoint, body, queryParams?)` | PUT + JSON | debug-logs request; error text included | -| `putFormData<T>(endpoint, formData, queryParams?)` | PUT + FormData | no `Content-Type` | -| `delete<T>(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<void> { - 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<T[]> { - await this.client.ensureValidToken(); - return this.client.getHttpClient().get<T[]>(`/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<string[]> { - 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<string[]>('/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..5e14474 100644 --- a/.claude/references/mp-provider/error-handling.md +++ b/.claude/references/mp-provider/error-handling.md @@ -4,142 +4,75 @@ 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 + - MPValidationError + - errorName related: - client.md - architecture.md -last_verified: 2026-04-17 +last_verified: 2026-10-10 --- ## 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.<area>.<op>_failed', { ...ids, error: errorName(e) })`) and re-throw the original error. +- **Error shape is `Error`.** HTTP/token/guard failures are plain `Error`s — consumers must read `.message` for the status code. The one subclass is `MPValidationError` (Zod failures in `MPHelper`, see below). +- **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 | Base URL fails to parse | `Invalid MP API base URL` | +| 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 | `MPValidationError` (`helper.ts:33`): `Validation failed for record ${index}: ...`, plus `.recordIndex` and `.zodError` (no API call made). A non-`ZodError` throw from `parse` becomes a plain `Error` with the same message prefix | + +`${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.** Check `instanceof MPValidationError` (import from `@/lib/providers/ministry-platform/helper` — it is not re-exported by the barrel) to read `.zodError.issues`. ## 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..530d310 100644 --- a/.claude/references/mp-provider/services/communication.md +++ b/.claude/references/mp-provider/services/communication.md @@ -5,22 +5,23 @@ 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-10-10 --- ## Purpose Create MP `Communications` records (renders + schedules delivery) and send direct messages via `/messages`. Both support optional file attachments via multipart branch. ## Files -- `src/lib/providers/ministry-platform/services/communication.service.ts` — implementation (80 lines) -- `src/lib/providers/ministry-platform/services/communication.service.test.ts` — tests (162 lines) +- `src/lib/providers/ministry-platform/services/communication.service.ts` — implementation (95 lines) +- `src/lib/providers/ministry-platform/services/communication.service.test.ts` — tests (225 lines) ## Key concepts - **Two endpoints, same pattern:** - `POST /communications` — creates a `Communications` row, renders body, schedules delivery. Supports `Email | Text | Letter` (`CommunicationInfo.CommunicationType`). - `POST /messages` — direct send; addresses are passed in the payload (no template/contact resolution). - **Attachment branch:** if `attachments && attachments.length > 0`, serialize the info object as a JSON string under the FormData key `communication` / `message` and append files as `file-{i}`. -- **Empty array = no attachments** — `attachments: []` takes the JSON branch (`communication.service.test.ts:107-114`). +- **Empty array = no attachments** — `attachments: []` takes the JSON branch (`communication.service.test.ts:150-161`). +- **`$userId` (audit attribution)** — optional third arg `params?: Pick<TableQueryParams, '$userId'>`; only `$userId` is forwarded as a query param on both branches (`toQueryParams`, `communication.service.ts:7-10`). ## API / Interface @@ -29,12 +30,14 @@ Signatures copied from `src/lib/providers/ministry-platform/services/communicati ```typescript public async createCommunication( communication: CommunicationInfo, - attachments?: File[] + attachments?: File[], + params?: UserIdParams // Pick<TableQueryParams, '$userId'> ): Promise<Communication> public async sendMessage( message: MessageInfo, - attachments?: File[] + attachments?: File[], + params?: UserIdParams ): Promise<Communication> ``` @@ -94,30 +97,31 @@ export interface Communication { ``` ## How it works -- Branch on `attachments?.length > 0` (`communication.service.ts:22`, `:42`). +- Branch on `attachments && attachments.length > 0` (`communication.service.ts:32`, `:55`). - Non-attachment path: spread info object (`{ ...communication }`) — creates a shallow copy before the HTTP POST. - Attachment path delegates to private `createCommunicationWithAttachments` / `sendMessageWithAttachments`: ```typescript -// communication.service.ts:53-65 +// communication.service.ts:66-79 private async createCommunicationWithAttachments( communication: CommunicationInfo, - attachments: File[] + attachments: File[], + queryParams?: QueryParams ): Promise<Communication> { const formData = new FormData(); formData.append('communication', JSON.stringify(communication)); attachments.forEach((file, index) => { formData.append(`file-${index}`, file, file.name); }); - return await this.client.getHttpClient().postFormData<Communication>('/communications', formData); + return await this.client.getHttpClient().postFormData<Communication>('/communications', formData, queryParams); } ``` -- `sendMessage` variant uses FormData key `message` instead of `communication` (`communication.service.ts:72`). +- `sendMessage` variant uses FormData key `message` instead of `communication` (`communication.service.ts:87`). ## Usage -From tests (`communication.service.test.ts:67-85`): +From tests (`communication.service.test.ts:110-128`): ```typescript const file = new File(['content'], 'test.pdf', { type: 'application/pdf' }); @@ -128,19 +132,19 @@ const result = await service.createCommunication(communicationInfo, [file]); // formData.get('file-0').name === 'test.pdf' ``` -Note: no application-layer wrapper currently calls these methods (greppable via `Grep createCommunication src/services`). They are exposed through `MPHelper` (`helper.ts:559, :573`) and ready for use by `src/components/**/actions.ts`. +Note: no application-layer wrapper currently calls these methods (greppable via `Grep createCommunication src/services`). They are exposed through `MPHelper` (`helper.ts:595, :611`) and ready for use by `src/components/**/actions.ts`. ## Error handling | Operation | Error | Test location | |---|---|---| -| `createCommunication` | generic POST failure | `communication.service.test.ts:101-105` | -| `sendMessage` | generic POST failure | `communication.service.test.ts:145-149` | +| `createCommunication` | generic POST failure | `communication.service.test.ts:144-148` | +| `sendMessage` | generic POST failure | `communication.service.test.ts:208-212` | -Token validation happens once per outer call (`communication.service.test.ts:152-161`). +Token validation happens once per outer call (`communication.service.test.ts:215-224`). ## Gotchas -- **Empty `attachments: []` takes the JSON branch** — the guard is `attachments.length > 0`, not just truthiness. If you want to force multipart with no files, you cannot (`communication.service.ts:22`). +- **Empty `attachments: []` takes the JSON branch** — the guard is `attachments.length > 0`, not just truthiness. If you want to force multipart with no files, you cannot (`communication.service.ts:32`). - **Response shape uses MP database snake_case column names** (`Communication_ID`, `Author_User_ID`) while the request shape uses camelCase (`AuthorUserId`). Do not assume symmetry. - **Attachment limit** — not enforced at this layer. MP API enforces server-side; expect 413 Payload Too Large on oversize. - **JSON-stringifying the payload twice** — on multipart, the info object is `JSON.stringify`'d into a single FormData field. Server must parse it back. No merge with top-level form fields. diff --git a/.claude/references/mp-provider/services/domain.md b/.claude/references/mp-provider/services/domain.md index a983451..41ca63a 100644 --- a/.claude/references/mp-provider/services/domain.md +++ b/.claude/references/mp-provider/services/domain.md @@ -5,19 +5,19 @@ 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-10-10 --- ## Purpose Read domain-level configuration (display name, timezone, culture, MFA settings) and fetch the lookup values for the domain's global filter. ## Files -- `src/lib/providers/ministry-platform/services/domain.service.ts` — implementation (42 lines) -- `src/lib/providers/ministry-platform/services/domain.service.test.ts` — tests (94 lines) +- `src/lib/providers/ministry-platform/services/domain.service.ts` — implementation (53 lines) +- `src/lib/providers/ministry-platform/services/domain.service.test.ts` — tests (128 lines) ## 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<GlobalFilter | Method | HTTP | Endpoint | |---|---|---| | `getDomainInfo` | GET | `/domain` | -| `getGlobalFilters` | GET | `/domain/filters` (optional `$userId`, `$ignorePermissions`) | +| `getGlobalFilters` | GET | `/domain/filters` (optional `$userId` only) | ### Type shapes @@ -68,7 +68,6 @@ export interface GlobalFilterItem { // :100 export interface GlobalFilterParams { - $ignorePermissions?: boolean; $userId?: number; } ``` @@ -76,11 +75,11 @@ export interface GlobalFilterParams { ## How it works - Both methods call `ensureValidToken()` then `getHttpClient().get(endpoint, params?)`. - No URL encoding needed — endpoints are static. -- Errors propagate from `HttpClient` after `logger.error`. +- Errors propagate from `HttpClient` after `logger.error(event, { error: errorName(e) })`. ## Usage -From tests (`domain.service.test.ts:66-74`): +From tests (`domain.service.test.ts:86-94`): ```typescript const filters = [{ Key: 1, Value: 'Global' }]; @@ -90,20 +89,19 @@ const result = await service.getGlobalFilters({ $userId: 123 } as any); // expect(mockHttpClient.get).toHaveBeenCalledWith('/domain/filters', { $userId: 123 }); ``` -Also exposed via `MPHelper.getDomainInfo` / `.getGlobalFilters` (`src/lib/providers/ministry-platform/helper.ts:346, :373`) — no direct callers in `src/services/**` or `src/components/**` as of this revision (greppable: no hits). +Also exposed via `MPHelper.getDomainInfo` / `.getGlobalFilters` (`src/lib/providers/ministry-platform/helper.ts:380, :406`). Only app caller: `DomainTimezoneService.getMpTimezone` (`src/services/domainTimezoneService.ts:259`) reads `getDomainInfo().TimeZoneName`; `getGlobalFilters` has no app callers. ## Error handling | Status | Operation | Test location | |---|---|---| -| 403 Forbidden | `getGlobalFilters` | `domain.service.test.ts:76-80` | -| 500 Internal Server Error | `getDomainInfo` | `domain.service.test.ts:48-52` | +| 403 Forbidden | `getGlobalFilters` | `domain.service.test.ts:111-115` | +| 500 Internal Server Error | `getDomainInfo` | `domain.service.test.ts:68-72` | ## Gotchas -- **`Key: 0` = unfiltered records** — documented in the JSDoc (`domain.service.ts:28-29`). Treat 0 as sentinel, not as a real filter id. -- **`$ignorePermissions: true` bypasses per-user filter scoping** — only usable where caller has admin context. MP 403s otherwise. +- **`Key: 0` = unfiltered records** — documented in the JSDoc (`domain.service.ts:28-30`). Treat 0 as sentinel, not as a real filter id. +- **No `$ignorePermissions`** — requests already run as the admin-level service account; the flag could only widen that. A non-integer `$userId` throws `Expected positive integer for user ID` before any request. - **`GlobalFilterTableName` is optional** — on domains with no global filter configured, `DomainInfo.GlobalFilterTableName` is `undefined`, and `getGlobalFilters` still returns `[]` (not an error). -- **Response type is loose where MP sends more fields** — test fixture `{ DomainName: 'Test', DomainId: 1 }` does not match `DomainInfo` (which uses `DisplayName`, no `DomainId`). The mock is intentionally loose; production MP returns the `DomainInfo` shape. ## Related docs - `../README.md` — provider overview diff --git a/.claude/references/mp-provider/services/file.md b/.claude/references/mp-provider/services/file.md index 5d2010a..891e278 100644 --- a/.claude/references/mp-provider/services/file.md +++ b/.claude/references/mp-provider/services/file.md @@ -5,19 +5,19 @@ type: reference applies_to: [src/lib/providers/ministry-platform/services/file.service.ts] symbols: [FileService, getFilesByRecord, uploadFiles, updateFile, deleteFile, getFileContentByUniqueId, getFileMetadata, getFileMetadataByUniqueId] related: [../README.md, table.md, communication.md] -last_verified: 2026-04-17 +last_verified: 2026-10-10 --- ## Purpose CRUD on files attached to MP records via `/files/*` REST endpoints. Uploads use `multipart/form-data`; downloads by UniqueFileId are public (no auth header). ## Files -- `src/lib/providers/ministry-platform/services/file.service.ts` — implementation (213 lines) -- `src/lib/providers/ministry-platform/services/file.service.test.ts` — tests (332 lines) +- `src/lib/providers/ministry-platform/services/file.service.ts` — implementation (251 lines) +- `src/lib/providers/ministry-platform/services/file.service.test.ts` — tests (446 lines) ## Key concepts -- Uploads use `FormData` with field key pattern `file-{index}` per file (`file.service.ts:52-54`). Optional params (`description`, `isDefaultImage`, `longestDimension`) are appended as both form fields AND query string params (`file.service.ts:57-71`). -- `getFileContentByUniqueId` is the only method that does **NOT** call `ensureValidToken` and does **NOT** send an Authorization header — it uses raw `fetch()` (`file.service.ts:159-185`). +- Uploads use `FormData` with field key pattern `file-{index}` per file (`file.service.ts:81-83`). Optional params (`description`, `isDefaultImage`, `longestDimension`) are appended as both form fields AND query string params (`file.service.ts:85-100`). +- `getFileContentByUniqueId` is the only method that does **NOT** call `ensureValidToken` and does **NOT** send an Authorization header — it uses raw `fetch()` (`file.service.ts:190-221`). - Query-string `$` prefix pattern: `$description`, `$default`, `$longestDimension`, `$fileName`, `$userId`, `$thumbnail`. - `isDefaultImage: boolean` maps to query param `$default` (name mismatch is intentional). - `FileDescription` shape: `src/lib/providers/ministry-platform/types/provider.types.ts:19-34`. @@ -75,7 +75,7 @@ public async getFileMetadataByUniqueId(uniqueFileId: string): Promise<FileDescri ### Param types -`FileUploadParams` (`provider.types.ts:168`): +`FileUploadParams` (`provider.types.ts:167`): ```typescript export interface FileUploadParams { @@ -86,18 +86,18 @@ export interface FileUploadParams { } ``` -`FileUpdateParams` (`provider.types.ts:175`) adds `fileName?: string`. +`FileUpdateParams` (`provider.types.ts:174`) adds `fileName?: string`. ## How it works -- `uploadFiles` builds FormData: appends each file as `file-{i}`, then appends optional params as string form fields. Separately builds `queryParams` with `$`-prefixed keys and calls `postFormData(endpoint, formData, queryParams)` (`file.service.ts:50-77`). -- `updateFile` — same pattern but PUT, file field is `file` (singular) not `file-0`, and an extra `fileName`/`$fileName` is supported (`file.service.ts:87-131`). -- `getFileContentByUniqueId` builds URL via `client.getHttpClient().buildUrl(...)`, then uses global `fetch` with `method: 'GET'` only (no headers). Throws `Error` with `"GET /files/{id} failed: {status} {statusText}"` on non-ok response (`file.service.ts:176-178`). -- `deleteFile` omits the `$userId` query param entirely when `userId` is undefined (`file.service.ts:143-146`). -- `getFilesByRecord` sends `{ $default: 'true'|'false' }` only when `defaultOnly !== undefined` (`file.service.ts:23-26`). +- `uploadFiles` builds FormData: appends each file as `file-{i}`, then appends optional params as string form fields. Separately builds `queryParams` with `$`-prefixed keys and calls `postFormData(endpoint, formData, queryParams)` (`file.service.ts:69-111`). +- `updateFile` — same pattern but PUT, file field is `file` (singular) not `file-0`, and an extra `fileName`/`$fileName` is supported (`file.service.ts:116-161`). +- `getFileContentByUniqueId` builds URL via `client.getHttpClient().buildUrl(...)`, then uses global `fetch` with `method: 'GET'`, `redirect: 'error'`, and `AbortSignal.timeout(FILE_CONTENT_TIMEOUT_MS)` (no headers). Throws `Error` with `"GET /files/{uniqueId} failed: {status} {statusText}"` (literal `{uniqueId}`, never the ID) on non-ok response (`file.service.ts:212-214`). +- `deleteFile` omits the `$userId` query param entirely when `userId` is falsy (`file.service.ts:174-177`). +- `getFilesByRecord` sends `{ $default: 'true'|'false' }` only when `defaultOnly !== undefined` (`file.service.ts:51-54`). ## Usage -From test fixtures (`file.service.test.ts:97-119`): +From test fixtures (`file.service.test.ts:108-131`): ```typescript const file = new File(['a'], 'doc.pdf', { type: 'application/pdf' }); @@ -111,10 +111,10 @@ await service.uploadFiles('Contacts', 10, [file], { // Query string: $description, $default, $longestDimension, $userId ``` -Public blob download (`file.service.test.ts:230-243`): +Public blob download (`file.service.test.ts:242-259`): ```typescript -const blob = await service.getFileContentByUniqueId('abc'); +const blob = await service.getFileContentByUniqueId('0f8fad5b-d9cb-469f-a165-70867728950e'); // must be a GUID // No Authorization header, no ensureValidToken ``` @@ -122,18 +122,19 @@ const blob = await service.getFileContentByUniqueId('abc'); | Status | Operation | Test location | |---|---|---| -| 404 Not Found | `getFilesByRecord` / metadata lookups | `file.service.test.ts:62-66`, `:307-311`, `:326-330` | -| 404 Not Found | `getFileContentByUniqueId` (thrown as `GET /files/... failed: 404 Not Found`) | `file.service.test.ts:281-292` | -| 403 Forbidden | `deleteFile` | `file.service.test.ts:222-226` | -| 413 Payload Too Large | `uploadFiles` | `file.service.test.ts:133-140` | -| 500 Internal Server Error | `updateFile` | `file.service.test.ts:197-201` | +| 404 Not Found | `getFilesByRecord` / metadata lookups | `file.service.test.ts:74-78`, `:323-327`, `:342-346` | +| 404 Not Found | `getFileContentByUniqueId` (thrown as `GET /files/... failed: 404 Not Found`) | `file.service.test.ts:297-309` | +| 403 Forbidden | `deleteFile` | `file.service.test.ts:234-238` | +| 413 Payload Too Large | `uploadFiles` | `file.service.test.ts:145-152` | +| 500 Internal Server Error | `updateFile` | `file.service.test.ts:209-213` | ## Gotchas -- **`isDefaultImage` → `$default`** — param name changes between the TS interface and the query string. Don't search for `$isDefaultImage`; it is always `$default` (`file.service.ts:69, :118`). -- **`getFileContentByUniqueId` bypasses auth** — relies on MP's public blob endpoint. If MP changes this to require auth, this method will 401 silently (the raw `fetch` has no token refresh logic). +- **`isDefaultImage` → `$default`** — param name changes between the TS interface and the query string. Don't search for `$isDefaultImage`; it is always `$default` (`file.service.ts:98, :148`). +- **`getFileContentByUniqueId` bypasses auth** — relies on MP's public blob endpoint (so the unique ID is a download capability: it never appears in logs or messages, which say `{uniqueId}`). Its raw `fetch` has its own `FILE_CONTENT_TIMEOUT_MS` (20 s) and `redirect: "error"`, but no token refresh. +- **Every path segment is validated** before any token work: table name (`Invalid table name`), record/file ID (`Expected positive integer for record ID` / `file ID`), unique ID (`Invalid GUID format`). - **Duplicate param transmission** — `uploadFiles` and `updateFile` send the same optional values in both FormData AND query string. Intentional (MP API accepts either) but doubles the wire payload for large descriptions. -- **`userId` is NOT put in FormData for uploads** — only the query string gets `$userId` (`file.service.ts:71`). Compare to other optional params which go to both. -- **`$thumbnail` omitted when undefined** — only `thumbnail === true | false` sends it; implicit `undefined` skips (`file.service.ts:165-167`). +- **`userId` is NOT put in FormData for uploads** — only the query string gets `$userId` (`file.service.ts:100`). Compare to other optional params which go to both. +- **`$thumbnail` omitted when undefined** — only `thumbnail === true | false` sends it; implicit `undefined` skips (`file.service.ts:197-199`). ## Related docs - `../README.md` — provider overview diff --git a/.claude/references/mp-provider/services/metadata.md b/.claude/references/mp-provider/services/metadata.md index 73a231f..cccbdc1 100644 --- a/.claude/references/mp-provider/services/metadata.md +++ b/.claude/references/mp-provider/services/metadata.md @@ -4,22 +4,22 @@ domain: mp-provider type: reference applies_to: [src/lib/providers/ministry-platform/services/metadata.service.ts] symbols: [MetadataService, getTables, refreshMetadata] -related: [../README.md, table.md, ../../ministryplatform.schema.md] -last_verified: 2026-04-17 +related: [../README.md, table.md, ../../mp-schema/tables.md] +last_verified: 2026-10-11 --- ## Purpose Discover MP tables available to the current user, and invalidate the MP metadata cache after schema changes. ## Files -- `src/lib/providers/ministry-platform/services/metadata.service.ts` — implementation (39 lines, smallest service) -- `src/lib/providers/ministry-platform/services/metadata.service.test.ts` — tests (102 lines) +- `src/lib/providers/ministry-platform/services/metadata.service.ts` — implementation (38 lines, smallest service) +- `src/lib/providers/ministry-platform/services/metadata.service.test.ts` — tests (113 lines) ## Key concepts - `refreshMetadata` is a `GET /refreshMetadata` (no body) — MP uses this to trigger cache invalidation across all servers. - `getTables` is an authenticated list endpoint; an optional `search` string filters server-side via `?$search=`. -- Empty-string search is coerced to "no search" — `!search` check drops it (`metadata.service.ts:32`, verified by `metadata.service.test.ts:75-81`). -- Result type `TableMetadata[]` is a loose shape with optional `Columns?: ColumnMetadata[]` — real endpoint returns only basic table info unless columns are expanded. +- Empty-string search is coerced to "no search" — `!search` check drops it (`metadata.service.ts:32`, verified by `metadata.service.test.ts:87-93`). +- Result type `TableMetadata[]`: `Name` (the table name), optional `Columns?: ColumnMetadata[]`, and an index signature. A live `/tables?$search=Contacts` response (2026-10-11) carried exactly `Name`, `AccessLevel`, `SpecialPermissions`, `Columns` per entry — **no `Table_ID`, `Table_Name` or `Display_Name`** (the type used to declare those three; it was corrected to `Name`). `generate-types.ts` builds every model from these `Columns`, and still reads the name defensively as `Name ?? Table_Name` (`generate-types.ts:141-145`). ## API / Interface @@ -40,19 +40,16 @@ public async getTables(search?: string): Promise<TableMetadata[]> ### Type shape -From `src/lib/providers/ministry-platform/types/provider.types.ts:265`: +From `src/lib/providers/ministry-platform/types/provider.types.ts:264`: ```typescript export interface TableMetadata { - Table_ID: number; - Table_Name: string; - Display_Name: string; - Description?: string; + Name: string; // the API key is `Name`, not `Table_Name` Columns?: ColumnMetadata[]; - [key: string]: unknown; + [key: string]: unknown; // AccessLevel, SpecialPermissions (untyped for now) } -// :274 +// (after TableMetadata) export interface ColumnMetadata { Name: string; DataType: ParameterDataType; @@ -72,38 +69,39 @@ export interface ColumnMetadata { - Both methods call `ensureValidToken()` first. - `getTables('')` → falsy → `params = undefined` (no `$search` sent). - `getTables('contact')` → `params = { $search: 'contact' }`. -- Errors from `HttpClient` propagate after `logger.error`. +- Errors from `HttpClient` propagate after `logger.error(event, { error: errorName(e) })`. ## Usage -Only caller in the app is `FieldManagementService.getTableMetadata` (`src/services/fieldManagementService.ts:65-72`): +Only caller in the app is `FieldManagementService.getTableMetadata` (`src/services/fieldManagementService.ts:75-88`): ```typescript public async getTableMetadata(tableName: string): Promise<TableMetadata | null> { + await AuthorizationService.getInstance().requireSecurityRole({ + table: "dp_Tables", + operation: "read", + }); const tables = await this.mp!.getTables(tableName); - - if (tables.length === 0) return null; - - // getTables with search may return multiple matches — find the exact one - return tables.find((t) => t.Table_Name === tableName) ?? tables[0]; + return tables.find((t) => t.Name === tableName) ?? null; } ``` -Note the consumer guard: MP `$search` is substring-match, so callers must filter for exact name. `getTables('Contacts')` can return `Contact_Addresses`, `Contact_Log`, etc. +Note the consumer guard: `$search` is not an exact-name lookup and may return several tables, so callers must filter on `Name === exact` and treat "no exact match" as absent rather than taking the first result. ## Error handling | Status | Operation | Test location | |---|---|---| -| 403 Forbidden | `getTables` | `metadata.service.test.ts:83-87` | -| 500 Internal Server Error | `refreshMetadata` | `metadata.service.test.ts:46-50` | +| 403 Forbidden | `getTables` | `metadata.service.test.ts:95-99` | +| 500 Internal Server Error | `refreshMetadata` | `metadata.service.test.ts:58-62` | ## Gotchas -- **`$search` is substring-match** — always post-filter on `Table_Name === exact` if you need the exact table. Pattern used by `FieldManagementService`. +- **`$search` is not an exact lookup** — always post-filter on `Name === exact` if you need the exact table (and return nothing on a miss — never `tables[0]`). Pattern used by `FieldManagementService`. +- **The name key is `Name`** — there is no `Table_Name` on a `/tables` entry; code matching on `Table_Name` silently never matches. - **`refreshMetadata` has no return data** — it's fire-and-forget. Uses `GET` (not `POST`) even though it has side effects. -- **No `Columns` populated by default** — generic `getTables` call returns a list without the `Columns` array. To get column-level metadata in this codebase, generate types via `npm run mp:generate:models` (see CLAUDE.md). +- **`Columns` come back on the list call** — `generate-types.ts` relies on them (it only falls back to record sampling under `--detailed` when a table has none). For committed column-level types, use the generated models (`npm run mp:generate:models`). ## Related docs - `../README.md` — provider overview - `table.md` — uses names discovered here as the `table` argument -- `../../ministryplatform.schema.md` — auto-generated schema dump (authoritative at build time) +- `../../mp-schema/tables.md` — generated table catalog (snapshot) diff --git a/.claude/references/mp-provider/services/procedure.md b/.claude/references/mp-provider/services/procedure.md index beb422d..2bea311 100644 --- a/.claude/references/mp-provider/services/procedure.md +++ b/.claude/references/mp-provider/services/procedure.md @@ -4,8 +4,8 @@ domain: mp-provider type: reference applies_to: [src/lib/providers/ministry-platform/services/procedure.service.ts] symbols: [ProcedureService, getProcedures, executeProcedure, executeProcedureWithBody] -related: [../README.md, table.md, ../../required-stored-procs.md] -last_verified: 2026-04-17 +related: [../README.md, table.md, ../../mp-schema/required-procs.md] +last_verified: 2026-10-10 --- ## Purpose @@ -13,12 +13,12 @@ Execute MP stored procedures over REST at `/procs/{name}`. Supports query-string ## Files - `src/lib/providers/ministry-platform/services/procedure.service.ts` — implementation (100 lines) -- `src/lib/providers/ministry-platform/services/procedure.service.test.ts` — tests (242 lines) +- `src/lib/providers/ministry-platform/services/procedure.service.test.ts` — tests (260 lines) ## Key concepts - **Dev-credential routing:** procedures whose name begins with `api_dev_` (case-insensitive, requires trailing underscore) use `client.ensureValidDevToken()` + `client.getDevHttpClient()`. All other procedures use the default pipeline. - **`@DomainID` convention:** every `api_*` procedure must declare `@DomainID INT` as its first parameter. The MP API injects the current domain automatically — callers do NOT pass it. See `src/lib/providers/ministry-platform/db/api_Common_StoredProcParameters.sql:92` (`WHERE p.ParameterName <> '@DomainID'`) and `.claude/commands/newstoredproc.md:171`. -- **Result shape:** `Promise<unknown[][]>` — an outer array of result sets, each an array of rows. Callers index `result[0]` for the first result set (see `src/services/toolService.ts:132`, `:161-167`). +- **Result shape:** `Promise<unknown[][]>` — an outer array of result sets, each an array of rows. Callers index `result[0]` for the first result set (see `src/services/toolService.ts:139-140`, `:175-181`). - Body parameter keys use `@ParamName` convention (matching SQL parameter names). ## API / Interface @@ -35,7 +35,8 @@ public async executeProcedure( public async executeProcedureWithBody( procedure: string, - parameters: Record<string, unknown> + parameters: Record<string, unknown>, + queryParams?: QueryParams // e.g. { $userId } for audit attribution ): Promise<unknown[][]> ``` @@ -45,45 +46,48 @@ public async executeProcedureWithBody( |---|---|---|---| | `getProcedures` | GET | `/procs` (optional `?$search=`) | Default only (never dev) | | `executeProcedure` | GET | `/procs/{name}` (params → query string) | Default or dev (by prefix) | -| `executeProcedureWithBody` | POST | `/procs/{name}` (params → body) | Default or dev (by prefix) | +| `executeProcedureWithBody` | POST | `/procs/{name}` (params → body, `queryParams` → query string) | Default or dev (by prefix) | ### Dev-prefix detection ```typescript // procedure.service.ts:98-100 function isDevProcedure(procedure: string): boolean { - return procedure.trim().toLowerCase().startsWith(DEV_PROC_PREFIX); // 'api_dev_' + return procedure.toLowerCase().startsWith(DEV_PROC_PREFIX); // 'api_dev_' } ``` - Matches `api_dev_DeployTool`, `API_DEV_X`, `Api_Dev_Y`. -- Does NOT match `api_developer_GetThing` (trailing underscore required). Verified by `procedure.service.test.ts:144-153`. -- `getProcedures` never uses the dev pipeline regardless of `$search` value (`procedure.service.test.ts:75-82`). +- Does NOT match `api_developer_GetThing` (trailing underscore required). Verified by `procedure.service.test.ts:153-162`. +- `getProcedures` never uses the dev pipeline regardless of `$search` value (`procedure.service.test.ts:87-94`). ## How it works - Both execute methods call `resolveHttpClient(procedure)` which branches on `isDevProcedure` and invokes the matching `ensureValid*Token` + `get*HttpClient` pair (`procedure.service.ts:87-95`). -- Procedure name is URL-encoded on every call (`procedure.service.ts:45`, `:71`). -- Errors from `HttpClient` propagate after `logger.error`. +- Procedure name is validated with `sanitizeIdentifier` (`^[A-Za-z_][A-Za-z0-9_]*$`, ≤128) and then URL-encoded, before any token work; a bad name throws `Invalid procedure name`. There is **no** allowlist of callable procedures — by design for this template; every caller is gated and passes a literal proc name. A downstream clone that wants defence in depth can add one here. +- Errors propagate after `logger.error('mp.procs.execute_failed', { procedure, error: errorName(e) })`. ## Stored procedures called by this codebase | Procedure | Caller | Credential | Doc | |---|---|---|---| -| `api_Tools_GetPageData` | `src/services/toolService.ts:127` | default | `../../required-stored-procs.md` | -| `api_Common_GetSelection` | `src/services/toolService.ts:154` | default | `../../required-stored-procs.md` | -| `api_Tools_GetUserTools` | `src/services/toolService.ts:185` | default | `../../required-stored-procs.md` | -| `api_MPNextTools_GetPages` | `src/services/toolService.ts:207`, `fieldManagementService.ts:44` | default | — | -| `api_MPNextTools_GetPageFields` | `src/services/fieldManagementService.ts:54` | default | — | -| `api_MPNextTools_UpdatePageFieldOrder` | `src/services/fieldManagementService.ts:94` | default | — | -| `api_dev_DeployTool` | `src/services/toolService.ts:268` | **dev** | — | -| `api_CloudTools_GetSelection` | (legacy reference in facts) | default | — | +| `api_Tools_GetPageData` | `src/services/toolService.ts:134` | default | `../../mp-schema/required-procs.md` | +| `api_Common_GetSelection` | `src/services/toolService.ts:168` | default | `../../mp-schema/required-procs.md` | +| `api_Tools_GetUserTools` | `src/services/toolService.ts:202` | default | `../../mp-schema/required-procs.md` | +| `api_MPNextTools_GetPages` | `src/services/toolService.ts:228`, `fieldManagementService.ts:50` | default | `../../mp-schema/required-procs.md` | +| `api_MPNextTools_GetPageFields` | `src/services/fieldManagementService.ts:64` | default | `../../mp-schema/required-procs.md` | +| `api_MPNextTools_UpdatePageFieldOrder` | `src/services/fieldManagementService.ts:118` | default | `../../mp-schema/required-procs.md` | +| `api_dev_DeployTool` | `src/services/toolService.ts:297` | **dev** | `../../mp-schema/required-procs.md` | ## Usage -Real caller from `src/services/toolService.ts:127-140`: +Real caller from `src/services/toolService.ts:126-148`: ```typescript public async getPageData(pageID: number): Promise<PageData | null> { + await AuthorizationService.getInstance().requireSecurityRole({ + table: 'dp_Pages', + operation: 'read', + }); try { // DomainID is automatically injected by MP API const result = await this.mp!.executeProcedureWithBody('api_Tools_GetPageData', { @@ -102,28 +106,28 @@ public async getPageData(pageID: number): Promise<PageData | null> { } ``` -Dev-pipeline example from `src/services/toolService.ts:268`: +Dev-pipeline example from `src/services/toolService.ts:297`: ```typescript -const resultSets = await this.mp!.executeProcedureWithBody('api_dev_DeployTool', payload); +const resultSets = await this.mp!.executeProcedureWithBody('api_dev_DeployTool', payload, { $userId }); ``` ## Error handling | Status | Test location | |---|---| -| 403 Forbidden | `procedure.service.test.ts:69-73` | -| 404 Not Found | `procedure.service.test.ts:114-118` | -| 500 Internal Server Error | `procedure.service.test.ts:177-183` | +| 403 Forbidden | `procedure.service.test.ts:81-85` | +| 404 Not Found | `procedure.service.test.ts:123-127` | +| 500 Internal Server Error | `procedure.service.test.ts:195-201` | ## Gotchas -- **Never pass `@DomainID`** — the MP API injects it from the authenticated domain context. Passing it manually causes a parameter mismatch. Comments in real callers repeat this: `// DomainID is automatically injected by MP API` (`src/services/toolService.ts:126`, `:178`). +- **Never pass `@DomainID`** — the MP API injects it from the authenticated domain context. Passing it manually causes a parameter mismatch. Comments in real callers repeat this: `// DomainID is automatically injected by MP API` (`src/services/toolService.ts:133`, `:192`). - **Every `api_*` procedure definition must declare `@DomainID INT` first** — MP API will not expose procedures that omit it (`.claude/commands/newstoredproc.md:171`). This is SQL-side, not TS-side. -- **Dev-prefix match is trim+lowercase** — leading whitespace in the procedure name is tolerated; internal whitespace is URL-encoded and forwarded (`procedure.service.test.ts:105-112`). -- **Result shape is nested `unknown[][]`** — callers must guard for empty result sets and empty row arrays before indexing (`src/services/toolService.ts:132, :189`). +- **Dev-prefix match is case-insensitive** — whitespace anywhere in a procedure name is now refused by the identifier guard. +- **Result shape is nested `unknown[][]`** — callers must guard for empty result sets and empty row arrays before indexing (`src/services/toolService.ts:139, :206`). ## Related docs - `../README.md` — provider overview - `table.md` — sibling table CRUD service -- `../../required-stored-procs.md` — the three stored procs required by the app at runtime, with param shapes -- `../../ministryplatform.storedprocs.md` — full auto-generated procedure catalog +- `../../mp-schema/required-procs.md` — the procs the app calls at runtime, with param shapes +- `../../mp-schema/stored-procs.md` — full auto-generated procedure catalog diff --git a/.claude/references/mp-provider/services/table.md b/.claude/references/mp-provider/services/table.md index a984899..9be5b72 100644 --- a/.claude/references/mp-provider/services/table.md +++ b/.claude/references/mp-provider/services/table.md @@ -5,23 +5,23 @@ type: reference applies_to: [src/lib/providers/ministry-platform/services/table.service.ts] symbols: [TableService, getTableRecords, createTableRecords, updateTableRecords, deleteTableRecords, copyRecord, copyRecordWithSubpages] related: [../README.md, procedure.md, file.md, ../../services/README.md] -last_verified: 2026-04-17 +last_verified: 2026-10-10 --- ## Purpose -HTTP-level wrapper around the Ministry Platform REST `/tables/{table}` endpoints. Stateless; each method calls `client.ensureValidToken()` then the underlying `HttpClient` verb. No validation at this layer — Zod `schema` validation lives one level up in `MPHelper` (see Gotchas). +HTTP-level wrapper around the Ministry Platform REST `/tables/{table}` endpoints. Stateless; each method validates its path segments, calls `client.ensureValidToken()`, then the underlying `HttpClient` verb. No record-body validation at this layer — Zod `schema` validation lives one level up in `MPHelper` (see Gotchas). ## Files -- `src/lib/providers/ministry-platform/services/table.service.ts` — implementation (150 lines) -- `src/lib/providers/ministry-platform/services/table.service.test.ts` — tests (519 lines) +- `src/lib/providers/ministry-platform/services/table.service.ts` — implementation (152 lines) +- `src/lib/providers/ministry-platform/services/table.service.test.ts` — tests (636 lines) ## Key concepts - Constructor takes a `MinistryPlatformClient`; no singleton. - Every method: `ensureValidToken()` → `getHttpClient().{verb}()` → return typed result. - `TableQueryParams` include `$select`, `$filter`, `$orderby`, `$groupby`, `$having`, `$top`, `$skip`, `$distinct`, `$userId`, `$globalFilterId`, `$allowCreate` (see `src/lib/providers/ministry-platform/types/provider.types.ts:1`). -- Table name is URL-encoded via `encodeURIComponent()` on every endpoint (`table.service.ts:22`, `:44`, `:63`, `:87`, `:116`, `:142`). -- **Partial vs full updates** are not parameters on `TableService` — they are a `MPHelper.updateTableRecords` concern (`partial: boolean`, default `true`; see `src/lib/providers/ministry-platform/helper.ts:266-267`). -- **`schema` (Zod validation)** is also not a `TableService` parameter — it is a `MPHelper` concern (`src/lib/providers/ministry-platform/helper.ts:175`, `:256`). `TableService` itself does not validate. +- Table name is validated (`sanitizeIdentifier` via `tableEndpoint` in `services/guards.ts`) and then URL-encoded on every endpoint; `recordId` (copy methods) and each `ids` entry (delete) must be positive safe integers. All checks run before any token work and throw a fixed message naming the field. +- **Partial vs full updates** are not parameters on `TableService` — they are a `MPHelper.updateTableRecords` concern (`partial: boolean`, default `true`; see `src/lib/providers/ministry-platform/helper.ts:286-287`). +- **`schema` (Zod validation)** is also not a `TableService` parameter — it is a `MPHelper` concern (`src/lib/providers/ministry-platform/helper.ts:201`, `:286`). `TableService` itself does not validate. ## API / Interface @@ -75,41 +75,42 @@ public async deleteTableRecords<T extends TableRecord = TableRecord>( | `deleteTableRecords` | DELETE | `/tables/{table}` (ids merged into query as `id: number[]`) | ## How it works -- `deleteTableRecords` merges `ids` into query params as `id: [1, 2, 3]` (`table.service.ts:141`), not in the body. +- `deleteTableRecords` merges `ids` into query params as `id: [1, 2, 3]` (`table.service.ts:145`), not in the body. - `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. +- `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 (JSDoc at `table.service.ts:73`, `:101`). +- `RecurrencePattern` and `CopyParameters` defined in `src/lib/providers/ministry-platform/types/provider.types.ts:220, :245`. +- Errors from `HttpClient` propagate unchanged after `logger.error('mp.table.<op>_failed', { table, recordId?, error: errorName(e) })`. ## Usage -Real callers (via `MPHelper`, not `TableService` directly — see ADR in Gotchas): +Real callers (via `MPHelper`, not `TableService` directly — see CLAUDE.md "Data Flow"): ```typescript -// src/services/groupService.ts:232 — create with $userId +// src/services/groupService.ts:374 — create with $userId (from the AuthorizationService gate) const result = await this.mp!.createTableRecords('Groups', [apiData], { $select: 'Group_ID, Group_Name', - $userId: userId, + $userId, }); -// src/services/groupService.ts:245 — partial update +// src/services/groupService.ts:398 — partial update const result = await this.mp!.updateTableRecords('Groups', [apiData], { partial: true, $select: 'Group_ID, Group_Name', - $userId: userId, + $userId, }); -// src/services/userService.ts:82 — filtered read +// src/services/userService.ts (getUserProfile) — filtered read const records = await this.mp!.getTableRecords<MPUserProfile>({ ... }); ``` With Zod validation (recommended — enforced in `MPHelper`, not `TableService`): ```typescript -// from src/lib/providers/ministry-platform/helper.ts:161-169 (doc example) +// adapted from src/lib/providers/ministry-platform/helper.ts:186-196 (JSDoc example) +// Schemas are not in the models barrel: import from '.../models/ContactLogSchema'. await mp.createTableRecords('Contact_Log', [{ Contact_ID: 12345, - Contact_Date: new Date().toISOString(), + Contact_Date: await tz.toMpSqlDatetime(new Date()), // DomainTimezoneService (CLAUDE.md rule 17) Made_By: 1, Notes: 'Follow-up call completed' }], { @@ -118,27 +119,29 @@ await mp.createTableRecords('Contact_Log', [{ }); ``` +Generated Zod schemas validate `DateTime`/`Timestamp` columns with a regex that accepts the `YYYY-MM-DD HH:MM:SS` string `toMpSqlDatetime` returns as well as ISO forms (`T` separator, optional `Z`/offset), so `schema` and CLAUDE.md rule 17 combine as shown. See `../type-generation.md` § Zod type mapping and `../../ministryplatform.datetimehandling.md`. + ## Error handling -Errors from the underlying `HttpClient` propagate after being logged (`table.service.ts:28`, `:48`, `:67`, `:95`, `:124`, `:147`). Covered by tests: +Errors from the underlying `HttpClient` propagate after being logged (`table.service.ts:29`, `:47`, `:65`, `:93`, `:121`, `:148`). Covered by tests: | Status | Operation | Test location | |---|---|---| -| 400 Bad Request | create (validation failure) | `table.service.test.ts:184-192` | -| 400 Bad Request | copyRecord | `table.service.test.ts:430-444` | -| 403 Forbidden | delete (permission denied) | `table.service.test.ts:323-331` | -| 404 Not Found | get (table/record missing) | `table.service.test.ts:100-108` | -| 404 Not Found | update (record missing) | `table.service.test.ts:241-249` | -| 404 Not Found | delete (record missing) | `table.service.test.ts:313-321` | -| 409 Conflict | create (duplicate key) | `table.service.test.ts:194-202` | -| 500 Server Error | copyRecordWithSubpages | `table.service.test.ts:501-517` | -| auth refresh failure | any | `table.service.test.ts:110-118` | +| 400 Bad Request | create (validation failure) | `table.service.test.ts:196-204` | +| 400 Bad Request | copyRecord | `table.service.test.ts:435-449` | +| 403 Forbidden | delete (permission denied) | `table.service.test.ts:335-343` | +| 404 Not Found | get (table/record missing) | `table.service.test.ts:112-120` | +| 404 Not Found | update (record missing) | `table.service.test.ts:253-261` | +| 404 Not Found | delete (record missing) | `table.service.test.ts:325-333` | +| 409 Conflict | create (duplicate key) | `table.service.test.ts:206-214` | +| 500 Server Error | copyRecordWithSubpages | `table.service.test.ts:506-522` | +| auth refresh failure | any | `table.service.test.ts:122-130` | ## Gotchas -- **`schema` and `partial` params are not on `TableService`** — they are added by `MPHelper` (`helper.ts:175, :256-258`). Calling `new TableService(...).createTableRecords(..., { schema })` is a type error. -- **Ambiguous columns** — when `$filter` / `$select` traverses FKs, prefix tables (e.g., `Contacts.Contact_ID`, `Contact_ID_TABLE.First_Name`). See CLAUDE.md practice #10 and `../../services/README.md`. +- **`schema` and `partial` params are not on `TableService`** — they are added by `MPHelper` (`helper.ts:201, :286-287`). Calling `new TableService(...).createTableRecords(..., { schema })` is a type error. +- **Ambiguous columns** — when `$filter` / `$select` traverses FKs, prefix tables (e.g., `Contacts.Contact_ID`, `Contact_ID_TABLE.First_Name`). See CLAUDE.md practice #10 and `../../services/query-patterns.md`. - **Escape user input in filters** — always `term.replace(/'/g, "''")` before interpolating into `$filter` (CLAUDE.md practice #11). -- **`deleteTableRecords` requires at least one id** — empty array produces `?id=` which is provider-dependent; no runtime guard here. +- **`deleteTableRecords` requires at least one id** — an empty array passes the guard and sends `DELETE /tables/{table}` with no `id` params (an empty array serializes to nothing); callers must check length. - **URL-encoding is applied to table names only** — column names inside `$filter` / `$select` are not encoded by the service. ## Related docs diff --git a/.claude/references/mp-provider/type-generation.md b/.claude/references/mp-provider/type-generation.md index 42f0353..abfa134 100644 --- a/.claude/references/mp-provider/type-generation.md +++ b/.claude/references/mp-provider/type-generation.md @@ -4,32 +4,52 @@ domain: mp-provider type: reference applies_to: - src/lib/providers/ministry-platform/scripts/generate-types.ts + - src/lib/providers/ministry-platform/scripts/codegen-escape.ts + - src/lib/providers/ministry-platform/scripts/generate-storedprocs.ts - src/lib/providers/ministry-platform/models/ + - .claude/references/mp-schema/tables.md + - .claude/references/mp-schema/stored-procs.md symbols: - generate-types + - commentText + - propertyKey + - positiveInt + - mapDataTypeToZod + - MP_DATETIME_PATTERN + - MP_DATE_PATTERN + - generateIndexFile + - generateSchemaDocument related: - architecture.md - ../mp-schema/README.md -last_verified: 2026-04-17 +last_verified: 2026-10-11 --- ## Purpose -`tsx` script that queries Ministry Platform's metadata API via `MPHelper.getTables()` and writes TypeScript interfaces, optional Zod schemas, a barrel `index.ts`, and a summary schema doc. Produces the 603 model files under `src/lib/providers/ministry-platform/models/` consumed by the barrel `export * from './models'` in `index.ts`. +`tsx` script that queries Ministry Platform's metadata API via `MPHelper.getTables()` and writes TypeScript interfaces, optional Zod schemas, a barrel `index.ts`, and the committed table catalog `../mp-schema/tables.md`. Produces the 615 model files (307 interfaces + 307 Zod schemas + `index.ts`, as of the 2026-10-11 regeneration) under `src/lib/providers/ministry-platform/models/` consumed by the barrel `export * from './models'` in `index.ts`. ## Files - `src/lib/providers/ministry-platform/scripts/generate-types.ts` — CLI +- `src/lib/providers/ministry-platform/scripts/codegen-escape.ts` — escaping helpers for MP metadata interpolated into generated source (+ `codegen-escape.test.ts`) +- `src/lib/providers/ministry-platform/scripts/generate-types.test.ts` — unit tests for the Zod mapping, barrel and catalog generators +- `src/lib/providers/ministry-platform/scripts/generate-storedprocs.ts` — sibling CLI writing `../mp-schema/stored-procs.md` (+ `generate-storedprocs.test.ts`) - `src/lib/providers/ministry-platform/scripts/README.md` — human-facing usage -- `src/lib/providers/ministry-platform/models/` — output: 603 `.ts` files (verified by snapshot: `.claude/references/_meta/facts/2026-04-17.md`) -- `.claude/references/ministryplatform.schema.md` — schema doc written by the same script +- `src/lib/providers/ministry-platform/models/` — output: 615 `.ts` files (307 tables × interface + schema, plus `index.ts`) +- `.claude/references/mp-schema/tables.md` — table catalog written by the same script, frontmatter included (`last_verified` = generation date). Written only on a full run; a `--search` run skips it so a partial table list never overwrites the catalog ## Key concepts - **Invoked via npm scripts:** - - `npm run mp:generate` → `tsx .../generate-types.ts` (default output `./generated-types`) - - `npm run mp:generate:models` → `tsx .../generate-types.ts -o src/lib/providers/ministry-platform/models --zod --clean` (canonical) + - `npm run mp:generate` → `tsx --conditions=react-server .../generate-types.ts` (default output `./generated-types`) + - `npm run mp:generate:models` → `tsx --conditions=react-server .../generate-types.ts -o src/lib/providers/ministry-platform/models --zod --clean` (canonical) +- **`--conditions=react-server` is required** for every direct `tsx` run of `generate-types.ts` / `generate-storedprocs.ts`. They import `MPHelper`, and the MP client modules (`helper.ts`, `client.ts`, `auth/client-credentials.ts`) start with `import 'server-only'`, which throws unless resolved under that export condition. The npm scripts pass it. `build-sql-install.ts` imports nothing from the provider, so it doesn't need the flag. See GOTCHA-046. - **Data source:** MP `/tables` metadata endpoint via `MPHelper.getTables(search?)`, which hits `MetadataService` - **Column-driven typing is the default path;** `--detailed` falls back to record-sampling only if a table has no column metadata -- **Field-name safety:** non-identifier field names are quoted (e.g. `"Allow_Check-in"`, `"SSN/EIN"`) — see `generate-types.ts:163-166` +- **Field-name safety:** non-identifier field names are emitted as JSON string literals (e.g. `"Allow_Check-in"`, `"SSN/EIN"`) — `formatFieldName` → `propertyKey` (`codegen-escape.ts`), also applied to the `--detailed` fallback `${name}_ID` key +- **All MP metadata is escaped before interpolation** (the output is committed and compiled, so a hostile/odd value must not become code). One helper per context in `codegen-escape.ts`: + - `propertyKey(name)` — bare if `/^[a-zA-Z_$][a-zA-Z0-9_$]*$/`, else `JSON.stringify(name)` + - `commentText(value)` — line terminators (CR, LF, U+2028, U+2029) → space, `*/` → `*\/`; used for FK target table/column, `SpecialPermissions`, `AccessLevel`, and the table name in the JSDoc header + - `positiveInt(value)` — positive safe integer (number or digit-only string) else `undefined`; gates every `Size` interpolation (`/* max N chars */`, `Max length: N`, Zod `.max(N)`) — a non-numeric size drops the constraint instead of emitting it - **Environment load order:** `.env.local` → `.env.development` → `.env` (each loaded via `dotenv.config`, later files don't override earlier ones because `dotenv` skips already-set keys — so `.env.local` wins) — `generate-types.ts:9-20` ## API / Interface @@ -46,11 +66,11 @@ last_verified: 2026-04-17 | `--clean` | `-c` | flag | `false` | Delete all `.ts` files in output dir before generating | | `--help` | `-h` | flag | — | Show help, exit | -Source: `generate-types.ts:56-104`. +Source: `generate-types.ts:50-98`. ### Required env vars -Validated at startup (`generate-types.ts:512-528`): +Validated at startup (`generate-types.ts:545-561`): - `MINISTRY_PLATFORM_BASE_URL` - `MINISTRY_PLATFORM_CLIENT_ID` - `MINISTRY_PLATFORM_CLIENT_SECRET` @@ -63,19 +83,19 @@ If missing, prints each missing var and `process.exit(1)`. |------|-----------|-------| | `{PascalCaseTable}.ts` | always (valid name) | `export interface {Name} { ... }` + `export type {Name}Record = {Name}` | | `{PascalCaseTable}Schema.ts` | `--zod` AND table has columns | `export const {Name}Schema = z.object({ ... })` + `export type {Name}Input = z.infer<...>` | -| `index.ts` | always | `export * from './{Name}';` for every generated file | -| `ministryplatform.schema.md` | always | Written to `.claude/references/ministryplatform.schema.md` — one-line summary per table | +| `index.ts` | always | `export * from "./{Name}";` for every **interface** file present in the output dir after the run (`generateIndexFile` scans the directory, sorted), so a `--search` run keeps the other tables in the barrel. `*Schema.ts` companions are **not** re-exported; import them by path (`@/lib/providers/ministry-platform/models/ContactLogSchema`) | +| `../mp-schema/tables.md` | full runs (no `--search`) | Written to `.claude/references/mp-schema/tables.md` with its frontmatter — one-line summary per table (`SCHEMA_DOC_PATH`) | ### TypeScript type mapping -From `mapDataTypeToTypeScript` (`generate-types.ts:168-222`): +From `mapDataTypeToTypeScript` (`generate-types.ts`): | MP `ParameterDataType` | TS (required) | Notes | |---|---|---| -| `String`/`Text`/`LargeString` | `string /* max N chars */` | size comment if present | -| `Email` | `string /* email */` | | +| `String`/`Text`/`LargeString` | `string /* max N chars */` | size comment only if `positiveInt(Size)` | +| `Email` | `string /* email */` | `/* email, max N chars */` when sized | | `Phone` | `string /* phone number */` | | -| `Url` | `string /* URL */` | | +| `Url` | `string /* URL */` | `/* URL, max N chars */` when sized | | `Integer16`/`Integer32`/`Integer64` | `number /* ..-bit integer */` | | | `Decimal`/`Real`/`Money` | `number /* decimal */` or `/* currency amount */` | | | `Counter` | `number /* auto-increment */` | | @@ -84,19 +104,19 @@ From `mapDataTypeToTypeScript` (`generate-types.ts:168-222`): | `Time` | `string /* ISO time (HH:MM:SS) */` | | | `DateTime`/`Timestamp` | `string /* ISO datetime */` | | | `Guid` | `string /* GUID/UUID */` | | -| `Binary`/`Image` | `Blob \| string` | | +| `Binary`/`Image` | `Blob \| string /* binary data */` | | | `Xml` | `string /* XML content */` | | -| `Password`/`SecretKey` | `string` | | +| `Password`/`SecretKey` | `string /* password hash */` / `string /* secret key */` | | | `Separator` | `never` | filtered out of interfaces | | default | `unknown` | | Non-required fields append ` | null` and use `?:` instead of `:`. -Annotations emitted as inline comments (`generate-types.ts:359-365`): `Primary Key`, `Foreign Key -> {ReferencedTable}.{ReferencedColumn}`, `Read Only`, `Computed`, `Has Default`. +Annotations emitted as inline comments (`generateDetailedTypeDefinition` in `generate-types.ts`; FK names pass through `commentText`): `Primary Key`, `Foreign Key -> {ReferencedTable}.{ReferencedColumn}`, `Read Only`, `Computed`, `Has Default`. ### Zod type mapping -From `mapDataTypeToZod` (`generate-types.ts:224-287`): +From `mapDataTypeToZod` (`generate-types.ts`; `N` = `positiveInt(col.Size)`, `.max` omitted when undefined): | MP type | Zod | |---|---| @@ -107,16 +127,18 @@ From `mapDataTypeToZod` (`generate-types.ts:224-287`): | `Integer*`/`Counter` | `z.number().int()` | | `Decimal`/`Real`/`Money` | `z.number()` | | `Boolean` | `z.boolean()` | -| `Date` | `z.string().date()` | -| `DateTime`/`Timestamp` | `z.string().datetime()` | +| `Date` | `z.string().regex(/MP_DATE_PATTERN/)` — `YYYY-MM-DD`, optionally followed by a time part as below (MP returns Date columns as `YYYY-MM-DDT00:00:00`) | +| `DateTime`/`Timestamp` | `z.string().regex(/MP_DATETIME_PATTERN/)` — `^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z\|[+-]\d{2}:?\d{2})?$` | | `Guid` | `z.string().uuid()` | | default | `z.unknown()` | Non-required columns chain `.nullable()`. +**Why a regex, not `z.string().datetime()`/`.date()`:** MP stores and returns wall-clock values with no zone marker, and `DomainTimezoneService.toMpSqlDatetime()` emits `YYYY-MM-DD HH:MM:SS` (CLAUDE.md rule 17). Zod 4's `.datetime()` requires a `T` and a `Z`/offset, so it rejected exactly those values. The pattern accepts both wall-clock and ISO (space or `T`, optional seconds/fraction, optional `Z`/`±HH:MM`). It is emitted inline in each `*Schema.ts` (no shared import), so generated files stay standalone; the source of truth is the `MP_DATETIME_PATTERN` / `MP_DATE_PATTERN` constants in `generate-types.ts`. + ### Naming -- `sanitizeTypeName` (`generate-types.ts:140-154`) splits on `-_\s/`, title-cases each piece, strips non-alphanumerics, and prepends `_` if the name starts with a digit +- `sanitizeTypeName` (`generate-types.ts:134-148`) splits on `-_\s/`, title-cases each piece, strips non-alphanumerics, and prepends `_` if the name starts with a digit ## How it works @@ -128,42 +150,43 @@ Non-required columns chain `.nullable()`. 6. For each table with a valid `Name`: - Build `{PascalCase}.ts` from column metadata (default) or sampled records (`--detailed` on tables without columns) - If `--zod` and table has columns, write `{PascalCase}Schema.ts` -7. Write `index.ts` barrel exports -8. Write `.claude/references/ministryplatform.schema.md` via `generateSchemaDocument` +7. Write `index.ts` barrel from a scan of the output directory (`generateIndexFile`) +8. Unless `--search` was given, write `.claude/references/mp-schema/tables.md` (with frontmatter) via `generateSchemaDocument` 9. Print summary: `Successfully generated N table types [+ M Zod schemas] (X total files)` ## Usage ```bash -# Canonical: regenerate all 603 files, clean first, include Zod schemas +# Canonical: regenerate all model files, clean first, include Zod schemas npm run mp:generate:models # Filtered + custom output -npx tsx src/lib/providers/ministry-platform/scripts/generate-types.ts -o ./types -s "Contact" --zod +npx tsx --conditions=react-server src/lib/providers/ministry-platform/scripts/generate-types.ts -o ./types -s "Contact" --zod # Detailed (sample records for tables with no column metadata) -npx tsx src/lib/providers/ministry-platform/scripts/generate-types.ts -d --sample-size 10 +npx tsx --conditions=react-server src/lib/providers/ministry-platform/scripts/generate-types.ts -d --sample-size 10 # Help -npx tsx src/lib/providers/ministry-platform/scripts/generate-types.ts --help +npx tsx --conditions=react-server src/lib/providers/ministry-platform/scripts/generate-types.ts --help ``` The canonical npm script: ```json // package.json -"mp:generate:models": "tsx src/lib/providers/ministry-platform/scripts/generate-types.ts -o src/lib/providers/ministry-platform/models --zod --clean" +"mp:generate:models": "tsx --conditions=react-server src/lib/providers/ministry-platform/scripts/generate-types.ts -o src/lib/providers/ministry-platform/models --zod --clean" ``` ## Gotchas - **Never hand-edit files under `models/`.** The `--clean` flag in `mp:generate:models` deletes every `.ts` file in the output directory before regenerating. Any local edits are lost. -- **`--clean` is scoped to `.ts` extension.** Non-ts files in the output dir survive (see `generate-types.ts:575-579`). -- **`--detailed` only fires when columns are missing.** `generate-types.ts:608-622` — `if (options.detailed && (!table.Columns || table.Columns.length === 0))`. If every table has column metadata, `--detailed` has no effect and doesn't incur sampling costs. -- **Schema doc path is hard-coded.** `generateSchemaDocument` always writes to `./.claude/references/ministryplatform.schema.md` relative to `process.cwd()` (`generate-types.ts:658`). Running the script from a different cwd can write the doc in an unexpected place. -- **`require.main === module` guard is CJS-only.** The script uses `require.main` at `generate-types.ts:712` — works under `tsx` (CJS shim) but would need adjustment if the project moves to pure ESM for scripts. +- **`--clean` is scoped to `.ts` extension.** Non-ts files in the output dir survive (see `generate-types.ts:603-614`); and it only runs when the output dir already exists. +- **`--detailed` only fires when columns are missing.** `generate-types.ts:641` — `if (options.detailed && (!table.Columns || table.Columns.length === 0))`. If every table has column metadata, `--detailed` has no effect and doesn't incur sampling costs. +- **Catalog path is relative to cwd.** `SCHEMA_DOC_PATH` (`.claude/references/mp-schema/tables.md`) is resolved against `process.cwd()`, so run the script from the repo root (the npm scripts do). +- **`--search` never rewrites the catalogs.** `generate-types.ts` skips `tables.md` on a `-s` run; `generate-storedprocs.ts` refuses `-s` without an explicit `-o`. +- **`require.main === module` guard is CJS-only.** The script checks `require.main` at `generate-types.ts:751` (guarded with `typeof require`, so Vitest can import the exported generators without running `main()`) — works under `tsx` (CJS shim) but would need adjustment if the project moves to pure ESM for scripts. - **Zod schemas do not infer nullability from MP metadata perfectly.** `mapDataTypeToZod` uses `.nullable()` for non-required fields, so schemas match the TypeScript `| null` emission, but do not match the `?:` optional marker on the interface. Consumers doing `Schema.parse(record)` on a POSTed record must include the key (or use `.optional()` themselves). ## Related docs - `architecture.md` — where generated models plug into the stack -- `../mp-schema/README.md` — summary schema doc the script emits +- `../mp-schema/README.md` — the catalogs the generators write (`tables.md`, `stored-procs.md`) diff --git a/.claude/references/mp-schema/README.md b/.claude/references/mp-schema/README.md index 8189467..ab774ff 100644 --- a/.claude/references/mp-schema/README.md +++ b/.claude/references/mp-schema/README.md @@ -11,19 +11,19 @@ Auto-generated catalogs of Ministry Platform tables and stored procedures, plus | File | Purpose | When to read | |------|---------|--------------| -| `tables.md` | 301 MP tables with PKs, FKs, access levels (auto-generated) | Looking up a table name, PK, or FK relationship before writing a query | -| `stored-procs.md` | 532 MP stored procedures with parameter signatures (auto-generated) | Verifying a proc name, its parameters, or bounded string sizes | -| `required-procs.md` | The 7 procs the app actively calls via `ToolService` + `FieldManagementService` + infrastructure docs | Working on `ToolService`, `FieldManagementService`, page/selection lookups, or user tool routing | -| `data-model-map.md` | Map from MP schema → which tables, FK traversals, and procs the app actually queries (19 hot tables, 7 procs) | Understanding data access footprint or impact of schema changes | +| `tables.md` | 307 MP tables with PKs, FKs, access levels (auto-generated) | Looking up a table name, PK, or FK relationship before writing a query | +| `stored-procs.md` | 539 MP stored procedures with parameter signatures (auto-generated) | Verifying a proc name, its parameters, or bounded string sizes | +| `required-procs.md` | The 7 procs the app actively calls via `ToolService` + `FieldManagementService`, plus the installed-but-uncalled procs in `db/` | Working on `ToolService`, `FieldManagementService`, page/selection lookups, or user tool routing | +| `data-model-map.md` | Map from MP schema → which tables, FK traversals, and procs the app actually queries (34 tables, 7 procs) | Understanding data access footprint or impact of schema changes | ## Regeneration commands | Output | Command | Generator | |---|---|---| -| `tables.md` content | `npm run mp:generate:models` | `src/lib/providers/ministry-platform/scripts/generate-types.ts` (runs with `--clean --zod`, emits `.ts` + `Schema.ts` under `src/lib/providers/ministry-platform/models/`) | -| `stored-procs.md` content | `npm run mp:generate:storedprocs` | `src/lib/providers/ministry-platform/scripts/generate-storedprocs.ts` | +| `tables.md` | `npm run mp:generate:models` | `src/lib/providers/ministry-platform/scripts/generate-types.ts` (runs with `--clean --zod`, emits `.ts` + `Schema.ts` under `src/lib/providers/ministry-platform/models/`, and writes `tables.md` directly — skipped on a `--search` run) | +| `stored-procs.md` | `npm run mp:generate:storedprocs` | `src/lib/providers/ministry-platform/scripts/generate-storedprocs.ts` (writes `stored-procs.md` directly; `-o` overrides, and a `-s` run requires `-o`) | -The auto-generated markdown in `tables.md` and `stored-procs.md` is a snapshot — rerun the generator when MP schema drifts. +Both generators write the whole file, frontmatter included, with `last_verified` set to the generation date — do not hand-edit them; regenerate instead. Last regenerated from the live MP instance on 2026-10-11. ## Code surfaces @@ -32,7 +32,7 @@ The auto-generated markdown in `tables.md` and `stored-procs.md` is a snapshot | `src/lib/providers/ministry-platform/models/` | Generated TS types + Zod schemas for all MP tables | | `src/lib/providers/ministry-platform/services/procedure.service.ts` | HTTP client for `/procs/{name}` | | `src/lib/providers/ministry-platform/helper.ts` | `MPHelper` facade (`executeProcedure`, `executeProcedureWithBody`, `getTableRecords`, etc.) | -| `src/services/toolService.ts` | App-level service wrapping the 3 required procs | +| `src/services/toolService.ts` | App-level service wrapping 5 of the 7 procs (`fieldManagementService.ts` wraps the other `api_MPNextTools_*` procs) | ## Related domains diff --git a/.claude/references/mp-schema/data-model-map.md b/.claude/references/mp-schema/data-model-map.md index a200c49..965b195 100644 --- a/.claude/references/mp-schema/data-model-map.md +++ b/.claude/references/mp-schema/data-model-map.md @@ -5,7 +5,7 @@ type: reference applies_to: [src/services/**, src/components/**/actions.ts] symbols: [] related: [tables.md, stored-procs.md, required-procs.md, ../services/query-patterns.md] -last_verified: 2026-04-17 +last_verified: 2026-10-10 --- ## Purpose @@ -17,28 +17,22 @@ Call site = one invocation of `getTableRecords`/`createTableRecords`/`updateTabl | Table | Call sites | Consumers | |---|---|---| -| `Groups` | 4 | `src/services/groupService.ts:164` (searchGroups), `:174` (getGroup), `:232` (createGroup), `:245` (updateGroup) | -| `Contacts` | 3 | `src/services/groupService.ts:153` (searchContacts), `src/services/addressLabelService.ts:79` (getAddressesForContacts), `:96` (getAddressForContact) | -| `dp_Users` | 2 | `src/services/userService.ts:69` (getUserIdByGuid), `:82` (getUserProfile) | -| `dp_User_Roles` | 1 | `src/services/userService.ts:93` (roles in getUserProfile) | -| `dp_User_User_Groups` | 1 | `src/services/userService.ts:98` (user groups in getUserProfile) | -| `dp_Roles` | 1 | `src/services/toolService.ts:232` (listRoles) | -| `Group_Types` | 1 | `src/services/groupService.ts:63` (fetchAllLookups) | -| `Ministries` | 1 | `src/services/groupService.ts:68` | -| `Congregations` | 1 | `src/services/groupService.ts:74` | -| `Meeting_Days` | 1 | `src/services/groupService.ts:80` | -| `Meeting_Frequencies` | 1 | `src/services/groupService.ts:85` | -| `Meeting_Durations` | 1 | `src/services/groupService.ts:90` | -| `Life_Stages` | 1 | `src/services/groupService.ts:95` | -| `Group_Focuses` | 1 | `src/services/groupService.ts:100` | -| `Priorities` | 1 | `src/services/groupService.ts:105` | -| `Rooms` | 1 | `src/services/groupService.ts:110` | -| `Books` | 1 | `src/services/groupService.ts:116` | -| `dp_SMS_Numbers` | 1 | `src/services/groupService.ts:121` | -| `Group_Ended_Reasons` | 1 | `src/services/groupService.ts:127` | -| `<dynamic>` | 1 | `src/services/toolService.ts:335` (resolveContactIds — table name supplied at runtime from `api_Tools_GetPageData` result) | - -Unique tables touched (excluding the dynamic case): **19**. Lookup tables at `groupService.fetchAllLookups` dominate the count but each fires once per wizard load. +| `Contacts` | 10 | `src/services/groupService.ts:248` (searchContacts); `src/services/addressLabelService.ts:86` (getAddressesForContacts), `:109` (getAddressForContact); `src/services/familyService.ts:118` (searchContacts), `:200`, `:243` (getHousehold), `:614`/`:621` (saveMember update/create), `:668` (link `Participant_Record`), `:794` (link `Donor_Record`) | +| `Groups` | 4 | `src/services/groupService.ts:263` (searchGroups), `:280` (getGroup), `:374` (createGroup), `:398` (updateGroup) | +| `Donors` | 4 | `src/services/familyService.ts:465` (getNextEnvelopeNumber), `:738` (resolveUniqueEnvelopeNo), `:772`/`:788` (upsertDonor update/create) | +| `Households` | 3 | `src/services/familyService.ts:214` (getHousehold), `:515`/`:524` (saveHousehold create/update) | +| `Addresses` | 2 | `src/services/familyService.ts:564`/`:572` (upsertAddress update/create) | +| `Participants` | 2 | `src/services/familyService.ts:633`/`:646` (saveMember update/create) | +| `Congregations` | 2 | `src/services/groupService.ts:164` (fetchAllLookups), `src/services/familyService.ts:360` (getLookups) | +| `dp_Users` | 2 | `src/services/authorizationService.ts:44` (`resolveUserId`), `src/services/userService.ts:60` (`getUserProfile`) | +| `dp_User_Roles` | 1 | `src/services/authorizationService.ts:57` (`loadSecurityRoles`) | +| `dp_Roles` | 1 | `src/services/toolService.ts:257` (listRoles) | +| `dp_Configuration_Settings` | 1 | `src/services/googlePlacesService.ts:35` (resolveApiKey — `GoogleMapsAPIKey`) | +| `Group_Types`, `Ministries`, `Meeting_Days`, `Meeting_Frequencies`, `Meeting_Durations`, `Life_Stages`, `Group_Focuses`, `Priorities`, `Rooms`, `Books`, `dp_SMS_Numbers`, `Group_Ended_Reasons` | 1 each | `src/services/groupService.ts:153`–`:217` (fetchAllLookups) | +| `Household_Sources`, `Household_Positions`, `Participant_Types`, `Marital_Statuses`, `Prefixes`, `Suffixes`, `Genders`, `Contact_Statuses`, `Primary_Languages`, `Faith_Backgrounds`, `Countries` | 1 each | `src/services/familyService.ts:366`–`:416` (getLookups) | +| `<dynamic>` | 2 | `src/services/toolService.ts:372` (resolveContactIds — table from `api_Tools_GetPageData`), `src/services/familyService.ts:182` (resolveContactIdFromPage — table/PK/FK path from the caller, validated with `validateColumnName`/`validateFkPath`) | + +Unique tables touched (excluding the dynamic cases): **34**. Lookup tables (`groupService.fetchAllLookups`, `familyService.getLookups`) dominate the count but each fires once per wizard/form load. ## FK traversals used @@ -46,26 +40,18 @@ Scanned from `$select`/`orderBy` strings using the `_TABLE` suffix convention. | FK chain (in `$select`/`orderBy`) | Base table | Consumer (file:line) | Purpose | |---|---|---|---| -| `Contact_ID_TABLE.First_Name` | `dp_Users` | `src/services/userService.ts:85` | User profile name (via linked Contact) | -| `Contact_ID_TABLE.Nickname` | `dp_Users` | `src/services/userService.ts:85` | Preferred display name | -| `Contact_ID_TABLE.Last_Name` | `dp_Users` | `src/services/userService.ts:85` | User profile surname | -| `Contact_ID_TABLE.Email_Address` | `dp_Users` | `src/services/userService.ts:85` | User profile email | -| `Contact_ID_TABLE.Mobile_Phone` | `dp_Users` | `src/services/userService.ts:85` | User profile phone | -| `Contact_ID_TABLE.dp_fileUniqueId AS Image_GUID` | `dp_Users` | `src/services/userService.ts:85` | User avatar image GUID | -| `Role_ID_TABLE.Role_Name` | `dp_User_Roles` | `src/services/userService.ts:96` | Role labels for the authenticated user | -| `User_Group_ID_TABLE.User_Group_Name` | `dp_User_User_Groups` | `src/services/userService.ts:101` | User-group labels for the authenticated user | -| `Group_Type_ID_TABLE.Group_Type` | `Groups` | `src/services/groupService.ts:166` | Group search result badge | -| `Household_ID_TABLE.Household_Name` | `Contacts` | `src/services/addressLabelService.ts:29` | Household label on mailing labels | -| `Household_ID_TABLE.Bulk_Mail_Opt_Out` | `Contacts` | `src/services/addressLabelService.ts:30` | Opt-out filter for bulk mailings | -| `Household_ID_TABLE_Address_ID_TABLE.Address_Line_1` | `Contacts` | `src/services/addressLabelService.ts:31` | 2-level FK → household address line 1 | -| `Household_ID_TABLE_Address_ID_TABLE.Address_Line_2` | `Contacts` | `src/services/addressLabelService.ts:32` | 2-level FK → household address line 2 | -| `Household_ID_TABLE_Address_ID_TABLE.City` | `Contacts` | `src/services/addressLabelService.ts:33` | 2-level FK → city | -| `Household_ID_TABLE_Address_ID_TABLE.[State/Region]` | `Contacts` | `src/services/addressLabelService.ts:34` | 2-level FK → state (bracketed due to `/`) | -| `Household_ID_TABLE_Address_ID_TABLE.Postal_Code` | `Contacts` | `src/services/addressLabelService.ts:35`, `:83` (orderBy) | 2-level FK → ZIP; also `orderBy` | -| `Household_ID_TABLE_Address_ID_TABLE.Bar_Code` | `Contacts` | `src/services/addressLabelService.ts:36` | 2-level FK → USPS delivery barcode | -| `Household_ID_TABLE_Address_ID_TABLE.Delivery_Point_Code` | `Contacts` | `src/services/addressLabelService.ts:37` | 2-level FK → delivery point | - -Note the `Contacts.Household_ID` disambiguation at `src/services/addressLabelService.ts:28` — required because `Household_ID` exists on both `Contacts` and the joined `Households` table. See `../services/query-patterns.md` for the rule. +| `Contact_ID_TABLE.{First_Name,Nickname,Last_Name,Email_Address}` | `dp_Users` | `src/services/userService.ts:63` (`getUserProfile`) | User profile via linked Contact | +| `Role_ID_TABLE.Role_Name` | `dp_User_Roles` | `src/services/authorizationService.ts:59` (`loadSecurityRoles`) | Security-role names for the authorization gate | +| `Group_Type_ID_TABLE.Group_Type` | `Groups` | `src/services/groupService.ts:265` | Group search result badge | +| `Primary_Contact_TABLE.Display_Name`, `Parent_Group_TABLE.Group_Name`, `Promote_to_Group_TABLE.Group_Name`, `Descended_From_TABLE.Group_Name` (all `AS` aliases) | `Groups` | `src/services/groupService.ts:280` (getGroup) | Display names for edit-mode seeding | +| `Household_ID_TABLE.Household_Name` | `Contacts` | `src/services/addressLabelService.ts:30` | Household label on mailing labels | +| `Household_ID_TABLE.Bulk_Mail_Opt_Out` | `Contacts` | `src/services/addressLabelService.ts:31` | Opt-out filter for bulk mailings | +| `Household_ID_TABLE_Address_ID_TABLE.{Address_Line_1,Address_Line_2,City,[State/Region],Postal_Code,Bar_Code,Delivery_Point_Code}` | `Contacts` | `src/services/addressLabelService.ts:32-38`, `:90` (orderBy `Postal_Code`) | 2-level FK → household address (`[State/Region]` bracketed due to `/`) | +| `Household_ID_TABLE_Address_ID_TABLE.Address_Line_1` | `Contacts` | `src/services/familyService.ts:129` (searchContacts) | Address hint in contact search | +| `Address_ID_TABLE.*`, `Alternate_Mailing_Address_TABLE.*` (`AS Addr1_*` / `Addr2_*`) | `Households` | `src/services/familyService.ts:227-238` | Main + alternate mailing address | +| `Participant_Record_TABLE.Participant_Type_ID`, `Donor_Record_TABLE.Envelope_No` | `Contacts` | `src/services/familyService.ts:267-268` | Member participant type + envelope | + +Note the `Contacts.Household_ID` disambiguation at `src/services/addressLabelService.ts:29` — required because `Household_ID` exists on both `Contacts` and the joined `Households` table. `familyService.getHousehold` prefixes every column (`Households.*`, `Contacts.*`) for the same reason. See `../services/query-patterns.md` for the rule. ## Stored procs called in code @@ -73,29 +59,30 @@ Cross-referenced with `required-procs.md`. All call via `MPHelper.executeProcedu | Proc | Caller (file:line) | Purpose | |---|---|---| -| `api_Tools_GetPageData` | `src/services/toolService.ts:127` (getPageData) | Resolve Page_ID → table name + primary key | -| `api_Common_GetSelection` | `src/services/toolService.ts:154` (getSelectionRecordIds) | Selection_ID → Record_ID[] | -| `api_Tools_GetUserTools` | `src/services/toolService.ts:185` (getUserTools) | User_ID → authorized Tool_Path[] | -| `api_MPNextTools_GetPages` | `src/services/toolService.ts:207` (listPages), `src/services/fieldManagementService.ts:44` (getPages) | List MP pages for pickers | -| `api_MPNextTools_GetPageFields` | `src/services/fieldManagementService.ts:54` (getPageFields) | Per-page field metadata | -| `api_MPNextTools_UpdatePageFieldOrder` | `src/services/fieldManagementService.ts:94` (updatePageFieldOrder) | Upsert field config (batched, concurrency 5) | -| `api_dev_DeployTool` | `src/services/toolService.ts:268` (deployTool) | Dev-creds deployment of a tool | +| `api_Tools_GetPageData` | `src/services/toolService.ts:134` (getPageData) | Resolve Page_ID → table name + primary key | +| `api_Common_GetSelection` | `src/services/toolService.ts:168` (getSelectionRecordIds) | Selection_ID → Record_ID[] | +| `api_Tools_GetUserTools` | `src/services/toolService.ts:202` (getUserTools) | User_ID → authorized Tool_Path[] | +| `api_MPNextTools_GetPages` | `src/services/toolService.ts:228` (listPages), `src/services/fieldManagementService.ts:50` (getPages) | List MP pages for pickers | +| `api_MPNextTools_GetPageFields` | `src/services/fieldManagementService.ts:64` (getPageFields) | Per-page field metadata | +| `api_MPNextTools_UpdatePageFieldOrder` | `src/services/fieldManagementService.ts:118` (updatePageFieldOrder) | Upsert field config (batched, concurrency 5) | +| `api_dev_DeployTool` | `src/services/toolService.ts:297` (deployTool) | Dev-creds deployment of a tool | ## Common filter patterns -- `End_Date IS NULL` — active-row filter on `Ministries`, `Congregations`, `Groups`. `src/services/groupService.ts:71`, `:77`, `:167`. -- `Active = 1` — `dp_SMS_Numbers` active filter. `src/services/groupService.ts:124`. -- `Bookable = 1` — `Rooms` filter. `src/services/groupService.ts:113`. -- `<Name> LIKE '<escaped>%'` — prefix search (user-provided term is passed through `escapeFilterString`). `src/services/groupService.ts:156` (contacts), `:167` (groups); `src/services/toolService.ts:229` uses `%term%` for roles. -- `<PK> IN (<csv>)` — batched ID lookups (batch size 100). `src/services/addressLabelService.ts:82`, `src/services/toolService.ts:338`. -- `User_GUID = '<validated>'` — GUID lookup on `dp_Users` (passed through `validateGuid`). `src/services/userService.ts:72`, `:84`. -- `<PK> = <validatedInt>` — single-record lookup (input through `validatePositiveInt`). `src/services/groupService.ts:176`. +- `End_Date IS NULL` — active-row filter on `Ministries`, `Congregations`, `Groups`. `src/services/groupService.ts:161`, `:167`, `:266`; `src/services/familyService.ts:363`. +- `Active = 1` — `dp_SMS_Numbers` active filter. `src/services/groupService.ts:214`. +- `Bookable = 1` — `Rooms` filter. `src/services/groupService.ts:203`. +- `<Name> LIKE '<escaped>%'` — prefix search (user-provided term is passed through `escapeFilterString`). `src/services/groupService.ts:251` (contacts), `:266` (groups); `src/services/familyService.ts:131` (contacts, plus `Contact_Status_ID = 1`); `src/services/toolService.ts:254` uses `%term%` for roles. +- `<PK> IN (<csv>)` — batched ID lookups (`MP_FETCH_BATCH_SIZE` = 100, `src/lib/constants.ts`). `src/services/addressLabelService.ts:89`, `src/services/toolService.ts:375`. +- `User_GUID = '<validated>'` — GUID lookup on `dp_Users` (passed through `validateGuid`). `src/services/userService.ts:62`, `src/services/authorizationService.ts:47`. +- `<PK> = <validatedInt>` — single-record lookup (input through `validatePositiveInt`). `src/services/groupService.ts:289`, `src/services/familyService.ts:185`, `:206`. +- `MAX(Envelope_No) AS Highest` — aggregate in `$select` on `Donors`. `src/services/familyService.ts:467`. -All user-supplied strings in filters are escaped via `escapeFilterString` or validated via `validateGuid`/`validatePositiveInt`/`validateColumnName` from `src/lib/validation.ts`. +All user-supplied strings in filters are escaped via `escapeFilterString` or validated via `validateGuid`/`validatePositiveInt`/`validateColumnName`/`validateFkPath` from `src/lib/validation.ts`. ## Scope -Code touches **19 unique tables** (+1 runtime-resolved table in `ToolService.resolveContactIds`) out of **301** in the MP schema (~6%). The remaining ~94% are untouched by application code; they are not enumerated here. Full schema is in `tables.md`. +Code touches **34 unique tables** (+ runtime-resolved tables in `ToolService.resolveContactIds` and `FamilyService.resolveContactIdFromPage`) out of **301** in the MP schema (~11%). The rest are untouched by application code; they are not enumerated here. Full schema is in `tables.md`. ## Related docs diff --git a/.claude/references/mp-schema/required-procs.md b/.claude/references/mp-schema/required-procs.md index e0fde22..bf1f7ed 100644 --- a/.claude/references/mp-schema/required-procs.md +++ b/.claude/references/mp-schema/required-procs.md @@ -5,7 +5,7 @@ type: reference applies_to: [src/services/toolService.ts, src/services/fieldManagementService.ts, src/lib/providers/ministry-platform/services/procedure.service.ts, src/lib/providers/ministry-platform/helper.ts] symbols: [api_Tools_GetPageData, api_Common_GetSelection, api_Tools_GetUserTools, api_MPNextTools_GetPages, api_MPNextTools_GetPageFields, api_MPNextTools_UpdatePageFieldOrder, api_dev_DeployTool] related: [stored-procs.md, ../services/README.md] -last_verified: 2026-04-17 +last_verified: 2026-10-10 --- ## Purpose @@ -16,8 +16,8 @@ Stored procedures actively invoked by the application (7 procs). All are called | Proc | Caller | Notes | |------|--------|-------| | `api_Tools_GetPageData` | `ToolService.getPageData()` | Page metadata lookup | -| `api_Common_GetSelection` | `ToolService.getSelectionRecordIds()` | Selection → Record_ID[] | -| `api_Tools_GetUserTools` | `ToolService.getUserTools()` | User → tool paths | +| `api_Common_GetSelection` | `ToolService.getSelectionRecordIds()` | Selection → Record_ID[]; `@UserID` from the auth gate | +| `api_Tools_GetUserTools` | `ToolService.getUserTools()` | User → tool paths; `@UserId` from the auth gate | | `api_MPNextTools_GetPages` | `ToolService.listPages()`, `FieldManagementService.getPages()` | No params; in-memory filter | | `api_MPNextTools_GetPageFields` | `FieldManagementService.getPageFields()` | Per-page field metadata | | `api_MPNextTools_UpdatePageFieldOrder` | `FieldManagementService.updatePageFieldOrder()` | Per-field mutation | @@ -27,7 +27,8 @@ Stored procedures actively invoked by the application (7 procs). All are called - HTTP: `POST /procs/{procedureName}` with body `{ "@ParamName": value, ... }` - Return: `unknown[][]` (array of result sets, each an array of row objects) -- `@DomainID` is auto-injected by the MP API +- `@DomainID` is auto-injected by the MP API — never pass it. SQL side: every `api_*` proc must declare `@DomainID INT` as its **first** parameter or MP will not expose it; all procs in `src/lib/providers/ministry-platform/db/` do +- Every caller runs `AuthorizationService.requireSecurityRole(...)` before the call; user IDs passed to procs come from that gate's return value, never from the caller Call chain: @@ -45,12 +46,15 @@ Server Action → {ToolService | FieldManagementService} (singleton) → MPHelpe |-----------|-----------|-----------|-------------| | @PageID | Input | Integer32 | Ministry Platform Page ID | -**Return:** First result set, first row → `PageData` (`src/lib/tool-params.ts`) with fields: `Page_ID`, `Display_Name`, `Table_Name`, `Primary_Key`. +**Return:** First result set, first row → `PageData` (`src/lib/tool-params.ts:14`): `Page_ID`, `Display_Name`, `Singular_Name`, `Table_Name`, `Primary_Key`, plus optional fields such as `Contact_ID_Field`, `Selected_Record_Expression`, `Filter_Clause`. -**Called by:** `ToolService.getPageData(pageID)` at `src/services/toolService.ts:123` +**DDL:** not in this repo (MP-provided proc). + +**Called by:** `ToolService.getPageData(pageID)` at `src/services/toolService.ts:126` (call at `:134`) **Consumer(s):** -- `fetchAddressLabels()` in `src/components/address-labels/actions.ts` +- `resolveContactIdFromPage()` in `src/app/(web)/tools/addeditfamily/actions.ts` +- `ToolService.resolveContactIds()` (internal) → `resolveContactRecords()` in `src/components/dev-panel/panels/contact-records-actions.ts` --- @@ -66,7 +70,9 @@ Server Action → {ToolService | FieldManagementService} (singleton) → MPHelpe **Return:** Searches all result sets for one containing `Record_ID` column → extracts `number[]`. -**Called by:** `ToolService.getSelectionRecordIds(selectionId, userId, pageId)` at `src/services/toolService.ts:152` +**DDL:** not in this repo (MP-provided proc). + +**Called by:** `ToolService.getSelectionRecordIds(selectionId, pageId)` at `src/services/toolService.ts:162` (call at `:168`). `@UserID` is the gate's return value — never caller-supplied. **Consumer(s):** - `fetchAddressLabels()` in `src/components/address-labels/actions.ts` @@ -84,7 +90,9 @@ Server Action → {ToolService | FieldManagementService} (singleton) → MPHelpe **Return:** First result set → array of `{ Tool_Path: string }`. -**Called by:** `ToolService.getUserTools(userId)` at `src/services/toolService.ts:183` +**DDL:** not in this repo (MP-provided proc). + +**Called by:** `ToolService.getUserTools()` at `src/services/toolService.ts:196` (call at `:202`). `@UserId` is the gate's return value. **Consumer(s):** - `getUserTools()` in `src/components/dev-panel/panels/user-tools-actions.ts` @@ -93,7 +101,7 @@ Server Action → {ToolService | FieldManagementService} (singleton) → MPHelpe ## api_MPNextTools_GetPages -**Purpose:** Lists every MP `dp_Pages` row that MPNext Tools cares about (for deploy-tool and field-management pickers). SP takes no search param; callers filter in-memory and cap results. +**Purpose:** Lists every MP `dp_Pages` row (`Page_ID, Display_Name, Table_Name`, ordered by `Display_Name`) for the deploy-tool and field-management pickers. SP takes no search param; callers filter in-memory and cap results. | Parameter | Direction | Data Type | Description | |-----------|-----------|-----------|-------------| @@ -104,8 +112,8 @@ Server Action → {ToolService | FieldManagementService} (singleton) → MPHelpe **DDL:** `src/lib/providers/ministry-platform/db/api_MPNextTools_GetPages.sql` **Called by:** -- `ToolService.listPages(search?)` at `src/services/toolService.ts:206` -- `FieldManagementService.getPages()` at `src/services/fieldManagementService.ts:43` +- `ToolService.listPages(search?)` at `src/services/toolService.ts:223` (in-memory filter on `Display_Name`/`Table_Name`, capped at 100) +- `FieldManagementService.getPages()` at `src/services/fieldManagementService.ts:45` --- @@ -121,7 +129,7 @@ Server Action → {ToolService | FieldManagementService} (singleton) → MPHelpe **DDL:** `src/lib/providers/ministry-platform/db/api_MPNextTools_GetPageFields.sql` -**Called by:** `FieldManagementService.getPageFields(pageId)` at `src/services/fieldManagementService.ts:53` +**Called by:** `FieldManagementService.getPageFields(pageId)` at `src/services/fieldManagementService.ts:59` --- @@ -131,23 +139,23 @@ Server Action → {ToolService | FieldManagementService} (singleton) → MPHelpe | Parameter | Direction | Data Type | Description | |-----------|-----------|-----------|-------------| -| @PageID | Input | Integer32 | Page ID | -| @FieldName | Input | String | Field column name | -| @GroupName | Input | String (nullable) | UI group name | -| @ViewOrder | Input | Integer32 | Display order | -| @Required | Input | Boolean | Required flag | -| @Hidden | Input | Boolean | Hidden flag | -| @DefaultValue | Input | String (nullable) | Default literal | -| @FilterClause | Input | String (nullable) | DDL filter | -| @DependsOnField | Input | String (nullable) | Dependency field name | -| @FieldLabel | Input | String (nullable) | Display label | -| @WritingAssistantEnabled | Input | Boolean | AI-assist toggle | +| @PageID | Input | INT | Page ID | +| @FieldName | Input | VARCHAR(64) | Field column name | +| @GroupName | Input | NVARCHAR(75) = NULL | UI group name | +| @ViewOrder | Input | SMALLINT = NULL | Display order | +| @Required | Input | BIT = 0 | Required flag | +| @Hidden | Input | BIT = 0 | Hidden flag | +| @DefaultValue | Input | NVARCHAR(128) = NULL | Default literal | +| @FilterClause | Input | NVARCHAR(2000) = NULL | Lookup filter clause | +| @DependsOnField | Input | VARCHAR(64) = NULL | Dependency field name | +| @FieldLabel | Input | NVARCHAR(64) = NULL | Display label | +| @WritingAssistantEnabled | Input | BIT = 0 | AI-assist toggle | **Return:** Not consumed. **DDL:** `src/lib/providers/ministry-platform/db/api_MPNextTools_UpdatePageFieldOrder.sql` -**Called by:** `FieldManagementService.updatePageFieldOrder(fields[])` at `src/services/fieldManagementService.ts:76` +**Called by:** `FieldManagementService.updatePageFieldOrder(...)` at `src/services/fieldManagementService.ts:90` (call at `:118`, batches of `CONCURRENCY = 5`) --- @@ -157,22 +165,22 @@ Server Action → {ToolService | FieldManagementService} (singleton) → MPHelpe | Parameter | Direction | Data Type | Description | |-----------|-----------|-----------|-------------| -| @ToolName | Input | String(30) | Tool name | -| @LaunchPage | Input | String(1024) | Launch URL / route | -| @Description | Input | String(100) nullable | Tool description | -| @LaunchWithCredentials | Input | Bit (0/1) | Include creds on launch | -| @LaunchWithParameters | Input | Bit (0/1) | Include params on launch | -| @LaunchInNewTab | Input | Bit (0/1) | Open in new tab | -| @ShowOnMobile | Input | Bit (0/1) | Visible on mobile | -| @PageIDs | Input | String (CSV, nullable) | Comma-joined `Page_ID` list | -| @AdditionalData | Input | String(65) nullable | Free-form metadata | -| @RoleIDs | Input | String (CSV, nullable) | Comma-joined `Role_ID` list | +| @ToolName | Input | NVARCHAR(30) | Tool name | +| @LaunchPage | Input | NVARCHAR(1024) | Launch URL / route | +| @Description | Input | NVARCHAR(100) = NULL | Tool description | +| @LaunchWithCredentials | Input | BIT = 1 | Include creds on launch | +| @LaunchWithParameters | Input | BIT = 1 | Include params on launch | +| @LaunchInNewTab | Input | BIT = 0 | Open in new tab | +| @ShowOnMobile | Input | BIT = 0 | Visible on mobile | +| @PageIDs | Input | NVARCHAR(MAX) = NULL | Comma-joined `Page_ID` list | +| @AdditionalData | Input | NVARCHAR(65) = NULL | Applied to each inserted `dp_Tool_Pages` row | +| @RoleIDs | Input | NVARCHAR(MAX) = NULL | Comma-joined `Role_ID` list; Administrators auto-included | **Return:** 3 result sets — `[toolRow]`, `pageRows[]`, `roleRows[]`. Consumed as `DeployToolResult`. **DDL:** `src/lib/providers/ministry-platform/db/api_dev_DeployTool.sql` -**Called by:** `ToolService.deployTool(input)` at `src/services/toolService.ts:247` +**Called by:** `ToolService.deployTool(input)` at `src/services/toolService.ts:272` (call at `:297`, with `{ $userId }` from the gate as a query param) --- @@ -186,9 +194,18 @@ Server Action → {ToolService | FieldManagementService} (singleton) → MPHelpe | `src/services/toolService.ts` | Application service wrapping `api_Tools_GetPageData`, `api_Common_GetSelection`, `api_Tools_GetUserTools`, `api_MPNextTools_GetPages` (partial), `api_dev_DeployTool` | | `src/services/fieldManagementService.ts` | Application service wrapping `api_MPNextTools_GetPages`, `api_MPNextTools_GetPageFields`, `api_MPNextTools_UpdatePageFieldOrder` | -## Note on `api_CloudTools_GetSelection` +## Installed but not called by app code + +These ship in `src/lib/providers/ministry-platform/db/` (combined into `_INSTALL/ministryplatform-install.sql` by `npm run mp:build:install`) but nothing in `src/` invokes them. All declare `@DomainID INT` first. + +| Proc | DDL | +|---|---| +| `api_Common_GenerateSlug` | `db/api_Common_GenerateSlug.sql` | +| `api_Common_StoredProcParameters` | `db/api_Common_StoredProcParameters.sql` (skips `@DomainID` when listing params, `:92`) | +| `api_dev_GetProcedureDefinition` | `db/api_dev_GetProcedureDefinition.sql` | +| `api_MPNextTools_GetAuditLog` | `db/api_MPNextTools_GetAuditLog.sql` | -The facts snapshot lists `api_CloudTools_GetSelection` as a referenced proc. As of 2026-04-17, only the newer `api_Common_GetSelection` is invoked from `ToolService`. The `CloudTools` name survives in code comments only — see TODO `2026-04-17-services-stale-cloudtools-comment.md`. +`api_CloudTools_GetSelection` (in `stored-procs.md`) is the legacy name; only `api_Common_GetSelection` is called. ## Related docs diff --git a/.claude/references/mp-schema/stored-procs.md b/.claude/references/mp-schema/stored-procs.md index cf805bb..6f606b6 100644 --- a/.claude/references/mp-schema/stored-procs.md +++ b/.claude/references/mp-schema/stored-procs.md @@ -2,12 +2,12 @@ title: Ministry Platform Stored Procedures domain: mp-schema type: generated-reference -last_verified: 2026-04-17 +last_verified: 2026-10-11 source: npm run mp:generate:storedprocs --- ## Purpose -Auto-generated reference of every Ministry Platform stored procedure exposed via the MP API, with its input parameters and bounded string sizes. Regenerate with `npm run mp:generate:storedprocs` (runs `src/lib/providers/ministry-platform/scripts/generate-storedprocs.ts`). +Auto-generated reference of every Ministry Platform stored procedure exposed via the MP API, with its input parameters and bounded string sizes. Regenerate with `npm run mp:generate:storedprocs` (runs `src/lib/providers/ministry-platform/scripts/generate-storedprocs.ts`). Do not edit by hand — the generator overwrites this file. ## How to call - HTTP: `POST /procs/{procName}` with JSON body `{ "@ParamName": value, ... }` (or GET for simple query params). @@ -17,14 +17,14 @@ Auto-generated reference of every Ministry Platform stored procedure exposed via ## Generated metadata -**Generated (source):** 2026-04-16T02:10:03.119Z -**Procedures:** 532 +**Generated (source):** 2026-10-11T11:13:44.159Z +**Procedures:** 539 --- All procedures listed with input parameters. String sizes shown where bounded, e.g. `String(50)`. -### api_* (526) +### api_* (533) - `api_Advanced_EventsAndRoomsByRecord(@RecordID: Integer32)` - `api_Advanced_GetRecurringItems(@SequenceID: Integer32)` @@ -90,6 +90,7 @@ All procedures listed with input parameters. String sizes shown where bounded, e - `api_CIM_GetCheckInHouseholds(@SearchTerm: String(50), @RestrictToEventList: String(2000), @IsAttended: Boolean, @HouseholdId: Integer32, @AllowPhoneNumber: Boolean, @AllowIdCardBarcode: Boolean, @AllowLastName: Boolean)` - `api_CIM_GetCheckInResults(@HouseholdID: Integer32, @EventList: Text, @IsAttended: Boolean, @ID_Card: String(50), @GuestContactID: Integer32)` - `api_CIM_GetCheckInRooms(@EventList: String(2000), @ActiveOnly: Boolean)` +- `api_CIM_GetClassroomParticipants(@EventID: Integer32, @GroupParticipantIds: Text, @APIClientID: String(256))` - `api_CIM_GetDomainTimezone(no parameters)` - `api_Classroom_GetCongregations(no parameters)` - `api_Classroom_GetDropDownData(no parameters)` @@ -115,7 +116,7 @@ All procedures listed with input parameters. String sizes shown where bounded, e - `api_CloudTools_ResetUserSecurity(@ContactID: Integer32, @UserID: Integer32)` - `api_CloudTools_UpdateCommunicationMessage(@CommunicationMessageID: Integer32, @StatusID: Integer32, @UserID: Integer32)` - `api_Common_FindMatchingContact(@FirstName: String(50), @LastName: String(50), @Suffix: String(10), @EmailAddress: String(150), @Phone: String(50), @RequireEmail: Boolean)` -- `api_Common_GenerateSlug(@TableName: String(50), @RecordID: Integer32, @SubPage: String(50))` +- `api_Common_GenerateSlug(@TableName: String(75), @UserID: Integer32, @RecordID: Integer32, @SubPage: String(50), @SelectionName: String(50))` - `api_Common_GetConfigurationLists(@ApplicationCode: String(50))` - `api_Common_GetConfigurationSettings(@ApplicationCode: String(50), @KeyName: String(50))` - `api_Common_GetCongregations(no parameters)` @@ -352,6 +353,9 @@ All procedures listed with input parameters. String sizes shown where bounded, e - `api_ListSync_GetListSubscribers(@PublicationID: Integer32)` - `api_ListSync_RemoveSubscriber(@ContactID: Integer32, @PublicationID: Integer32)` - `api_MatchOrCreateContact(@FirstName: String(50), @LastName: String(50), @EmailAddress: String(255), @PhoneNumber: String(25), @Address: String(75), @City: String(50), @State: String(2), @Zip: String(15))` +- `api_mcp_CreatePageView(@UserID: Integer32, @TableName: String(128), @ViewTitle: String(50), @ViewClause: String(4000), @FieldList: String(4000), @OrderBy: String(255), @Description: String(500), @PageID: Integer32, @UserGroupID: Integer32, @Global: Boolean)` +- `api_mcp_CreateSelection(@UserID: Integer32, @TableName: String(128), @SelectionName: String(50), @Records: Xml)` +- `api_mcp_GetAuditLog(@UserID: Integer32, @TableName: String(128), @PageID: Integer32, @RecordID: Integer32, @AuditUserID: Integer32, @AuditUserName: String(128), @DaysBack: Integer32, @StartDate: DateTime, @EndDate: DateTime, @AuditTypeID: Integer32, @FieldName: String(100), @IncludeDetail: Boolean, @DetailFormat: String(10), @TopN: Integer32, @MaxValueLength: Integer32)` - `api_MOBILE_Dashboard_Kid(no parameters)` - `api_MOBILE_GetCareCaseDetail(@careCaseID: Integer32)` - `api_MOBILE_GetCareCases(@showAll: Boolean)` @@ -361,6 +365,9 @@ All procedures listed with input parameters. String sizes shown where bounded, e - `api_MOBILE_GetPersonFiles(@contactID: Integer32)` - `api_MOBILE_GetYearGivingByType(@year: Integer32)` - `api_MOBILE_PersonLookup(@searchItem: String(50), @searchItem2: String(50))` +- `api_MPNextTools_GetPageFields(@PageID: Integer32)` +- `api_MPNextTools_GetPages(no parameters)` +- `api_MPNextTools_UpdatePageFieldOrder(@PageID: Integer32, @FieldName: String(64), @GroupName: String(75), @ViewOrder: Integer16, @Required: Boolean, @Hidden: Boolean, @DefaultValue: String(128), @FilterClause: String(2000), @DependsOnField: String(64), @FieldLabel: String(64), @WritingAssistantEnabled: Boolean)` - `api_MPP_DeleteEventGroup(@EventID: Integer32, @GroupID: Integer32)` - `api_MPP_DeleteMeetingParticipants(@EventParticipantID: Integer32)` - `api_MPP_DeleteRecurringDonations(@SubscriptionCode: String(50))` @@ -472,8 +479,8 @@ All procedures listed with input parameters. String sizes shown where bounded, e - `api_OGCC_GetEncryptionKey(no parameters)` - `api_OGCC_GetPledgeCampainsWithPledges(no parameters)` - `api_OGCC_GetProgramsAndPaymentTypes(no parameters)` -- `api_PAPP_GetContact_JSON(@contactGUID: String(256))` -- `api_PAPP_VolunteerBirthdays_JSON(no parameters)` +- `api_PAPP_GetContact_JSON(@contactGUID: String(256), @UserID: String(40))` +- `api_PAPP_VolunteerBirthdays_JSON(@UserID: String(40), @RestrictCampusID: Integer32)` - `api_PocketPlatform_Device_Updater(no parameters)` - `api_PocketPlatform_Set_LatestSermonDate(no parameters)` - `api_Tools_AppendSelection(@SelectionID: Integer32, @IDs: Text)` @@ -491,9 +498,9 @@ All procedures listed with input parameters. String sizes shown where bounded, e - `api_Tools_GetDefaultEventParticipants(@EventId: Integer32)` - `api_Tools_GetDepositDataToExportToRealmAccounting(@DepositID: Integer32)` - `api_Tools_GetDomainData(no parameters)` -- `api_Tools_GetDonationPossibleMatches(@FirstName: String(50), @LastName: String(50), @Phone: String(25), @Email: String(255), @AddressLine1: String(75), @DateOfBirth: String(10))` +- `api_Tools_GetDonationPossibleMatches(@FirstName: String(50), @LastName: String(50), @Phone: String(25), @Email: String(255), @AddressLine1: String(75), @DateOfBirth: String(10), @MinimumMatchCount: Integer32, @MinorRegistrationMode: Boolean)` - `api_Tools_GetGroupAttendance(@EventId: Integer32, @GroupId: Integer32)` -- `api_Tools_GetHouseholdsWithHeads(@Search: Text)` +- `api_Tools_GetHouseholdsWithHeads(@Search: String(200))` - `api_Tools_GetMatchingPrograms(@Program_ID: Integer32, @Program_Name: String(130), @Account_Number: String(25), @Congregation: Integer32)` - `api_Tools_GetMergeTags(@PageId: Integer32)` - `api_Tools_GetOrphanedHouseholds(@DeleteContactIds: String(4000))` @@ -508,7 +515,7 @@ All procedures listed with input parameters. String sizes shown where bounded, e - `api_Tools_GetRolePages(@RoleId: Integer32)` - `api_Tools_GetSelectedBatchSummaries(@SelectionId: Integer32, @BatchId: Integer32)` - `api_Tools_GetSelectedContacts(@SelectionId: Integer32, @SelectFields: String(4000))` -- `api_Tools_GetSelectedDonations(@SelectionId: Integer32, @DonationId: Integer32)` +- `api_Tools_GetSelectedDonations(@SelectionId: Integer32, @DonationId: Integer32, @UserId: Integer32)` - `api_Tools_GetSelectedDonationsSummary(@SelectionId: Integer32, @DonationId: Integer32)` - `api_Tools_GetSelectedEventParticipants(@EventId: Integer32)` - `api_Tools_GetSelectedPaymentsSummary(@SelectionId: Integer32, @PaymentId: Integer32)` @@ -528,7 +535,7 @@ All procedures listed with input parameters. String sizes shown where bounded, e - `api_Tools_GetUserTemplates(@UserId: Integer32, @CurrentTemplateId: Integer32)` - `api_Tools_GetUserTools(@UserId: Integer32)` - `api_Tools_LoadFormById(@FormId: Integer32)` -- `api_Tools_MoveFilesToAnotherRecord(@PageName: String(64), @PrimaryRecordId: Integer32, @RecordIds: String(4000))` +- `api_Tools_MoveFilesToAnotherRecord(@TableName: String(64), @PrimaryRecordId: Integer32, @RecordIds: String(4000))` - `api_Tools_NextAvailableEnvelopeNumber(@NumberOfEnvelopes: Integer32, @ContactID: Integer32, @CongregationID: Integer32)` - `api_Tools_PostContactMergeCleanUp(@ContactID: Integer32)` - `api_Tools_ReassignUserToContactId(@UserId: Integer32, @ContactId: Integer32)` diff --git a/.claude/references/mp-schema/tables.md b/.claude/references/mp-schema/tables.md index 11e5d0c..1235a51 100644 --- a/.claude/references/mp-schema/tables.md +++ b/.claude/references/mp-schema/tables.md @@ -2,22 +2,25 @@ title: Ministry Platform Schema — Tables domain: mp-schema type: generated-reference -last_verified: 2026-04-17 +last_verified: 2026-10-11 source: npm run mp:generate:models --- ## Purpose -Auto-generated catalog of Ministry Platform tables, primary keys, and foreign key relationships. Regenerate with `npm run mp:generate:models` (runs `src/lib/providers/ministry-platform/scripts/generate-types.ts` with `--clean --zod`, emitting to `src/lib/providers/ministry-platform/models/`). +Auto-generated catalog of Ministry Platform tables, primary keys, and foreign key relationships. Regenerate with `npm run mp:generate:models` (runs `src/lib/providers/ministry-platform/scripts/generate-types.ts` with `--clean --zod`, emitting to `src/lib/providers/ministry-platform/models/`). Do not edit by hand — the generator overwrites this file. ## Generated metadata -**Generated (source):** 2026-04-16T02:07:32.640Z | **Tables:** 301 +**Generated (source):** 2026-10-11T11:13:43.173Z | **Tables:** 307 **Type files:** `src/lib/providers/ministry-platform/models/{PascalCaseTableName}.ts` and `{PascalCaseTableName}Schema.ts` **Access:** R=Read, RW=ReadWrite, RWAD=ReadWriteAssignDelete --- +### _Deployment_Logs [RWAD] [None] +PK: `Log_ID` | FK: Deployment_ID→_Deployments + ### _Deployments [R] [None] PK: `Deployment_ID` @@ -222,7 +225,7 @@ PK: `Donor_ID` | FK: Contact_ID→Contacts, Statement_Frequency_ID→Statement_F ### dp_Account_Information [RWAD] [None] PK: `Account_Information_ID` | FK: Congregation_ID→Congregations, Integration_Definition_Type_ID→dp_Integration_Definition_Types -### dp_API_Clients [RWAD] [FileAttach, DataExport] +### dp_API_Clients [RWAD] [FileAttach, DataExport, SecureRecord] PK: `API_Client_ID` | FK: Client_User_ID→dp_Users, Authentication_Flow_ID→dp_Authentication_Flows ### dp_Application_Labels [RWAD] [FileAttach, DataExport] @@ -234,15 +237,24 @@ PK: `Audit_Retention_Message_ID` | FK: _Audit_Retention_Policy_ID→dp_Audit_Ret ### dp_Audit_Retention_Policies [RWAD] [FileAttach, DataExport] PK: `Audit_Retention_Policy_ID` | FK: Retention_Type_ID→dp_Audit_Retention_Types, Audit_Type_ID→dp_Audit_Types +### dp_Audit_Retention_Runs [RWAD] [DataExport] +PK: `Audit_Retention_Run_ID` + ### dp_Authentication_Log [RWAD] [FileAttach, DataExport, SecureRecord] PK: `Authentication_ID` ### dp_Bookmarks [RWAD] [FileAttach, DataExport] PK: `Bookmark_ID` +### dp_Charts [R] [None] +PK: `Chart_ID` | FK: Page_ID→dp_Pages, Aggregation_Type_ID→dp_Aggregation_Types, Default_Chart_Type_ID→dp_Chart_Types + ### dp_Communication_Messages [RWAssign] [DataExport] PK: `Communication_Message_ID` | FK: Communication_ID→dp_Communications, Action_Status_ID→dp_Communication_Action_Statuses, Contact_ID→Contacts +### dp_Communication_Messages_Archived [RWAssign] [DataExport] +PK: `Communication_Message_Archived_ID` | FK: Original_Communication_ID→dp_Communications, Action_Status_ID→dp_Communication_Action_Statuses, Contact_ID→Contacts + ### dp_Communication_Publications [RWAD] [FileAttach, DataExport] PK: `Communication_Publication_ID` | FK: Communication_ID→dp_Communications, Publication_ID→dp_Publications @@ -424,7 +436,7 @@ PK: `Form_Response_Answer_ID` | FK: Form_Field_ID→Form_Fields, Form_Response_I PK: `Form_Response_ID` | FK: Form_ID→Forms, Contact_ID→Contacts, Event_ID→Events, Pledge_Campaign_ID→Pledge_Campaigns, Opportunity_ID→Opportunities, Opportunity_Response→Responses, Congregation_ID→Congregations, Event_Participant_ID→Event_Participants ### Forms [RWAD] [FileAttach, DataExport, SecureRecord] -PK: `Form_ID` | FK: Congregation_ID→Congregations, Primary_Contact→Contacts, Response_Message→dp_Communication_Templates +PK: `Form_ID` | FK: Congregation_ID→Congregations, Primary_Contact→Contacts, Response_Message→dp_Communication_Templates, Product_ID→Products, Program_ID→Programs ### Frequencies [RWAD] [None] PK: `Frequency_ID` @@ -511,7 +523,7 @@ PK: `Invoice_Source_ID` PK: `Invoice_Status_ID` ### Invoices [RWAD] [FileAttach, DataExport, SecureRecord] -PK: `Invoice_ID` | FK: Purchaser_Contact_ID→Contacts, Invoice_Status_ID→Invoice_Statuses, Congregation_ID→Congregations, Invoice_Source→Invoice_Sources +PK: `Invoice_ID` | FK: Purchaser_Contact_ID→Contacts, Invoice_Status_ID→Invoice_Statuses, Congregation_ID→Congregations, Invoice_Source→Invoice_Sources, Form_Response_ID→Form_Responses ### Item_Priorities [R] [None] PK: `Item_Priority_ID` @@ -726,6 +738,9 @@ PK: `Device_ID` | FK: User_ID→dp_Users, App_ID→Pocket_Platform_Apps ### Pocket_Platform_Migrations [R] [None] PK: `Pocket_Platform_Migrations_ID` +### Pocket_Platform_Social_Media_Providers [R] [None] +PK: `Social_Media_Provider_ID` + ### Prefixes [R] [None] PK: `Prefix_ID` @@ -849,6 +864,9 @@ PK: `Statement_Method_ID` ### Statement_Types [R] [None] PK: `Statement_Type_ID` +### Statistics_Logs [R] [None] +PK: `Log_ID` + ### Suffixes [R] [None] PK: `Suffix_ID` @@ -879,7 +897,7 @@ PK: `ID` ### vw_mp_contact_mail_name [R] [DataExport] PK: `Contact_ID` -### vw_mp_giving_unit_summary [R] [DataExport] +### vw_mp_giving_unit_summary_standard [R] [DataExport] PK: `Household_ID` | FK: Household_ID→Households, Contact_ID→Contacts ### vw_mp_Participation_Compliance [R] [DataExport] @@ -903,6 +921,15 @@ PK: `ID` | FK: Response_ID→Responses, Group_Role_ID→Group_Roles, Participant ### vw_mp_User_Rights [R] [DataExport] PK: `View_ID` | FK: Contact_ID→Contacts, User_ID→dp_Users +### Wait_Stats_Baseline [R] [None] +PK: `WaitType` + +### Wait_Stats_History [R] [None] +PK: `WaitStatsID` + +### Wait_Types [R] [None] +PK: `Wait_Type_ID` + ### Weekly_Snapshots [R] [None] PK: `Weekly_Snapshot_ID` | FK: Congregation_ID→Congregations @@ -912,11 +939,3 @@ PK: `Wifi_Device_Session_ID` | FK: Wifi_Device_ID→Wifi_Devices ### Wifi_Devices [RWAD] [None] PK: `Wifi_Device_ID` | FK: Contact_ID→Contacts -### Z_Event_Notifications [R] [None] -PK: `Z_Event_Notifications_ID` - -### Z_Form_Notifications [R] [None] -PK: `Z_Form_Notifications_ID` - -### Z_Opp_Notifications [R] [None] -PK: `Z_Opp_Notifications_ID` diff --git a/.claude/references/routing/README.md b/.claude/references/routing/README.md index 4ff33ed..3f940cd 100644 --- a/.claude/references/routing/README.md +++ b/.claude/references/routing/README.md @@ -5,32 +5,36 @@ domain: routing --- ## What's in this domain -Next.js 16 App Router layout, the `(web)` route group that wraps protected pages with auth, and the `proxy.ts` edge gate (renamed from `middleware.ts` in Next.js 16). +Next.js 16 App Router layout, the `(web)` route group that wraps protected pages with auth, and the `proxy.ts` request gate (renamed from `middleware.ts` in Next.js 16; runs on the Node.js runtime). ## File map | File | Purpose | When to read | |------|---------|--------------| -| `app-router.md` | Route group `(web)`, nested layouts, dashboard, tool routes, public `/signin`, `/api/auth/[...all]` | Adding/moving a route, understanding layout nesting | -| `proxy.md` | Next.js 16 `proxy()` function, cookie-only auth check, public path whitelist, `x-pathname` forwarding | Changing auth gate, adding a new public path | +| `app-router.md` | Route group `(web)`, nested layouts, dashboard, tool routes, `/no-access`, public `/signin` + `/auth-error`, `/session-error`, `/api/auth/[...all]` | Adding/moving a route, understanding layout nesting | +| `proxy.md` | Next.js 16 `proxy()` function, cookie-only auth check, public path whitelist, `x-pathname` forwarding, CSP nonce | Changing auth gate, adding a new public path | ## Code surfaces | Path | Role | |------|------| -| `src/proxy.ts` | Edge proxy — cookie check + redirect to `/signin` | +| `src/proxy.ts` | Proxy — cookie check + redirect to `/signin`, per-request CSP | | `src/proxy.test.ts` | Proxy behavior tests | | `src/app/layout.tsx` | Root HTML shell (`<html><body>`) | | `src/app/(web)/layout.tsx` | Route-group layout — mounts `AuthWrapper` + `Providers` + fonts | -| `src/app/(web)/tools/layout.tsx` | Tools sub-layout — full-height gray background | +| `src/app/(web)/tools/layout.tsx` | Tools sub-layout — full-height gray background; UX redirect of role-less users to `/no-access` | +| `src/app/(web)/tools/require-tool-access.ts` | `requireToolAccess()` — page-level enforcement every tool page awaits first | | `src/app/(web)/page.tsx` | Dashboard (5 tool cards) | -| `src/app/(web)/home/page.tsx` | Redirects to `/` via `redirect('/')` | +| `src/app/(web)/no-access/page.tsx` | No-security-role explanation + sign-out form | | `src/app/(web)/error.tsx` | Client error boundary for `(web)` | +| `src/app/error.tsx` / `src/app/global-error.tsx` | Boundaries outside `(web)` / for a root-layout throw | | `src/app/signin/page.tsx` | Public OAuth sign-in page | -| `src/app/api/auth/[...all]/route.ts` | Better Auth catch-all handler | +| `src/app/auth-error/page.tsx` | Public OAuth-failure page | +| `src/app/session-error/page.tsx` | Recovery page for a session with no `userGuid` (outside `(web)`) | +| `src/app/api/auth/[...all]/route.ts` | Better Auth catch-all handler behind a deny-by-default allowlist | | `src/app/providers.tsx` | Client context composition (`UserProvider` + `Toaster`) | | `src/components/layout/auth-wrapper.tsx` | Server component full session check | ## Dual-layer auth gate -- **Layer 1 — `proxy.ts`** (edge): cookie presence only, no API call +- **Layer 1 — `proxy.ts`** (Node.js runtime): cookie presence only, no API call - **Layer 2 — `AuthWrapper`** (server component): full `auth.api.getSession()` validation - See `proxy.md` for rationale diff --git a/.claude/references/routing/app-router.md b/.claude/references/routing/app-router.md index 9004e04..68c7b75 100644 --- a/.claude/references/routing/app-router.md +++ b/.claude/references/routing/app-router.md @@ -2,48 +2,54 @@ title: App Router layout domain: routing type: reference -applies_to: [src/app/layout.tsx, src/app/(web)/layout.tsx, src/app/(web)/page.tsx, src/app/(web)/home/page.tsx, src/app/(web)/tools/layout.tsx, src/app/(web)/error.tsx, src/app/signin/page.tsx, src/app/api/auth/[...all]/route.ts, src/app/providers.tsx] -symbols: [RootLayout, WebLayout, ToolsLayout, Home, SignIn, Providers] +applies_to: [src/app/layout.tsx, src/app/(web)/layout.tsx, src/app/(web)/page.tsx, src/app/(web)/no-access/page.tsx, src/app/(web)/tools/layout.tsx, src/app/(web)/error.tsx, src/app/error.tsx, src/app/global-error.tsx, src/app/signin/page.tsx, src/app/auth-error/page.tsx, src/app/session-error/page.tsx, src/app/api/auth/[...all]/route.ts, src/app/providers.tsx] +symbols: [RootLayout, WebLayout, ToolsLayout, NoAccessPage, SignIn, Providers] related: [proxy.md, ../auth/README.md, ../components/README.md, ../contexts/README.md] -last_verified: 2026-04-17 +last_verified: 2026-10-10 --- ## Purpose -Next.js 16 App Router tree. A `(web)` route group gates all authenticated pages with `AuthWrapper` + client `Providers`; `/signin` and `/api/auth/*` sit outside the group so they render without a session. +Next.js 16 App Router tree. A `(web)` route group gates all authenticated pages with `AuthWrapper` + client `Providers`; `/signin`, `/auth-error`, `/session-error` and `/api/auth/*` sit outside the group so they render without `AuthWrapper`. ## Files - `src/app/layout.tsx` — root layout, imports `globals.css`, renders `<html lang="en"><body>{children}</body></html>` - `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/<tool>/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 -- `src/app/api/auth/[...all]/route.ts` — Better Auth catch-all handler (`export const { GET, POST } = toNextJsHandler(auth)`) +- `src/app/(web)/page.tsx` — dashboard `/` — 5 tool cards (Template, Address Labels, Group Wizard, Field Management, Add/Edit Family) +- `src/app/(web)/no-access/page.tsx` — `/no-access` — explains the refusal to signed-in users with no MP security role and offers a sign-out `<form action={handleSignOut}>`; inside `(web)` but outside `tools/` +- `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/<tool>/page.tsx` — per-tool entry (`addeditfamily`, `addresslabels`, `fieldmanagement`, `groupwizard`, `template`) +- `src/app/(web)/error.tsx` — `"use client"` error boundary for the group (renders inside the shell) +- `src/app/error.tsx` — error boundary for routes outside `(web)`; `src/app/global-error.tsx` — last-resort boundary for a root-layout throw +- `src/app/signin/page.tsx` — public OAuth redirect page (server component, `dynamic = "force-dynamic"`; the client body is `sign-in-content.tsx`) +- `src/app/auth-error/page.tsx` — public landing page for OAuth callback failures (`onAPIError`) +- `src/app/session-error/page.tsx` — recovery page for a session with no `userGuid`; outside `(web)` so it cannot loop through `AuthWrapper` +- `src/app/api/auth/[...all]/route.ts` — Better Auth catch-all, wrapped in a deny-by-default allowlist (`allowedAuthRoutes`) and a `POST /sign-in/social` body filter in front of `toNextJsHandler(auth)` - `src/app/providers.tsx` — `"use client"` composition (`UserProvider` + sonner `Toaster`) ## Key concepts - **Route group `(web)`** — parens in directory name group routes without contributing a URL segment; used to apply a shared layout (auth) to everything under it without affecting the URL -- **Public routes sit outside `(web)`** — `src/app/signin/` and `src/app/api/` are siblings of `(web)`, so they skip `AuthWrapper` +- **Public routes sit outside `(web)`** — `src/app/signin/`, `src/app/auth-error/`, `src/app/session-error/` and `src/app/api/` are siblings of `(web)`, so they skip `AuthWrapper` (`/session-error` is still cookie-gated by the proxy) - **Nested layouts compose top-down** — `RootLayout` → `WebLayout` (inside `(web)`) → `ToolsLayout` (inside `(web)/tools/`) - **Root layout is async** (`async function RootLayout`) — `src/app/layout.tsx:3` - **WebLayout is async** (`async function WebLayout`) — `src/app/(web)/layout.tsx:28` -- **Error boundary per group** — `src/app/(web)/error.tsx` only catches errors inside `(web)` +- **Error boundary per group** — `src/app/(web)/error.tsx` only catches errors inside `(web)`; `src/app/error.tsx` covers the routes outside it - **Named-exports rule does NOT apply to route files** — Next.js App Router requires `export default` for `layout.tsx`, `page.tsx`, `loading.tsx`, `error.tsx`, and `route.ts` GET/POST (these are named exports on route handlers) ## Route tree ``` / ├── / # (web)/page.tsx — dashboard -├── /home # (web)/home/page.tsx — redirects to / +├── /no-access # (web)/no-access/page.tsx — no MP security role; sign-out form ├── /tools/template -├── /tools/templateeditor ├── /tools/addresslabels ├── /tools/groupwizard ├── /tools/fieldmanagement +├── /tools/addeditfamily ├── /signin # public -└── /api/auth/* # public (Better Auth catch-all) +├── /auth-error # public (OAuth callback failures) +├── /session-error # outside (web); proxy still requires the cookie +└── /api/auth/* # public, allowlisted (Better Auth catch-all) ``` ## API / Interface @@ -69,21 +75,18 @@ export default async function WebLayout({ } ``` -`src/app/(web)/home/page.tsx`: +`src/app/api/auth/[...all]/route.ts` (abridged — not a bare `toNextJsHandler` export): ```typescript -import { redirect } from 'next/navigation'; - -export default function Home() { - redirect('/'); -} -``` - -`src/app/api/auth/[...all]/route.ts`: -```typescript -import { auth } from "@/lib/auth"; -import { toNextJsHandler } from "better-auth/next-js"; - -export const { GET, POST } = toNextJsHandler(auth); +export const allowedAuthRoutes = { + GET: ["/get-session", "/callback/ministryplatform"], + POST: ["/sign-in/social"], +} as const; + +const handlers = toNextJsHandler(auth); +// guard(): non-allowlisted path (or a POST /sign-in/social body failing +// isAllowedSignInSocialBody) → plain 404; otherwise withNoStore(await handler(request)) +export const GET = guard("GET", handlers.GET); +export const POST = guard("POST", handlers.POST); ``` `src/app/providers.tsx`: @@ -109,7 +112,7 @@ export function Providers({ children }: ProvidersProps) { - If segment lives under `(web)/`: `RootLayout` → `WebLayout` (mounts `AuthWrapper`) → (optional `ToolsLayout`) → `page.tsx` - `AuthWrapper` calls `auth.api.getSession({ headers: await headers() })`; missing session triggers `redirect('/signin?callbackUrl=...')` - Once session validated, `Providers` mounts client context tree (`UserProvider` fetches MP profile client-side) -- `/signin` skips the whole group tree — rendered under `RootLayout` only +- `/signin`, `/auth-error` and `/session-error` skip the whole group tree — rendered under `RootLayout` only ## Next.js 16 routing notes - **Dev output path**: `next dev` writes to `.next/dev` (not `.next`) — see `proxy.md` and Next.js 16 upgrade guide @@ -118,7 +121,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/<slug>/page.tsx` — inherits `WebLayout` (auth) + `ToolsLayout` (gray bg) +- Add a tool route: create `src/app/(web)/tools/<slug>/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/routing/proxy.md b/.claude/references/routing/proxy.md index e2fe20b..391ee57 100644 --- a/.claude/references/routing/proxy.md +++ b/.claude/references/routing/proxy.md @@ -1,101 +1,101 @@ --- -title: Proxy (Next.js 16 edge gate) +title: Proxy (Next.js 16 request gate) domain: routing type: reference -applies_to: [src/proxy.ts, src/proxy.test.ts, src/components/layout/auth-wrapper.tsx] -symbols: [proxy, config] +applies_to: [src/proxy.ts, src/proxy.test.ts, src/lib/security-headers.ts, src/components/layout/auth-wrapper.tsx] +symbols: [proxy, config, isPublicPath] related: [app-router.md, ../auth/README.md, ../components/README.md] -last_verified: 2026-04-17 +last_verified: 2026-10-10 --- ## Purpose -Edge-runtime gate that does a cheap cookie-presence check and redirects unauthenticated users to `/signin?callbackUrl=...` before any server component runs. Real session validation happens downstream in `AuthWrapper`. +Request gate in front of every page: a cheap cookie-presence check that redirects unauthenticated users to `/signin?callbackUrl=...` before any server component runs, plus the per-request CSP nonce. Real session validation happens downstream in `AuthWrapper`. ## Files - `src/proxy.ts` — `proxy()` function + `config` matcher -- `src/proxy.test.ts` — behavior tests (public paths, redirect, callback URL, error paths, matcher, `x-pathname` forwarding) +- `src/proxy.test.ts` — behavior tests (public paths, redirect, callback URL, error paths, matcher anchoring, `x-pathname` forwarding, CSP) +- `src/lib/security-headers.ts` — `buildCsp`, `generateNonce`, `cspHeaderName`, `cspReportUri`, `reportingEndpointsHeader` - `src/components/layout/auth-wrapper.tsx` — layer-2 full session validation ## Key concepts -- **Next.js 16 rename** — `middleware.ts` → `proxy.ts`, `export function middleware` → `export function proxy`. Same runtime and semantics; rename only. Codemod: `npx @next/codemod@latest middleware-to-proxy .` -- **Cookie-only check at the edge** — uses `getSessionCookie` from `better-auth/cookies`; no DB or API call, no JWT decode +- **Next.js 16 rename** — `middleware.ts` → `proxy.ts`, `export function middleware` → `export function proxy`. Codemod: `npx @next/codemod@latest middleware-to-proxy .` +- **Runtime is Node.js, not edge** — in Next.js 16 `proxy` always runs on the `nodejs` runtime and the `runtime` config option cannot be set (edge is only available by staying on the deprecated `middleware`) +- **Cookie-only check** — uses `getSessionCookie` from `better-auth/cookies`; no MP/API call, no cookie decryption - **Full validation is deferred** to `AuthWrapper` server component — see "Why dual-layer" below -- **Public-path whitelist** — `/api/*` and `/signin` bypass the cookie check +- **Public-path whitelist** (`isPublicPath`) — exactly `/api/auth`, `/api/auth/*`, `/signin` and `/auth-error` bypass the cookie check. Every other `/api/*` route is gated - **`x-pathname` header forwarding** — proxy writes pathname + search onto request headers via `NextResponse.next({ request: { headers } })` so `AuthWrapper` can reconstruct the original URL for `callbackUrl` even on passthroughs -- **Matcher** excludes `_next/static`, `_next/image`, `favicon.ico`, and `assets/` +- **CSP + nonce** — a fresh nonce per request; the policy is set on the **request** headers (Next reads the nonce from there at render time) and on every **response**, redirects included. `x-nonce` is also forwarded. See `../security/README.md` +- **Matcher** excludes `_next/static/`, `_next/image`, `favicon.ico` and `assets/` — each exclusion anchored, so `/favicon.ico-admin` or `/_next/imagery` is still gated ## API / Interface -`src/proxy.ts` (entire file): +`src/proxy.ts` (abridged — CSP construction elided): ```typescript -import { NextResponse, NextRequest } from 'next/server'; -import { getSessionCookie } from 'better-auth/cookies'; +function isPublicPath(pathname: string): boolean { + return ( + pathname === '/api/auth' || + pathname.startsWith('/api/auth/') || + pathname === '/signin' || + pathname === '/auth-error' + ); +} export async function proxy(request: NextRequest) { const { pathname } = request.nextUrl; const fullPath = pathname + request.nextUrl.search; - + // ...nonce + CSP built here... const forwardedHeaders = new Headers(request.headers); forwardedHeaders.set('x-pathname', fullPath); + forwardedHeaders.set('x-nonce', nonce); + forwardedHeaders.set(headerName, csp); + const passThrough = () => - NextResponse.next({ request: { headers: forwardedHeaders } }); + withCsp(NextResponse.next({ request: { headers: forwardedHeaders } })); + const redirectToSignin = () => { /* /signin?callbackUrl=<fullPath>, withCsp */ }; - // Early returns for public paths - if (pathname.startsWith('/api') || pathname === '/signin') { - console.log(`Proxy: Allowing public path ${pathname}`); - return passThrough(); - } + if (isPublicPath(pathname)) return passThrough(); try { const sessionCookie = getSessionCookie(request); - - if (!sessionCookie) { - console.log("Proxy: Redirecting to signin - no session cookie"); - const signinUrl = new URL('/signin', request.url); - signinUrl.searchParams.set('callbackUrl', fullPath); - return NextResponse.redirect(signinUrl); - } - - console.log(`Proxy: Allowing request to ${pathname}`); + if (!sessionCookie) return redirectToSignin(); return passThrough(); - } catch (error) { - console.error('Proxy: Error checking session:', error); - const signinUrl = new URL('/signin', request.url); - signinUrl.searchParams.set('callbackUrl', fullPath); - return NextResponse.redirect(signinUrl); + // Identifier/shape only — never the request path or query string. + console.error('proxy.session_check_failed', { message: /* error.message */ }); + return redirectToSignin(); } } export const config = { matcher: [ - '/((?!_next/static|_next/image|favicon.ico|assets/).*)', + '/((?!_next/static/|_next/image$|favicon\\.ico$|assets/).*)', ], }; ``` ## Public paths (whitelist) -| Path prefix | Reason | +| Path | Reason | |---|---| -| `/api` (includes `/api/auth/*`) | Better Auth catch-all and any server API must be reachable without a session | +| `/api/auth`, `/api/auth/*` | Better Auth catch-all — itself deny-by-default (allowlist in `src/app/api/auth/[...all]/route.ts`). Exact segment match, so `/api/authz` is **not** public | | `/signin` | OAuth sign-in page itself must render unauthenticated | +| `/auth-error` | OAuth callback failures land here with no session; gating it would bounce to `/signin`, which auto-restarts OAuth — an infinite loop | -Anything else requires a session cookie. +Anything else — including any other `/api/*` route and `/session-error` — requires a session cookie. ## Why dual-layer (proxy + AuthWrapper) | Layer | Runs in | Check | Cost | Failure mode | |---|---|---|---|---| -| `proxy()` | Edge runtime, every request | Cookie presence only | ~µs, no I/O | Redirects to `/signin` | -| `AuthWrapper` | Server component (Node), once per page render | `auth.api.getSession()` — full JWT validate + custom session | ms, async | Redirects to `/signin` | +| `proxy()` | Node.js runtime, every matched request | Cookie presence only | ~µs, no I/O | Redirects to `/signin` | +| `AuthWrapper` | Server component, once per page render | `auth.api.getSession()` — decrypts/validates the JWE session cookie + `customSession` | ms, async | No session → `/signin`; session without `userGuid` → `/session-error` | -- Edge can't run Node-only Better Auth internals cheaply — cookie check is the fast path +- The proxy runs on every matched request (pages, RSC fetches, server-action POSTs), so it stays a cheap cookie check; the full check runs where the page renders - An attacker with a stale/forged cookie passes the proxy but fails `AuthWrapper` - `AuthWrapper` also mints `session.user.userGuid` via the custom session plugin — required before any MP API call (see `../auth/README.md`) ## `x-pathname` forwarding `AuthWrapper` reads this header to build a correct `callbackUrl` when the proxy let the request through but the full session check fails: -`src/components/layout/auth-wrapper.tsx:5-19`: +`src/components/layout/auth-wrapper.tsx:5-31` (comments trimmed): ```typescript export async function AuthWrapper({ children }: { children: React.ReactNode }) { const hdrs = await headers(); @@ -108,42 +108,47 @@ export async function AuthWrapper({ children }: { children: React.ReactNode }) { redirect(`${signinUrl.pathname}${signinUrl.search}`); } + const userGuid = (session.user as { userGuid?: string | null }).userGuid; + if (!userGuid) { + redirect("/session-error"); + } + return <>{children}</>; } ``` -Only `passThrough()` (which calls `NextResponse.next({ request: { headers } })`) propagates the header. Redirect responses do not — by the time the browser re-requests `/signin`, the pathname is already in `callbackUrl`. +Only `passThrough()` (which calls `NextResponse.next({ request: { headers } })`) propagates `x-pathname`. Redirect responses do not — by the time the browser re-requests `/signin`, the pathname is already in `callbackUrl`. ## How it works (flow) Protected route hit: 1. Browser → `/tools/template?recordID=123` 2. `proxy()` checks matcher → applies 3. Builds `forwardedHeaders` with `x-pathname: /tools/template?recordID=123` -4. Path is not `/api` nor `/signin` → run cookie check +4. Path is not public (`isPublicPath`) → run cookie check 5. `getSessionCookie(request)` → present → `NextResponse.next(...)` with forwarded headers 6. Next.js routes to `(web)/tools/template/page.tsx` — `WebLayout` mounts `AuthWrapper` 7. `AuthWrapper` calls `auth.api.getSession(...)` with forwarded headers → validates -8. If valid → render; if not → `redirect('/signin?callbackUrl=/tools/template?recordID=123')` +8. If valid → render; if not → `redirect('/signin?callbackUrl=/tools/template?recordID=123')` (or `/session-error` when the session has no `userGuid`) -## Next.js 16 specifics (verified via context7) +## Next.js 16 specifics - File must be named `proxy.ts` (or `proxy.js`) at project root or `src/` - Function export must be named `proxy` (not `middleware`) - `config` shape unchanged — `{ matcher: [...] }` -- `middleware.js` is **deprecated** in Next.js 16 (docs source: `next.js/docs/01-app/02-guides/upgrading/version-16.mdx`) +- `middleware` is **deprecated** in Next.js 16; `proxy` runs on `nodejs` only and setting `runtime` throws (source: `node_modules/next/dist/docs/01-app/02-guides/upgrading/version-16.md`, `.../03-api-reference/03-file-conventions/proxy.md`) ## Test coverage (`src/proxy.test.ts`) -- Public paths: `/api/*`, nested `/api/*`, `/signin` — all skip cookie check +- Public paths: `/api/auth`, nested `/api/auth/*`, `/signin`, `/auth-error` skip the cookie check; `/api/other` is gated - Protected paths: no cookie → redirect to `/signin` with `callbackUrl` (with and without query string) - Error during `getSessionCookie` → redirect to `/signin` with `callbackUrl` -- `x-pathname` forwarded on public paths and authenticated passthrough -- Matcher config exported correctly (excludes `_next/static`, `_next/image`, `favicon.ico`, `assets/`) +- `x-pathname` forwarded on public paths and authenticated passthrough, not on redirects +- Matcher: exempt paths skipped; prefix look-alikes (`/favicon.ico-admin`, `/_next/imagery`, `/assets-admin`, …) still matched +- CSP: set on request and response with the same per-request nonce, on redirects too; `CSP_REPORT_URI` → `Reporting-Endpoints` ## Gotchas -- **Adding a public route**: must add a branch to the `if (pathname.startsWith('/api') || pathname === '/signin')` check in `src/proxy.ts:18`, otherwise proxy redirects before the page is reachable -- **Expecting full session in proxy**: don't add DB/API calls here — edge runtime and cold-path latency. Use `AuthWrapper` instead +- **Adding a public route**: must add it to `isPublicPath()` in `src/proxy.ts`, otherwise proxy redirects before the page is reachable. Use exact or segment-anchored matches, never a bare `startsWith` prefix +- **Expecting full session in proxy**: don't add MP/API calls here — it runs on every matched request. Use `AuthWrapper` instead - **Redirect responses don't carry `x-pathname`**: only `NextResponse.next()` forwards the header; don't add logic that expects the header on a redirect response -- **Matcher regex**: any new static directory you don't want gated must be added to the exclusion group (currently `_next/static`, `_next/image`, `favicon.ico`, `assets/`) -- **Edge runtime**: proxy runs on the edge; Node-only APIs unavailable +- **Matcher regex**: any new static directory you don't want gated must be added to the exclusion group (currently `_next/static/`, `_next/image`, `favicon.ico`, `assets/`), anchored like the existing entries — an excluded path also gets no CSP header ## Related docs - `app-router.md` — route tree, `(web)` group, where `AuthWrapper` mounts diff --git a/.claude/references/security/README.md b/.claude/references/security/README.md index 0b4e1bf..94af00b 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) | --- @@ -21,9 +23,11 @@ policy. Read this before changing anything under `src/lib/auth.ts`, Sign-in is deliberately **not** role-gated — no check in `getUserInfo`, `mapProfileToUser`, `customSession` or `AuthWrapper` — so a role-less user still -gets a session, the header, the user menu and a **working sign-out**. Refusing -at sign-in would strand them in the app with no way out. They are redirected -from `/tools/*` to `/no-access`, which renders inside the shell. +gets a session and the app shell. They are redirected from `/tools/*` to +`/no-access`, which renders inside the shell and carries a **Sign out** form +(`handleSignOut`) so they can end the session, e.g. to sign in as a different +MP user. That form and the one on `/session-error` are the app's only sign-out +controls (the dropdown `UserMenu` was removed 2026-10-10). ### Why authentication is not enough @@ -43,29 +47,36 @@ rendered, so the gate runs at every layer that is independently reachable: | Layer | File | Call | |---|---|---| -| Page | `src/app/(web)/tools/layout.tsx` | `hasSecurityRole()` then `redirect("/no-access")` | -| Action | `src/components/<feature>/actions.ts` | `requireAccess()` wrapping `requireSecurityRole()` | -| Service | `src/services/*.ts` | `requireSecurityRole()` on **every** method, reads included | +| Page | `src/app/(web)/tools/<tool>/page.tsx` | first statement `await requireToolAccess({ table, operation })` (`src/app/(web)/tools/require-tool-access.ts`) → `requireSecurityRole()`; a refusal redirects to `/no-access` | +| Action | `src/components/<feature>/actions.ts`, `src/app/(web)/tools/addeditfamily/actions.ts` | `requireAccess()` wrapping `requireSecurityRole()` | +| Service | `src/services/*.ts` | `requireSecurityRole()` on every MP-data method, reads included. Exceptions: `UserService.getUserProfile` (own-profile carve-out), `FamilyService.getDefaults` (static), `DomainTimezoneService.*` (domain config). `GooglePlacesService.*` gates `dp_Configuration_Settings`/`read` on every call, cached key or not | -A layout gate covers its child pages for free — React renders the layout first -and only renders `children` once it returns. +`src/app/(web)/tools/layout.tsx` also calls `hasSecurityRole()` and redirects +to `/no-access`, but that is **UX only, not enforcement**: Next 16 renders each +route segment independently, so a layout `redirect()` does not stop the page +segment running or its output reaching the RSC payload, and layouts are not +re-rendered on client navigation. That is why every tool page gates itself +(`require-tool-access.test.ts` fails if one does not). ### `AuthorizationService` design points - **Two entry points.** `requireSecurityRole()` throws `UnauthorizedError` and logs a structured denial — it is the enforcement point, and it returns the acting MP `User_ID`. `hasSecurityRole()` returns a decision without logging — - for UI affordances and the layout redirect, **never** as enforcement. + for UI affordances and the `tools/layout.tsx` redirect, **never** as enforcement. - **Per-request memoization via React `cache()`** — not module-level, not TTL. - The gate runs up to three times per request; this collapses that to one MP - read. Nothing crosses requests, which is what keeps a role revoked in MP + The gate runs up to three times per request (page, action, service); this + collapses that to one MP read. Nothing crosses requests, which is what keeps a role revoked in MP effective on the user's *very next* request. - **Fails closed.** No session, no `userGuid`, no `dp_Users` row, or no role 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 +95,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 @@ -128,6 +140,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 @@ -152,8 +173,24 @@ Split across two files, and the split is not arbitrary: | Where | Headers | Why there | |---|---|---| -| `next.config.ts` on `/(.*)` | `X-Frame-Options`, `X-Content-Type-Options`, `Referrer-Policy`, `Permissions-Policy`, HSTS (prod only) | Request-independent, and reaches `/api` plus the static paths the proxy matcher skips | -| `src/proxy.ts` via `src/lib/security-headers.ts` | `Content-Security-Policy` | The nonce must be fresh per request; a build-time value is a constant an attacker reads off any page | +| `next.config.ts` on `/(.*)` | `X-Frame-Options`, `X-Content-Type-Options`, `Referrer-Policy`, `Permissions-Policy`, `Cross-Origin-Opener-Policy` / `Cross-Origin-Resource-Policy` (`same-origin`), HSTS (prod only) | Request-independent, and reaches `/api` plus the static paths the proxy matcher skips | +| `src/proxy.ts` via `src/lib/security-headers.ts` | `Content-Security-Policy`, plus `Reporting-Endpoints` when `CSP_REPORT_URI` is set | The nonce must be fresh per request; a build-time value is a constant an attacker reads off any page | + +`next.config.ts` also sets `poweredByHeader: false`, `images.unoptimized: true` +(nothing uses `next/image`, so the optimizer was only SSRF surface) and +`logging.serverFunctions: false` (otherwise `next dev` prints every server +action's arguments, i.e. household records, to the terminal). + +COOP `same-origin` is safe only because sign-in is a **full-page redirect**. A +popup-based OAuth flow would lose `window.opener` and break. + +### Proxy path matching + +The public-path check matches `/api/auth` **as a segment** (`=== '/api/auth'` or +`startsWith('/api/auth/')`), so a future `/api/authz` is not public by accident. +The matcher's exclusions are anchored (`_next/static/`, `_next/image$`, +`favicon\.ico$`, `assets/`) so a look-alike path such as `/favicon.ico-admin` +still gets the session check and the CSP. `src/proxy.test.ts` pins both. Anti-framing is expressed **twice on purpose** — `X-Frame-Options` reaches the routes the proxy skips, `frame-ancestors` covers the rest. They are not both CSP @@ -180,6 +217,13 @@ a browser-vendor list and the deploying church's call, not a repo default. `form-action` to the **whole redirect chain**, not just its first hop. 3. **`img-src` includes the MP origins**, for contact photos served from MP. +Those MP origins are accepted only over `https:` (`http:` to a loopback host in +dev). `data:`, `javascript:`, plain-http real hosts and credentialed URLs are +**dropped** from the policy rather than trusted — `new URL('data:,x').origin` +is the string `"null"`, which would otherwise land in the directive. +`base-uri` is `'none'`: nothing emits a `<base>`, and `'self'` would still let +an injected one re-root every relative URL. + ### Nonces force dynamic rendering Next reads the nonce off the **incoming request headers** at render time, so the @@ -195,8 +239,12 @@ in `sign-in-content.tsx` and its `page.tsx` is a server component. `src/app/signin/page.test.tsx` pins **both** halves — the export, and the absence of the directive. -`/_not-found` remains static and nonce-less. Accepted: it is Next's built-in 404 -with no interactivity to lose. +`/_not-found` and `/_global-error` remain static and nonce-less. Accepted: the +404 has no interactivity to lose, and the global error shell renders client-side. + +**CI enforces this.** After `npm run build`, `npm run check:prerender` +(`scripts/check-prerender.mjs`) fails if any other route was prerendered. Fix +the route; widen its allowlist only with a written justification. ### `CSP_ENFORCE` @@ -210,6 +258,34 @@ policy can block things a clean report-only pass never flagged. Walk sign-in, sign-out, images and **every Radix surface** (dropdown, dialog, select, tooltip) against a production build before calling it done. +Last walk: **2026-10-10**, enforced policy, production build — sign-in, every +tool, Radix select/popover/command and dialog (scroll-lock `<style>` applied), +zero violations. Sign-out walked the same day from the `/no-access` form: MP +logout → back to the app → `/signin` → MP login prompt (both sessions ended), +zero violations — the `form-action` MP origin is what lets that redirect chain +through. + +### `CSP_REPORT_URI` (opt-in reporting) + +This repo ships **no collector**. Each deployment that wants reports sets +`CSP_REPORT_URI` to one it owns (Sentry's security endpoint, report-uri.com, or +a small route that logs shape only). When set, the policy gains +`report-uri <url>; report-to csp-endpoint` and responses carry +`Reporting-Endpoints: csp-endpoint="<url>"` — both spellings, because Firefox +still honours only `report-uri`. The value must be `https:` (loopback `http:` in +dev only) and may not contain `;`, `,`, `"` or whitespace; anything else is +ignored, never escaped. + +### Customer-deployed clones + +- **Adding an origin** (analytics, chat widget, an embedded MP widget): add it + to the specific directive, per deployment. Never `'unsafe-inline'` in + `script-src`, never a wildcard. +- **Roll-out order:** set `CSP_REPORT_URI`, run `CSP_ENFORCE=false` for at least + one release cycle, read the reports, then enforce. +- **After any CSP change:** `npm run build && npm start`, open the console, walk + every tool. Zero violations is the bar. + --- ## Identity @@ -234,8 +310,8 @@ So this app keys on the only identifier MP guarantees unique — the OIDC `sub` ``` - The **real** address is preserved separately as `mpEmail`, for display only. - Nothing in the UI reads `session.user.email`; the header uses - `MPUserProfile.Email_Address` straight from MP. + Nothing in the UI reads `session.user.email`; the profile `UserProvider` + loads (`MPUserProfile.Email_Address`) comes straight from MP. - `account.accountLinking.enabled = false` is the second lock. - `emailVerified` reports the provider's actual claim instead of asserting `true`. @@ -252,8 +328,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: @@ -262,41 +339,59 @@ 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. +- **One MP client** (owner decision 2026-10-10, reversing Step 4's dedicated + client; ADR-027): sign-in runs as `MINISTRY_PLATFORM_CLIENT_ID` / + `_SECRET`, the service account's client. Accepted consequence: one leaked + secret is both full MP data access and user sign-in, and the id_token + audience is the service account's client id. Mitigation is keeping that + secret out of reach and its MP redirect URIs exact. +- **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` As of Better Auth 1.6, `parseAdditionalUserInputFromProviderProfile` strips any additional field declared `input: false` **before** the user record is created — -so `input: false` silently drops `userGuid` and breaks every MP lookup (avatar, -user menu, `User_ID` resolution). But `input: true` also means the field is +so `input: false` silently drops `userGuid` and breaks every MP lookup (`UserProvider`, +`User_ID` resolution). But `input: true` also means the field is writable through `/update-user`. **No value of `input` satisfies both.** The control is at the endpoint layer @@ -307,6 +402,62 @@ 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: 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 + (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 + (<https://vercel.com/docs/headers/request-headers>, 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. + +`.github/SECURITY.md` (GitHub's security policy location) carries the same list +for people who never open `.claude/`. + +--- + ## Logging The rule: **identifiers and shape, never content.** `no-console` is enforced by @@ -327,11 +478,17 @@ Structured events alerts can grep on: |---|---| | `mp.read.unauthorized` | role gate refuses a read | | `mp.write.unauthorized` | role gate refuses a write | -| `mp.write.non_user` | a write ran with no resolved acting user | +| `mp.write.non_user` | the gate refused a call whose session resolved to no MP `User_ID` (any reason except `no_session`) | | `mp.request.failed` | a non-2xx MP HTTP response | | `auth.userinfo.invalid_sub` | MP userinfo returned no usable `sub` | | `auth.userinfo.fetch_failed` | MP userinfo endpoint returned non-2xx | | `ui.render.error` | a React error boundary caught a render error | +| `proxy.session_check_failed` | `getSessionCookie` threw in `src/proxy.ts` (request redirected to `/signin`) | + +The OIDC sign-in events (`auth.oidc.discovery_failed`, +`auth.userinfo.id_token_unverified`, `auth.userinfo.id_token_claims_invalid`, +`auth.userinfo.sub_mismatch`) are listed with their `reason` fields in +[`../auth/oauth-flow.md`](../auth/oauth-flow.md#failure-modes-and-log-events-identifiers-only). --- @@ -342,9 +499,9 @@ segment, so one boundary cannot do every job: | File | Catches | Why separate | |---|---|---| -| `src/app/(web)/error.tsx` | anything below the `(web)` layout | renders **inside** the shell, so the header and **sign-out survive** | +| `src/app/(web)/error.tsx` | anything below the `(web)` layout | renders **inside** the shell (the `(web)` layout, `AuthWrapper` and `Providers` survive) | | `src/app/error.tsx` | `/signin`, `/session-error`, `/auth-error` | those routes have no shell | -| `src/app/global-error.tsx` | a throw in the root layout itself | replaces it; imports nothing from the app | +| `src/app/global-error.tsx` | a throw in the root layout itself | replaces it; imports nothing from the app. Has a plain `<a href="/">` that works without JS, and "Try again" is disabled until hydration (`useSyncExternalStore`) so a pre-hydration click is not swallowed | Two details that bite: @@ -376,13 +533,17 @@ 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. 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 | -| F8 | Low | **Resolved as WONTFIX.** PKCE stays `false`: MP advertises `S256` in discovery but rejects the token exchange with `invalid_grant`. See below. | +| 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) | +| 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 -`userId` parameter), F10 and F11 (no `ContactService`; `getMpTimezone` is a -documented carve-out). +`userId` parameter) and F10 (no `ContactService`). F11 *was* ported (2026-09-30): +`getMpTimezone` now requires a session — see [Carve-outs](#carve-outs). ### F8 (PKCE) — closed as not-possible, with evidence @@ -401,27 +562,15 @@ 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: exact redirect URIs +registered on the MP client (`MINISTRY_PLATFORM_CLIENT_ID`), `Referrer-Policy`, +and no code-bearing URLs in logs. ### Still open — needs a human -- **The MP OAuth client's registered redirect URI must be updated.** Better Auth - 1.7 changed the callback path, so the `redirect_uri` this app sends is now - `<BETTER_AUTH_URL>/api/auth/callback/ministryplatform` — previously - `/api/auth/oauth2/callback/ministry-platform`. Note BOTH halves changed: the - 1.7 upgrade moved `/oauth2/callback/` to `/callback/`, and the provider id was - renamed from `ministry-platform` to `ministryplatform`. Register the new value in the - Ministry Platform OAuth client **before** deploying, or MP will reject the - authorization request outright. -- **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. -- 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. +Nothing at present. The enforced-CSP browser walk was completed 2026-10-10 — +see [`CSP_ENFORCE`](#csp_enforce). diff --git a/.claude/references/services/README.md b/.claude/references/services/README.md index 5139e85..84472fd 100644 --- a/.claude/references/services/README.md +++ b/.claude/references/services/README.md @@ -5,17 +5,20 @@ domain: services --- ## What's in this domain -Application service layer — singletons that wrap `MPHelper` and expose domain operations (tools, users, groups, address labels, field management) to server actions. All 5 services follow the same lazy-initialized `getInstance()` pattern. +Application service layer — singletons in `src/services/` that wrap `MPHelper` (or, for Google Places, an external provider) and expose domain operations to server actions and server components. 9 services; every file starts with `import "server-only"`. ## File map | File | Purpose | When to read | |------|---------|--------------| | `query-patterns.md` | MP REST query rules: `_TABLE_` FK traversal, ambiguous-column prefixing, quote escaping, `$userId` audit, batching | Before writing/modifying any MP query in a service | -| `tool-service.md` | `ToolService` — pages, user tools, selection resolution, contact-ID resolver, dev-tool deploy | Editing `src/services/toolService.ts` or its callers | -| `user-service.md` | `UserService` — profile fetch with roles + groups, GUID→ID lookup | Editing user profile/session code | +| `tool-service.md` | `ToolService` — page metadata, user tools, selection resolution, contact-ID resolver, dev-tool deploy | Editing `src/services/toolService.ts` or its callers | +| `user-service.md` | `UserService` — display-only profile fetch (names + email) | Editing user profile/session code | | `address-label-service.md` | `AddressLabelService` — Contacts→Households→Addresses multi-level FK join | Editing address-label feature | -| `group-service.md` | `GroupService` — lookup fetching, contact/group search, group CRUD with date coercion | Editing group-wizard feature | +| `group-service.md` | `GroupService` — lookups, contact/group search, group read/create/update with column allowlist | Editing group-wizard feature | | `field-management-service.md` | `FieldManagementService` — page field editor backing store, `api_MPNextTools_*` SPs | Editing field-management feature | +| `family-service.md` | `FamilyService` — Add/Edit Family: household load/save, lookups, envelope numbers | Editing the addeditfamily tool | + +`AuthorizationService` is documented in `../security/README.md`; `DomainTimezoneService` in `../ministryplatform.datetimehandling.md`. `GooglePlacesService` has no dedicated doc (see inventory below). ## Code surfaces | Path | Role | @@ -23,27 +26,46 @@ Application service layer — singletons that wrap `MPHelper` and expose domain | `src/services/toolService.ts` | `ToolService` singleton | | `src/services/userService.ts` | `UserService` singleton | | `src/services/addressLabelService.ts` | `AddressLabelService` singleton | -| `src/services/groupService.ts` | `GroupService` singleton | +| `src/services/groupService.ts` | `GroupService` singleton + `GROUP_WRITABLE_FIELDS` | | `src/services/fieldManagementService.ts` | `FieldManagementService` singleton | +| `src/services/familyService.ts` | `FamilyService` singleton + `PartialSaveError` | +| `src/services/authorizationService.ts` | `AuthorizationService` singleton + `UnauthorizedError`, `resolveRolePolicy` | +| `src/services/domainTimezoneService.ts` | `DomainTimezoneService` singleton + `resolveIanaTimezone`, `domainTimezoneService` instance | +| `src/services/googlePlacesService.ts` | `GooglePlacesService` singleton | | `src/services/*.test.ts` | Vitest suites (one per service) | -| `src/lib/providers/ministry-platform/helper.ts` | `MPHelper` — the only dependency services construct | -| `src/lib/validation.ts` | `validateGuid`, `validatePositiveInt`, `validateColumnName`, `escapeFilterString` — used by services | - -## Services inventory (5) -| Service | One-liner | -|---------|-----------| -| `ToolService` | Resolves MP pages, user tools, selection record IDs, contact IDs; also deploys dev tools | -| `UserService` | Fetches MP user profile (by `User_GUID`) plus roles and user-group memberships | -| `AddressLabelService` | Pulls mailing addresses for contacts via multi-level FK traversal | -| `GroupService` | Group-wizard lookups, contact/group search, group create/update with date coercion | -| `FieldManagementService` | Lists MP pages, reads `dp_Page_Fields`, persists field-order edits | +| `src/lib/providers/ministry-platform/helper.ts` | `MPHelper` — the MP dependency services construct | +| `src/lib/validation.ts` | `validateGuid`, `validatePositiveInt`, `validateColumnName`, `validateFkPath`, `escapeFilterString` — used by services | +| `src/lib/constants.ts` | `MP_FETCH_BATCH_SIZE` (100) — IN-clause batch size | + +## Services inventory (9) +| Service | One-liner | Key methods | +|---------|-----------|-------------| +| `ToolService` | MP page metadata, user tools, selection record IDs, page-record → Contact_ID resolution; dev-tool deploy | `getPageData`, `getSelectionRecordIds`, `getUserTools`, `resolveContactIds`, `listPages`, `listRoles`, `deployTool` | +| `UserService` | Signed-in user's display profile (names + email) by `User_GUID` | `getUserProfile` | +| `AddressLabelService` | Mailing addresses for contacts via multi-level FK traversal | `getAddressesForContacts`, `getAddressForContact` | +| `GroupService` | Group-wizard lookups, contact/group search, group read/create/update | `fetchAllLookups`, `searchContacts`, `searchGroups`, `getGroup`, `createGroup`, `updateGroup` | +| `FieldManagementService` | Lists MP pages, reads `dp_Page_Fields` + table metadata, persists field-order edits | `getPages`, `getPageFields`, `getTableMetadata`, `updatePageFieldOrder` | +| `FamilyService` | Add/Edit Family: contact search, household load/save, lookups, envelope numbers | `searchContacts`, `resolveContactIdFromPage`, `getHousehold`, `getLookups`, `getDefaults`, `getNextEnvelopeNumber`, `saveHousehold` | +| `AuthorizationService` | Role gate for all MP-data access (`MP_SECURITY_ROLES`, fails closed); returns acting MP `User_ID` | `requireSecurityRole`, `hasSecurityRole` | +| `DomainTimezoneService` | MP domain time zone (cached) and wall-clock ↔ instant conversion at the MP boundary | `getMpTimezone`, `toMpSqlDatetime`, `parseMpDatetime`, `clearCache` | +| `GooglePlacesService` | Address autocomplete; API key from MP `dp_Configuration_Settings` (`COMMON`/`GoogleMapsAPIKey`), else `GOOGLE_PLACES_API_KEY`, cached per process | `isEnabled`, `autocomplete`, `getPlaceDetails` | + +## Singleton shapes +- `static async getInstance(): Promise<T>` — `ToolService`, `UserService`, `AddressLabelService`, `GroupService`, `FieldManagementService`, `FamilyService`, `GooglePlacesService` (callers `await`). +- `static getInstance(): T` (synchronous) — `AuthorizationService`, `DomainTimezoneService`. + +## Authorization +- Every MP-touching method calls `AuthorizationService.getInstance().requireSecurityRole({ table, operation })` first — reads included (CLAUDE.md rule 12). Writes take `$userId` from its return value (rule 13). +- Methods with **no** gate: `UserService.getUserProfile` (rule-12 carve-out via `shared-actions/user.ts`), `FamilyService.getDefaults` (static data, no MP call), `DomainTimezoneService.*` (domain config only). +- `GooglePlacesService` gates every public method (`isEnabled`, `autocomplete`, `getPlaceDetails`) on `dp_Configuration_Settings`/`read` — on each call, not only on a cache miss, because the resolved API key and provider are cached per process. The gate runs outside `resolveApiKey`'s try/catch, so a refusal throws `UnauthorizedError` instead of falling back to the env var. ## Data flow ``` Server Action ("use server") - -> auth.api.getSession({ headers }) // validate session - -> Service.getInstance() // lazy singleton + -> requireAccess(table, op) // AuthorizationService.requireSecurityRole (action layer) + -> await Service.getInstance() // lazy singleton -> service method (typed) + -> AuthorizationService.requireSecurityRole(...) // service layer; returns User_ID -> MPHelper.getTableRecords / executeProcedureWithBody / createTableRecords / updateTableRecords -> Ministry Platform REST API ``` @@ -52,6 +74,7 @@ Server Action ("use server") - `../mp-provider/README.md` — `MPHelper` and underlying HTTP/provider layer - `../mp-schema/README.md` — table/stored-proc reference used by these services - `../data-flow/README.md` — end-to-end server action → service → MP flow -- `../auth/README.md` — `session.user.userGuid` used before calling services +- `../security/README.md` — `AuthorizationService`, write attribution +- `../auth/README.md` — `session.user.userGuid` (resolved to `User_ID` by the gate) - `../components/README.md` — server actions that call services - `../testing/README.md` — mock patterns (`vi.hoisted`, MPHelper class mock, singleton reset) diff --git a/.claude/references/services/address-label-service.md b/.claude/references/services/address-label-service.md index 4076a1c..3fe76b6 100644 --- a/.claude/references/services/address-label-service.md +++ b/.claude/references/services/address-label-service.md @@ -5,19 +5,22 @@ type: reference applies_to: [src/services/addressLabelService.ts, src/services/addressLabelService.test.ts] symbols: [AddressLabelService, ContactAddressRow] related: [query-patterns.md, ../dto-constants/dtos.md, ../components/address-labels.md] -last_verified: 2026-04-17 +last_verified: 2026-10-10 --- ## Purpose Fetch mailing addresses for one or many contacts by traversing `Contacts → Households → Addresses` via multi-level FK joins. Backs the address-label print feature. ## Files -- `src/services/addressLabelService.ts` — singleton (105 lines) -- `src/services/addressLabelService.test.ts` — 110 lines; covers singleton, happy path, batching (250 IDs → multiple calls), empty input, single-contact lookup +- `src/services/addressLabelService.ts` — singleton (118 lines) +- `src/services/addressLabelService.test.ts` — 154 lines; covers singleton, happy path, batching (250 IDs → multiple calls), empty input, single-contact lookup, invalid IDs (NaN/0/negative) rejected before the MP call + +## Authorization +Both methods call `AuthorizationService.getInstance().requireSecurityRole({ table: 'Contacts', operation: 'read' })` first — bulk names + mailing addresses are the app's largest PII surface. The action (`fetchAddressLabels`) gates again; per-request `cache()` memoization keeps it to one MP read. ## Singleton ```typescript -// src/services/addressLabelService.ts:45-63 +// src/services/addressLabelService.ts:46-64 export class AddressLabelService { private static instance: AddressLabelService; private mp: MPHelper | null = null; @@ -31,13 +34,13 @@ export class AddressLabelService { | Method | Returns | Behavior | |--------|---------|----------| -| `getAddressesForContacts(contactIds: number[])` | `ContactAddressRow[]` | Batches of 100, validates each ID with `validatePositiveInt`, orders by Postal_Code | +| `getAddressesForContacts(contactIds: number[])` | `ContactAddressRow[]` | Batches of `MP_FETCH_BATCH_SIZE` (100, `src/lib/constants.ts`), validates each ID with `validatePositiveInt`, orders by Postal_Code | | `getAddressForContact(contactId: number)` | `ContactAddressRow \| null` | Validates ID with `validatePositiveInt`, single-row lookup, `top: 1` | ## Shared select (multi-level FK traversal) ```typescript -// src/services/addressLabelService.ts:23-38 +// src/services/addressLabelService.ts:24-39 const SELECT_FIELDS = [ 'Contact_ID', 'Display_Name', @@ -65,17 +68,19 @@ Key rules (see `query-patterns.md`): ## getAddressesForContacts (batched) ```typescript -// src/services/addressLabelService.ts:21, 69-90 -const BATCH_SIZE = 100; - +// src/services/addressLabelService.ts:70-97 (MP_FETCH_BATCH_SIZE = 100 from src/lib/constants.ts) async getAddressesForContacts(contactIds: number[]): Promise<ContactAddressRow[]> { + await AuthorizationService.getInstance().requireSecurityRole({ + table: 'Contacts', + operation: 'read', + }); if (contactIds.length === 0) return []; const results: ContactAddressRow[] = []; - for (let i = 0; i < contactIds.length; i += BATCH_SIZE) { - const batch = contactIds.slice(i, i + BATCH_SIZE); - batch.forEach(validatePositiveInt); + for (let i = 0; i < contactIds.length; i += MP_FETCH_BATCH_SIZE) { + const batch = contactIds.slice(i, i + MP_FETCH_BATCH_SIZE); + batch.forEach((id) => validatePositiveInt(id)); const idList = batch.join(', '); const rows = await this.mp!.getTableRecords<ContactAddressRow>({ @@ -102,8 +107,12 @@ async getAddressesForContacts(contactIds: number[]): Promise<ContactAddressRow[] ## getAddressForContact ```typescript -// src/services/addressLabelService.ts:95-106 +// src/services/addressLabelService.ts:102-117 async getAddressForContact(contactId: number): Promise<ContactAddressRow | null> { + await AuthorizationService.getInstance().requireSecurityRole({ + table: 'Contacts', + operation: 'read', + }); validatePositiveInt(contactId); const rows = await this.mp!.getTableRecords<ContactAddressRow>({ @@ -119,7 +128,7 @@ async getAddressForContact(contactId: number): Promise<ContactAddressRow | null> ## Return type ```typescript -// src/services/addressLabelService.ts:4-19 +// src/services/addressLabelService.ts:7-22 export interface ContactAddressRow { Contact_ID: number; Display_Name: string; @@ -149,7 +158,7 @@ The server action (`src/components/address-labels/actions.ts`) transforms `Conta | `fetchAddressLabels` | `src/components/address-labels/actions.ts` | `getAddressesForContacts` (selection mode), `getAddressForContact` (single record) | ## Gotchas -- Returning `[]` for empty input skips the API call — don't "helpfully" add a default. +- Returning `[]` for empty input skips the API call (the gate still runs first) — don't "helpfully" add a default. - `Contacts.Household_ID` must stay prefixed — removing the `Contacts.` prefix will 500 with "ambiguous column". - `[State/Region]` must stay in brackets in the select; the TS key is `'State/Region'` (single-quoted). - `ContactAddressRow` returned from MP can have `Household_ID: null` when the contact isn't linked to a household — downstream filters must handle it. diff --git a/.claude/references/services/family-service.md b/.claude/references/services/family-service.md new file mode 100644 index 0000000..61ae4e6 --- /dev/null +++ b/.claude/references/services/family-service.md @@ -0,0 +1,89 @@ +--- +title: FamilyService +domain: services +type: reference +applies_to: [src/services/familyService.ts, src/services/familyService.test.ts, src/lib/dto/family.ts] +symbols: [FamilyService, PartialSaveError] +related: [query-patterns.md, tool-service.md, ../dto-constants/dtos.md, ../routing/app-router.md, ../ministryplatform.datetimehandling.md] +last_verified: 2026-10-10 +--- + +## Purpose +Backs the Add/Edit Family tool (`src/app/(web)/tools/addeditfamily/`): contact search, household load, lookup lists, envelope numbering, and the multi-step household save (addresses → household → contacts → participants → donors). + +## Files +- `src/services/familyService.ts` — singleton (801 lines) +- `src/services/familyService.test.ts` — 1015 lines +- `src/lib/dto/family.ts` — `HouseholdSchema` (Zod) + `Household`, `FamilyMember`, `FamilyLookups`, `FamilyDefaults`, `SaveProgress`, `SavedMemberId`, `ContactSearchResult` — see `../dto-constants/dtos.md` + +## Singleton +```typescript +// src/services/familyService.ts:95-106 +public static async getInstance(): Promise<FamilyService> { + if (!FamilyService.instance) { + FamilyService.instance = new FamilyService(); + FamilyService.instance.mp = new MPHelper(); // no separate initialize() + } + return FamilyService.instance; +} +``` + +## API + +| Method | Returns | Gate `table` / `operation` | Notes | +|--------|---------|----------------------------|-------| +| `searchContacts(term)` | `ContactSearchResult[]` (≤25) | `Contacts` / `read` | `[]` for trimmed term < 2 chars; `escapeFilterString`; `Display_Name LIKE '{escaped}%' AND Contact_Status_ID = 1`; detail = email, else household `Address_Line_1` | +| `resolveContactIdFromPage(tableName, primaryKey, recordId, contactIdField)` | `number \| null` | `{tableName}` / `read` | Validates all four args **before** the gate (`validateColumnName` ×2, `validatePositiveInt`, `validateFkPath`); `select: '{fkPath} AS Resolved_Contact_ID'` | +| `getHousehold(contactId)` | `Household \| null` | `Households` / `read` | Contact → `Household_ID`, then household (+ both addresses via `Address_ID_TABLE` / `Alternate_Mailing_Address_TABLE`) and members in parallel | +| `getLookups()` | `FamilyLookups` | `Contacts` / `read` | 12 lookup tables via `Promise.all`; `states` is the hard-coded `US_STATES` list | +| `getDefaults()` | `FamilyDefaults` | none | Returns a copy of the static `DEFAULT_FAMILY_DEFAULTS`; touches no MP data | +| `getNextEnvelopeNumber()` | `number` | `Contacts` / `read` | `MAX(Envelope_No) + 1` on `Donors` (global — `Donors` has no `Congregation_ID`) | +| `saveHousehold(household)` | `SaveProgress` | `Households` / `update` | Multi-step write; gate's `User_ID` is `$userId` for every write | + +## resolveContactIdFromPage +- Callers must pass page metadata resolved server-side from a page ID (`ToolService.getPageData`), never caller-supplied names. Both callers do: the `resolveContactIdFromPage` action and `src/app/(web)/tools/addeditfamily/page.tsx` (via `parseToolParams` → `pageData`). +- An empty `contactIdField` returns `null` after the gate. + +## saveHousehold — order of writes + +```typescript +// src/services/familyService.ts:474-543 (abridged) +const userId = await AuthorizationService.getInstance().requireSecurityRole({ + table: 'Households', + operation: 'update', +}); +progress.mainAddressId = await wrap(() => this.upsertAddress(household.address, userId)); +progress.altAddressId = /* alt address only if addressLine1 present */; +// create or partial-update Households (householdId === 0 → create) +for (const member of household.members) { + // skip blank new members; saveMember → Contacts, Participants, Donors +} +return progress; +``` + +- `wrap()` turns any failure into `PartialSaveError(progress, underlying)`; `saveFamily` returns `progress` to the client so a retry can reuse IDs already created. **Not transactional.** +- `upsertAddress` — skipped when line 1 and postal code are both blank. +- `saveMember` — new participant gets `Participant_Start_Date = toMpSqlDatetime(new Date())` and back-links `Contacts.Participant_Record`; new donor gets `Setup_Date` the same way, `DONOR_DEFAULTS` (statement frequency/type/method `1`), and back-links `Contacts.Donor_Record`. +- `resolveUniqueEnvelopeNo` — re-checks the requested envelope number against other donors and bumps to `getNextEnvelopeNumber()` on conflict, up to 5 attempts, then throws. Every interpolated ID is `validatePositiveInt`-checked. +- `Date_of_Birth`, `Season_Start`, `Season_End` go through a local `toDatetime()` (appends `T00:00:00` to a date-only string), not `DomainTimezoneService`. + +## Consumers + +| Caller | File | Method(s) | +|--------|------|-----------| +| `searchContacts` | `src/app/(web)/tools/addeditfamily/actions.ts` | `searchContacts` | +| `fetchFamilyLookups` | same | `getLookups` | +| `fetchFamilyDefaults` | same | `getDefaults` | +| `fetchHousehold` | same | `getHousehold` | +| `resolveContactIdFromPage` | same | `ToolService.getPageData`, then `resolveContactIdFromPage` | +| `fetchNextEnvelopeNumber` | same | `getNextEnvelopeNumber` | +| `saveFamily` | same | `saveHousehold` (after `HouseholdSchema.safeParse`) | +| `AddEditFamilyPage` | `src/app/(web)/tools/addeditfamily/page.tsx` | `resolveContactIdFromPage` | + +Every action calls `requireAccess()` (→ `requireSecurityRole`) first. The same actions file also calls `GooglePlacesService` (`placesEnabled`, `placeAutocomplete`, `placeDetails`); those actions gate `Addresses`/`read`, and the service gates again on `dp_Configuration_Settings`/`read`. + +## Related docs +- `query-patterns.md` — validation, escaping, `$userId` +- `tool-service.md` — `getPageData`, sibling `resolveContactIds` +- `../dto-constants/dtos.md` — family DTOs +- `../security/README.md` — authorization gate diff --git a/.claude/references/services/field-management-service.md b/.claude/references/services/field-management-service.md index 59cf4d0..3a7362b 100644 --- a/.claude/references/services/field-management-service.md +++ b/.claude/references/services/field-management-service.md @@ -5,19 +5,29 @@ type: reference applies_to: [src/services/fieldManagementService.ts, src/services/fieldManagementService.test.ts] symbols: [FieldManagementService, PageListItem, PageField] related: [query-patterns.md, ../mp-schema/required-procs.md, ../components/field-management.md] -last_verified: 2026-04-17 +last_verified: 2026-10-10 --- ## Purpose -Reads MP page list + `dp_Page_Fields` rows for the drag-and-drop field editor, merges in table-metadata columns to surface unsaved fields, and persists reorders via `api_MPNextTools_UpdatePageFieldOrder` (concurrency-capped at 5). +Reads MP page list, `dp_Page_Fields` rows and table metadata for the drag-and-drop field editor (the `fetchPageFieldData` action merges metadata columns in to surface unsaved fields), and persists reorders via `api_MPNextTools_UpdatePageFieldOrder` (concurrency-capped at 5). ## Files -- `src/services/fieldManagementService.ts` — 110 lines -- `src/services/fieldManagementService.test.ts` — 254 lines +- `src/services/fieldManagementService.ts` — 134 lines +- `src/services/fieldManagementService.test.ts` — 303 lines + +## Authorization +Every method calls `AuthorizationService.getInstance().requireSecurityRole(...)` first (see `../security/README.md`). `updatePageFieldOrder` is a **domain-wide** configuration write (changes page layout for every MP user), and takes `$userId` from the gate's return value. + +| Method | Gate `table` / `operation` | +|--------|---------------------------| +| `getPages` | `dp_Pages` / `read` | +| `getPageFields` | `dp_Page_Fields` / `read` | +| `getTableMetadata` | `dp_Tables` / `read` | +| `updatePageFieldOrder` | `dp_Page_Fields` / `update` | ## Singleton ```typescript -// src/services/fieldManagementService.ts:25-41 +// src/services/fieldManagementService.ts:27-43 export class FieldManagementService { private static instance: FieldManagementService; private mp: MPHelper | null = null; @@ -33,13 +43,13 @@ export class FieldManagementService { |--------|---------|-------| | `getPages()` | `PageListItem[]` | SP `api_MPNextTools_GetPages` | | `getPageFields(pageId)` | `PageField[]` | SP `api_MPNextTools_GetPageFields` | -| `getTableMetadata(tableName)` | `TableMetadata \| null` | `mp.getTables(tableName)` — exact-match preferred | -| `updatePageFieldOrder(fields[])` | `void` | SP `api_MPNextTools_UpdatePageFieldOrder` per field (batches of 5 via `Promise.all`) | +| `getTableMetadata(tableName)` | `TableMetadata \| null` | `mp.getTables(tableName)` — exact `Name` match only, else `null` | +| `updatePageFieldOrder(fields[])` | `void` | SP `api_MPNextTools_UpdatePageFieldOrder` per field (batches of 5 via `Promise.all`), `$userId` from gate | ## Types ```typescript -// src/services/fieldManagementService.ts:4-23 +// src/services/fieldManagementService.ts:6-25 export interface PageListItem { Page_ID: number; Display_Name: string; @@ -65,7 +75,7 @@ export interface PageField { ## getPages ```typescript -// src/services/fieldManagementService.ts:43-51 +// src/services/fieldManagementService.ts:45-57 (gate call omitted) public async getPages(): Promise<PageListItem[]> { const result = await this.mp!.executeProcedureWithBody('api_MPNextTools_GetPages', {}); if (result && result.length > 0 && result[0].length > 0) { @@ -80,7 +90,7 @@ Same SP also used by `ToolService.listPages` (which wraps it with in-memory sear ## getPageFields ```typescript -// src/services/fieldManagementService.ts:53-63 +// src/services/fieldManagementService.ts:59-73 (gate call omitted) public async getPageFields(pageId: number): Promise<PageField[]> { const result = await this.mp!.executeProcedureWithBody('api_MPNextTools_GetPageFields', { "@PageID": pageId, @@ -95,21 +105,21 @@ public async getPageFields(pageId: number): Promise<PageField[]> { ## getTableMetadata ```typescript -// src/services/fieldManagementService.ts:65-72 +// src/services/fieldManagementService.ts:75-88 (gate call and comment omitted) public async getTableMetadata(tableName: string): Promise<TableMetadata | null> { const tables = await this.mp!.getTables(tableName); - if (tables.length === 0) return null; - // getTables with search may return multiple matches — find the exact one - return tables.find((t) => t.Table_Name === tableName) ?? tables[0]; + return tables.find((t) => t.Name === tableName) ?? null; } ``` -`mp.getTables()` delegates to MP's metadata endpoint; `search` is a LIKE-style param so a prefix match may return multiple tables. +`mp.getTables()` delegates to MP's `GET /tables?$search=`, which can return several tables. Each entry's name is under **`Name`** — a live `/tables?$search=Contacts` response (2026-10-11) carried exactly `Name`, `AccessLevel`, `SpecialPermissions`, `Columns` per entry; there is no `Table_Name`. The match is exact and case-sensitive (the page's `Table_Name` and the `/tables` `Name` both come from MP's own table metadata, so they agree verbatim). + +There is **no fallback to `tables[0]`**: a near-miss would merge another table's columns into this page's layout. With no exact match it returns `null`, which `fetchPageFieldData` already handles — it merges no extra columns and tags no separators, so the editor shows only the page's existing `dp_Page_Fields` rows. ## updatePageFieldOrder — concurrency-capped ```typescript -// src/services/fieldManagementService.ts:74-109 +// src/services/fieldManagementService.ts:88-133 private static readonly CONCURRENCY = 5; public async updatePageFieldOrder( @@ -127,6 +137,12 @@ public async updatePageFieldOrder( Writing_Assistant_Enabled: boolean; }[] ): Promise<void> { + const $userId = await AuthorizationService.getInstance().requireSecurityRole({ + table: "dp_Page_Fields", + operation: "update", + }); + const queryParams = { $userId }; + for (let i = 0; i < fields.length; i += FieldManagementService.CONCURRENCY) { const batch = fields.slice(i, i + FieldManagementService.CONCURRENCY); await Promise.all(batch.map((f) => @@ -142,13 +158,13 @@ public async updatePageFieldOrder( "@DependsOnField": f.Depends_On_Field, "@FieldLabel": f.Field_Label, "@WritingAssistantEnabled": f.Writing_Assistant_Enabled, - }) + }, queryParams) )); } } ``` -- One SP call per field, 5 parallel calls per batch. +- One SP call per field, 5 parallel calls per batch, each carrying `$userId` for MP audit. - Not atomic — partial failures leave some fields updated, others not. The server action wraps in try/catch and surfaces an error to the UI. ## Stored procedures @@ -159,7 +175,7 @@ public async updatePageFieldOrder( | `api_MPNextTools_GetPageFields` | `getPageFields` | `@PageID INT` | | `api_MPNextTools_UpdatePageFieldOrder` | `updatePageFieldOrder` (1-per-field) | `@PageID, @FieldName, @GroupName, @ViewOrder, @Required, @Hidden, @DefaultValue, @FilterClause, @DependsOnField, @FieldLabel, @WritingAssistantEnabled` | -Each SP accepts `@DomainID INT` as the implicit first parameter (auto-injected by MP API — see MP install scripts in `src/lib/providers/ministry-platform/db/`). +Each SP declares `@DomainID INT` as its first parameter (auto-injected by MP API — see MP install scripts in `src/lib/providers/ministry-platform/db/`). See `../mp-schema/required-procs.md` for full parameter/return docs. @@ -167,13 +183,13 @@ See `../mp-schema/required-procs.md` for full parameter/return docs. | Action | File | |--------|------| -| `fetchPages` | `src/components/field-management/actions.ts` | +| `fetchPages` | `src/components/field-management/actions.ts` — gate `dp_Pages`/`read` | | `fetchPageFieldData` | `src/components/field-management/actions.ts` (parallels `getPageFields` + `getTableMetadata`, merges unsaved rows with negative `Page_Field_ID` values) | -| `savePageFieldOrder` | `src/components/field-management/actions.ts` — wraps `updatePageFieldOrder` in try/catch | +| `savePageFieldOrder` | `src/components/field-management/actions.ts` — gate `dp_Page_Fields`/`update`, wraps `updatePageFieldOrder` in try/catch | ## Gotchas - `updatePageFieldOrder` is not transactional. A mid-batch failure leaves `dp_Page_Fields` partially updated. -- `getTableMetadata` falls back to `tables[0]` when there's no exact `Table_Name` match — this can misbehave if MP returns a prefix-matching unrelated table. Exact match is preferred; the caller assumes this is usually right. +- `getTableMetadata` matches on `Name`, not `Table_Name` — `/tables` entries have no `Table_Name` key (the `PageListItem.Table_Name` from `dp_Pages` is a different shape). No exact match → `null`, never the first search result. - `getPages` shape is `result[0]` — not `result[0][0]`. Different from `getPageData` in ToolService (which returns a single row). ## Related docs diff --git a/.claude/references/services/group-service.md b/.claude/references/services/group-service.md index 9a06d8f..63c44ef 100644 --- a/.claude/references/services/group-service.md +++ b/.claude/references/services/group-service.md @@ -3,21 +3,21 @@ title: GroupService domain: services type: reference applies_to: [src/services/groupService.ts, src/services/groupService.test.ts] -symbols: [GroupService] -related: [query-patterns.md, ../components/group-wizard.md] -last_verified: 2026-04-17 +symbols: [GroupService, GROUP_WRITABLE_FIELDS, GetGroupResult, GroupWizardDisplayNames] +related: [query-patterns.md, ../components/group-wizard.md, ../ministryplatform.datetimehandling.md] +last_verified: 2026-10-10 --- ## Purpose -Backs the group-wizard feature: parallel lookup-table fetch, contact/group search, and group create/update with date-string coercion and `$userId` audit. +Backs the group-wizard feature: parallel lookup-table fetch, contact/group search, group read (with display names for FK fields), and group create/update through a column allowlist, MP-TZ date conversion and gate-sourced `$userId` audit. ## Files -- `src/services/groupService.ts` — 252 lines -- `src/services/groupService.test.ts` — 401 lines +- `src/services/groupService.ts` — 405 lines +- `src/services/groupService.test.ts` — 581 lines ## Singleton ```typescript -// src/services/groupService.ts:27-45 +// src/services/groupService.ts:113-131 export class GroupService { private static instance: GroupService; private mp: MPHelper | null = null; @@ -27,6 +27,18 @@ export class GroupService { } ``` +## Authorization +Every method calls `AuthorizationService.getInstance().requireSecurityRole(...)` first (see `../security/README.md`). Writes use its return value as `$userId` — callers never pass a user ID. + +| Method | Gate `table` / `operation` | +|--------|---------------------------| +| `fetchAllLookups` | `Groups` / `read` | +| `searchContacts` | `Contacts` / `read` | +| `searchGroups` | `Groups` / `read` | +| `getGroup` | `Groups` / `read` | +| `createGroup` | `Groups` / `create` | +| `updateGroup` | `Groups` / `update` | + ## API | Method | Returns | Notes | @@ -34,14 +46,14 @@ export class GroupService { | `fetchAllLookups()` | `GroupWizardLookups` | 13 lookup tables via `Promise.all` | | `searchContacts(term)` | `ContactSearchResult[]` (≤20) | `escapeFilterString()`, `Display_Name LIKE '{escaped}%'` | | `searchGroups(term)` | `GroupSearchResult[]` (≤20) | `escapeFilterString()`, `Group_Name LIKE '{escaped}%' AND End_Date IS NULL`, FK-traverses `Group_Type` | -| `getGroup(groupId)` | `GroupWizardFormData \| null` | `validatePositiveInt(groupId)`, strips time from ISO dates | -| `createGroup(data, userId)` | `{ Group_ID, Group_Name }` | `prepareForApi()` coerces date strings, passes `$userId` | -| `updateGroup(groupId, data, userId)` | `{ Group_ID, Group_Name }` | `partial: true`, `$userId` | +| `getGroup(groupId)` | `GetGroupResult \| null` (`{ data, displayNames }`) | `validatePositiveInt(groupId)`, FK display-name joins, strips time from ISO dates | +| `createGroup(data)` | `{ Group_ID, Group_Name }` | `prepareForApi()` allowlist + date conversion; `$userId` from gate | +| `updateGroup(groupId, data)` | `{ Group_ID, Group_Name }` | `validatePositiveInt(groupId)`, `partial: true`, `Group_ID` written last; `$userId` from gate | ## fetchAllLookups — 13 parallel queries ```typescript -// src/services/groupService.ts:47-149 (abridged — 13 entries) +// src/services/groupService.ts:133-239 (abridged — 13 entries) const [groupTypes, ministries, congregations, meetingDays, ...] = await Promise.all([ this.mp!.getTableRecords({ table: 'Group_Types', select: 'Group_Type_ID, Group_Type', orderBy: 'Group_Type' }), this.mp!.getTableRecords({ table: 'Ministries', select: 'Ministry_ID, Ministry_Name', filter: 'End_Date IS NULL', orderBy: 'Ministry_Name' }), @@ -66,7 +78,7 @@ Active-only filters applied on `Ministries`, `Congregations`, `Rooms` (`Bookable ## searchContacts / searchGroups ```typescript -// src/services/groupService.ts:151-171 +// src/services/groupService.ts:241-270 (gate calls omitted) async searchContacts(term: string): Promise<ContactSearchResult[]> { const escaped = escapeFilterString(term); return this.mp!.getTableRecords<ContactSearchResult>({ @@ -90,99 +102,110 @@ async searchGroups(term: string): Promise<GroupSearchResult[]> { } ``` -- `escapeFilterString()` from `src/lib/validation.ts` — escapes `'`, `%`, `_` +- `escapeFilterString()` from `src/lib/validation.ts` — bracket-escapes `[`, `%`, `_`, doubles `'`; throws on >100 chars or control characters - `searchGroups` FK-traverses `Group_Type_ID_TABLE.Group_Type` to inline the type name -## getGroup — date coercion on read +## getGroup — display names + date strip on read ```typescript -// src/services/groupService.ts:173-225 (abridged) -async getGroup(groupId: number): Promise<GroupWizardFormData | null> { - const records = await this.mp!.getTableRecords<Record<string, unknown>>({ - table: 'Groups', - filter: `Group_ID = ${validatePositiveInt(groupId)}`, - top: 1, - }); - if (!records || records.length === 0) return null; - - const r = records[0]; - return { - Group_Name: r.Group_Name as string, - // ...48+ fields mapped - Start_Date: r.Start_Date ? (r.Start_Date as string).split('T')[0] : '', - End_Date: r.End_Date ? (r.End_Date as string).split('T')[0] : null, - Promotion_Date: r.Promotion_Date ? (r.Promotion_Date as string).split('T')[0] : null, - 'Secure_Check-in': (r['Secure_Check-in'] as boolean) ?? false, - // ... - }; -} +// src/services/groupService.ts:272-363 (abridged) +const records = await this.mp!.getTableRecords<Record<string, unknown>>({ + table: 'Groups', + select: [ + 'Groups.*', + 'Primary_Contact_TABLE.Display_Name AS Primary_Contact_Display_Name', + 'Parent_Group_TABLE.Group_Name AS Parent_Group_Name', + 'Promote_to_Group_TABLE.Group_Name AS Promote_to_Group_Name', + 'Descended_From_TABLE.Group_Name AS Descended_From_Name', + ].join(', '), + filter: `Groups.Group_ID = ${validatePositiveInt(groupId)}`, + top: 1, +}); +if (!records || records.length === 0) return null; +// ...map ~40 fields into GroupWizardFormData, e.g. +// Start_Date: r.Start_Date ? (r.Start_Date as string).split('T')[0] : '', +// 'Secure_Check-in': (r['Secure_Check-in'] as boolean) ?? false, +return { data, displayNames: { contacts, groups } }; ``` -- MP returns ISO datetimes; form expects `YYYY-MM-DD`. `split('T')[0]` strips the time. -- `Secure_Check-in` uses bracket access — hyphen in the column name. +- `Groups.*` / `Groups.Group_ID` are prefixed because the FK joins make bare names ambiguous. +- `AS` aliases seed `displayNames.contacts` (`Primary_Contact`) and `displayNames.groups` (`Parent_Group`, `Promote_to_Group`, `Descended_From`) so edit mode needs no second round trip. +- MP returns wall-clock datetimes; the form expects `YYYY-MM-DD`, so `split('T')[0]` strips the time. -## createGroup / updateGroup — date coercion on write +## createGroup / updateGroup — allowlist + MP-TZ dates on write ```typescript -// src/services/groupService.ts:10-25 -function toDatetime(value: string | null | undefined): string | null { - if (!value) return null; - if (value.includes('T')) return value; - return `${value}T00:00:00Z`; -} - -function prepareForApi(data: GroupWizardFormData): Record<string, unknown> { - return { - ...data, - Start_Date: toDatetime(data.Start_Date), - End_Date: toDatetime(data.End_Date), - Promotion_Date: toDatetime(data.Promotion_Date), - }; +// src/services/groupService.ts:88-111 +const DATE_FIELDS = ['Start_Date', 'End_Date', 'Promotion_Date'] as const; + +async function prepareForApi(data: Partial<GroupWizardFormData>): Promise<Record<string, unknown>> { + const source = data as Record<string, unknown>; + const picked: Record<string, unknown> = {}; + for (const field of GROUP_WRITABLE_FIELDS) { + if (Object.hasOwn(source, field)) picked[field] = source[field]; + } + const tz = DomainTimezoneService.getInstance(); + 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; } ``` +- `GROUP_WRITABLE_FIELDS` (`src/services/groupService.ts:40-86`) — frozen allowlist of exactly the columns the wizard edits; equals the union of `STEP_FIELDS` in `src/components/group-wizard/schema.ts` (pinned by a test). Any other key (`Domain_ID`, `_Last_Attendance_Posted`, a smuggled `Group_ID`) is dropped. +- Dates go through `DomainTimezoneService.toMpSqlDatetime` (MP-TZ wall clock) — see `../ministryplatform.datetimehandling.md`. + ```typescript -// src/services/groupService.ts:227-251 -async createGroup(data, userId) { - const apiData = prepareForApi(data); +// src/services/groupService.ts:365-404 (abridged) +async createGroup(data: GroupWizardFormData) { + const $userId = await AuthorizationService.getInstance().requireSecurityRole({ table: 'Groups', operation: 'create' }); + const apiData = await prepareForApi(data); const result = await this.mp!.createTableRecords('Groups', [apiData], { $select: 'Group_ID, Group_Name', - $userId: userId, + $userId, }); return result[0] as unknown as { Group_ID: number; Group_Name: string }; } -async updateGroup(groupId, data, userId) { - const apiData = { Group_ID: groupId, ...prepareForApi(data as GroupWizardFormData) }; +async updateGroup(groupId: number, data: Partial<GroupWizardFormData>) { + const $userId = await AuthorizationService.getInstance().requireSecurityRole({ table: 'Groups', operation: 'update' }); + validatePositiveInt(groupId); + const apiData = { ...(await prepareForApi(data)), Group_ID: groupId }; const result = await this.mp!.updateTableRecords('Groups', [apiData], { partial: true, $select: 'Group_ID, Group_Name', - $userId: userId, + $userId, }); return result[0] as unknown as { Group_ID: number; Group_Name: string }; } ``` -- `$userId` — numeric MP User ID for audit. Resolve from session via `UserService.getUserIdByGuid(session.user.userGuid)`. +- `$userId` — numeric MP `User_ID` returned by the gate; the only source of write attribution (CLAUDE.md rule 13). +- `Group_ID` is spread **last** from the validated argument, so nothing in the payload can retarget the update. - `partial: true` on update — omitted fields are left untouched. - `$select` limits returned columns to `Group_ID, Group_Name`. ## Consumers -| Action | File | -|--------|------| -| `fetchGroupWizardLookups` | `src/components/group-wizard/actions.ts` | -| `searchContacts` / `searchGroups` | `src/components/group-wizard/actions.ts` | -| `fetchGroupRecord` | `src/components/group-wizard/actions.ts` | -| `createGroup` / `updateGroup` | `src/components/group-wizard/actions.ts` | +All in `src/components/group-wizard/actions.ts`; each action calls `requireAccess()` (→ `requireSecurityRole`) first. + +| Action | Service method | +|--------|----------------| +| `fetchGroupWizardLookups` | `fetchAllLookups` | +| `searchContacts` / `searchGroups` | `searchContacts` / `searchGroups` (action returns `[]` for terms < 2 chars) | +| `fetchGroupRecord` | `getGroup` | +| `createGroup` / `updateGroup` | `createGroup` / `updateGroup` (action Zod-parses the payload with `groupWizardSchema` first) | ## Gotchas -- `searchContacts(term)` assumes `term.length >= 2` — the action validates that; if a service caller forgets, an empty prefix query returns everything up to `top: 20`. +- `searchContacts(term)` assumes `term.length >= 2` — the action enforces that; if a service caller forgets, an empty prefix query returns everything up to `top: 20`. - `getGroup` returns `null` on "not found"; `createGroup`/`updateGroup` do not — they throw on API error. -- All three lookup active-filters (`End_Date IS NULL` for Ministries/Congregations, `Bookable = 1` for Rooms, `Active = 1` for SMS) must stay — UI relies on active-only lists. -- Passing `userId = session.user.id` instead of the MP User_ID produces corrupt audit rows. Always resolve via `UserService.getUserIdByGuid()`. +- All lookup active-filters (`End_Date IS NULL` for Ministries/Congregations, `Bookable = 1` for Rooms, `Active = 1` for SMS) must stay — UI relies on active-only lists. +- A new wizard field must go into **both** `STEP_FIELDS` and `GROUP_WRITABLE_FIELDS`; otherwise the allowlist test fails and the field is never written. ## Related docs - `query-patterns.md` — validation, escaping, FK traversal, `$userId` - `../components/group-wizard.md` — consuming UI and schema -- `user-service.md` — `getUserIdByGuid` for `$userId` resolution +- `../ministryplatform.datetimehandling.md` — `DomainTimezoneService` conversion rules +- `../security/README.md` — authorization gate and write attribution diff --git a/.claude/references/services/query-patterns.md b/.claude/references/services/query-patterns.md index adfdc4b..cff7650 100644 --- a/.claude/references/services/query-patterns.md +++ b/.claude/references/services/query-patterns.md @@ -2,25 +2,26 @@ title: MP query patterns for services domain: services type: reference -applies_to: [src/services/toolService.ts, src/services/userService.ts, src/services/addressLabelService.ts, src/services/groupService.ts, src/services/fieldManagementService.ts, src/lib/validation.ts] -symbols: [validateGuid, validatePositiveInt, validateColumnName, escapeFilterString] +applies_to: [src/services/toolService.ts, src/services/userService.ts, src/services/addressLabelService.ts, src/services/groupService.ts, src/services/fieldManagementService.ts, src/services/familyService.ts, src/services/authorizationService.ts, src/lib/validation.ts, src/lib/constants.ts] +symbols: [validateGuid, validatePositiveInt, validateColumnName, validateFkPath, escapeFilterString, MP_FETCH_BATCH_SIZE] related: [../mp-provider/architecture.md, ../mp-schema/required-procs.md, tool-service.md, user-service.md, address-label-service.md, group-service.md] -last_verified: 2026-04-17 +last_verified: 2026-10-10 --- ## Purpose Conventions for building safe MP REST queries inside service methods — FK traversal via `_TABLE`, ambiguous-column prefixing, quote escaping, `$userId` audit, and batching. ## Files -- `src/lib/validation.ts` — `validateGuid`, `validatePositiveInt`, `validateColumnName`, `escapeFilterString` +- `src/lib/validation.ts` — `validateGuid`, `validatePositiveInt`, `validateColumnName`, `validateFkPath`, `escapeFilterString` (also `validateMailerId`, not a filter guard) +- `src/lib/constants.ts` — `MP_FETCH_BATCH_SIZE` (100) - `src/services/*.ts` — all call sites follow these rules ## Key concepts - **Implicit joins.** MP's REST API auto-joins tables named via `_TABLE` in `$select`. If a column appears in more than one joined table, the API returns an **ambiguous column** error. - **`_TABLE` FK traversal.** Suffix an FK column with `_TABLE` then dot-access a field on the target table. Chain `_TABLE_` between levels; use the dot **only** before the final field. -- **Quote-escape all user input.** `'` → `''` via `escapeFilterString()` (also `%` → `[%]`, `_` → `[_]` for LIKE). -- **Typed ID validators.** `validateGuid`, `validatePositiveInt`, `validateColumnName` throw before a value reaches any filter string. -- **`$userId` audit.** Passing `$userId` to `createTableRecords` / `updateTableRecords` stamps MP audit columns with that MP User ID (not the Better Auth session user ID). +- **Quote-escape all user input.** `'` → `''` via `escapeFilterString()` (also `[` → `[[]`, `%` → `[%]`, `_` → `[_]` for LIKE; quote look-alikes → `_`). +- **Typed ID validators.** `validateGuid`, `validatePositiveInt`, `validateColumnName`, `validateFkPath` throw before a value reaches any filter/select string. Errors name the field, never the value (CLAUDE.md rule 14). +- **`$userId` audit.** Passing `$userId` to `createTableRecords` / `updateTableRecords` / `executeProcedureWithBody` stamps the MP audit log with that MP `User_ID`. Its only source is `AuthorizationService.requireSecurityRole()`'s return value, taken inside the service (CLAUDE.md rule 13). ## FK traversal @@ -30,7 +31,7 @@ Conventions for building safe MP REST queries inside service methods — FK trav | `TableName.ColumnName` | Disambiguate a column that appears in multiple joined tables | `Contacts.Contact_ID` | | `FKColumn_TABLE.ColumnName` | Follow one FK → pull a column from the related table | `Contact_ID_TABLE.First_Name` | | `FK1_TABLE_FK2_TABLE.ColumnName` | Multi-level FK traversal (underscores between levels, dot only before final field) | `Household_ID_TABLE_Address_ID_TABLE.City` | -| `FKColumn_TABLE.Column AS Alias` | FK traversal with alias | `Contact_ID_TABLE.dp_fileUniqueId AS Image_GUID` | +| `FKColumn_TABLE.Column AS Alias` | FK traversal with alias | `Parent_Group_TABLE.Group_Name AS Parent_Group_Name` | | `[Column/With-Special]` | Bracket notation for columns with `/`, `-`, space | `Household_ID_TABLE_Address_ID_TABLE.[State/Region]` | **Invalid:** `Building_ID_TABLE.Location_ID_TABLE.Column` (dots between intermediate FK levels). Use underscores: `Building_ID_TABLE_Location_ID_TABLE.Column`. @@ -40,7 +41,7 @@ Conventions for building safe MP REST queries inside service methods — FK trav | Column | Appears in | Resolution | |--------|-----------|------------| | `Contact_ID` | Contacts, dp_Users, Participants, Group_Participants, Household_Care_Log, many more | Prefix with table, e.g., `Contacts.Contact_ID` | -| `User_ID` | dp_Users, dp_User_Roles, dp_User_User_Groups | Prefix when joining, e.g., `dp_Users.User_ID` | +| `User_ID` | dp_Users, dp_User_Roles | Prefix when joining, e.g., `dp_Users.User_ID` | | `Household_ID` | Contacts, Households | Prefix, e.g., `Contacts.Household_ID` | | `Display_Name` | Contacts, Groups, Ministries, etc. | Prefix when joining creates shadow | @@ -48,18 +49,18 @@ Conventions for building safe MP REST queries inside service methods — FK trav ### 1-level FK traversal — `UserService.getUserProfile` ```typescript -// src/services/userService.ts:81-87 +// src/services/userService.ts — UserService.getUserProfile const records = await this.mp!.getTableRecords<MPUserProfile>({ table: "dp_Users", filter: `User_GUID = '${validateGuid(id)}'`, - select: "User_ID, User_GUID, Contact_ID_TABLE.First_Name,Contact_ID_TABLE.Nickname,Contact_ID_TABLE.Last_Name,Contact_ID_TABLE.Email_Address,Contact_ID_TABLE.Mobile_Phone,Contact_ID_TABLE.dp_fileUniqueId AS Image_GUID", + select: "Contact_ID_TABLE.First_Name,Contact_ID_TABLE.Nickname,Contact_ID_TABLE.Last_Name,Contact_ID_TABLE.Email_Address", top: 1 }); ``` ### Multi-level FK traversal — `AddressLabelService` ```typescript -// src/services/addressLabelService.ts:23-38 +// src/services/addressLabelService.ts:24-39 const SELECT_FIELDS = [ 'Contact_ID', 'Display_Name', @@ -78,37 +79,35 @@ const SELECT_FIELDS = [ ].join(', '); ``` -### FK traversal in `$select` (role names) — `UserService` +### FK traversal in `$select` (role names) — `AuthorizationService` ```typescript -// src/services/userService.ts:93-102 -this.mp!.getTableRecords<{ Role_Name: string }>({ +// src/services/authorizationService.ts — loadSecurityRoles +const records = await mp.getTableRecords<{ Role_Name: string }>({ table: "dp_User_Roles", - filter: `User_ID = ${profile.User_ID}`, select: "Role_ID_TABLE.Role_Name", -}), -this.mp!.getTableRecords<{ User_Group_Name: string }>({ - table: "dp_User_User_Groups", - filter: `User_ID = ${profile.User_ID}`, - select: "User_Group_ID_TABLE.User_Group_Name", -}), + filter: `User_ID = ${userId}`, +}); ``` ## Quote escaping (SQL-injection prevention) ```typescript -// src/lib/validation.ts +// src/lib/validation.ts:127-144 (abridged) export function escapeFilterString(value: string): string { + // throws on non-string, length > MAX_SEARCH_TERM_LENGTH (100), or ASCII control chars return value - .replace(/'/g, "''") // escape single quotes (SQL-literal escape) + .replace(/\[/g, '[[]') // FIRST — `[` opens a LIKE character class .replace(/%/g, '[%]') // escape LIKE wildcard - .replace(/_/g, '[_]'); // escape LIKE any-single-char wildcard + .replace(/_/g, '[_]') // escape LIKE any-single-char wildcard + .replace(/'/g, "''") // escape single quotes (SQL-literal escape) + .replace(QUOTE_LOOKALIKES, '_'); // curly/fullwidth quotes → single-char wildcard } ``` Used where? ```typescript -// src/services/groupService.ts:151-160 +// src/services/groupService.ts:241-255 (gate call omitted) async searchContacts(term: string): Promise<ContactSearchResult[]> { const escaped = escapeFilterString(term); return this.mp!.getTableRecords<ContactSearchResult>({ @@ -121,75 +120,72 @@ async searchContacts(term: string): Promise<ContactSearchResult[]> { } ``` -Inline escape (no `%`/`_` wildcards): `ToolService.listRoles` +Contains-match: `ToolService.listRoles` ```typescript -// src/services/toolService.ts:226-239 +// src/services/toolService.ts:252-255 const filter = term - ? `Role_Name LIKE '%${term.replace(/'/g, "''")}%'` + ? `Role_Name LIKE '%${escapeFilterString(term)}%'` : undefined; ``` +Also used by `FamilyService.searchContacts` (`src/services/familyService.ts:116`). + ## Typed validators — fail fast before building a filter ```typescript // src/lib/validation.ts -export function validateGuid(value: string): string { /* /^[0-9a-f]{8}-.../ throws on fail */ } -export function validatePositiveInt(value: number): number { /* throws on 0, negative, non-int */ } +export function validateGuid(value: string): string { /* /^[0-9a-f]{8}-.../i throws on fail */ } +export function validatePositiveInt(value: number, field?: string): number { /* throws on non-number, 0, negative, non-safe-integer */ } export function validateColumnName(value: string): string { /* throws if ≠ /^[A-Za-z_][A-Za-z0-9_]*$/ */ } +export function validateFkPath(value: unknown, field = 'column path'): string { /* `X_TABLE.Col` / `A_TABLE_B_TABLE.Col`; ≤4 FK segments, ≤256 chars; no spaces, commas, AS */ } ``` | Where | Call | Purpose | |-------|------|---------| -| `userService.ts:72, 84` | `validateGuid(guid)` | Guard `User_GUID = '...'` filter | -| `toolService.ts:307-334` | `validateColumnName`, `validatePositiveInt` | Guard `resolveContactIds` — rejects bad table/PK/contact-id-field/record-ID values | -| `addressLabelService.ts:76` | `batch.forEach(validatePositiveInt)` | Guard batched `Contact_ID IN (...)` | -| `groupService.ts:176` | `validatePositiveInt(groupId)` | Guard `Group_ID = ${id}` in `getGroup` | +| `userService.ts` (`getUserProfile`), `authorizationService.ts` (`resolveUserId`) | `validateGuid(...)` | Guard `User_GUID = '...'` filter | +| `toolService.ts:335-371` | `validateColumnName`, `validateFkPath`, `validatePositiveInt` | Guard `resolveContactIds` — rejects bad page-metadata table/PK/contact-id-field and record-ID values | +| `familyService.ts:167-174` | `validateColumnName`, `validateFkPath`, `validatePositiveInt` | Guard `resolveContactIdFromPage` (same page-metadata shape) | +| `addressLabelService.ts:83`, `:107` | `validatePositiveInt` | Guard batched `Contact_ID IN (...)` and single `Contact_ID = ${id}` | +| `groupService.ts:289`, `:390` | `validatePositiveInt(groupId)` | Guard `Groups.Group_ID = ${id}` in `getGroup`; `Group_ID` in `updateGroup` | ## `$userId` for MP audit trail -`createTableRecords` / `updateTableRecords` accept a `$userId` option — MP records it as the audit user for the created/updated row. Pass the numeric MP `User_ID` (not `User_GUID`, not `session.user.id`). +`createTableRecords` / `updateTableRecords` (and `executeProcedureWithBody` via its third `queryParams` argument) accept a `$userId` option — MP records it as the audit user. It must be the numeric MP `User_ID` (not `User_GUID`, not `session.user.id`), and it comes **only** from the gate's return value inside the service — never a method parameter, never an action payload. ```typescript -// src/services/groupService.ts:227-237 -async createGroup(data, userId) { - const apiData = prepareForApi(data); - const result = await this.mp!.createTableRecords('Groups', [apiData], { - $select: 'Group_ID, Group_Name', - $userId: userId, +// src/services/groupService.ts:365-379 +async createGroup(data: GroupWizardFormData) { + // Gate FIRST; its return value is the ONLY source of write attribution. + const $userId = await AuthorizationService.getInstance().requireSecurityRole({ + table: 'Groups', + operation: 'create', }); - return result[0] as unknown as { Group_ID: number; Group_Name: string }; -} - -// src/services/groupService.ts:239-251 -async updateGroup(groupId, data, userId) { - const apiData = { Group_ID: groupId, ...prepareForApi(data as GroupWizardFormData) }; - const result = await this.mp!.updateTableRecords('Groups', [apiData], { - partial: true, + const apiData = await prepareForApi(data); + const result = await this.mp!.createTableRecords('Groups', [apiData], { $select: 'Group_ID, Group_Name', - $userId: userId, + $userId, }); return result[0] as unknown as { Group_ID: number; Group_Name: string }; } ``` -To resolve the numeric MP User ID from the session's `User_GUID`, call `UserService.getUserIdByGuid(userGuid)`. +Same pattern: `GroupService.updateGroup`, `FamilyService.saveHousehold` (one gate; `userId` threaded through every address/household/contact/participant/donor write), `FieldManagementService.updatePageFieldOrder` and `ToolService.deployTool` (SP calls with `{ $userId }`). `User_GUID` → `User_ID` resolution lives in `resolveUserId` inside `src/services/authorizationService.ts`. ## Batching guards Guard empty arrays, batch large IN-lists to ≤100 IDs per request: ```typescript -// src/services/addressLabelService.ts:21, 69-90 -const BATCH_SIZE = 100; - +// src/services/addressLabelService.ts:70-97 (gate call omitted) +// MP_FETCH_BATCH_SIZE = 100 — src/lib/constants.ts async getAddressesForContacts(contactIds: number[]): Promise<ContactAddressRow[]> { if (contactIds.length === 0) return []; const results: ContactAddressRow[] = []; - for (let i = 0; i < contactIds.length; i += BATCH_SIZE) { - const batch = contactIds.slice(i, i + BATCH_SIZE); - batch.forEach(validatePositiveInt); + for (let i = 0; i < contactIds.length; i += MP_FETCH_BATCH_SIZE) { + const batch = contactIds.slice(i, i + MP_FETCH_BATCH_SIZE); + batch.forEach((id) => validatePositiveInt(id)); const idList = batch.join(', '); const rows = await this.mp!.getTableRecords<ContactAddressRow>({ @@ -206,7 +202,7 @@ async getAddressesForContacts(contactIds: number[]): Promise<ContactAddressRow[] } ``` -`ToolService.resolveContactIds` uses the same pattern with `BATCH_SIZE = 100` at `src/services/toolService.ts:283, 332-339`. +`ToolService.resolveContactIds` uses the same `MP_FETCH_BATCH_SIZE` loop at `src/services/toolService.ts:369-384`. ## Filtering quick reference @@ -236,7 +232,7 @@ filter: "Group_ID = 5 AND Group_Role_ID = 7 AND End_Date IS NULL" |--------|-------|-------|----------------| | `State/Region` | Addresses | `/` | Bracket: `[State/Region]` in select; `'State/Region'` as TS key | | `Allow_Check-in` | Events | `-` | Bracket in select; `obj["Allow_Check-in"]` in TS | -| `Secure_Check-in` | Groups | `-` | `r['Secure_Check-in']` in TS (see `groupService.ts:214`) | +| `Secure_Check-in` | Groups | `-` | `r['Secure_Check-in']` in TS (see `groupService.ts:327`) | ## MPHelper parameter mapping @@ -259,11 +255,12 @@ filter: "Group_ID = 5 AND Group_Role_ID = 7 AND End_Date IS NULL" ## Gotchas - Forgetting to prefix an ambiguous column in `$select` or `$filter` → MP returns "Ambiguous column name" 500. Rule of thumb: any time you use `_TABLE` traversal AND the base table shares a column name with the related table, prefix the base-table column. - Using dots between FK levels (`Building_ID_TABLE.Location_ID_TABLE.Column`) — invalid; use `_TABLE_` underscores. -- Passing Better Auth `session.user.id` as `$userId` — it's a Better Auth internal ID, not the MP `User_ID`. Resolve via `UserService.getUserIdByGuid(session.user.userGuid)` first. -- Building a filter from unescaped user input → risk of broken queries and injection. Always `escapeFilterString()` or `term.replace(/'/g, "''")`. +- Taking `$userId` from anywhere but `requireSecurityRole()`'s return value (a method parameter, the action payload, `session.user.id`) — `session.user.id` is a Better Auth internal ID, not the MP `User_ID`, and a caller-supplied value is forgeable. +- Building a filter from unescaped user input → risk of broken queries and injection. Always `escapeFilterString()` for strings, a typed validator for IDs/column names. ## Related docs - `../mp-provider/architecture.md` — `MPHelper` facade (what services call) - `../mp-schema/required-procs.md` — stored procs called from services - `../auth/sessions.md` — `userGuid` vs `user.id` -- `tool-service.md`, `user-service.md`, `address-label-service.md`, `group-service.md`, `field-management-service.md` +- `tool-service.md`, `user-service.md`, `address-label-service.md`, `group-service.md`, `field-management-service.md`, `family-service.md` +- `../security/README.md` — authorization gate and write attribution diff --git a/.claude/references/services/tool-service.md b/.claude/references/services/tool-service.md index 205efe0..5ad5186 100644 --- a/.claude/references/services/tool-service.md +++ b/.claude/references/services/tool-service.md @@ -5,7 +5,7 @@ type: reference applies_to: [src/services/toolService.ts, src/services/toolService.test.ts] symbols: [ToolService, ContactRecord, ContactRecordResult, PageLookup, RoleLookup, DeployToolInput, DeployToolResult] related: [query-patterns.md, ../mp-schema/required-procs.md, ../components/dev-panel.md] -last_verified: 2026-04-17 +last_verified: 2026-10-10 --- ## Purpose @@ -13,11 +13,24 @@ Resolves MP page metadata, user tool authorization, selection record IDs, and co ## Files - `src/services/toolService.ts` — singleton implementation -- `src/services/toolService.test.ts` — 548 lines; covers every public method including error paths, batching, and `deployTool` payload shaping +- `src/services/toolService.test.ts` — 672 lines; covers every public method including error paths, batching, authorization gating, and `deployTool` payload shaping + +## Authorization +Every public method calls `AuthorizationService.getInstance().requireSecurityRole({ table, operation })` before touching MP (see `../security/README.md`). The returned `User_ID` is the acting user for `getSelectionRecordIds` / `getUserTools` and the `$userId` for `deployTool` — callers never pass a user ID. + +| Method | Gate `table` / `operation` | +|--------|---------------------------| +| `getPageData` | `dp_Pages` / `read` | +| `getSelectionRecordIds` | `dp_Selections` / `read` | +| `getUserTools` | `dp_Tools` / `read` | +| `listPages` | `dp_Pages` / `read` | +| `listRoles` | `dp_Roles` / `read` | +| `deployTool` | `dp_Tools` / `create` | +| `resolveContactIds` | `Contacts` / `read` (plus `dp_Pages` via `getPageData`) | ## Singleton ```typescript -// src/services/toolService.ts:81-115 +// src/services/toolService.ts:84-118 export class ToolService { private static instance: ToolService; private mp: MPHelper | null = null; @@ -38,9 +51,9 @@ export class ToolService { | Method | Returns | Calls | |--------|---------|-------| | `getPageData(pageID: number)` | `PageData \| null` | SP `api_Tools_GetPageData` | -| `getSelectionRecordIds(selectionId, userId, pageId)` | `number[]` | SP `api_Common_GetSelection` | -| `getUserTools(userId: number)` | `string[]` (tool paths) | SP `api_Tools_GetUserTools` | -| `resolveContactIds(tableName, primaryKey, contactIdField, recordIds)` | `ContactRecordResult` | `getTableRecords` on `{tableName}` in batches of 100 | +| `getSelectionRecordIds(selectionId, pageId)` | `number[]` | SP `api_Common_GetSelection` (`@UserID` from the gate) | +| `getUserTools()` | `string[]` (tool paths) | SP `api_Tools_GetUserTools` (`@UserId` from the gate) | +| `resolveContactIds(pageId, recordIds)` | `ContactRecordResult \| null` | `getPageData(pageId)`, then `getTableRecords` on the page's table in batches of 100 | | `listPages(search?: string)` | `PageLookup[]` (max 100) | SP `api_MPNextTools_GetPages`, filtered in-memory | | `listRoles(search?: string)` | `RoleLookup[]` (max 100) | `getTableRecords('dp_Roles')` | | `deployTool(input: DeployToolInput)` | `DeployToolResult` | SP `api_dev_DeployTool` (dev credentials) | @@ -48,7 +61,7 @@ export class ToolService { ## getPageData ```typescript -// src/services/toolService.ts:123-141 +// src/services/toolService.ts:126-148 const result = await this.mp!.executeProcedureWithBody('api_Tools_GetPageData', { "@PageID": pageID }); @@ -65,7 +78,11 @@ return null; ## getSelectionRecordIds ```typescript -// src/services/toolService.ts:152-174 +// src/services/toolService.ts:162-188 +const userId = await AuthorizationService.getInstance().requireSecurityRole({ + table: 'dp_Selections', + operation: 'read', +}); const result = await this.mp!.executeProcedureWithBody('api_Common_GetSelection', { '@SelectionID': selectionId, '@UserID': userId, @@ -83,11 +100,16 @@ return []; ``` - Some procs return metadata in set 0 and records later; the scan picks whichever set contains `Record_ID`. +- `@UserID` is the gate's return value, never a caller argument: a selection belongs to a specific MP user, so accepting a user ID from the payload would let any caller read someone else's selection. ## getUserTools ```typescript -// src/services/toolService.ts:183-198 +// src/services/toolService.ts:196-215 +const userId = await AuthorizationService.getInstance().requireSecurityRole({ + table: 'dp_Tools', + operation: 'read', +}); const result = await this.mp!.executeProcedureWithBody('api_Tools_GetUserTools', { "@UserId": userId }); @@ -97,69 +119,41 @@ if (result && result.length > 0 && result[0].length > 0) { return []; ``` -- Caller must already have resolved numeric `User_ID` from `session.user.userGuid` (typically via `UserService.getUserIdByGuid`) +- Takes no arguments: the numeric `User_ID` comes from `requireSecurityRole`, which resolves `session.user.userGuid` → `dp_Users.User_ID` ## resolveContactIds -Resolves record IDs (from a selection) to their associated Contact IDs. Works for direct Contact_ID columns AND FK traversal paths like `Participant_ID_Table.Contact_ID`. +Resolves record IDs on an MP page (a single record or a selection) to their associated Contact IDs. Works for direct Contact_ID columns AND FK traversal paths like `Participant_ID_Table.Contact_ID`. + +Takes a **page ID, not table/column names**. The page's `Table_Name`, `Primary_Key` and `Contact_ID_Field` are looked up server-side through `getPageData(pageId)` and validated before interpolation, so no caller chooses what the service account reads. `FamilyService.resolveContactIdFromPage` gets the same treatment from its action. ```typescript -// src/services/toolService.ts:299-350 +// src/services/toolService.ts — resolveContactIds public async resolveContactIds( - tableName: string, primaryKey: string, contactIdField: string, recordIds: number[] -): Promise<ContactRecordResult> { - const envelope = { tableName, primaryKey, contactIdField }; - - validateColumnName(primaryKey); - validateColumnName(tableName); - if (contactIdField.includes('.')) { - contactIdField.split('.').forEach(validateColumnName); - } else { - validateColumnName(contactIdField); - } - - if (recordIds.length === 0) return { ...envelope, records: [] }; - - if (contactIdField === primaryKey) { - // e.g., Contacts table — PK IS the contact ID - return { - ...envelope, - records: recordIds.map(id => ({ recordId: id, contactId: id })), - }; - } - - const contactIdResponseKey = contactIdField.includes('.') - ? contactIdField.split('.').pop()! - : contactIdField; - - const allRecords: ContactRecord[] = []; - for (let i = 0; i < recordIds.length; i += ToolService.BATCH_SIZE) { - const batch = recordIds.slice(i, i + ToolService.BATCH_SIZE); - batch.forEach(validatePositiveInt); - const rows = await this.mp!.getTableRecords<Record<string, number>>({ - table: tableName, - select: `${primaryKey}, ${contactIdField}`, - filter: `${primaryKey} IN (${batch.join(',')})`, - }); - for (const row of rows) { - allRecords.push({ - recordId: row[primaryKey], - contactId: row[contactIdResponseKey], - }); - } - } - - return { ...envelope, records: allRecords }; + pageId: number, recordIds: number[] +): Promise<ContactRecordResult | null> { + validatePositiveInt(pageId, 'pageId'); + await AuthorizationService.getInstance().requireSecurityRole({ table: 'Contacts', operation: 'read' }); + + const page = await this.getPageData(pageId); + if (!page?.Table_Name || !page.Primary_Key || !page.Contact_ID_Field?.trim()) return null; + + const tableName = validateColumnName(page.Table_Name); + const primaryKey = validateColumnName(page.Primary_Key); + const contactIdField = validateFkPath(page.Contact_ID_Field.trim(), 'Contact_ID_Field'); + // ...short-circuit when contactIdField === primaryKey, else + // getTableRecords in batches of MP_FETCH_BATCH_SIZE (100) } ``` +- **Returns `null`** when the page is unknown or lacks `Table_Name` / `Primary_Key` / `Contact_ID_Field`. - **Short-circuits** when the table's PK *is* Contact_ID (e.g., `Contacts` table). - **Batches** at 100 IDs per request. -- **Validates** `tableName`, `primaryKey`, every segment of `contactIdField`, and every record ID. +- **Validates** `pageId`, `Table_Name` and `Primary_Key` (`validateColumnName`), `Contact_ID_Field` (`validateFkPath` — no extra columns, aliases, or non-`_TABLE` segments), and every record ID. **Return type:** ```typescript -// src/services/toolService.ts:5-15 +// src/services/toolService.ts:8-18 export interface ContactRecord { recordId: number; contactId: number; } export interface ContactRecordResult { tableName: string; @@ -172,8 +166,9 @@ export interface ContactRecordResult { ## listPages / listRoles (deploy-tool pickers) ```typescript -// src/services/toolService.ts:206-220 +// src/services/toolService.ts:223-241 public async listPages(search?: string): Promise<PageLookup[]> { + await AuthorizationService.getInstance().requireSecurityRole({ table: 'dp_Pages', operation: 'read' }); const result = await this.mp!.executeProcedureWithBody('api_MPNextTools_GetPages', {}); const rows = (result?.[0] as PageLookup[] | undefined) ?? []; const term = search?.trim().toLowerCase(); @@ -188,11 +183,12 @@ public async listPages(search?: string): Promise<PageLookup[]> { - Reuses `api_MPNextTools_GetPages` (also used by `FieldManagementService.getPages`); SP has no search param so filter happens in-memory. ```typescript -// src/services/toolService.ts:226-239 +// src/services/toolService.ts:247-264 public async listRoles(search?: string): Promise<RoleLookup[]> { + await AuthorizationService.getInstance().requireSecurityRole({ table: 'dp_Roles', operation: 'read' }); const term = search?.trim(); const filter = term - ? `Role_Name LIKE '%${term.replace(/'/g, "''")}%'` + ? `Role_Name LIKE '%${escapeFilterString(term)}%'` : undefined; return this.mp!.getTableRecords<RoleLookup>({ table: 'dp_Roles', @@ -203,13 +199,17 @@ public async listRoles(search?: string): Promise<RoleLookup[]> { }); } ``` -- Inline single-quote escape (no `%`/`_` allowed in the wildcard position since the wildcards are at the outer `'%{term}%'`). +- Escaped with `escapeFilterString` (`src/lib/validation.ts`): bracket-escapes `[`, `%`, `_` and doubles `'`, so user input matches literally inside the outer `'%…%'`. ## deployTool (localhost-only dev feature) ```typescript -// src/services/toolService.ts:247-281 +// src/services/toolService.ts:272-310 public async deployTool(input: DeployToolInput): Promise<DeployToolResult> { + const $userId = await AuthorizationService.getInstance().requireSecurityRole({ + table: 'dp_Tools', + operation: 'create', + }); // Pre-flight validation if (!input.toolName.trim()) throw new Error('Tool Name is required'); if (!input.launchPage.trim()) throw new Error('Launch Page is required'); @@ -231,7 +231,7 @@ public async deployTool(input: DeployToolInput): Promise<DeployToolResult> { '@RoleIDs': input.roleIds.length ? input.roleIds.join(',') : null, }; - const resultSets = await this.mp!.executeProcedureWithBody('api_dev_DeployTool', payload); + const resultSets = await this.mp!.executeProcedureWithBody('api_dev_DeployTool', payload, { $userId }); const [toolRows, pageRows, roleRows] = resultSets ?? []; const tool = (toolRows?.[0] as DeployedToolRow | undefined); if (!tool) { @@ -248,6 +248,7 @@ public async deployTool(input: DeployToolInput): Promise<DeployToolResult> { - SP name prefix `api_dev_` → MP provider switches to `MINISTRY_PLATFORM_DEV_*` client credentials - Returns 3 result sets: tool row, page rows, role rows - Booleans encoded as `1`/`0` (BIT), empty arrays as `null` +- `$userId` (audit attribution) comes only from the gate's return value — `deployToolAction` passes no user ID ## Stored procedures called @@ -263,10 +264,13 @@ public async deployTool(input: DeployToolInput): Promise<DeployToolResult> { | Action | File | Method(s) used | |--------|------|----------------| -| `fetchAddressLabels` | `src/components/address-labels/actions.ts` | `getPageData`, `getSelectionRecordIds` | +| `fetchAddressLabels` | `src/components/address-labels/actions.ts` | `getSelectionRecordIds` | +| `parseToolParams` | `src/lib/tool-params.server.ts` | `getPageData` (dynamic import) | +| `resolveContactIdFromPage` | `src/app/(web)/tools/addeditfamily/actions.ts` | `getPageData` | | `resolveSelection` | `src/components/dev-panel/panels/selection-actions.ts` | `getSelectionRecordIds` | | `getUserTools` | `src/components/dev-panel/panels/user-tools-actions.ts` | `getUserTools` | -| Deploy tool panel | `src/components/dev-panel/panels/deploy-tool-actions.ts` | `listPages`, `listRoles`, `deployTool` | +| `resolveContactRecords` | `src/components/dev-panel/panels/contact-records-actions.ts` | `resolveContactIds` | +| `listPagesAction`, `listRolesAction`, `deployToolAction` | `src/components/dev-panel/panels/deploy-tool-actions.ts` | `listPages`, `listRoles`, `deployTool` | ## Related docs - `query-patterns.md` — shared query rules (validation, batching, quote escaping) diff --git a/.claude/references/services/user-service.md b/.claude/references/services/user-service.md index 3549a9c..ed2495c 100644 --- a/.claude/references/services/user-service.md +++ b/.claude/references/services/user-service.md @@ -5,19 +5,19 @@ type: reference applies_to: [src/services/userService.ts, src/services/userService.test.ts] symbols: [UserService, MPUserProfile] related: [query-patterns.md, ../auth/sessions.md, ../components/layout.md] -last_verified: 2026-04-17 +last_verified: 2026-10-10 --- ## Purpose -Fetches the enriched MP user profile (by `User_GUID`) plus role names and user-group names; also resolves `User_GUID` → numeric `User_ID` for `$userId` audit use. +Fetches the signed-in user's display profile (`MPUserProfile`: names + email) by `User_GUID`. Single `dp_Users` query; no roles, user groups, IDs, phone, or image. ## Files - `src/services/userService.ts` — singleton -- `src/services/userService.test.ts` — 159 lines; covers singleton, `getUserProfile` happy/empty/error paths, and `getUserIdByGuid` (including malformed GUID). +- `src/services/userService.test.ts` — covers singleton, `getUserProfile` single-query select, explicit mapping (extra row fields never pass through), `Email_Address` → `null` normalization, empty result, malformed GUID, error propagation. ## Singleton ```typescript -// src/services/userService.ts:11-45 +// src/services/userService.ts — UserService export class UserService { private static instance: UserService; private mp: MPHelper | null = null; @@ -31,123 +31,75 @@ export class UserService { | Method | Returns | Purpose | |--------|---------|---------| -| `getUserProfile(id: string)` | `MPUserProfile \| undefined` | Full profile: Contact fields + roles + user groups | -| `getUserIdByGuid(guid: string)` | `number` | MP numeric `User_ID` (for `$userId` audit) | +| `getUserProfile(id: string)` | `MPUserProfile \| undefined` | Display profile: `First_Name`, `Nickname`, `Last_Name`, `Email_Address` | -Both methods guard input via `validateGuid()` from `src/lib/validation.ts`. +Input guarded via `validateGuid()` from `src/lib/validation.ts`. `User_GUID` → numeric `User_ID` resolution for `$userId` lives in `AuthorizationService` (`resolveUserId` in `src/services/authorizationService.ts`), not here. ## getUserProfile -Three queries: profile first (so the numeric `User_ID` is known), then roles and user-groups in parallel via `Promise.all`. +One query. The row is mapped field-by-field so nothing else MP returns can reach the client. ```typescript -// src/services/userService.ts:81-110 +// src/services/userService.ts — UserService.getUserProfile public async getUserProfile(id: string): Promise<MPUserProfile | undefined> { const records = await this.mp!.getTableRecords<MPUserProfile>({ table: "dp_Users", filter: `User_GUID = '${validateGuid(id)}'`, - select: "User_ID, User_GUID, Contact_ID_TABLE.First_Name,Contact_ID_TABLE.Nickname,Contact_ID_TABLE.Last_Name,Contact_ID_TABLE.Email_Address,Contact_ID_TABLE.Mobile_Phone,Contact_ID_TABLE.dp_fileUniqueId AS Image_GUID", + select: "Contact_ID_TABLE.First_Name,Contact_ID_TABLE.Nickname,Contact_ID_TABLE.Last_Name,Contact_ID_TABLE.Email_Address", top: 1 }); - const profile = records[0]; - if (!profile) return undefined; - - const [roleRecords, groupRecords] = await Promise.all([ - this.mp!.getTableRecords<{ Role_Name: string }>({ - table: "dp_User_Roles", - filter: `User_ID = ${profile.User_ID}`, - select: "Role_ID_TABLE.Role_Name", - }), - this.mp!.getTableRecords<{ User_Group_Name: string }>({ - table: "dp_User_User_Groups", - filter: `User_ID = ${profile.User_ID}`, - select: "User_Group_ID_TABLE.User_Group_Name", - }), - ]); + const row = records[0]; + if (!row) return undefined; return { - ...profile, - roles: roleRecords.map((r) => r.Role_Name), - userGroups: groupRecords.map((g) => g.User_Group_Name), + First_Name: row.First_Name, + Nickname: row.Nickname, + Last_Name: row.Last_Name, + Email_Address: row.Email_Address ?? null, }; } ``` -### Query 1 — `dp_Users` +### Query — `dp_Users` | Param | Value | |-------|-------| | `filter` | `User_GUID = '{validated guid}'` | -| `select` | `User_ID, User_GUID, Contact_ID_TABLE.First_Name, Contact_ID_TABLE.Nickname, Contact_ID_TABLE.Last_Name, Contact_ID_TABLE.Email_Address, Contact_ID_TABLE.Mobile_Phone, Contact_ID_TABLE.dp_fileUniqueId AS Image_GUID` | +| `select` | `Contact_ID_TABLE.First_Name, Contact_ID_TABLE.Nickname, Contact_ID_TABLE.Last_Name, Contact_ID_TABLE.Email_Address` | | `top` | `1` | -FK traversal: `dp_Users.Contact_ID` → `Contacts` row. `dp_fileUniqueId` aliased to `Image_GUID` for the profile picture. - -### Query 2 — `dp_User_Roles` (parallel) -| Param | Value | -|-------|-------| -| `filter` | `User_ID = {profile.User_ID}` | -| `select` | `Role_ID_TABLE.Role_Name` | - -Maps to `string[]` of role names. - -### Query 3 — `dp_User_User_Groups` (parallel) -| Param | Value | -|-------|-------| -| `filter` | `User_ID = {profile.User_ID}` | -| `select` | `User_Group_ID_TABLE.User_Group_Name` | - -Maps to `string[]` of user-group names. - -## getUserIdByGuid - -```typescript -// src/services/userService.ts:68-79 -public async getUserIdByGuid(guid: string): Promise<number> { - const records = await this.mp!.getTableRecords<{ User_ID: number }>({ - table: 'dp_Users', - select: 'User_ID', - filter: `User_GUID = '${validateGuid(guid)}'`, - top: 1, - }); - if (!records || records.length === 0) { - throw new Error('User not found'); - } - return records[0].User_ID; -} -``` - -- Throws `Invalid GUID format: ...` for malformed input (via `validateGuid`) -- Throws `User not found` when `dp_Users` returns 0 rows or null +FK traversal: `dp_Users.Contact_ID` → `Contacts` row. ## Return type ```typescript -// MPUserProfile — from src/lib/providers/ministry-platform/types/user-profile.types.ts -{ - User_ID: number; - User_GUID: string; - Contact_ID: number; +// src/lib/providers/ministry-platform/types/user-profile.types.ts +export interface MPUserProfile { First_Name: string; Nickname: string; Last_Name: string; Email_Address: string | null; - Mobile_Phone: string | null; - Image_GUID: string | null; - roles: string[]; - userGroups: string[]; } ``` +Deliberately minimal (client-bound DTO). Formerly also carried `User_ID`, `User_GUID`, `Contact_ID`, `Mobile_Phone`, `Image_GUID`, `roles`, `userGroups` — none rendered. Add a field back only with the UI that needs it. + ## Caller contract > Pass `session.user.userGuid`, **not** `session.user.id`. Better Auth's `user.id` is the internal auth record ID, not the MP `User_GUID`. ```typescript -// src/components/shared-actions/user.ts — correct usage -const profile = await UserService.getInstance().then(s => s.getUserProfile(session.user.userGuid)); +// src/components/shared-actions/user.ts — getCurrentUserProfile (takes no arguments) +const session = await auth.api.getSession({ headers: await headers() }); +const userGuid = (session?.user as Record<string, unknown> | undefined)?.userGuid; +if (typeof userGuid !== 'string' || userGuid.length === 0) throw new Error('Unauthorized'); + +const userService = await UserService.getInstance(); +return userService.getUserProfile(userGuid); ``` +The GUID comes from the session only — never an action parameter (a `userGuid` argument was an IDOR). + See `../auth/sessions.md` for `userGuid` vs `user.id`. ## Consumers @@ -155,13 +107,10 @@ See `../auth/sessions.md` for `userGuid` vs `user.id`. | Action | File | Method | |--------|------|--------| | `getCurrentUserProfile` | `src/components/shared-actions/user.ts` | `getUserProfile` | -| `getUserTools` | `src/components/dev-panel/panels/user-tools-actions.ts` | `getUserIdByGuid` (then ToolService.getUserTools) | -| Group-wizard actions (create/update) | `src/components/group-wizard/actions.ts` | `getUserIdByGuid` (for `$userId` audit) | -| Address-label actions | `src/components/address-labels/actions.ts` | `getUserIdByGuid` | ## Gotchas -- `getUserProfile` does NOT re-check the session; caller is responsible for `auth.api.getSession()`. -- If `dp_Users` has 0 rows for the GUID, `getUserProfile` returns `undefined` (not throw); `getUserIdByGuid` throws `User not found`. +- `getUserProfile` does NOT re-check the session and has **no** `AuthorizationService` gate — the only service method without one. Its sole caller, `getCurrentUserProfile`, is a documented CLAUDE.md rule-12 carve-out (own profile only). Any new caller must gate itself. +- If `dp_Users` has 0 rows for the GUID, `getUserProfile` returns `undefined` (not throw). - The `select` string has no spaces after some commas (`...Nickname,Contact_ID_TABLE...`) — MP REST accepts this; do not "fix" unless the API starts rejecting it. ## Related docs diff --git a/.claude/references/testing/README.md b/.claude/references/testing/README.md index 0a92324..514d250 100644 --- a/.claude/references/testing/README.md +++ b/.claude/references/testing/README.md @@ -5,21 +5,22 @@ domain: testing --- ## What's in this domain -Vitest test runner config, global setup, mocking patterns, and inventory of 116 test files (1,535 cases) co-located next to source. Coverage over authored code is 98.84% statements / 99.70% lines, enforced by thresholds in `vitest.config.mts`. +Vitest test runner config, global setup, mocking patterns, and inventory of 120 test files (2,237 cases) co-located next to source. Coverage over authored code is 99.13% statements / 99.80% lines (2026-10-10), enforced by thresholds (97 / 98) in `vitest.config.mts`. ## File map | File | Purpose | When to read | |------|---------|--------------| | `setup.md` | `vitest.config.mts`, `src/test-setup.ts`, env vars, jsdom, v8 coverage, the `coverage.include` contract and enforced thresholds | Adding Vitest config or adjusting global setup | -| `mocks.md` | Mandatory mock patterns (`vi.hoisted`, MPHelper mock class, singleton reset, auth/headers, fake timers) | Writing any new test that mocks an import | +| `mocks.md` | Mandatory mock patterns (`vi.hoisted`, MPHelper mock class, singleton reset, `AuthorizationService` gate, auth/headers, fake timers) | Writing any new test that mocks an import | | `cookbook.md` | Copy-paste recipes pulled from real tests (service, server action, component, OAuth) | Writing a new test in an existing category | -| `inventory.md` | All 116 test files grouped by area with one-liner coverage | Locating where a surface is tested | +| `inventory.md` | All 120 test files grouped by area | Locating where a surface is tested | ## Code surfaces | Path | Role | |------|------| | `vitest.config.mts` | Runner config (jsdom, globals, v8 coverage + thresholds, `@` alias) | -| `src/test-setup.ts` | Env stubs + `@testing-library/jest-dom` | +| `src/test-setup.ts` | Env stubs + `ResizeObserver` stub + `@testing-library/jest-dom/vitest` | +| `src/test-utils/mock-oidc.ts` | Test-only mock MP OIDC provider for `src/auth.*.test.ts` | | `src/**/*.test.ts` | Unit tests (services, lib, auth, proxy) | | `src/**/*.test.tsx` | React component/hook tests | diff --git a/.claude/references/testing/cookbook.md b/.claude/references/testing/cookbook.md index 356a673..df0d1fc 100644 --- a/.claude/references/testing/cookbook.md +++ b/.claude/references/testing/cookbook.md @@ -5,7 +5,7 @@ type: reference applies_to: [src/**/*.test.ts, src/**/*.test.tsx] symbols: [ToolService, fetchAddressLabels, DevPanel, MinistryPlatformClient] related: [mocks.md, setup.md] -last_verified: 2026-04-17 +last_verified: 2026-10-10 --- ## Purpose @@ -13,7 +13,7 @@ Copy-paste starting points for the four test flavors in this codebase. Each reci ## Recipe 1 — Service test (wraps MPHelper) -**Source:** `src/services/toolService.test.ts` +**Source:** `src/services/toolService.test.ts` (abridged — docstring and other cases elided) ```typescript import { describe, it, expect, vi, beforeEach } from 'vitest'; @@ -33,29 +33,45 @@ vi.mock('@/lib/providers/ministry-platform', () => { }; }); +const { mockRequireSecurityRole, mockHasSecurityRole } = vi.hoisted(() => ({ + mockRequireSecurityRole: vi.fn(async () => 42), + mockHasSecurityRole: vi.fn(async () => true), +})); + +vi.mock('@/services/authorizationService', () => ({ + AuthorizationService: { + getInstance: () => ({ + requireSecurityRole: mockRequireSecurityRole, + hasSecurityRole: mockHasSecurityRole, + }), + }, + UnauthorizedError: class UnauthorizedError extends Error {}, +})); + describe('ToolService', () => { beforeEach(() => { vi.clearAllMocks(); - // eslint-disable-next-line @typescript-eslint/no-explicit-any (ToolService as any).instance = undefined; }); - it('should call executeProcedureWithBody with correct params', async () => { - const mockPageData = { - Page_ID: 292, - Display_Name: 'Contacts', - Table_Name: 'Contacts', - Primary_Key: 'Contact_ID', - }; - mockExecuteProcedureWithBody.mockResolvedValueOnce([[mockPageData]]); - - const service = await ToolService.getInstance(); - const result = await service.getPageData(292); - - expect(mockExecuteProcedureWithBody).toHaveBeenCalledWith('api_Tools_GetPageData', { - '@PageID': 292, + describe('getPageData', () => { + it('should call executeProcedureWithBody with correct params', async () => { + const mockPageData = { + Page_ID: 292, + Display_Name: 'Contacts', + Table_Name: 'Contacts', + Primary_Key: 'Contact_ID', + }; + mockExecuteProcedureWithBody.mockResolvedValueOnce([[mockPageData]]); + + const service = await ToolService.getInstance(); + const result = await service.getPageData(292); + + expect(mockExecuteProcedureWithBody).toHaveBeenCalledWith('api_Tools_GetPageData', { + '@PageID': 292, + }); + expect(result).toEqual(mockPageData); }); - expect(result).toEqual(mockPageData); }); }); ``` @@ -63,12 +79,13 @@ describe('ToolService', () => { Checklist: - `vi.hoisted()` mock fns - Mock `MPHelper` as class with method fields +- Mock the `AuthorizationService` gate (services gate every MP call); its return value is the `$userId` the service must use — see `mocks.md` - Reset singleton in `beforeEach` - Mock return shape matches real MP API (SP calls return `[[row, ...]]` — array of result sets) -## Recipe 2 — Server action test (auth + services + external libs) +## Recipe 2 — Server action test (gate + services + external libs) -**Source:** `src/components/address-labels/actions.test.ts` +**Source:** `src/components/address-labels/actions.test.ts` (abridged — PDF/DOCX/zip/barcode lib mocks and most cases elided) ```typescript import { describe, it, expect, vi, beforeEach } from 'vitest'; @@ -77,28 +94,41 @@ const mockGetSession = vi.hoisted(() => vi.fn()); const mockGetSelectionRecordIds = vi.hoisted(() => vi.fn()); const mockGetAddressesForContacts = vi.hoisted(() => vi.fn()); const mockGetAddressForContact = vi.hoisted(() => vi.fn()); -const mockGetUserIdByGuid = vi.hoisted(() => vi.fn()); +const mockRequireSecurityRole = vi.hoisted(() => vi.fn()); vi.mock('@/lib/auth', () => ({ - auth: { api: { getSession: mockGetSession } }, + auth: { + api: { + getSession: mockGetSession, + }, + }, })); vi.mock('next/headers', () => ({ headers: vi.fn().mockResolvedValue(new Headers()), })); -vi.mock('@/services/toolService', () => ({ - ToolService: { - getInstance: vi.fn().mockResolvedValue({ - getSelectionRecordIds: mockGetSelectionRecordIds, +vi.mock('@/services/authorizationService', () => ({ + AuthorizationService: { + getInstance: () => ({ + requireSecurityRole: mockRequireSecurityRole, + hasSecurityRole: async () => { + try { + await mockRequireSecurityRole(); + return true; + } catch { + return false; + } + }, }), }, + UnauthorizedError: class UnauthorizedError extends Error {}, })); -vi.mock('@/services/userService', () => ({ - UserService: { +vi.mock('@/services/toolService', () => ({ + ToolService: { getInstance: vi.fn().mockResolvedValue({ - getUserIdByGuid: mockGetUserIdByGuid, + getSelectionRecordIds: mockGetSelectionRecordIds, }), }, })); @@ -112,16 +142,25 @@ vi.mock('@/services/addressLabelService', () => ({ }, })); +// ... @react-pdf/renderer, docxtemplater, pizzip, zip-guard, barcode mocks ... + import { fetchAddressLabels } from './actions'; -import type { LabelConfig, LabelData } from '@/lib/dto'; +import type { LabelConfig } from '@/lib/dto'; import type { ToolParams } from '@/lib/tool-params'; describe('fetchAddressLabels', () => { + const defaultConfig: LabelConfig = { /* stockId '5160', household, postnet, ... */ }; + beforeEach(() => { - mockGetSession.mockResolvedValue({ - user: { id: 'user-1', userGuid: '550e8400-e29b-41d4-a716-446655440000' } + mockGetSession.mockResolvedValue({ user: { id: 'user-1', userGuid: '550e8400-e29b-41d4-a716-446655440000' } }); + mockRequireSecurityRole.mockReset(); + mockRequireSecurityRole.mockImplementation(async () => { + const session = await mockGetSession(); + if (!session?.user?.id) throw new Error('Unauthorized'); + return 42; }); - mockGetUserIdByGuid.mockResolvedValue(42); + mockGetSelectionRecordIds.mockReset(); + mockGetAddressesForContacts.mockReset(); mockGetAddressForContact.mockReset(); }); @@ -134,14 +173,15 @@ describe('fetchAddressLabels', () => { ``` Checklist: -- `@/lib/auth` + `next/headers` always mocked together -- Every service the action touches gets its own `getInstance` mock -- External libs (`@react-pdf/renderer`, `docxtemplater`, etc.) stubbed with class/POJO depending on how the action instantiates them +- Mock the `AuthorizationService` gate — feature actions call `requireSecurityRole`, not `auth.api.getSession` directly. Here the gate mock is driven by `mockGetSession` so session-shape tests keep their meaning; override with `mockRequireSecurityRole.mockRejectedValueOnce(...)` to test a refusal +- `@/lib/auth` + `next/headers` mocked together +- Every service the action touches gets its own `getInstance` mock (async services → `mockResolvedValue`) +- External libs (`@react-pdf/renderer`, `docxtemplater`, `pizzip`, etc.) stubbed with class/POJO depending on how the action instantiates them - Session fixture uses realistic GUID so validation passes ## Recipe 3 — React component test (localhost/env gating) -**Source:** `src/components/dev-panel/dev-panel.test.tsx` +**Source:** `src/components/dev-panel/dev-panel.test.tsx` (abridged) ```typescript import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; @@ -206,7 +246,7 @@ Checklist: ## Recipe 4 — OAuth flow test (fake timers) -**Source:** `src/lib/providers/ministry-platform/client.test.ts` +**Source:** `src/lib/providers/ministry-platform/client.test.ts` (abridged — console-silencing `beforeEach` and other cases elided) ```typescript import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; diff --git a/.claude/references/testing/inventory.md b/.claude/references/testing/inventory.md index 5797e91..9b4007a 100644 --- a/.claude/references/testing/inventory.md +++ b/.claude/references/testing/inventory.md @@ -5,102 +5,213 @@ type: reference applies_to: [src/**/*.test.ts, src/**/*.test.tsx] symbols: [] related: [setup.md, cookbook.md] -last_verified: 2026-09-13 +last_verified: 2026-10-10 --- ## Purpose -Test files grouped by area. Totals from the current facts snapshot: -**116 files / 1,535 test cases** (`vitest run`, see -`../_meta/facts/2026-09-13.md`). - -> The per-file tables below were written at 37 files and have NOT been expanded -> to all 116. They remain accurate for the files they list. For anything not -> listed, the co-location rule is reliable: a source file `foo.ts` is tested by -> `foo.test.ts` beside it. Counts by area: -> -> | Area | Files | -> |---|---| -> | `src/app/**` | 23 | -> | `src/components/group-wizard` | 12 | -> | `src/lib/providers/ministry-platform` | 12 | -> | `src/lib/**` (excl. MP provider) | 12 | -> | `src/components/dev-panel` | 11 | -> | `src/components/address-labels` | 10 | -> | `src/components/template-editor` | 10 | -> | `src/services` | 9 | -> | `src/components/field-management` | 7 | -> | `src/components/*` (layout, shared-actions, tool, user-menu) | 6 | -> | `src/contexts` | 2 | -> | `src/` (proxy, auth) | 2 | - -## Ministry Platform provider (12 files) - -| Test File | What It Covers | -|---|---| -| `src/lib/providers/ministry-platform/helper.test.ts` | MPHelper CRUD, Zod validation, procedures, files, copy/recurrence/generateSequence | -| `src/lib/providers/ministry-platform/provider.test.ts` | Singleton, service delegation, generateSequence with optional fields | -| `src/lib/providers/ministry-platform/client.test.ts` | OAuth token lifecycle, refresh, concurrent calls (uses `vi.useFakeTimers`) | -| `src/lib/providers/ministry-platform/utils/http-client.test.ts` | HTTP methods, URL building, FormData, error handling | -| `src/lib/providers/ministry-platform/utils/logger.test.ts` | Debug vs production gating, error always logs | -| `src/lib/providers/ministry-platform/auth/client-credentials.test.ts` | OAuth2 `client_credentials` token flow | -| `src/lib/providers/ministry-platform/services/table.service.test.ts` | CRUD + `copyRecord` + `copyRecordWithSubpages` | -| `src/lib/providers/ministry-platform/services/communication.service.test.ts` | Communications and messages with/without attachments | -| `src/lib/providers/ministry-platform/services/domain.service.test.ts` | Domain info, global filters | -| `src/lib/providers/ministry-platform/services/metadata.service.test.ts` | Metadata refresh, tables search | -| `src/lib/providers/ministry-platform/services/procedure.service.test.ts` | Procedure listing + query/body execution with URL encoding | -| `src/lib/providers/ministry-platform/services/file.service.test.ts` | File CRUD, FormData building, unauthenticated blob retrieval | - -## Services (5 files) - -| Test File | What It Covers | -|---|---| -| `src/services/toolService.test.ts` | Page data, user tools, selection records, contact resolution, `listPages`, `listRoles`, `deployTool` | -| `src/services/groupService.test.ts` | Group CRUD and membership | -| `src/services/fieldManagementService.test.ts` | Pages, fields, table metadata, batched order updates | -| `src/services/addressLabelService.test.ts` | Address fetching, batching, single contact | -| `src/services/userService.test.ts` | Profile with roles/groups, `getUserIdByGuid`, GUID validation | - -## Components (10 files) - -| Test File | What It Covers | -|---|---| -| `src/components/address-labels/actions.test.ts` | PDF, DOCX, and template merge actions + error branches | -| `src/components/address-labels/word-document.test.ts` | `buildWordDocument` page layout and barcode branches | -| `src/components/field-management/actions.test.ts` | `fetchPages` / `fetchPageFieldData` column merge / `savePageFieldOrder` | -| `src/components/dev-panel/dev-panel.test.tsx` | Localhost + `NODE_ENV` gating, localStorage persistence, open/close toggling | -| `src/components/dev-panel/panels/deploy-tool-actions.test.ts` | Deploy-tool server actions (authz, validation, SP payload shaping) | -| `src/components/dev-panel/panels/selection-actions.test.ts` | Selection resolution server actions | -| `src/components/dev-panel/panels/user-tools-actions.test.ts` | Authorization checks, session validation | -| `src/components/layout/auth-wrapper.test.tsx` | `AuthWrapper` render gating based on session | -| `src/components/user-menu/actions.test.ts` | Sign-out action, OIDC logout redirect | -| `src/components/shared-actions/user.test.ts` | `getCurrentUserProfile` session-derived GUID lookup, `Unauthorized` on missing/empty `userGuid`, IDOR regression (caller-forged argument ignored), error propagation | - -## Core lib (8 files) - -| Test File | What It Covers | -|---|---| -| `src/lib/validation.test.ts` | GUID / positive int / column name / filter escaping | -| `src/lib/barcode-helpers.test.ts` | Barcode generation utilities | -| `src/lib/imb-encoder.test.ts` | USPS Intelligent Mail barcode encoding | -| `src/lib/postnet-encoder.test.ts` | POSTNET barcode encoding | -| `src/lib/label-stock.test.ts` | Label stock definitions and calculations | -| `src/lib/barcode-image.test.ts` | BMP rendering for IMb/POSTNET with JSON fallback | -| `src/auth.test.ts` | Name splitting, session structure, OAuth config | -| `src/proxy.test.ts` | Route protection, public paths, session, errors | - -## Contexts (2 files) - -| Test File | What It Covers | -|---|---| -| `src/contexts/user-context.test.tsx` | `UserProvider` lifecycle, profile loading, errors | -| `src/contexts/session-context.test.tsx` | `useAppSession` hook wrapper | - -## Totals -- Files in this inventory: 37 (12 provider + 5 services + 10 component + 8 lib + 2 contexts) +Every test file, grouped by area, with its case count from `vitest run` +(2026-10-11): **120 files / 2,237 test cases**. See +`../_meta/facts/2026-10-10.md`. Counts drift — trust a fresh `npx vitest run` +over this table. Co-location rule: `foo.ts` is tested by `foo.test.ts` beside it. +The "What It Covers" column is filled where it was verified; blank rows are +self-describing by file name. + +## Counts by area + +| Area | Files | Cases | +|---|---|---| +| `src/app/**` (pages, route handler, providers) | 22 | 406 | +| `src/` root (`auth.*`, `proxy`) | 11 | 250 | +| `src/components/address-labels` | 11 | 126 | +| `src/components/dev-panel` | 11 | 102 | +| `src/components/field-management` | 7 | 113 | +| `src/components/group-wizard` | 12 | 159 | +| `src/components/{layout,shared-actions,tool}` | 5 | 44 | +| `src/contexts` | 2 | 16 | +| `src/lib/providers/ministry-platform` | 16 | 521 | +| `src/lib/**` (excl. MP provider) | 14 | 255 | +| `src/services` | 9 | 245 | +| **Total** | **120** | **2,237** | + +## `src/app/**` (pages, route handler, providers) (22 files, 406 cases) + +| Test File | Cases | What It Covers | +|---|---|---| +| `src/app/(web)/error.test.tsx` | 12 | | +| `src/app/(web)/no-access/page.test.tsx` | 4 | `/no-access` page incl. the `handleSignOut` form | +| `src/app/(web)/page.test.tsx` | 12 | | +| `src/app/(web)/tools/addeditfamily/actions.test.ts` | 43 | | +| `src/app/(web)/tools/addeditfamily/add-edit-family.test.tsx` | 43 | | +| `src/app/(web)/tools/addeditfamily/page.test.tsx` | 10 | | +| `src/app/(web)/tools/addresslabels/address-labels.test.tsx` | 23 | | +| `src/app/(web)/tools/addresslabels/page.test.tsx` | 2 | | +| `src/app/(web)/tools/fieldmanagement/field-management.test.tsx` | 15 | | +| `src/app/(web)/tools/fieldmanagement/page.test.tsx` | 4 | | +| `src/app/(web)/tools/groupwizard/group-wizard.test.tsx` | 20 | | +| `src/app/(web)/tools/groupwizard/page.test.tsx` | 3 | | +| `src/app/(web)/tools/layout.test.tsx` | 5 | | +| `src/app/(web)/tools/require-tool-access.test.ts` | 9 | `requireToolAccess` page-level gate; asserts every tool page self-gates | +| `src/app/(web)/tools/template/page.test.tsx` | 4 | | +| `src/app/(web)/tools/template/template-tool.test.tsx` | 12 | | +| `src/app/api/auth/[...all]/route.test.ts` | 77 | | +| `src/app/auth-error/page.test.tsx` | 16 | | +| `src/app/global-error.test.tsx` | 8 | | +| `src/app/providers.test.tsx` | 3 | | +| `src/app/session-error/page.test.tsx` | 3 | | +| `src/app/signin/page.test.tsx` | 78 | | + +## `src/` root (`auth.*`, `proxy`) (11 files, 250 cases) + +| Test File | Cases | What It Covers | +|---|---|---| +| `src/auth.id-token-sign-in.test.ts` | 16 | | +| `src/auth.ip-address.test.ts` | 24 | | +| `src/auth.oidc-discovery.test.ts` | 28 | | +| `src/auth.secret-guard.test.ts` | 25 | | +| `src/auth.session-config.test.ts` | 9 | | +| `src/auth.session-lifetime.test.ts` | 7 | | +| `src/auth.shared-instance.test.ts` | 5 | | +| `src/auth.test.ts` | 51 | Name splitting, session structure, OAuth config | +| `src/auth.user-info.test.ts` | 41 | | +| `src/auth.user-oauth-tokens.test.ts` | 6 | | +| `src/proxy.test.ts` | 38 | Route protection, public paths, session, errors | + +## `src/components/address-labels` (11 files, 126 cases) + +| Test File | Cases | What It Covers | +|---|---|---| +| `src/components/address-labels/actions.test.ts` | 53 | PDF, DOCX, and template merge actions + error branches | +| `src/components/address-labels/address-label.test.tsx` | 7 | | +| `src/components/address-labels/address-labels-form.test.tsx` | 15 | | +| `src/components/address-labels/address-labels-summary.test.tsx` | 8 | | +| `src/components/address-labels/imb-barcode.test.tsx` | 6 | | +| `src/components/address-labels/label-document.test.tsx` | 5 | | +| `src/components/address-labels/mail-merge-tab.test.tsx` | 14 | | +| `src/components/address-labels/merge-template.integration.test.ts` | 2 | Real (unmocked) `mergeTemplate` + PizZip run, incl. a forged-size zip bomb | +| `src/components/address-labels/postnet-barcode.test.tsx` | 5 | | +| `src/components/address-labels/sample-template.test.ts` | 3 | | +| `src/components/address-labels/word-document.test.ts` | 8 | `buildWordDocument` page layout and barcode branches | + +## `src/components/dev-panel` (11 files, 102 cases) + +| Test File | Cases | What It Covers | +|---|---|---| +| `src/components/dev-panel/dev-panel.test.tsx` | 11 | Localhost + `NODE_ENV` gating, localStorage persistence, open/close toggling | +| `src/components/dev-panel/panels/contact-records-actions.test.ts` | 3 | | +| `src/components/dev-panel/panels/contact-records-panel.test.tsx` | 11 | | +| `src/components/dev-panel/panels/deploy-tool-actions.test.ts` | 16 | Deploy-tool server actions (prod guard, session guard, delegation, env status) | +| `src/components/dev-panel/panels/deploy-tool-panel.test.tsx` | 22 | | +| `src/components/dev-panel/panels/params-panel.test.tsx` | 8 | | +| `src/components/dev-panel/panels/require-dev-session.test.ts` | 6 | `requireDevSession`: production refusal, session requirement | +| `src/components/dev-panel/panels/selection-actions.test.ts` | 4 | Selection resolution server action (dev-session guard, delegation) | +| `src/components/dev-panel/panels/selection-panel.test.tsx` | 9 | | +| `src/components/dev-panel/panels/user-tools-actions.test.ts` | 3 | Dev-session guard, refusal propagation, delegation to `ToolService.getUserTools` | +| `src/components/dev-panel/panels/user-tools-panel.test.tsx` | 9 | | + +## `src/components/field-management` (7 files, 113 cases) + +| Test File | Cases | What It Covers | +|---|---|---| +| `src/components/field-management/actions.test.ts` | 18 | `fetchPages` / `fetchPageFieldData` column merge / `savePageFieldOrder` | +| `src/components/field-management/field-order-editor.test.tsx` | 10 | | +| `src/components/field-management/new-group-dialog.test.tsx` | 9 | | +| `src/components/field-management/page-search.test.tsx` | 8 | | +| `src/components/field-management/sortable-field-item.test.tsx` | 22 | | +| `src/components/field-management/sortable-group.test.tsx` | 13 | | +| `src/components/field-management/use-field-order-state.test.ts` | 33 | | + +## `src/components/group-wizard` (12 files, 159 cases) + +| Test File | Cases | What It Covers | +|---|---|---| +| `src/components/group-wizard/actions.test.ts` | 32 | | +| `src/components/group-wizard/contact-search.test.tsx` | 11 | | +| `src/components/group-wizard/group-search.test.tsx` | 13 | | +| `src/components/group-wizard/schema.test.ts` | 22 | | +| `src/components/group-wizard/step-attributes.test.tsx` | 11 | | +| `src/components/group-wizard/step-identity.test.tsx` | 13 | | +| `src/components/group-wizard/step-meeting.test.tsx` | 11 | | +| `src/components/group-wizard/step-organization.test.tsx` | 9 | | +| `src/components/group-wizard/step-review.test.tsx` | 9 | | +| `src/components/group-wizard/step-settings.test.tsx` | 10 | | +| `src/components/group-wizard/wizard-navigation.test.tsx` | 11 | | +| `src/components/group-wizard/wizard-stepper.test.tsx` | 7 | | + +## `src/components/{layout,shared-actions,tool}` (5 files, 44 cases) + +| Test File | Cases | What It Covers | +|---|---|---| +| `src/components/layout/auth-wrapper.test.tsx` | 6 | `AuthWrapper` redirect to `/signin` (callbackUrl) or `/session-error`; renders children when authenticated | +| `src/components/shared-actions/domain.test.ts` | 6 | `getMpTimezone`: refuses without a session, delegates to `DomainTimezoneService` | +| `src/components/shared-actions/sign-out.test.ts` | 13 | `handleSignOut`: session cleared first, MP-origin-only `id_token_hint`, end-session URL shape, env guards | +| `src/components/shared-actions/user.test.ts` | 6 | `getCurrentUserProfile` session-derived GUID lookup, `Unauthorized` on missing/empty `userGuid`, IDOR regression (caller-forged argument ignored), error propagation | +| `src/components/tool/tool-framework.test.tsx` | 13 | `ToolContainer` / `ToolHeader` / `ToolFooter` optional-prop branches | + +## `src/contexts` (2 files, 16 cases) + +| Test File | Cases | What It Covers | +|---|---|---| +| `src/contexts/use-app-session.test.ts` | 2 | `useAppSession` hook wrapper | +| `src/contexts/user-context.test.tsx` | 14 | `UserProvider` lifecycle, profile loading, errors, edge branches | + +## `src/lib/providers/ministry-platform` (16 files, 521 cases) + +| Test File | Cases | What It Covers | +|---|---|---| +| `src/lib/providers/ministry-platform/auth/client-credentials.test.ts` | 29 | OAuth2 `client_credentials` token flow | +| `src/lib/providers/ministry-platform/client.test.ts` | 36 | OAuth token lifecycle, refresh, concurrent calls (uses `vi.useFakeTimers`) | +| `src/lib/providers/ministry-platform/helper.test.ts` | 67 | MPHelper CRUD, Zod validation, procedures, files, copy/recurrence/generateSequence | +| `src/lib/providers/ministry-platform/provider.test.ts` | 29 | Singleton, service delegation, generateSequence with optional fields | +| `src/lib/providers/ministry-platform/scripts/codegen-escape.test.ts` | 25 | Codegen escaping: `commentText`, `propertyKey`, `positiveInt` | +| `src/lib/providers/ministry-platform/services/communication.service.test.ts` | 12 | Communications and messages with/without attachments | +| `src/lib/providers/ministry-platform/services/domain.service.test.ts` | 8 | Domain info, global filters | +| `src/lib/providers/ministry-platform/services/file.service.test.ts` | 39 | File CRUD, FormData building, unauthenticated blob retrieval | +| `src/lib/providers/ministry-platform/services/guards.test.ts` | 53 | `sanitizeIdentifier` / `sanitizeRecordId` / `sanitizeUniqueId` / `tableEndpoint` / `errorName` | +| `src/lib/providers/ministry-platform/services/metadata.service.test.ts` | 7 | Metadata refresh, tables search | +| `src/lib/providers/ministry-platform/services/procedure.service.test.ts` | 19 | Procedure listing + query/body execution with URL encoding | +| `src/lib/providers/ministry-platform/services/table.service.test.ts` | 49 | CRUD + `copyRecord` + `copyRecordWithSubpages` | +| `src/lib/providers/ministry-platform/utils/http-client.test.ts` | 104 | HTTP methods, URL building, FormData, error handling | +| `src/lib/providers/ministry-platform/utils/logger.test.ts` | 4 | Debug vs production gating, error always logs | +| `src/lib/providers/ministry-platform/scripts/generate-storedprocs.test.ts` | 4 | | +| `src/lib/providers/ministry-platform/scripts/generate-types.test.ts` | 36 | | + +## `src/lib/**` (excl. MP provider) (14 files, 255 cases) + +| Test File | Cases | What It Covers | +|---|---|---| +| `src/lib/auth-client.test.ts` | 4 | | +| `src/lib/barcode-helpers.test.ts` | 14 | Barcode generation utilities | +| `src/lib/barcode-image.test.ts` | 6 | BMP rendering for IMb/POSTNET with JSON fallback | +| `src/lib/dto/address-label.dto.test.ts` | 5 | | +| `src/lib/dto/family.test.ts` | 9 | | +| `src/lib/env.test.ts` | 45 | `getMpBaseUrl` / `getAuthBaseUrl` / `getMpClient` validation | +| `src/lib/imb-encoder.test.ts` | 10 | USPS Intelligent Mail barcode encoding | +| `src/lib/label-stock.test.ts` | 9 | Label stock definitions and calculations | +| `src/lib/postnet-encoder.test.ts` | 9 | POSTNET barcode encoding | +| `src/lib/providers/google-places/provider.test.ts` | 11 | | +| `src/lib/security-headers.test.ts` | 28 | `buildCsp`, `generateNonce`, CSP report URI / `Reporting-Endpoints` header | +| `src/lib/tool-params.test.ts` | 28 | `parseToolParams` (from `tool-params.server.ts`: input shapes, NaN guard, decoding, `pageData`), `isNewRecord`, `isEditMode` | +| `src/lib/validation.test.ts` | 60 | GUID / positive int / column name / filter escaping | +| `src/lib/zip-guard.test.ts` | 17 | `assertZipWithinLimits` / `ZipLimitError` | + +## `src/services` (9 files, 245 cases) + +| Test File | Cases | What It Covers | +|---|---|---| +| `src/services/addressLabelService.test.ts` | 9 | Address fetching, batching, single contact | +| `src/services/authorizationService.test.ts` | 32 | `requireSecurityRole` / `hasSecurityRole`, `resolveRolePolicy`, `MP_SECURITY_ROLES` fails closed | +| `src/services/domainTimezoneService.test.ts` | 22 | `resolveIanaTimezone`, `getMpTimezone`, `toMpSqlDatetime`, `parseMpDatetime` | +| `src/services/familyService.test.ts` | 56 | | +| `src/services/fieldManagementService.test.ts` | 21 | Pages, fields, table metadata, batched order updates | +| `src/services/googlePlacesService.test.ts` | 18 | | +| `src/services/groupService.test.ts` | 27 | Group CRUD, lookups, contact/group search | +| `src/services/toolService.test.ts` | 53 | Page data, user tools, selection records, contact resolution, `listPages`, `listRoles`, `deployTool` | +| `src/services/userService.test.ts` | 7 | `getUserProfile`: display fields only, single query, GUID validation, not-found, error propagation | ## Known uncovered surfaces -- `src/lib/providers/ministry-platform/scripts/` (`build-sql-install.ts`, `generate-types.ts`, `generate-storedprocs.ts`) — CLI build scripts exercised via `npm run mp:build:install` / `npm run mp:generate:models` +- `src/lib/providers/ministry-platform/scripts/` CLI build scripts (`build-sql-install.ts`, `generate-types.ts`, `generate-storedprocs.ts`) — excluded from coverage; exercised via `npm run mp:build:install` / `npm run mp:generate:models`. Only the escaping helpers have a unit test (`codegen-escape.test.ts`). - Auto-generated model files under `src/lib/providers/ministry-platform/models/` — excluded from coverage via `vitest.config.mts` +- `src/components/ui/**` (vendored shadcn primitives) — excluded from coverage ## Related docs - `setup.md` — runner config diff --git a/.claude/references/testing/mocks.md b/.claude/references/testing/mocks.md index c350c52..6b30ffc 100644 --- a/.claude/references/testing/mocks.md +++ b/.claude/references/testing/mocks.md @@ -3,9 +3,9 @@ title: Mocking Patterns domain: testing type: reference applies_to: [src/**/*.test.ts, src/**/*.test.tsx] -symbols: [vi.hoisted, vi.mock, MPHelper] +symbols: [vi.hoisted, vi.mock, MPHelper, AuthorizationService] related: [setup.md, cookbook.md, inventory.md] -last_verified: 2026-04-17 +last_verified: 2026-10-10 --- ## Purpose @@ -66,33 +66,50 @@ Every method that the service under test calls must be declared as a class field ## Pattern: Singleton reset between tests -All services in `src/services/` use a private static `instance` field (`toolService.ts:82`, `userService.ts:12`, etc.). Without resetting, cached instances hold stale mocked methods across tests (including stale `mockClear`ed state). +All services in `src/services/` use a private static `instance` field (`toolService.ts:85`, `userService.ts:13`, etc.). Without resetting, cached instances hold stale mocked methods across tests (including stale `mockClear`ed state). ```typescript -// From src/services/toolService.test.ts:19-23 +// From src/services/toolService.test.ts:46-50 beforeEach(() => { vi.clearAllMocks(); - // eslint-disable-next-line @typescript-eslint/no-explicit-any (ToolService as any).instance = undefined; }); ``` -Required for: `ToolService`, `UserService`, `AddressLabelService`, `GroupService`, `FieldManagementService`. +Required for every service's own test: `AddressLabelService`, `AuthorizationService`, `DomainTimezoneService`, `FamilyService`, `FieldManagementService`, `GooglePlacesService`, `GroupService`, `ToolService`, `UserService` (all 9 `src/services/*.test.ts` files do it). -## Pattern: Mocking service singletons in server-action tests +## Pattern: Mocking the `AuthorizationService` gate -Server actions call `ServiceClass.getInstance()` (async). Mock the static method to return an object shaped like the service API used by the action. +Every MP-touching service method and feature server action calls `AuthorizationService.getInstance().requireSecurityRole(...)` (CLAUDE.md rule 12), which returns the acting MP `User_ID`. Tests of anything *above* the gate mock it so they exercise their own logic; the gate has its own tests in `src/services/authorizationService.test.ts`. Note `getInstance` is **synchronous** here (as is `DomainTimezoneService`'s; the other seven services' are async). ```typescript -// From src/components/dev-panel/panels/user-tools-actions.test.ts:21-35 -vi.mock('@/services/userService', () => ({ - UserService: { - getInstance: vi.fn().mockResolvedValue({ - getUserIdByGuid: mockGetUserIdByGuid, +// From src/services/toolService.test.ts:28-42 +const { mockRequireSecurityRole, mockHasSecurityRole } = vi.hoisted(() => ({ + mockRequireSecurityRole: vi.fn(async () => 42), + mockHasSecurityRole: vi.fn(async () => true), +})); + +vi.mock('@/services/authorizationService', () => ({ + AuthorizationService: { + getInstance: () => ({ + requireSecurityRole: mockRequireSecurityRole, + hasSecurityRole: mockHasSecurityRole, }), }, + UnauthorizedError: class UnauthorizedError extends Error {}, })); +``` +- The stub's return value (`42`) is the only source of `$userId` write attribution (rule 13), so service tests assert `$userId: 42` to prove the service takes it from the gate. +- To test a refusal: `mockRequireSecurityRole.mockRejectedValueOnce(new UnauthorizedError(...))`. +- `src/components/address-labels/actions.test.ts:33-49` shows a variant whose `hasSecurityRole` delegates to the same mock. + +## Pattern: Mocking service singletons in server-action tests + +Server actions call `ServiceClass.getInstance()` (async). Mock the static method to return an object shaped like the service API used by the action. + +```typescript +// From src/components/dev-panel/panels/user-tools-actions.test.ts:20-26 vi.mock('@/services/toolService', () => ({ ToolService: { getInstance: vi.fn().mockResolvedValue({ @@ -106,10 +123,10 @@ When mocking multiple services, each `getInstance` returns only the methods the ## Pattern: Mocking auth + `next/headers` for server actions -Most server actions do `await auth.api.getSession({ headers: await headers() })`. +Code that reads the session directly — the `AuthorizationService` gate itself, `requireDevSession`, `AuthWrapper`, and the `shared-actions/` carve-outs — does `await auth.api.getSession({ headers: await headers() })`. Feature actions mostly reach it only through the gate, so their tests mock the gate (above) and often also these two. ```typescript -// From src/components/dev-panel/panels/user-tools-actions.test.ts:9-19 +// From src/components/dev-panel/panels/user-tools-actions.test.ts:8-18 vi.mock('@/lib/auth', () => ({ auth: { api: { @@ -128,7 +145,7 @@ Session shapes used in tests: // Authenticated { user: { id: 'internal-id', userGuid: '550e8400-e29b-41d4-a716-446655440000' } } -// Missing userGuid (tests "User GUID not found") +// Missing userGuid (e.g. getCurrentUserProfile → throws 'Unauthorized') { user: { id: 'internal-id' } } // Unauthenticated (tests "Unauthorized") @@ -178,7 +195,7 @@ vi.mock('next/navigation', () => ({ Used in `src/lib/providers/ministry-platform/client.test.ts` to test OAuth token refresh without waiting real time. ```typescript -// From src/lib/providers/ministry-platform/client.test.ts:21-34 +// From src/lib/providers/ministry-platform/client.test.ts:33-46 beforeEach(async () => { vi.clearAllMocks(); vi.useFakeTimers(); @@ -213,7 +230,7 @@ await waitFor(() => { ## Pattern: Per-test env stubbing ```typescript -// From src/components/dev-panel/dev-panel.test.tsx:35-45 +// From src/components/dev-panel/dev-panel.test.tsx:44-54 beforeEach(() => { vi.stubEnv("NODE_ENV", "development"); setHostname("localhost"); diff --git a/.claude/references/testing/setup.md b/.claude/references/testing/setup.md index 1697354..39babdc 100644 --- a/.claude/references/testing/setup.md +++ b/.claude/references/testing/setup.md @@ -5,7 +5,7 @@ type: reference applies_to: [vitest.config.mts, src/test-setup.ts, package.json] symbols: [defineConfig] related: [mocks.md, cookbook.md] -last_verified: 2026-09-13 +last_verified: 2026-10-10 --- ## Purpose @@ -17,18 +17,20 @@ Vitest runner config, global setup, and commands. Everything the harness needs b and Vite warned it would break when `configLoader: 'native'` becomes the default. `.mts` is resolved automatically, so no script changes were needed. Being real ESM, it must use `import.meta.dirname`, not `__dirname`. -- `src/test-setup.ts` — env stubs + `@testing-library/jest-dom` import +- `src/test-setup.ts` — env stubs, `ResizeObserver` stub, `@testing-library/jest-dom/vitest` import +- `src/test-utils/mock-oidc.ts` — test-only mock MP OIDC provider (`installMockOidc()`, RS256-signed id_tokens, `CookieJar`, `codeFlow`, …) imported only by `src/auth.*.test.ts`; excluded from coverage - `package.json` — `test`, `test:run`, `test:coverage` scripts -## Versions (from `.claude/references/_meta/facts/2026-09-13.md`) +## Versions (from `package.json`, 2026-10-10; installed vitest 5.0.3) | Package | Version | |---|---| -| vitest | ^4.1.0 | -| @vitest/coverage-v8 | ^4.1.0 | -| @testing-library/react | ^16.3.2 | -| @testing-library/jest-dom | ^6.9.1 | -| jsdom | ^28.1.0 | -| @vitejs/plugin-react | ^6.0.1 | +| vitest | ^5.0.0 | +| @vitest/coverage-v8 | ^5.0.0 | +| @testing-library/react | ^16.3.3 | +| @testing-library/user-event | ^14.6.7 | +| @testing-library/jest-dom | ^7.0.1 | +| jsdom | ^30.1.2 | +| @vitejs/plugin-react | ^6.1.2 | ## Commands | Script | Behavior | @@ -92,6 +94,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 @@ -108,6 +111,11 @@ export default defineConfig({ // `import.meta.dirname`, not `__dirname`: this file is ESM (.mts), and // Vite's native config loader has no CommonJS globals. '@': path.resolve(import.meta.dirname, './src'), + // `server-only` throws unless resolved under the `react-server` export + // condition, which Next applies to server code and Vitest does not. Map + // it to the package's own no-op entry so modules marked server-only can + // still be unit tested. The guard itself is exercised by `next build`. + 'server-only': path.resolve(import.meta.dirname, './node_modules/server-only/empty.js'), }, }, }); @@ -115,34 +123,53 @@ export default defineConfig({ ## Key settings - **`coverage.include`** — REQUIRED. Without it the v8 provider only reports files a test imported, so untested files vanish from the report instead of counting as 0%. Note `coverage.all` was removed in Vitest 5 and directory exclusions must end in `**`. -- **`coverage.thresholds`** — statements 97 / lines 98, enforced by the `test:coverage` CI job. Branches and functions are deliberately unenforced. +- **`coverage.thresholds`** — statements 97 / lines 98, enforced by the `npm run test:coverage` step of the CI `test` job. Branches and functions are deliberately unenforced. - **`environment: 'jsdom'`** — DOM available without a browser (see `../DECISIONS.md` for rationale vs happy-dom) - **`globals: true`** — `describe/it/expect/vi` available without imports (tests still import explicitly by convention) - **`plugins: [react()]`** — `@vitejs/plugin-react` required for `.tsx` files and JSX transform - **`include`** — matches `src/**/*.{test,spec}.{ts,tsx}` (both extensions supported) - **Alias `@` -> `./src`** — mirrors `tsconfig.json` path alias - **Coverage provider: `v8`** — not istanbul; faster, native to Node -- **Coverage excludes** — auto-generated MP models under `src/lib/providers/ministry-platform/models/` are excluded so generated field counts don't dominate coverage +- **Coverage excludes** — generated MP models, build-time scripts, `src/test-utils/**`, vendored `src/components/ui/**`, `loading.tsx`, the two font/metadata layouts, `index.ts` barrels and `types.ts` modules (see the config above) +- **`server-only` alias** — maps `server-only` to its no-op `empty.js` so modules marked `import 'server-only'` (CLAUDE.md rule 15) can be unit-tested; the real guard is exercised by `next build` ## `src/test-setup.ts` (full source) ```typescript -import '@testing-library/jest-dom'; +// jest-dom v7 moved the Vitest `expect` type augmentation to this entry point. +// The bare '@testing-library/jest-dom' import registers the matchers at runtime +// but only augments Jest's types, so `toBeInTheDocument` etc. fail type checking. +import '@testing-library/jest-dom/vitest'; import { vi } from 'vitest'; +if (typeof globalThis.ResizeObserver === 'undefined') { + class ResizeObserverStub { + observe() {} + unobserve() {} + disconnect() {} + } + globalThis.ResizeObserver = ResizeObserverStub as unknown as typeof ResizeObserver; +} + // Mock environment variables for tests vi.stubEnv('MINISTRY_PLATFORM_BASE_URL', 'https://test-mp.example.com'); +// One MP client serves both the service account and user sign-in (see +// getMpClient in src/lib/env.ts). `test-client-id` is the id_token audience +// the mock OIDC providers sign for (MOCK_CLIENT_ID in src/test-utils/mock-oidc.ts). 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'); ``` ## Env var handling -- `vi.stubEnv()` seeds required env vars at test bootstrap so modules that read `process.env` at import time (e.g. `src/lib/auth.ts`, `src/lib/providers/ministry-platform/config.ts`) don't throw. +- `vi.stubEnv()` seeds required env vars at test bootstrap so modules that read `process.env` at import time (e.g. `src/lib/auth.ts`, `src/lib/env.ts`) don't throw. +- `src/lib/auth.ts` skips `assertAuthEnvironment` when `process.env.VITEST` is set (Vitest sets it); `src/auth.secret-guard.test.ts` clears it to prove the guard runs on import. +- Suites using `src/test-utils/mock-oidc.ts` run under `// @vitest-environment node` (`jose` rejects jsdom-realm typed arrays). - Tests may call `vi.stubEnv('NODE_ENV', 'development')` per-test and `vi.unstubAllEnvs()` in `afterEach` (see `src/components/dev-panel/dev-panel.test.tsx`). ## Coverage @@ -150,14 +177,15 @@ vi.stubEnv('NODE_ENV', 'test'); - Reporters: `text` (console), `json` (machine-readable), `html` (`coverage/index.html`) - Run: `npm run test:coverage` or `npx vitest run --coverage --coverage.reportOnFailure` -## Counts (facts snapshot 2026-04-17) -- **Test files:** 37 -- **Test cases:** 507 (from `vitest run`) +## Counts (facts snapshot 2026-10-10) +- **Test files:** 120 +- **Test cases:** 2,237 (from `vitest run`, 2026-10-11) +- See `../_meta/facts/2026-10-10.md` ## Conventions - Co-locate: `foo.ts` -> `foo.test.ts`; `foo.tsx` -> `foo.test.tsx` - Per-file: `import { describe, it, expect, vi, beforeEach } from 'vitest'` even though `globals: true` (consistency) -- React tests use `@testing-library/react` + `jest-dom` matchers (auto-extended via setup file) +- React tests use `@testing-library/react` + `jest-dom` matchers (auto-extended via setup file). The setup file must import `@testing-library/jest-dom/vitest`, not the bare package: since jest-dom v7 only `/vitest` augments Vitest's `expect` types, so the bare import passes tests but fails `tsc`/`next build`. ## Related docs - `mocks.md` — required mock patterns diff --git a/.claude/references/utils/README.md b/.claude/references/utils/README.md index 83df48c..a0775d2 100644 --- a/.claude/references/utils/README.md +++ b/.claude/references/utils/README.md @@ -12,20 +12,23 @@ Hand-rolled, framework-agnostic utilities under `src/lib/`: USPS barcode encoder |------|---------|--------------| | `barcodes.md` | `imbEncode()`, `postnetEncode()`, `buildRoutingCode()`, `buildImbTrackingCode()`, `preEncodeBarcodes()`, BMP image renderers | Touching address-label barcode generation, Word-document export, or adjusting IMb service type / mailer ID | | `label-stock.md` | Avery 5160/5161/5162/5163 dimensions, `getLabelPosition()` page/row/column math | Adding a new label stock, tweaking PDF/Word layout | -| `tool-params.md` | `parseToolParams()`, `PageData`, `isNewRecord()`, `isEditMode()`, standard MP tool query params (`pageID`, `s`, `recordID`, `recordDescription`, …) | Building or modifying any tool page (`src/app/(web)/tools/*/page.tsx`) | +| `tool-params.md` | `parseToolParams()` (server-only), `PageData`, `isNewRecord()`, `isEditMode()`, standard MP tool query params (`pageID`, `s`, `recordID`, `recordDescription`, …), shadcn `cn()` | Building or modifying any tool page (`src/app/(web)/tools/*/page.tsx`) | ## Code surfaces | Path | Role | |------|------| | `src/lib/imb-encoder.ts` | USPS-B-3200 Intelligent Mail Barcode (IMb) 4-state encoder → 65 `BarState` bars | | `src/lib/postnet-encoder.ts` | POSTNET tall/short bar encoder with check digit | -| `src/lib/barcode-helpers.ts` | Routing-code and tracking-code builders; `preEncodeBarcodes()` batch encoder with IMb→POSTNET fallback | +| `src/lib/barcode-helpers.ts` | Routing-code and tracking-code builders (Mailer ID validated); `preEncodeBarcodes()` batch encoder with logged IMb→POSTNET fallback | | `src/lib/barcode-image.ts` | `imbBarcodeToBmp()` + `postnetBarcodeToBmp()` — 24-bit BMP renderers for Word embedding | | `src/lib/label-stock.ts` | `LABEL_STOCKS` array, `getLabelStock()`, `getLabelPosition()` (points-based geometry) | -| `src/lib/tool-params.ts` | `ToolParams`, `PageData`, `parseToolParams()` (calls `ToolService.getPageData`), `isNewRecord()`, `isEditMode()` | -| `src/lib/utils.ts` | shadcn `cn(...inputs)` — `twMerge(clsx(...))`; 27 consumers total (22 under `src/components/ui/**` + 5 feature components) | +| `src/lib/tool-params.ts` | Client-safe: `ToolParams`, `PageData`, `isNewRecord()`, `isEditMode()` | +| `src/lib/tool-params.server.ts` | `server-only`: `parseToolParams()` (dynamically imports `ToolService` for `getPageData`) | +| `src/lib/zip-guard.ts` | `assertZipWithinLimits()` / `ZipLimitError` — .docx template zip-bomb guard (see `../components/address-labels.md`) | +| `src/lib/utils.ts` | shadcn `cn(...inputs)` — `twMerge(clsx(...))`; 26 non-test consumers (22 under `src/components/ui/**` + 4 feature components) | ## Related domains - `../components/README.md` — `address-labels` consumes every barcode utility + `label-stock`; `tool` / `dev-panel` consume `tool-params` - `../services/README.md` — `ToolService.getPageData(pageID)` is invoked by `parseToolParams()` +- `../dto-constants/constants.md` — `validateMailerId` and other validators used by these utilities - `../routing/README.md` — tool pages pass Next.js `searchParams` into `parseToolParams()` diff --git a/.claude/references/utils/barcodes.md b/.claude/references/utils/barcodes.md index baaecca..9a5be1d 100644 --- a/.claude/references/utils/barcodes.md +++ b/.claude/references/utils/barcodes.md @@ -20,7 +20,7 @@ symbols: related: - ../components/README.md - label-stock.md -last_verified: 2026-04-17 +last_verified: 2026-10-10 --- ## Purpose @@ -80,6 +80,7 @@ export function buildImbTrackingCode( serviceType: string, serialNumber: number, ): string; +// Calls validateMailerId(mailerId) first — throws unless exactly 6 or 9 digits. // Layout: [barcodeId='00'(2)] + [serviceType(3)] + [mailerId(6|9)] + [serial(9|6)] // Always 20 digits total. @@ -90,7 +91,7 @@ export function preEncodeBarcodes( // - barcodeFormat === 'none' → labels returned unchanged // - barcodeFormat === 'imb' && config.mailerId → try IMb // on success: barStates = bars.join(''), barType = 'imb' -// on throw OR no mailerId: fall through to POSTNET +// on throw OR no mailerId: fall through to POSTNET (throw → console.warn { labelIndex, error: <name> }) // - barcodeFormat === 'postnet' | 'imb' fallback: // tries full routingCode, then retries with first 5 digits // on success: barStates = JSON.stringify(bars), barType = 'postnet' @@ -138,12 +139,12 @@ export function postnetBarcodeToBmp( ### BMP rendering - Simple uncompressed BMP: 14-byte file header + 40-byte DIB (BITMAPINFOHEADER) + bottom-up 24-bit BGR pixel rows padded to 4-byte boundary. -- IMb: bar width = `max(floor(width/65 * 0.4), 1)`; per-state `topOffset`/`barHeight` as fractions of image height: +- IMb: pitch = `width / barStates.length`; bar width = `max(floor(pitch * 0.4), 1)`; per-state `topOffset`/`barHeight` as fractions of image height: - `T`: top 0.35, height 0.3 - `D`: top 0.15, height 0.85 - `A`: top 0.00, height 0.65 - `F`: top 0.00, height 1.00 -- POSTNET: tall = full height; short = 0.4 × height; bars bottom-aligned. +- POSTNET: same pitch/bar-width formula over the bar count; tall = full height; short = 0.4 × height; bars bottom-aligned. ### Routing & tracking helpers - `buildRoutingCode('32904-7322', '20')` → `'32904732220'` (11 digits) @@ -154,7 +155,7 @@ export function postnetBarcodeToBmp( ## Usage ### Generating barcodes for an address-label print run -From `src/components/address-labels/actions.ts:22-26`: +From `src/components/address-labels/actions.ts:19-29` (excerpt): ```typescript import { preEncodeBarcodes } from '@/lib/barcode-helpers'; @@ -165,7 +166,7 @@ import { imbBarcodeToBmp, postnetBarcodeToBmp } from '@/lib/barcode-image'; `preEncodeBarcodes(labels, config)` runs before PDF/Word export so each row carries a `barStates`/`barType` pair consumed by the renderers. ### Word-document embedding -From `src/components/address-labels/word-document.ts:18`: +From `src/components/address-labels/word-document.ts:17`: ```typescript import { imbBarcodeToBmp, postnetBarcodeToBmp } from '@/lib/barcode-image'; @@ -174,11 +175,11 @@ import { imbBarcodeToBmp, postnetBarcodeToBmp } from '@/lib/barcode-image'; Word accepts BMP natively, so the renderer returns raw `Buffer`s that can be inline-embedded without converting to PNG. ### PDF-preview rendering -- `src/components/address-labels/imb-barcode.tsx` — `@react-pdf/renderer` `<View>` component for PDF output; imports `type BarState` from `@/lib/imb-encoder`. +- `src/components/address-labels/imb-barcode.tsx` — `@react-pdf/renderer` `<View>` component for PDF output; takes the 65-char `barStates` string (renders nothing unless length is exactly 65) and keeps its own `BAR_DIMS` table — it does not import from `@/lib/imb-encoder`. - `src/components/address-labels/postnet-barcode.tsx` — `@react-pdf/renderer` `<View>` component for PDF output; imports `type PostnetBar` from `@/lib/postnet-encoder`. ### IMb test vector (USPS-B-3200 example 4) -From `src/lib/imb-encoder.test.ts:11-13`: +From `src/lib/imb-encoder.test.ts:9-13` (abridged): ```typescript expect(imbEncode('0123456709498765432101234567891').join('')).toBe( @@ -187,9 +188,10 @@ expect(imbEncode('0123456709498765432101234567891').join('')).toBe( ``` ## Gotchas -- **Fallback swallows IMb errors silently.** In `preEncodeBarcodes` (`src/lib/barcode-helpers.ts:48`), an IMb throw falls through to POSTNET with no logging. If a mailer config is subtly wrong (bad service type, bad mailer ID length), labels silently downgrade to POSTNET. See glossary candidate "IMb→POSTNET fallback". -- **POSTNET `barStates` is JSON, IMb `barStates` is a raw string.** Consumers must branch on `barType` before rendering. Enforced at `src/lib/barcode-helpers.ts:47` (IMb) vs `:57,61` (POSTNET) and `src/lib/barcode-image.ts:64` (IMb expects plain string) vs `:98-103` (POSTNET parses JSON and silently falls back to a 1×1 BMP on parse failure). +- **IMb → POSTNET fallback is logged, but without record content.** In `preEncodeBarcodes` (`src/lib/barcode-helpers.ts`), each encode failure emits `console.warn('[preEncodeBarcodes] IMb encode failed, falling back to POSTNET', { labelIndex, error })` (POSTNET retry adds `routingLength`; final failure logs `... no barcode will be rendered`). `error` is the error's **name** only (`describeFailure`) — label name, address, ZIP/routing digits and the error message are deliberately omitted (CLAUDE.md rule 14). Find the bad row by `labelIndex` in the request. Users still see POSTNET silently on a subtly wrong mailer config. See [GOTCHA-040](../GOTCHAS.md). +- **POSTNET `barStates` is JSON, IMb `barStates` is a raw string.** Consumers must branch on `barType` before rendering. Enforced at `src/lib/barcode-helpers.ts:59` (IMb) vs `:70,78` (POSTNET) and `src/lib/barcode-image.ts:63` (IMb expects plain string) vs `:97-103` (POSTNET parses JSON and silently falls back to a 1×1 BMP on parse failure). - **`buildRoutingCode` treats DP `'00'` as absent.** Pass a real 1–2 digit delivery point; `'00'` returns a 5/9-digit routing, not 7/11-digit. See `src/lib/barcode-helpers.ts:13`. +- **An invalid Mailer ID never reaches the encoder silently.** `buildImbTrackingCode` throws via `validateMailerId` (caught by the IMb `try` → POSTNET fallback), and the render actions reject a malformed Mailer ID up front in `prepareLabels` (`src/components/address-labels/actions.ts`). - **BMP invalid-input behavior differs.** `imbBarcodeToBmp` silently skips unknown bar chars; `postnetBarcodeToBmp` returns a 1×1 fallback on JSON parse failure — neither throws. ## Related docs diff --git a/.claude/references/utils/label-stock.md b/.claude/references/utils/label-stock.md index cb6a009..d54922b 100644 --- a/.claude/references/utils/label-stock.md +++ b/.claude/references/utils/label-stock.md @@ -13,7 +13,7 @@ symbols: related: - barcodes.md - ../components/README.md -last_verified: 2026-04-17 +last_verified: 2026-10-10 --- ## Purpose @@ -84,7 +84,7 @@ All four: `pageWidth=612`, `pageHeight=792`, `rowGap=0`. ## Usage ### Looking up a stock in a server action -From `src/components/address-labels/actions.ts:21`: +From `src/components/address-labels/actions.ts:19`: ```typescript import { getLabelStock } from '@/lib/label-stock'; @@ -106,15 +106,15 @@ import type { LabelStockConfig } from '@/lib/label-stock'; ``` ### Word export (geometry only) -From `src/components/address-labels/word-document.ts:16`: +From `src/components/address-labels/word-document.ts:15`: ```typescript import type { LabelStockConfig } from '@/lib/label-stock'; ``` ## Gotchas -- **`getLabelStock` returns `undefined` for unknown IDs.** Callers must guard before dereferencing — see `src/app/(web)/tools/addresslabels/address-labels.tsx:64-65` and `src/components/address-labels/actions.ts:135-138`. The test `should return undefined for unknown stock ID` (`src/lib/label-stock.test.ts:28`) pins the behavior. -- **`startPosition` offset is handled by the caller**, not by `getLabelPosition`. Features that "skip N used labels" add the offset to the index before calling. +- **`getLabelStock` returns `undefined` for unknown IDs.** Callers must guard before dereferencing — see `src/app/(web)/tools/addresslabels/address-labels.tsx:64-65` and `prepareLabels` in `src/components/address-labels/actions.ts` (`Unknown label stock` envelope). The test `should return undefined for unknown stock ID` (`src/lib/label-stock.test.ts:28`) pins the behavior. +- **`startPosition` offset is handled by the caller**, not by `getLabelPosition`. `label-document.tsx` skips `startPosition - 1` cells before placing labels. `prepareLabels` rejects a `startPosition` beyond `columns * rows`. - **Landscape / non-Letter stocks are not modeled.** If a new stock is added, it must follow the 612×792 Letter-portrait assumption or the PDF layout code in `label-document.tsx` needs updating. ## Related docs diff --git a/.claude/references/utils/tool-params.md b/.claude/references/utils/tool-params.md index 5d55f9e..fc254de 100644 --- a/.claude/references/utils/tool-params.md +++ b/.claude/references/utils/tool-params.md @@ -4,6 +4,7 @@ domain: utils type: reference applies_to: - src/lib/tool-params.ts + - src/lib/tool-params.server.ts - src/lib/utils.ts symbols: - ToolParams @@ -15,21 +16,22 @@ symbols: related: - ../services/README.md - ../components/README.md -last_verified: 2026-04-17 +last_verified: 2026-10-10 --- ## Purpose -Parses the Ministry Platform-standard tool URL query string (`pageID`, `s`, `recordID`, `recordDescription`, etc.) into a typed `ToolParams` object, optionally hydrating `pageData` via `ToolService.getPageData()`. Every tool under `src/app/(web)/tools/*/page.tsx` funnels through this. The sibling `src/lib/utils.ts` holds the shadcn `cn()` Tailwind class merger (documented below for completeness). +Parses the Ministry Platform-standard tool URL query string (`pageID`, `s`, `recordID`, `recordDescription`, etc.) into a typed `ToolParams` object, optionally hydrating `pageData` via `ToolService.getPageData()`. Every tool under `src/app/(web)/tools/*/page.tsx` funnels through this. The parser lives in the server-only `src/lib/tool-params.server.ts`; the types and pure predicates stay in the client-safe `src/lib/tool-params.ts`. The sibling `src/lib/utils.ts` holds the shadcn `cn()` Tailwind class merger (documented below for completeness). ## Files -- `src/lib/tool-params.ts` — parser + `ToolParams`, `PageData`, `isNewRecord`, `isEditMode` -- `src/lib/utils.ts` — shadcn `cn()` (`twMerge(clsx(...))`), 27 consumers - -No test file exists for `tool-params.ts` (see TODO drop). +- `src/lib/tool-params.ts` — **client-safe**: `ToolParams`, `PageData`, `isNewRecord`, `isEditMode`. Must never import a service (CLAUDE.md rule 16). +- `src/lib/tool-params.server.ts` — `import "server-only"`; `parseToolParams` (+ private `parseIntOrUndefined`, `safeDecode`). Imports `ToolService` via dynamic `import()`. +- `src/lib/tool-params.test.ts` — covers input shapes, NaN guard, `recordDescription` decoding, `pageData` hydration, both predicates +- `src/lib/utils.ts` — shadcn `cn()` (`twMerge(clsx(...))`), 26 non-test consumers ## Key concepts - **MP tool URL contract** — When Ministry Platform launches a tool, it appends a standard query string naming the page, selection, and (optionally) target record. `parseToolParams` is the single ingestion point. -- **Page hydration** — If `pageID` is present, `parseToolParams` eagerly fetches `PageData` via the `api_Tools_GetPageData` stored procedure (through `ToolService`). Failure is non-fatal: it's caught and logged, leaving `pageData: undefined`. +- **Page hydration** — If `pageID` is present, `parseToolParams` eagerly fetches `PageData` via the `api_Tools_GetPageData` stored procedure (through `ToolService`, which gates `dp_Pages`/`read`). Failure — including an authorization refusal — is non-fatal: it's caught and logged, leaving `pageData: undefined`. +- **Gate before parse** — every tool page calls `requireToolAccess(...)` (`src/app/(web)/tools/require-tool-access.ts`) *before* `parseToolParams`. - **`recordID === -1` means "new record"** — MP uses `-1` to signal new-record intent; `isNewRecord()` treats `-1` or `undefined` as new; `isEditMode()` is its negation. - **`searchParams` is polymorphic** — Accepts both a `URLSearchParams` (runtime) and Next.js 16's awaited `{ [k]: string | string[] | undefined }` shape. @@ -84,10 +86,12 @@ export interface ToolParams { export async function parseToolParams( searchParams: URLSearchParams | { [key: string]: string | string[] | undefined } ): Promise<ToolParams>; -// - Integer params parsed via parseInt(_, 10) -// - recordDescription is decodeURIComponent()'d -// - pageData fetched via ToolService.getInstance().getPageData(pageID); -// failures are caught and logged (pageData stays undefined) +// - Integer params parsed via parseIntOrUndefined (NaN → undefined) +// - recordDescription goes through safeDecode (decodeURIComponent, raw on URIError) +// - pageData fetched via (await import("@/services/toolService")).ToolService +// .getInstance().getPageData(pageID); failures are caught and logged +// (pageData stays undefined) +// Defined in src/lib/tool-params.server.ts (server-only). export function isNewRecord(params: ToolParams): boolean; // params.recordID === -1 || params.recordID === undefined @@ -105,30 +109,36 @@ export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)) } ``` -Standard shadcn helper for merging Tailwind class strings while deduplicating conflicting utilities. Used by every component in `src/components/ui/**` (22 files) plus select feature components. +Standard shadcn helper for merging Tailwind class strings while deduplicating conflicting utilities. Used by every component in `src/components/ui/**` (22 files) plus 4 feature components (`field-management/page-search.tsx`, `group-wizard/{contact-search,group-search,wizard-stepper}.tsx`). ## How it works 1. `parseToolParams` normalizes `searchParams` reads through a single `getValue(key)` shim (handles both `URLSearchParams` and Next.js's awaited object shape; arrays collapse to first element). -2. Raw strings are converted: numeric fields via `parseInt(..., 10)`, `recordDescription` via `decodeURIComponent`. -3. If `pageID` parsed as a number, `ToolService.getInstance()` + `getPageData(pageID)` is awaited. -4. Any error fetching page data is swallowed with `console.warn('Could not fetch page data for pageID:', ..., '- Stored procedure may not exist yet')` and `pageData` remains `undefined` — **tools must handle that case**. +2. Raw strings are converted: numeric fields via `parseIntOrUndefined` (finite integer or `undefined`), `recordDescription` via `safeDecode` (`decodeURIComponent`, falling back to the raw string on a malformed escape such as a stray `%`). +3. If `pageID` parsed as a number, `ToolService` is dynamically imported and `getInstance()` + `getPageData(pageID)` is awaited. +4. Any error fetching page data is swallowed with `console.warn('tool_params.page_data_unavailable', { pageID })` and `pageData` remains `undefined` — **tools must handle that case**. 5. `isNewRecord` / `isEditMode` are pure predicates. ## Usage ### In a tool page (Next.js 16 server component) -From `src/app/(web)/tools/addresslabels/page.tsx:2-11`: +From `src/app/(web)/tools/addresslabels/page.tsx:1-16`: ```typescript -import { parseToolParams } from '@/lib/tool-params'; +import { AddressLabels } from './address-labels'; +import { requireToolAccess } from '@/app/(web)/tools/require-tool-access'; +import { parseToolParams } from '@/lib/tool-params.server'; interface AddressLabelsPageProps { searchParams: Promise<{ [key: string]: string | string[] | undefined }>; } 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 <AddressLabels params={params} />; } ``` @@ -148,22 +158,23 @@ import { ToolParams, isNewRecord, isEditMode } from "@/lib/tool-params"; ``` ## Consumers -Server-side (call `parseToolParams`): +Server-side (call `parseToolParams`, imported from `@/lib/tool-params.server`): +- `src/app/(web)/tools/addeditfamily/page.tsx` - `src/app/(web)/tools/addresslabels/page.tsx` - `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 (`add-edit-family.tsx`, `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`. -- **`recordDescription` is URI-decoded; other strings are not.** Only `recordDescription` passes through `decodeURIComponent`. `q` and `addl` come through raw. -- **Integer parse errors silently produce `NaN` via `parseInt` semantics.** `parseInt('abc', 10)` returns `NaN` (note: `NaN === NaN` is `false` — use `Number.isNaN()` to test). The conditional `x ? parseInt(x, 10) : undefined` still assigns `NaN` back when `x` is a non-empty non-numeric string. Callers that check `typeof pageID === 'number'` see `NaN` (passes, since `typeof NaN === 'number'`), so pre-validation is the caller's responsibility. +- **`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.server.ts:64-78`. +- **`recordDescription` is URI-decoded; other strings are not.** Only `recordDescription` passes through `safeDecode`. `q` and `addl` come through raw. A malformed escape (`50% off`) used to throw `URIError` and crash the tool page; it now comes through raw. +- **Non-numeric integer params become `undefined`, not `NaN`.** `parseIntOrUndefined` returns `undefined` unless the parse is finite (GOTCHA-043). Callers still validate range (e.g. `> 0`) themselves. +- **Never import `parseToolParams` (or any service) into `tool-params.ts`.** A client component importing it would drag `next/headers` into the client graph and fail the Turbopack build; a dynamic `import()` there still creates the graph edge (CLAUDE.md rule 16). - **`searchParams` must be awaited first.** Next.js 16 dynamic APIs (`params`, `searchParams`) are `Promise`s; `await searchParams` in the page, then pass the result to `parseToolParams`. ## Related docs 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 c8e14eb..c29857a 100644 --- a/.env.example +++ b/.env.example @@ -4,25 +4,39 @@ # 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 # ============================================================================= # 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 app's one MinistryPlatform API Client. It is used for server-side data +# access (and is the audit-log user when no userId is provided) AND for user +# sign-in (OIDC): its id is the id_token audience and the sign-out client_id. +# Register it in MP with: +# - redirect URI: <BETTER_AUTH_URL>/api/auth/callback/ministryplatform +# - post-logout redirect URI: <BETTER_AUTH_URL> (exact, no trailing slash) +# Both values are required; the app refuses to start without them. 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 # ----------------------------------------------------------------------------- @@ -70,16 +84,56 @@ 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 # silent (no policy). Set to "false" only to diagnose a violation. CSP_ENFORCE= + +# Optional CSP violation collector (Sentry, report-uri.com, or your own route). +# Unset = no reporting. When set, the policy gains `report-uri` + `report-to` +# and responses carry a matching `Reporting-Endpoints` header. Must be https:// +# (http://localhost is accepted in development only); a value that is not, or +# that contains `;`, `,`, `"` or whitespace, is ignored. When adding a new +# origin, run with CSP_ENFORCE=false (report-only) for one release cycle first. +CSP_REPORT_URI= + +# 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 <file>" + echo "Only .env.example (placeholder values only) may be committed." + exit 1 +fi + +exit 0 diff --git a/.github/SECURITY.md b/.github/SECURITY.md new file mode 100644 index 0000000..16ebf26 --- /dev/null +++ b/.github/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 +<https://github.com/MinistryPlatform-Community/MPNext-Tools/security/advisories/new>. + +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/.github/workflows/test.yml b/.github/workflows/test.yml index 1a14124..25170ac 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -57,6 +57,27 @@ jobs: - name: Test with coverage run: npm run test:coverage + # Catches what tests and tsc cannot: a `server-only` module pulled into + # a client component, Turbopack graph errors, and boot-time env guards. + # The values are placeholders that satisfy `src/lib/env.ts` and the + # secret guard in `src/lib/auth.ts`; the build makes no MP call (OIDC + # discovery is lazy), so nothing here needs to be real or secret. + - name: Build + run: npm run build + env: + BETTER_AUTH_SECRET: ci-build-only-placeholder-secret-0123456789abcdef + BETTER_AUTH_URL: http://localhost:3000 + MINISTRY_PLATFORM_BASE_URL: https://mp.example.invalid/ministryplatformapi + MINISTRY_PLATFORM_CLIENT_ID: ci-build + MINISTRY_PLATFORM_CLIENT_SECRET: ci-build-placeholder + MP_SECURITY_ROLES: "*" + NEXT_PUBLIC_MINISTRY_PLATFORM_FILE_URL: https://mp.example.invalid/ministryplatformapi/files + + # A statically prerendered route has no CSP nonce, so it would never + # hydrate under the enforced policy. See scripts/check-prerender.mjs. + - name: Check prerendered routes + run: npm run check:prerender + - uses: codecov/codecov-action@v5 if: always() with: 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/.vscode/settings.json b/.vscode/settings.json index 224ae1e..73fba2c 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -25,7 +25,8 @@ "statusBar.debuggingBackground": "#1857a4", "statusBar.debuggingForeground": "#e7e7e7", "activityBar.activeBorder": "#e7e7e7", - "activityBarTop.activeBorder": "#e7e7e7" + "activityBarTop.activeBorder": "#e7e7e7", + "statusBar.inactiveBackground": "#1857a4" }, "peacock.color": "#1857a4" } \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md index 5eb1a22..7158454 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6,6 +6,7 @@ This guide provides essential information for AI assistants (like Claude) workin - **Dev**: `npm run dev` (Next.js dev server) - **Build**: `npm run build` (builds SQL install script, then production build with Turbopack + type checking) +- **Prerender check**: `npm run check:prerender` (after a build; fails if any route besides `/_not-found`/`/_global-error` was statically prerendered — such a page has no CSP nonce) - **Lint**: `npm run lint` (ESLint CLI — `next lint` was removed in Next.js 16) - **Type check**: `npm run typecheck` (`tsc --noEmit`; enforced in CI) - **Generate MP Types**: `npm run mp:generate:models` (generates TypeScript types + Zod schemas from Ministry Platform API, cleans output directory first) @@ -18,7 +19,7 @@ This guide provides essential information for AI assistants (like Claude) workin - Generated types automatically quote field names with special characters (e.g., `"Allow_Check-in"`) - The `mp:generate:models` script uses `--clean` flag to remove old files before regenerating -- Manual generation with options: `tsx src/lib/providers/ministry-platform/scripts/generate-types.ts --help` +- Manual generation with options: `tsx --conditions=react-server src/lib/providers/ministry-platform/scripts/generate-types.ts --help` ## Branching & Release @@ -54,12 +55,16 @@ 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) -- **Services Layer**: Singleton service classes in `src/services/` wrap MPHelper for domain logic (ToolService, UserService, AddressLabelService, GroupService, FieldManagementService) + - **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 runs as the app's **one** MP client, `MINISTRY_PLATFORM_CLIENT_ID`/`_SECRET` — the same client as the service account (ADR-027); its MP registration carries the sign-in redirect URIs. 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`, `MINISTRY_PLATFORM_CLIENT_ID`/`_SECRET` (service account **and** sign-in client), `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 and the MP client through `src/lib/env.ts` (`getMpBaseUrl()`, `getAuthBaseUrl()`, `getMpClient()`), 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 (AuthorizationService, DomainTimezoneService, ToolService, UserService, AddressLabelService, GroupService, FamilyService, FieldManagementService, GooglePlacesService) - **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 -- **Validation**: Zod v4 (`zod@^4.3`) — note: different API from Zod v3 (e.g., `z.object()` vs `z.interface()`) +- **Validation**: Zod v4 (`zod@^4.6`) — different API from Zod v3 (e.g., top-level `z.email()`/`z.url()`, `error` instead of `message` for custom messages); check context7 before relying on v3 habits - **Path Alias**: `@/*` maps to `src/*` ## Next.js 16 Notes @@ -102,21 +107,21 @@ to keep them from diverging. ``` src/components/ ├── address-labels/ # Address label printing & mail merge -├── dev-panel/ # Unified dev panel (localhost-only) - params, selection, contact records, user tools +├── dev-panel/ # Unified dev panel (localhost-only) - params, selection, contact records, user tools, deploy tool ├── field-management/ # Drag-and-drop MP page field order editor (PageSearch + FieldOrderEditor) ├── 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 └── feature-name/ # Feature components (kebab-case) ├── feature-name.tsx ├── actions.ts # Feature-specific server actions └── index.ts # Barrel exports ``` +Exception: the Add/Edit Family tool keeps its UI and `actions.ts` co-located in its route folder, `src/app/(web)/tools/addeditfamily/`. + ## Data Flow Server actions in `actions.ts` should call **service classes** (not MPHelper directly): @@ -153,10 +158,10 @@ import { authClient } from '@/lib/auth-client'; const { data: session, isPending } = authClient.useSession(); // Ministry Platform models (generated) -import { Congregation } from '@/lib/providers/ministry-platform/models'; +import { Congregations } from '@/lib/providers/ministry-platform/models'; -// Ministry Platform Zod schemas (for runtime validation) -import { ContactLogSchema } from '@/lib/providers/ministry-platform/models'; +// Ministry Platform Zod schemas (for runtime validation) — not in the models barrel; import the file directly +import { ContactLogSchema } from '@/lib/providers/ministry-platform/models/ContactLogSchema'; // Ministry Platform helper (used by services, not directly by components) import { MPHelper } from '@/lib/providers/ministry-platform'; @@ -188,58 +193,68 @@ export default MyComponent; // ❌ Avoid 12. **Authorize, don't just authenticate** — feature server actions **and** service methods that touch MP data call `AuthorizationService` (`requireSecurityRole`, for **reads** as well as writes), never a bare `auth.api.getSession()` check. MP's OIDC endpoint authenticates *any* `dp_Users` record, and this app reads MP with its own service account, so MP's per-user record security never applies to what it returns — "a session exists" proves nothing. Documented carve-outs, each justified in-file: `layout/auth-wrapper.tsx` (it *is* the session gate), `shared-actions/user.ts` (the user's own profile), `shared-actions/domain.ts` (one domain-wide config string), `dev-panel/panels/require-dev-session.ts` (dev-only, and the services it calls gate anyway). A fifth needs the same justification, in the file, in writing. 13. **Write attribution has exactly one source** — `$userId` comes from the gate's return value, assembled in the **service**, never passed in by an action and never read from a caller-supplied payload. Two layers stamping it can drift, and a caller value can slip past whichever was checked second. 14. **No debug logging in `src/`** — `no-console` is enforced by ESLint (`warn`/`error` only). Errors log **identifiers and shape** (table, IDs, HTTP status), never record content, `$filter` strings, request bodies, or response bodies — including inside thrown error messages, which travel further than logs do. -15. **`src/lib/tool-params.ts` must stay client-safe** — it is imported by client components. Importing a service from it drags `next/headers` into the client graph and fails the Turbopack build. Server-side parsing lives in `tool-params.server.ts`; a dynamic `import()` is *not* sufficient, it still creates a graph edge. -16. **Convert all date/time values at the MP boundary** - use `DomainTimezoneService` (never raw `new Date(x).toISOString()`, `` `${date}T00:00:00Z` ``, or `getFullYear()`) when sending or receiving datetime fields, since MP stores wall-clock values in the domain's time zone, not UTC. See **[Date/Time Handling Reference](.claude/references/ministryplatform.datetimehandling.md)**. +15. **Server modules start with `import 'server-only'`** — every service, `auth.ts`, `env.ts`, `tool-params.server.ts` and the MP client/credentials modules. A client import then fails `next build` instead of shipping server code (or secrets) to the browser. Add it to any new service. Vitest aliases it away; codegen runs `tsx --conditions=react-server` (GOTCHA-046). +16. **`src/lib/tool-params.ts` must stay client-safe** — it is imported by client components. Importing a service from it drags `next/headers` into the client graph and fails the Turbopack build. Server-side parsing lives in `tool-params.server.ts`; a dynamic `import()` is *not* sufficient, it still creates a graph edge. +17. **Convert all date/time values at the MP boundary** - use `DomainTimezoneService` (never raw `new Date(x).toISOString()`, `` `${date}T00:00:00Z` ``, or `getFullYear()`) when sending or receiving datetime fields, since MP stores wall-clock values in the domain's time zone, not UTC. See **[Date/Time Handling Reference](.claude/references/ministryplatform.datetimehandling.md)**. ## Validation Best Practices -When working with Ministry Platform data: +When working with Ministry Platform data — inside a **service** method, with `$userId` taken from the authorization gate (rule 13): ```typescript import { MPHelper } from '@/lib/providers/ministry-platform'; -import { ContactLogSchema } from '@/lib/providers/ministry-platform/models'; +import { ContactLogSchema } from '@/lib/providers/ministry-platform/models/ContactLogSchema'; +import { AuthorizationService } from '@/services/authorizationService'; const mp = new MPHelper(); +// The gate returns the acting MP dp_Users.User_ID — the only valid $userId source +const userId = await AuthorizationService.getInstance().requireSecurityRole({ + table: 'Contact_Log', + operation: 'create', +}); + // ✅ Good: Validate data before creating records await mp.createTableRecords('Contact_Log', records, { schema: ContactLogSchema, - $userId: currentUser.Contact_ID + $userId: userId }); // ✅ Good: Partial validation for updates (default) await mp.updateTableRecords('Contact_Log', partialRecords, { schema: ContactLogSchema, partial: true, // default, allows partial updates - $userId: currentUser.Contact_ID + $userId: userId }); // ✅ Good: Strict validation for full record updates await mp.updateTableRecords('Contact_Log', fullRecords, { schema: ContactLogSchema, partial: false, // require all fields - $userId: currentUser.Contact_ID + $userId: userId }); // ⚠️ Acceptable: Skip validation (backward compatible) await mp.createTableRecords('Contact_Log', records, { - $userId: currentUser.Contact_ID + $userId: userId }); ``` +Generated schemas validate `DateTime`/`Timestamp` columns with a regex that accepts both MP wall-clock (`YYYY-MM-DD HH:MM:SS`, what `DomainTimezoneService.toMpSqlDatetime()` returns) and ISO forms (`T` separator, optional `Z`/offset), so `schema` and rule 17 work together. Import a schema from its own file (`models/ContactLogSchema`) — the `models` barrel re-exports types only. + ## Testing - **Framework**: Vitest with jsdom environment, `@testing-library/react` + `@testing-library/user-event` for hooks/components, v8 coverage - **Counts**: test/file totals live in `.claude/references/_meta/facts/` (bit-rots quickly — trust `vitest run` output over any doc claim) -- **Coverage is enforced**: `vitest.config.mts` sets `thresholds: { statements: 97, lines: 98 }`, checked by the existing `test:coverage` CI job. Branches/functions are deliberately unenforced. Raise the thresholds as coverage rises; never lower them to green a red build. +- **Coverage is enforced**: `vitest.config.mts` sets `thresholds: { statements: 97, lines: 98 }`, checked by the "Test with coverage" step of the CI `test` job. Branches/functions are deliberately unenforced. Raise the thresholds as coverage rises; never lower them to green a red build. - **`coverage.include` is load-bearing**: without `include: ['src/**/*.{ts,tsx}']` the v8 provider reports only files a test imported, so untested files vanish instead of counting as 0%. Two traps: `coverage.all` was **removed in Vitest 5** (setting it is a type error and does nothing), and directory exclusions must end in `**` — a bare `'src/components/ui/'` matches nothing. -- **Config**: `vitest.config.ts` (runner), `src/test-setup.ts` (env vars + jest-dom) +- **Config**: `vitest.config.mts` (runner), `src/test-setup.ts` (env vars + jest-dom) - **jest-dom import**: `src/test-setup.ts` must import `@testing-library/jest-dom/vitest`, **not** the bare `@testing-library/jest-dom`. Since jest-dom v7 only the `/vitest` entry augments Vitest's `expect` types; the bare import registers matchers at runtime, so tests pass while `next build` fails with `Property 'toBeInTheDocument' does not exist`. - **Co-location**: Test files live next to source — `foo.ts` → `foo.test.ts` - **Critical**: Use `vi.hoisted()` for any mock variables referenced inside `vi.mock()` factories (hoisting causes `ReferenceError` otherwise) - **MPHelper mock**: Use mock class (`MPHelper: class { method = mockFn; }`), not `vi.fn().mockImplementation()` - **Singleton reset**: Reset `(ServiceClass as any).instance = undefined` in `beforeEach` to prevent state leakage -- **Server action tests**: Mock `@/lib/auth` (`auth.api.getSession`), `next/headers` (`headers()`), and service singletons +- **Server action tests**: Mock `@/lib/auth` (`auth.api.getSession`), `next/headers` (`headers()`), the `AuthorizationService` gate (`requireSecurityRole`), and service singletons - **CI type-checks**: `npm run typecheck` (`tsc --noEmit`) runs as a required step in the `test` job, alongside `npm run lint`. `tsconfig.json` includes `**/*.ts`/`**/*.tsx`, so a type error in a *test* file breaks `npm run build` — that used to reach `dev` unnoticed because CI ran tests only. - See **[Testing Reference](.claude/references/testing/README.md)** for all mock patterns, coverage data, and test inventory @@ -255,7 +270,7 @@ condition that clears it. Do not re-derive that analysis. - **Node**: pinned to the **Node 24 LTS line** — `engines.node` is `^24.15.0` (the `24.15` floor is jsdom 30's, the strictest dev dependency). `@types/node` - is pinned to the matching major (`^24.13.4`); do not let it drift ahead of the + is pinned to the matching major (`^24.19.2`); do not let it drift ahead of the runtime. `.nvmrc` holds `24` and CI reads it via `node-version-file`. **Vercel** deploys the latest `24.x` for this range (it only offers majors: 24.x/22.x/20.x), so `engines.node` overrides whatever the project's @@ -264,13 +279,15 @@ condition that clears it. Do not re-derive that analysis. with <https://vercel.com/docs/functions/runtimes/node-js/node-js-versions>, then bump `engines.node`, `.nvmrc`, `@types/node`, and `REQUIRED_NODE_MAJOR` in `scripts/setup.ts` together. -- **CI gates lint, types and tests** — `.github/workflows/test.yml` runs - `npm run lint`, `npm run typecheck` and `npm run test:coverage`, in that +- **CI gates lint, types, tests and the build** — `.github/workflows/test.yml` + runs `npm run lint`, `npm run typecheck`, `npm run test:coverage`, + `npm run build` (with placeholder env) and `npm run check:prerender`, in that order, as steps of the single `test` job. They are steps rather than separate jobs on purpose: branch protection requires the check named `test`, so extra jobs would be green-but-unrequired until someone also edited the protection - rules. CI still does not run `npm run build`, but `typecheck` now covers the - part of it that used to break. + rules. The build step is what catches a `server-only` module reaching a + client component (GOTCHA-046); the prerender check catches a route that lost + its dynamic signal and would ship without a CSP nonce (GOTCHA-047). - **CI installs with `npm ci`**, not `npm install` — it installs exactly what `package-lock.json` pins and fails if the lockfile and `package.json` disagree, instead of silently resolving new versions inside CI. @@ -281,6 +298,8 @@ condition that clears it. Do not re-derive that analysis. | Date | Advisories | Highlights | Record | |---|---|---|---| +| 2026-10-10 | 7 → **5** | `next` 16.3.7 → 16.4.0 (6 high: image-optimizer SSRF, ISR cache poisoning, info disclosure); `source-map-js` DoS cleared. Remaining 5 = one `braces` DoS with **no fix published**, lint-time only via `@next/eslint-plugin-next`. 3 holds still blocked; GrapesJS hold retired. | [2026-10-10](.claude/packages/2026-10-10.md) | +| 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 @@ -289,7 +308,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 fa27a1a..8efd05f 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,9 @@ [![Radix UI](https://img.shields.io/badge/Radix_UI-primitives-161618?logo=radixui&logoColor=white)](https://www.radix-ui.com/) [![Lucide](https://img.shields.io/badge/Lucide-icons-F56565?logo=lucide&logoColor=white)](https://lucide.dev/) -A Ministry Platform page tools application powered by Next.js 16, React 19, Better Auth, and a comprehensive Ministry Platform REST API integration with TypeScript and Zod validation. +A Ministry Platform page tools application powered by Next.js 16, React 19, Better Auth, and a Ministry Platform REST API integration with TypeScript and Zod validation. + +MPNext-Tools is a **template**: churches fork or copy it and run their own instance. If you run a copy, read [`.github/SECURITY.md`](.github/SECURITY.md) — fixes made here do not reach your fork automatically. ## Table of Contents @@ -23,13 +25,9 @@ A Ministry Platform page tools application powered by Next.js 16, React 19, Bett - [Quick Setup](#quick-setup) - [Manual Setup](#manual-setup) - [OAuth Client Setup](#oauth-client-setup) + - [Production Deployment](#production-deployment) - [Project Structure](#project-structure) - [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) - [Components](#components) - [Services](#services) @@ -38,20 +36,21 @@ A Ministry Platform page tools application powered by Next.js 16, React 19, Bett - [Claude Code Commands](#claude-code-commands) - [Documentation](#documentation) - [Code Style & Conventions](#code-style--conventions) +- [Security](#security) ## Features -- **Authentication**: Better Auth with Ministry Platform OAuth (genericOAuth plugin), stateless JWT 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 -- **Type Generation**: CLI tool to generate TypeScript interfaces and Zod v4 schemas from MP database schema -- **Validation**: Optional Zod v4 runtime validation in MPHelper before API calls -- **Testing**: Comprehensive Vitest 4.1 suite (see `.claude/references/_meta/facts/` for the authoritative snapshot) -- **Setup Wizard**: Interactive CLI setup with environment configuration, dependency management, and build verification +- **Authentication**: Better Auth with Ministry Platform OAuth/OIDC (genericOAuth plugin), stateless encrypted-cookie sessions, and OIDC RP-initiated logout +- **Authorization**: every MP read and write is gated by MP security role (`MP_SECURITY_ROLES`, fails closed); users without an allowed role land on `/no-access` +- **Tools Framework**: reusable components for building Ministry Platform page tools, with URL parameter parsing and create/edit dual mode +- **Address Labels**: print labels with USPS Intelligent Mail Barcodes (IMb), POSTNET fallback, and Word document mail merge +- **Group Wizard**: six-step create/edit wizard for MP `Groups` records +- **Field Management**: drag-and-drop editor for MP page field order, groups and per-field flags +- **Add/Edit Family**: household editor with contact search, address autocomplete (Google Places) and envelope numbering +- **Type-Safe API**: TypeScript strict mode with model and Zod v4 schema files generated from your MP instance +- **Security hardening**: nonce-based CSP, security headers, boot-time refusal of weak secrets and malformed URLs, sign-in rate limiting +- **Testing**: Vitest 5 suite with enforced coverage thresholds (see [Testing](#testing)) +- **Setup Wizard**: interactive CLI for environment configuration, dependency install, type generation and build verification ## Architecture @@ -60,9 +59,9 @@ A Ministry Platform page tools application powered by Next.js 16, React 19, Bett - **Next.js 16** with App Router and Turbopack (default bundler for dev and build) - **React 19** with Server Components by default - **TypeScript 6.0** in strict mode -- **Tailwind CSS v4** with `@tailwindcss/postcss` plugin +- **Tailwind CSS v4** with the `@tailwindcss/postcss` plugin - **Zod v4** for runtime validation -- **Vitest 4.1** for testing with v8 coverage +- **Vitest 5** with jsdom and v8 coverage ### Ministry Platform Integration @@ -76,20 +75,24 @@ MPHelper (Public API Facade) -> HttpClient (Raw HTTP with Bearer Auth) ``` -- REST API client with automatic OAuth2 client credentials token management (5-minute refresh buffer) -- Type-safe models and Zod validation schemas auto-generated from the MP schema +- REST API client using OAuth2 client credentials; the token is re-fetched every 5 minutes, well inside its 1-hour lifetime +- Type-safe models and Zod validation schemas generated from the MP schema - Six services: Table, Procedure, Communication, File, Metadata, Domain -### Authentication +### Authentication & Authorization -Better Auth with Ministry Platform OAuth via genericOAuth plugin (`src/lib/auth.ts`): +Better Auth with Ministry Platform OAuth via the genericOAuth plugin (`src/lib/auth.ts`): -- **Stateless JWT cookie sessions** with 1-hour cache (no database required) -- **Custom session enrichment** with name splitting via `customSession` plugin +- **One MP API client** (`MINISTRY_PLATFORM_CLIENT_ID`) serves as both the server-side service account and the sign-in client +- **Stateless sessions** (no database): hard 12-hour lifetime, encrypted (JWE) cookie cache, no user OAuth tokens stored, and boot-time refusal of a weak/missing `BETTER_AUTH_SECRET` or malformed base URLs - **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`) -- **Client-side profile loading**: `UserProvider` context fetches full `MPUserProfile` (roles, groups, contact data) on mount -- **Critical**: `session.user.id` is Better Auth's internal ID. Use `session.user.userGuid` for all MP API lookups. +- **Layered protection**: + 1. `src/proxy.ts` — session-cookie check on every non-public route (plus the CSP nonce) + 2. `AuthWrapper` — server-side session validation; redirects to `/signin`, or to `/session-error` for a session with no `userGuid` + 3. `requireToolAccess()` — every tool page checks the caller's MP security role before parsing params or loading data + 4. `AuthorizationService.requireSecurityRole()` — every server action and service method that touches MP data, reads included +- **Client-side profile**: `UserProvider` loads the signed-in user's display profile (name, nickname, email) +- **Critical**: `session.user.id` is Better Auth's internal ID. Use `session.user.userGuid` for all MP lookups. ### Data Flow @@ -101,12 +104,12 @@ Component -> Server Action -> Service (singleton) -> MPHelper -> Ministry Platfo - **Node.js**: **v24.15.0 or later on the 24.x line** (pinned via `engines.node` in `package.json`, `.nvmrc`, and the setup script). Node 24 is the current LTS and the version Vercel deploys; 20.x and 22.x are not supported. Use `nvm use` (or `fnm use`) to pick it up from `.nvmrc`. - **Package Manager**: npm -- **Ministry Platform**: Active instance with API credentials and OAuth client configured -- **Ministry Platform Database**: SQL install script applied (see [Database Setup](#database-setup) below) +- **Ministry Platform**: active instance with an API client configured (see [OAuth Client Setup](#oauth-client-setup)) +- **Ministry Platform Database**: SQL install script applied (see [Database Setup](#database-setup)) ### Database Setup -The repo ships a bundled SQL install script at `_INSTALL/ministryplatform-install.sql`. It contains the stored procedures and DDL that several tools depend on (for example, the Field Management tool requires `api_MPNextTools_GetPages`, `api_MPNextTools_GetPageFields`, and `api_MPNextTools_UpdatePageFieldOrder`). +The repo ships a bundled SQL install script at `_INSTALL/ministryplatform-install.sql`. It contains the stored procedures and DDL several tools depend on (for example, Field Management requires `api_MPNextTools_GetPages`, `api_MPNextTools_GetPageFields`, and `api_MPNextTools_UpdatePageFieldOrder`). Deploy it once per Ministry Platform database (and again whenever the SQL bundle changes): @@ -114,13 +117,13 @@ Deploy it once per Ministry Platform database (and again whenever the SQL bundle 2. Open `_INSTALL/ministryplatform-install.sql` 3. Execute the script against the MP database -The script is regenerated automatically as part of `npm run build` (via `npm run mp:build:install`), bundling everything under `src/lib/providers/ministry-platform/db/` into the single install file. +The script is regenerated automatically as part of `npm run build` (via the same script as `npm run mp:build:install`), bundling everything under `src/lib/providers/ministry-platform/db/` into the single install file. ## Getting Started ### Quick Setup -The interactive setup wizard automates the entire configuration process: +The interactive setup wizard automates the configuration: ```bash git clone <repository-url> @@ -130,13 +133,13 @@ npm run setup ``` The setup wizard will: -1. Verify Node.js version (v20+ required) -2. Detect project origin and configure git -3. Create `.env.local` from `.env.example` (if needed) -4. Prompt for Ministry Platform host and derive API and file URLs automatically -5. Configure Ministry Platform API client credentials -6. Auto-generate `BETTER_AUTH_SECRET` -7. Install and update dependencies +1. Verify the Node.js version (24.x, at least 24.15.0) +2. Detect the project origin (template clone → convert to a fork or reinitialize git) +3. Check git status +4. Create `.env.local` from `.env.example` (if needed) +5. Validate environment variables — prompts for the MP host (deriving `MINISTRY_PLATFORM_BASE_URL` and `NEXT_PUBLIC_MINISTRY_PLATFORM_FILE_URL`), the API client ID/secret, and `MP_SECURITY_ROLES`, and auto-generates `BETTER_AUTH_SECRET` +6. Run `npm install` +7. Run `npm update` 8. Regenerate Ministry Platform types against your MP instance 9. Generate the stored procedure reference (powers Claude Code suggestions) 10. Run a production build to verify configuration @@ -166,53 +169,57 @@ cd MPNext-Tools npm install ``` +`npm install` also runs the `prepare` script, which points git at `.githooks/` — its pre-commit hook refuses to commit any `.env*` file other than `.env.example`. + #### 2. Environment Configuration ```bash cp .env.example .env.local ``` -Update `.env.local` with the required variables: +Set the required variables in `.env.local`. `.env.example` documents every variable inline. ```env -# Generate with: openssl rand -base64 32 +# Generate with: openssl rand -base64 32 (at least 32 characters) BETTER_AUTH_SECRET=your_generated_secret -# Update for production +# An origin only; https:// for every real host (http:// only for localhost) BETTER_AUTH_URL=http://localhost:3000 -# Ministry Platform API Configuration +# The app's one MP API client — service account AND sign-in client MINISTRY_PLATFORM_CLIENT_ID=MPNext MINISTRY_PLATFORM_CLIENT_SECRET=your_client_secret MINISTRY_PLATFORM_BASE_URL=https://your-instance.ministryplatform.com/ministryplatformapi + +# Who may use the tools. FAILS CLOSED: unset/blank = nobody. +# "*" = any MP security role; or a comma list, e.g. Administrators,Staff +MP_SECURITY_ROLES=* ``` -**Optional environment variables** (see `.env.example` for full inline documentation): +**The app refuses to start** (including `next build`) when `BETTER_AUTH_SECRET` is missing, shorter than 32 characters or Better Auth's public default; when `BETTER_AUTH_SECRETS` is set; or when a base URL is malformed or non-https. `NEXTAUTH_SECRET` / `NEXTAUTH_URL` are accepted as fallbacks for migrations from NextAuth. + +**Optional environment variables:** | Variable | Purpose | |----------|---------| -| `MINISTRY_PLATFORM_DEV_CLIENT_ID` | Dev/staging-only MP API client ID. Ignored in production builds. | -| `MINISTRY_PLATFORM_DEV_CLIENT_SECRET` | Dev/staging-only MP API client secret. Ignored in production builds. | -| `GOOGLE_PLACES_API_KEY` | Enables address autocomplete (falls back to MP `dp_Configuration_Settings` → `GoogleMapsAPIKey` first). | -| `NEXT_PUBLIC_MINISTRY_PLATFORM_FILE_URL` | MP file URL — derived from the MP host by the setup wizard. | -| `NEXT_PUBLIC_APP_NAME` | Application display name. Default: `MPNextApp`. | -| `NEXT_PUBLIC_PROD_URL` | Production URL used by the Authorized Tools debug panel for path comparison. | +| `MINISTRY_PLATFORM_DEV_CLIENT_ID` / `_SECRET` | Dev/staging-only MP API client, used only to run `api_dev_*` procedures from the dev panel's Deploy Tool. Ignored in production builds. | +| `GOOGLE_PLACES_API_KEY` | Address autocomplete in Add/Edit Family. The MP `dp_Configuration_Settings` value (`COMMON` / `GoogleMapsAPIKey`) wins; this variable is the fallback. Neither set = plain text input. | +| `NEXT_PUBLIC_MINISTRY_PLATFORM_FILE_URL` | MP file URL — derived from the MP host by the setup wizard; also allowed by the CSP. | +| `NEXT_PUBLIC_PROD_URL` | Production URL used by the dev panel's Authorized Tools check. | +| `CSP_ENFORCE` | The CSP is enforced by default; only the exact string `false` switches to report-only. | +| `CSP_REPORT_URI` | Optional https:// CSP violation collector. | +| `AUTH_IP_ADDRESS_HEADERS` | Client-IP header(s) for sign-in rate limiting. Default on Vercel: `x-vercel-forwarded-for,x-real-ip`. | +| `AUTH_TRUSTED_PROXIES` | Proxy IPs/CIDRs to skip when walking an appended `X-Forwarded-For` chain. | #### 3. Regenerate Ministry Platform Types -The repo ships with pre-generated models under `src/lib/providers/ministry-platform/models/` so a fresh clone can build immediately. Regenerate them against **your own** MP instance to pick up custom tables, columns, and constraints: +The repo ships pre-generated models under `src/lib/providers/ministry-platform/models/` so a fresh clone builds immediately. Regenerate them against **your own** MP instance to pick up custom tables, columns, and constraints: ```bash npm run mp:generate:models ``` -This connects to your Ministry Platform API, fetches all table metadata, and regenerates: -- TypeScript interfaces for each table -- Zod v4 validation schemas for runtime validation -- Schema documentation at `.claude/references/ministryplatform.schema.md` -- Output to `src/lib/providers/ministry-platform/models/` - -Run this any time your MP schema changes. The `--clean` flag in `npm run mp:generate:models` removes stale files before regenerating. +This connects to your MP API, fetches all table metadata, and regenerates TypeScript interfaces and Zod v4 schemas into `src/lib/providers/ministry-platform/models/` (the `--clean` flag removes stale files first). Run it whenever your MP schema changes. Optionally, also refresh the stored procedure reference (used by Claude Code for query suggestions): @@ -220,20 +227,7 @@ Optionally, also refresh the stored procedure reference (used by Claude Code for npm run mp:generate:storedprocs ``` -**Advanced options:** -```bash -# Generate types for specific tables only -npx tsx src/lib/providers/ministry-platform/scripts/generate-types.ts -s "Contact" - -# Generate without Zod schemas -npx tsx src/lib/providers/ministry-platform/scripts/generate-types.ts -o ./types - -# Generate with detailed mode (samples records for better type inference) -npx tsx src/lib/providers/ministry-platform/scripts/generate-types.ts -d --sample-size 10 - -# See all options -npx tsx src/lib/providers/ministry-platform/scripts/generate-types.ts --help -``` +See [Type Generation](#type-generation) for advanced options. #### 4. Run the Development Server @@ -243,231 +237,198 @@ npm run dev 1. Navigate to [http://localhost:3000](http://localhost:3000) 2. You'll be redirected to Ministry Platform login -3. After successful login, you'll see the dashboard with available tools +3. After signing in you'll see the dashboard with the available tools (or `/no-access` if you hold none of the roles in `MP_SECURITY_ROLES`) --- ### OAuth Client Setup -Before running the application, configure an OAuth 2.0 / OIDC client in Ministry Platform. - -Log in as an administrator and navigate to **Administration > API Clients**. +The app uses **one** MP API client for both server-side data access and user sign-in. In Ministry Platform, sign in as an administrator and go to **Administration > API Clients**. #### Basic Settings -- **Client ID**: Your client ID (must match `MINISTRY_PLATFORM_CLIENT_ID` in `.env.local`) -- **Client Secret**: Generate a secure secret +- **Client ID**: must match `MINISTRY_PLATFORM_CLIENT_ID` +- **Client Secret**: generate a secure secret (→ `MINISTRY_PLATFORM_CLIENT_SECRET`) - **Display Name**: MPNext-Tools (or your preferred name) -- **Client User**: Create a scoped user or use API User +- **Client User**: a scoped user (it is the audit-log user for writes made without a signed-in user) #### Redirect URIs (Required) -Separate each entry with a semicolon (`;`): +Separate multiple entries with a semicolon (`;`). The path is `<BETTER_AUTH_URL>/api/auth/callback/ministryplatform`: | Environment | URI | |-------------|-----| -| Development | `http://localhost:3000/api/auth/oauth2/callback/ministry-platform` | -| Production | `https://yourdomain.com/api/auth/oauth2/callback/ministry-platform` | - -> The callback path uses Better Auth's genericOAuth plugin convention: `/api/auth/oauth2/callback/{providerId}`. +| Development | `http://localhost:3000/api/auth/callback/ministryplatform` | +| Production | `https://yourdomain.com/api/auth/callback/ministryplatform` | #### Post-Logout Redirect URIs (Required) +Exactly `<BETTER_AUTH_URL>`, with no trailing slash: + | Environment | URI | |-------------|-----| | Development | `http://localhost:3000` | | Production | `https://yourdomain.com` | -> Post-logout redirect URIs are **required** for proper OIDC logout. Without them, users will be auto-logged back in after signing out (SSO behavior). - -#### Generate Better Auth Secret - -```bash -openssl rand -base64 32 -``` +> Without a post-logout redirect URI, users are signed straight back in after signing out (SSO behavior). --- ### Production Deployment -1. Deploy `_INSTALL/ministryplatform-install.sql` to the production Ministry Platform database (see [Database Setup](#database-setup)) -2. Update `BETTER_AUTH_URL` to your production domain -3. Add production redirect and post-logout redirect URIs to Ministry Platform OAuth client -4. Ensure all environment variables are set in your hosting provider -5. Enable HTTPS/SSL certificates -6. Run `npm run mp:generate:models` to ensure types are current -7. Test the complete authentication flow +1. Deploy `_INSTALL/ministryplatform-install.sql` to the production MP database (see [Database Setup](#database-setup)) +2. Set every required variable in your hosting provider — including `MP_SECURITY_ROLES`, which fails closed — with `BETTER_AUTH_URL` set to your production origin (https) +3. Add the production redirect and post-logout redirect URIs to the MP API client +4. On a proxy other than Vercel, set `AUTH_IP_ADDRESS_HEADERS` (or `AUTH_TRUSTED_PROXIES`) to match it +5. Run `npm run mp:generate:models` so types match your instance +6. Test the complete sign-in, tool access and sign-out flow ## Project Structure ``` MPNext-Tools/ +├── .github/ +│ ├── SECURITY.md # Vulnerability reporting policy +│ └── workflows/test.yml # CI: lint, typecheck, coverage, build, prerender check +├── .githooks/pre-commit # Blocks staged .env* files (except .env.example) +├── _INSTALL/ministryplatform-install.sql # Bundled MP SQL install (generated at build) ├── src/ -│ ├── app/ # Next.js App Router -│ │ ├── (web)/ # Protected route group (AuthWrapper + Providers) -│ │ │ ├── page.tsx # Dashboard with tool cards -│ │ │ ├── home/ # Redirect to / -│ │ │ ├── tools/ # Tools section -│ │ │ │ ├── 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 -│ │ │ └── layout.tsx # Auth + Providers wrapper -│ │ ├── api/auth/[...all]/ # Better Auth route handler -│ │ ├── signin/ # OAuth sign-in page -│ │ ├── layout.tsx # Root HTML layout -│ │ └── providers.tsx # Context providers (UserProvider) -│ │ -│ ├── components/ # React components -│ │ ├── address-labels/ # Address label printing & mail merge -│ │ ├── 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 -│ │ └── dev-panel/ # Unified dev panel (localhost-only) +│ ├── app/ # Next.js App Router +│ │ ├── (web)/ # Protected route group (AuthWrapper + Providers) +│ │ │ ├── layout.tsx +│ │ │ ├── page.tsx # Dashboard with tool cards +│ │ │ ├── error.tsx +│ │ │ ├── no-access/ # Users without an allowed MP security role +│ │ │ └── tools/ +│ │ │ ├── layout.tsx +│ │ │ ├── require-tool-access.ts # Per-page role gate (every tool page calls it first) +│ │ │ ├── addeditfamily/ # Add/Edit Family (UI + actions co-located here) +│ │ │ ├── addresslabels/ # Address label printing +│ │ │ ├── fieldmanagement/ # MP page field layout editor +│ │ │ ├── groupwizard/ # Group create/edit wizard +│ │ │ └── template/ # Template tool (scaffold/demo) +│ │ ├── api/auth/[...all]/ # Better Auth handler (deny-by-default route allowlist) +│ │ ├── auth-error/ # OAuth callback failure page (public) +│ │ ├── session-error/ # Session without userGuid; sign-out escape hatch +│ │ ├── signin/ # OAuth sign-in page +│ │ ├── error.tsx, global-error.tsx # Error boundaries +│ │ ├── layout.tsx # Root HTML layout +│ │ └── providers.tsx # Context providers (UserProvider) │ │ -│ ├── contexts/ # React Context providers -│ │ ├── user-context.tsx # UserProvider (MP profile + roles) -│ │ ├── session-context.tsx # useAppSession() hook -│ │ └── index.ts +│ ├── components/ # See Component Organization below +│ ├── contexts/ # user-context.tsx, use-app-session.ts, index.ts │ │ -│ ├── lib/ # Shared libraries -│ │ ├── auth.ts # Better Auth server config -│ │ ├── auth-client.ts # Better Auth client hooks -│ │ ├── dto/ # Hand-written DTOs/ViewModels -│ │ ├── tool-params.ts # Tool URL parameter parsing -│ │ ├── utils.ts # Tailwind cn() utility -│ │ ├── imb-encoder.ts # USPS Intelligent Mail barcode encoder -│ │ ├── postnet-encoder.ts # POSTNET barcode encoder -│ │ ├── barcode-helpers.ts # Barcode generation utilities -│ │ ├── label-stock.ts # Label stock definitions (Avery 5160, etc.) +│ ├── lib/ +│ │ ├── auth.ts, auth-client.ts # Better Auth server config / client hooks +│ │ ├── env.ts # Validated env access (getMpBaseUrl, getAuthBaseUrl, getMpClient) +│ │ ├── security-headers.ts # CSP + security headers +│ │ ├── tool-params.ts # Client-safe tool URL param types/helpers +│ │ ├── tool-params.server.ts # parseToolParams() (server-only) +│ │ ├── imb-encoder.ts, postnet-encoder.ts, barcode-helpers.ts, barcode-image.ts +│ │ ├── label-stock.ts # Label stock definitions (Avery 5160, etc.) +│ │ ├── constants.ts, utils.ts, validation.ts, zip-guard.ts +│ │ ├── dto/ # Hand-written DTOs (address-label.dto.ts, family.ts) │ │ └── providers/ -│ │ └── ministry-platform/ # Ministry Platform provider -│ │ ├── auth/ # Client credentials OAuth -│ │ ├── services/ # 6 API services -│ │ ├── models/ # Generated types + Zod schemas -│ │ ├── types/ # Type definitions -│ │ ├── utils/ # HttpClient utility -│ │ ├── scripts/ # Type generation CLI -│ │ ├── docs/ # Provider documentation -│ │ ├── client.ts # Core MP client (token management) -│ │ ├── provider.ts # Singleton provider -│ │ ├── helper.ts # Public API (MPHelper) -│ │ └── index.ts # Barrel export +│ │ ├── google-places/ # Google Places API client +│ │ └── ministry-platform/ # MP provider +│ │ ├── auth/ # Client-credentials OAuth +│ │ ├── db/ # SQL stored procedures/DDL (bundled into _INSTALL) +│ │ ├── docs/ # Provider documentation +│ │ ├── models/ # Generated types + Zod schemas +│ │ ├── scripts/ # Type/proc generators, SQL bundler +│ │ ├── services/ # 6 API services +│ │ ├── types/, utils/ # Type definitions, HttpClient +│ │ └── client.ts, provider.ts, helper.ts, index.ts │ │ -│ ├── services/ # Application services (singletons) -│ │ ├── addressLabelService.ts # Address fetching & label data -│ │ ├── toolService.ts # Page data and user permissions -│ │ └── userService.ts # User profile with roles/groups -│ │ -│ ├── proxy.ts # Route protection (session cookie check) -│ └── test-setup.ts # Vitest environment setup +│ ├── services/ # 9 application services (singletons, server-only) +│ ├── proxy.ts # Route protection (session cookie check + CSP nonce) +│ └── test-setup.ts # Vitest environment setup │ ├── scripts/ -│ └── setup.ts # Interactive setup wizard +│ ├── setup.ts # Interactive setup wizard +│ └── check-prerender.mjs # Fails if a route was statically prerendered (no CSP nonce) │ -├── .claude/ # Claude Code configuration -│ ├── commands/ # 5 Claude Code skills -│ └── references/ # 6 reference documents +├── .claude/ # Claude Code configuration +│ ├── commands/ # 4 Claude Code commands +│ ├── references/ # Agent-facing reference docs (start at INDEX.md) +│ ├── packages/ # Dependency audit records +│ ├── playbooks/ # Porting guides for downstream forks +│ └── TODO/ # Known open issues │ -├── .env.example # Environment template -├── CLAUDE.md # Development guide -├── vitest.config.ts # Vitest configuration -├── components.json # shadcn/ui config (new-york style, slate theme) -├── next.config.ts # Next.js configuration -├── tsconfig.json # TypeScript strict mode, @/* alias -└── package.json # Dependencies and scripts +├── .env.example # Environment template (every variable documented) +├── CLAUDE.md # Development guide +├── vitest.config.mts # Vitest configuration (coverage thresholds) +├── components.json # shadcn/ui config (new-york style, slate theme) +├── next.config.ts # Next.js configuration +├── tsconfig.json # TypeScript strict mode, @/* alias +└── package.json # Dependencies and scripts ``` ## Tools -The application provides a dashboard at `/` with cards linking to each available tool. All tools are protected behind Ministry Platform OAuth authentication. +The dashboard at `/` shows a card for each tool. All tools require a Ministry Platform sign-in **and** an MP security role allowed by `MP_SECURITY_ROLES`. + +| Route | Tool | +|-------|------| +| `/tools/addresslabels` | Address Labels | +| `/tools/groupwizard` | Group Wizard | +| `/tools/fieldmanagement` | Field Management | +| `/tools/addeditfamily` | Add/Edit Family | +| `/tools/template` | Template (reference implementation) | ### Address Labels **Route**: `/tools/addresslabels` -A tool for printing address labels with USPS Intelligent Mail Barcodes (IMb) and optional Word document mail merge. +Prints address labels with USPS Intelligent Mail Barcodes (IMb) and optional Word document mail merge. -**Features:** -- Fetch addresses from Ministry Platform selections or saved selections -- Generate USPS Intelligent Mail Barcodes (IMb) with configurable Mailer ID -- POSTNET barcode fallback for simpler addressing -- Print-ready label sheets (Avery 5160 and other stock formats) +- Fetch addresses from Ministry Platform selections or the current record +- USPS Intelligent Mail Barcodes (IMb) with configurable Mailer ID, POSTNET fallback +- Print-ready label sheets (Avery 5160 and other stock formats) as PDF - Word document mail merge with `.docx` template support - Address validation and skip reporting -- Configurable label formats and barcode options - -**Components** (`src/components/address-labels/`): -- `AddressLabelsForm` — Configuration form (address source, barcode format, label stock) -- `AddressLabelsSummary` — Results summary with skip reporting -- `LabelDocument` — Print-ready label sheet layout -- `AddressLabel` — Individual label rendering -- `ImbBarcode` — USPS Intelligent Mail barcode SVG -- `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 + +**Components** (`src/components/address-labels/`): `AddressLabelsForm`, `AddressLabelsSummary`, `LabelDocument`, `AddressLabel`, `ImbBarcode`, `PostnetBarcode`, `MailMergeTab`. + +### Group Wizard + +**Route**: `/tools/groupwizard` + +A six-step create/edit wizard for MP `Groups` records — identity, organization, meeting, attributes, settings, review — with per-step React Hook Form + Zod validation and contact/group search comboboxes. Opened from a Groups record it edits that group; otherwise it creates one. + +**Components** (`src/components/group-wizard/`): `StepIdentity`, `StepOrganization`, `StepMeeting`, `StepAttributes`, `StepSettings`, `StepReview`, `WizardStepper`, `WizardNavigation`, `ContactSearch`, `GroupSearch`. ### Field Management **Route**: `/tools/fieldmanagement` -A drag-and-drop editor for configuring Ministry Platform page field layout — reorder fields, assign groups, and toggle per-field flags (required, hidden, writing-assistant) without touching the MP UI. +A drag-and-drop editor for Ministry Platform page field layout — reorder fields, assign groups, and toggle per-field flags (required, hidden, writing-assistant) without touching the MP UI. -**Features:** - Searchable page picker sourced from `dp_Pages` - Drag-and-drop reordering within and across field groups - Inline editing for label, default value, filter clause, depends-on field, and writing-assistant toggle - Create new groups on the fly -- Auto-merges table columns not yet present in `dp_Page_Fields` so new columns can be added to a page layout +- Auto-merges table columns not yet present in `dp_Page_Fields` + +**Components** (`src/components/field-management/`): `PageSearch`, `FieldOrderEditor`, `SortableGroup`, `SortableFieldItem`, `NewGroupDialog`, `useFieldOrderState`. -**Components** (`src/components/field-management/`): -- `PageSearch` — Searchable page picker -- `FieldOrderEditor` — Main drag-and-drop editor surface -- `SortableGroup` — Droppable group container -- `SortableFieldItem` — Draggable field row with inline flag editors -- `NewGroupDialog` — Create a new field group -- `useFieldOrderState` — State hook for field order/edits +**Stored procedures**: `api_MPNextTools_GetPages`, `api_MPNextTools_GetPageFields`, `api_MPNextTools_UpdatePageFieldOrder`. -**Stored procedures:** -- `api_MPNextTools_GetPages` — List configurable pages -- `api_MPNextTools_GetPageFields` — Fetch current `dp_Page_Fields` rows for a page -- `api_MPNextTools_UpdatePageFieldOrder` — Upsert field order and per-field flags +### Add/Edit Family + +**Route**: `/tools/addeditfamily` + +A household editor: search for a contact (or open from a record), then edit the household, its address (with Google Places autocomplete when configured) and its members, and save. Assigns a unique envelope number when needed. Its UI and server actions live in the route folder, `src/app/(web)/tools/addeditfamily/`. ### Template Tool **Route**: `/tools/template` -A reference implementation demonstrating the tool framework pattern. Use this as a starting point for building new tools. +A reference implementation of the tool framework pattern. Use it as a starting point for new tools. -**Structure:** ``` src/app/(web)/tools/template/ -├── page.tsx # Server component — parses URL params via parseToolParams() +├── page.tsx # Server component — requireToolAccess(), then parseToolParams() +├── loading.tsx # Loading state └── template-tool.tsx # Client component — renders inside ToolContainer ``` @@ -475,40 +436,39 @@ src/app/(web)/tools/template/ All tools follow a consistent pattern: -1. **Server component** (`page.tsx`) parses URL search params and fetches page metadata +1. **Server component** (`page.tsx`) calls `requireToolAccess(...)` **first**, then parses URL search params with `parseToolParams()` and fetches page metadata 2. **Client component** renders the UI inside `ToolContainer` with header/footer actions -3. **Server actions** (`actions.ts`) call service singletons for MP operations -4. Tools receive standard MP parameters: `pageID`, `s` (selection), `recordID`, `recordDescription` +3. **Server actions** (`actions.ts`) call service singletons, which gate every MP call with `AuthorizationService` +4. Tools receive standard MP parameters: `pageID`, `s` (selection), `sc`, `p`, `q`, `v`, `recordID`, `recordDescription`, `addl` -**Quick start:** +**Quick start** — use the Claude Code command to scaffold a new tool: ```bash -# Use the Claude Code command to scaffold a new tool /newtool MyToolName ``` -Or manually: -1. Copy the `template` folder to create your new tool directory -2. Rename files and components -3. Implement your logic inside the `ToolContainer` +Or manually: copy the `template` folder, rename files and components, and implement your logic inside `ToolContainer`. -**URL Parameter Utilities** (`src/lib/tool-params.ts`): -- `parseToolParams(searchParams)` — Parses and fetches page metadata -- `isNewRecord(params)` — Returns true if `recordID === -1` or undefined -- `isEditMode(params)` — Returns true if editing an existing record +**URL parameter utilities**: +- `parseToolParams(searchParams)` (`src/lib/tool-params.server.ts`) — parses params and fetches page metadata +- `isNewRecord(params)` / `isEditMode(params)` (`src/lib/tool-params.ts`, client-safe) — create vs. edit mode ## Ministry Platform Integration ### MPHelper — Public API -The main entry point for interacting with Ministry Platform: +`MPHelper` is the entry point to the MP REST API. Application code calls it from **service classes** in `src/services/`, never from components or server actions, and every call is preceded by the authorization gate: ```typescript import { MPHelper } from '@/lib/providers/ministry-platform'; -import { ContactLogSchema } from '@/lib/providers/ministry-platform/models'; +import { ContactLogSchema } from '@/lib/providers/ministry-platform/models/ContactLogSchema'; +import { AuthorizationService } from '@/services/authorizationService'; +import { DomainTimezoneService } from '@/services/domainTimezoneService'; const mp = new MPHelper(); +const authz = AuthorizationService.getInstance(); -// Get records with query parameters +// Read — gated like a write +await authz.requireSecurityRole({ table: 'Contacts', operation: 'read' }); const contacts = await mp.getTableRecords({ table: 'Contacts', filter: 'Contact_Status_ID=1', @@ -517,21 +477,24 @@ const contacts = await mp.getTableRecords({ top: 50 }); -// Create records with Zod validation (recommended) +// Create — $userId comes from the gate, datetimes from DomainTimezoneService. +// The generated schema accepts MP wall-clock datetimes (`YYYY-MM-DD HH:MM:SS`). +const userId = await authz.requireSecurityRole({ table: 'Contact_Log', operation: 'create' }); await mp.createTableRecords('Contact_Log', [{ Contact_ID: 12345, - Contact_Date: new Date().toISOString(), - Made_By: 1, + Contact_Date: await DomainTimezoneService.getInstance().toMpSqlDatetime(new Date()), + Made_By: userId, Notes: 'Follow-up call completed' }], { schema: ContactLogSchema, - $userId: 1 + $userId: userId }); -// Update with partial validation (default) -await mp.updateTableRecords('Contact_Log', records, { +// Partial update with Zod validation +await mp.updateTableRecords('Contact_Log', [{ Contact_Log_ID: 67890, Notes: 'Updated' }], { schema: ContactLogSchema, - partial: true + partial: true, + $userId: userId }); // Execute stored procedures @@ -541,7 +504,7 @@ const results = await mp.executeProcedureWithBody('api_Custom_Procedure', { // File operations const files = await mp.getFilesByRecord({ - tableName: 'Contacts', + table: 'Contacts', recordId: 12345 }); ``` @@ -553,25 +516,30 @@ const files = await mp.getFilesByRecord({ | **Table** | CRUD operations | `getTableRecords`, `createTableRecords`, `updateTableRecords`, `deleteTableRecords` | | **Procedure** | Stored procedures | `getProcedures`, `executeProcedure`, `executeProcedureWithBody` | | **Communication** | Email/SMS with attachments | `createCommunication`, `sendMessage` | -| **File** | File management | `getFilesByRecord`, `uploadFiles`, `updateFile`, `deleteFile`, `getFileContentByUniqueId` | +| **File** | File management | `getFilesByRecord`, `uploadFiles`, `updateFile`, `deleteFile`, `getFileContentByUniqueId`, `getFileMetadata` | | **Metadata** | Schema info | `getTables`, `refreshMetadata` | | **Domain** | Domain config | `getDomainInfo`, `getGlobalFilters` | -### Type Generation +MPHelper also exposes `copyRecord`, `copyRecordWithSubpages` and `generateSequence`. See the [provider docs](src/lib/providers/ministry-platform/docs/README.md). -Generate TypeScript interfaces and Zod schemas from your Ministry Platform database: +### Type Generation ```bash -# Generate all types with Zod schemas (recommended) +# Generate all types with Zod schemas into models/ (recommended) npm run mp:generate:models # Generate for specific tables -npx tsx src/lib/providers/ministry-platform/scripts/generate-types.ts -s "Contact" +npm run mp:generate -- -s "Contact" + +# Generate with detailed mode (samples records for better type inference) +npm run mp:generate -- -d --sample-size 10 # See all options -npx tsx src/lib/providers/ministry-platform/scripts/generate-types.ts --help +npm run mp:generate -- --help ``` +> **Running the generators directly?** Pass `--conditions=react-server` to `tsx` (e.g. `npx tsx --conditions=react-server src/lib/providers/ministry-platform/scripts/generate-types.ts --help`). The MP client modules start with `import 'server-only'`, which throws under plain Node without that condition. The `npm run mp:generate*` scripts already include it. + **CLI Options:** | Flag | Description | Default | |------|-------------|---------| @@ -582,107 +550,70 @@ npx tsx src/lib/providers/ministry-platform/scripts/generate-types.ts --help | `-d, --detailed` | Sample records for type inference | `false` | | `--sample-size <num>` | Records to sample | `5` | -**Generated output** (for `mp:generate:models`): -- TypeScript interfaces for every MP table with JSDoc comments, FK annotations, and type constraints -- Matching Zod validation schemas with email/URL/length validators -- A barrel export `index.ts` -- Schema documentation at `.claude/references/ministryplatform.schema.md` +**Generated output** (for `mp:generate:models`): one TypeScript interface per MP table (JSDoc, FK annotations, constraints), a matching `<Table>Schema.ts` Zod schema per table, and a barrel `index.ts` that re-exports the **types** — import a schema from its own file, e.g. `@/lib/providers/ministry-platform/models/ContactLogSchema`. ## Components ### UI Components (`src/components/ui/`) -22 shadcn/ui components built with Radix UI primitives and Tailwind CSS v4 (new-york style, slate theme, lucide icons): - -Alert, Alert Dialog, Avatar, Badge, Breadcrumb, Button, Card, Checkbox, Command, Dialog, Drawer, Dropdown Menu, Form, Input, Label, Popover, Radio Group, Select, Skeleton, Switch, Textarea, Tooltip - -### Layout Components (`src/components/layout/`) - -- **AuthWrapper** — Server component that validates session via `auth.api.getSession()` and redirects to `/signin` if unauthenticated +22 shadcn/ui components built on Radix UI primitives and Tailwind CSS v4 (new-york style, slate theme, lucide icons). -### Tool Framework (`src/components/tool/`) +### Layout & Tool Framework -- **ToolContainer** — Full-screen flex layout with header, scrollable content, and footer actions. Automatically renders `DevPanel` above the title bar on localhost (NODE_ENV=development only). -- **ToolHeader** — Dark slate title bar with optional info tooltip +- **AuthWrapper** (`layout/`) — server component; validates the session via `auth.api.getSession()`, redirects to `/signin` if unauthenticated and to `/session-error` if the session has no `userGuid` +- **ToolContainer** (`tool/`) — full-screen layout with header, scrollable content, and footer actions. Renders `DevPanel` above the title bar on localhost in development only. +- **ToolHeader** — dark slate title bar with optional info tooltip - **ToolFooter** — Close/Save action buttons with loading state ### Feature Components -- **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) +- **address-labels/** — label printing, IMb/POSTNET barcodes, PDF/DOCX output, mail merge +- **group-wizard/** — six-step group create/edit wizard +- **field-management/** — drag-and-drop MP page field order editor +- **dev-panel/** — localhost-only developer overlay: parsed URL params, MP selection data, contact records, authorized tools, deploy tool +- **shared-actions/** — cross-feature server actions: `getCurrentUserProfile()` (the *calling* user's profile; the `User_GUID` comes from the session, never a parameter), domain config, and `handleSignOut()` (app sign-out plus MP RP-initiated logout, used by the sign-out forms on `/no-access` and `/session-error`) -All components use kebab-case file naming, PascalCase component names, and named exports with barrel index files. +All components use kebab-case file names, PascalCase component names, and named exports with barrel `index.ts` files. ## Services -Application services provide business logic abstraction with the singleton pattern: +Application services in `src/services/` are server-only singletons that wrap MPHelper: -| Service | File | Purpose | Key Methods | -|---------|------|---------|-------------| -| **UserService** | `userService.ts` | User profile with roles and groups | `getUserProfile(userGuid)` — parallel queries for user data, roles, and group memberships | -| **ToolService** | `toolService.ts` | Page metadata and permissions | `getPageData(pageID)`, `getUserTools(domainId, userId)` — via stored procedures | -| **AddressLabelService** | `addressLabelService.ts` | Address fetching and label data | Address queries from MP selections and saved selections for label printing | - -**Data flow:** -``` -Server Action -> Service.getInstance() -> MPHelper -> Ministry Platform API -``` - -**DTOs** (`src/lib/dto/`): Hand-written application-level types including `LabelData`, `LabelConfig`, `SkipRecord`, `AddressMode`, `BarcodeFormat`, and more. +| Service | Purpose | Key Methods | +|---------|---------|-------------| +| **AuthorizationService** | Role gate for all MP data access (`MP_SECURITY_ROLES`, fails closed); returns the acting MP `User_ID` for write attribution | `requireSecurityRole`, `hasSecurityRole` | +| **DomainTimezoneService** | MP domain time zone and wall-clock ↔ instant conversion at the MP boundary | `getMpTimezone`, `toMpSqlDatetime`, `parseMpDatetime` | +| **ToolService** | Page metadata, selections, the user's authorized tools, dev-tool deploy | `getPageData`, `getSelectionRecordIds`, `getUserTools`, `resolveContactIds`, `listPages`, `listRoles`, `deployTool` | +| **UserService** | Signed-in user's display profile by `User_GUID` | `getUserProfile` | +| **AddressLabelService** | Mailing addresses for contacts | `getAddressesForContacts`, `getAddressForContact` | +| **GroupService** | Group Wizard lookups, search, read/create/update | `fetchAllLookups`, `searchContacts`, `searchGroups`, `getGroup`, `createGroup`, `updateGroup` | +| **FieldManagementService** | MP pages, page fields, table metadata, field-order saves | `getPages`, `getPageFields`, `getTableMetadata`, `updatePageFieldOrder` | +| **FamilyService** | Add/Edit Family: contact search, household load/save, lookups, envelope numbers | `searchContacts`, `resolveContactIdFromPage`, `getHousehold`, `getLookups`, `getDefaults`, `getNextEnvelopeNumber`, `saveHousehold` | +| **GooglePlacesService** | Address autocomplete (key from MP config, else env) | `isEnabled`, `autocomplete`, `getPlaceDetails` | + +**DTOs** (`src/lib/dto/`): hand-written application types — address labels (`LabelData`, `LabelConfig`, `SkipRecord`, …) and family (`Household` and related schemas). ## Testing -### Overview - -- **Framework**: Vitest 4.1 with jsdom environment -- **Libraries**: @testing-library/react, @testing-library/jest-dom -- **Coverage**: v8 provider -- **Total**: **621 test cases across 43 test files** (authoritative snapshot: `.claude/references/_meta/facts/`) - -### Running Tests +- **Framework**: Vitest 5 with jsdom +- **Libraries**: `@testing-library/react`, `@testing-library/user-event`, `@testing-library/jest-dom` +- **Coverage**: v8 provider, **enforced** thresholds (statements 97%, lines 98%) in `vitest.config.mts` +- **Co-location**: `foo.ts` → `foo.test.ts`, next to the source +- **Counts**: test totals drift quickly — trust `npm run test:run`; the latest snapshot lives in `.claude/references/_meta/facts/` ```bash npm test # Watch mode npm run test:run # Single run -npm run test:coverage # With coverage report +npm run test:coverage # With coverage report (what CI runs) ``` -### Test Coverage - -| Area | Test File | What's Covered | -|------|-----------|----------------| -| MPHelper | `helper.test.ts` | All CRUD operations, Zod validation, domain/metadata/procedure/file methods | -| HTTP Client | `http-client.test.ts` | URL building, GET/POST/PUT/DELETE, FormData uploads, error handling | -| Table Service | `table.service.test.ts` | Table CRUD with query params, error codes (404, 401, 409) | -| Address Label Service | `addressLabelService.test.ts` | Address fetching, label data generation | -| Address Label Actions | `actions.test.ts` | Address label server actions | -| MP Client | `client.test.ts` | OAuth token lifecycle, refresh, concurrent calls, expiry buffer | -| Auth | `auth.test.ts` | Custom session, name splitting, OAuth config, userGuid mapping | -| Provider | `provider.test.ts` | Singleton pattern, service delegation | -| Proxy | `proxy.test.ts` | Route protection, public paths, session cookie checks | -| Tool Service | `toolService.test.ts` | Page data, user tools, stored procedure calls | -| Selection Panel | `selection-actions.test.ts` | Selection resolution server actions | -| User Context | `user-context.test.tsx` | UserProvider lifecycle, profile loading, error handling | -| User Service | `userService.test.ts` | Profile with roles/groups, parallel queries | -| User Tools Panel | `user-tools-actions.test.ts` | Authorization checks, session validation | -| User Menu | `actions.test.ts` | Sign-out action, OIDC logout redirect | -| Shared Actions | `user.test.ts` | getCurrentUserProfile session-derived GUID, unauthorized paths, IDOR regression | -| Session Context | `session-context.test.tsx` | useAppSession hook wrapper | -| IMb Encoder | `imb-encoder.test.ts` | USPS Intelligent Mail barcode encoding | -| POSTNET Encoder | `postnet-encoder.test.ts` | POSTNET barcode encoding | -| Barcode Helpers | `barcode-helpers.test.ts` | Barcode generation utilities | -| Label Stock | `label-stock.test.ts` | Label stock definitions and calculations | - -### Key Testing Patterns +Per-file coverage and the full test inventory are in the [Testing Reference](.claude/references/testing/README.md). +**Key testing patterns**: - **`vi.hoisted()`** for mock variables referenced inside `vi.mock()` factories -- **MPHelper mock**: Use mock class syntax, not `vi.fn().mockImplementation()` +- **MPHelper mock**: use mock class syntax, not `vi.fn().mockImplementation()` - **Singleton reset**: `(ServiceClass as any).instance = undefined` in `beforeEach` -- **Server action mocks**: Mock `@/lib/auth`, `next/headers`, and service singletons +- **Server action mocks**: mock `@/lib/auth`, `next/headers`, and service singletons - **Fake timers**: `vi.useFakeTimers()` for token expiry testing ## Development @@ -692,178 +623,106 @@ npm run test:coverage # With coverage report ```bash # Development npm run dev # Start dev server (Turbopack) -npm run build # Production build (includes type checking) +npm run build # Bundle SQL install script, then production build (with type checking) npm start # Start production server +npm run check:prerender # After a build: fail if a route was statically prerendered (no CSP nonce) # Code Quality npm run lint # ESLint (native flat config) +npm run typecheck # tsc --noEmit npm test # Vitest watch mode npm run test:run # Single test run npm run test:coverage # Coverage report # Ministry Platform npm run mp:generate:models # Generate types + Zod schemas to models/ (recommended) +npm run mp:generate # Type generator with your own flags (-- --help) npm run mp:generate:storedprocs # Generate stored procedure reference +npm run mp:build:install # Bundle db/ SQL into _INSTALL/ministryplatform-install.sql # Setup npm run setup # Interactive setup wizard npm run setup:check # Validate setup without changes ``` +CI (`.github/workflows/test.yml`) runs lint, typecheck, tests with coverage, the build, and the prerender check as steps of a single required `test` job. + +### Branching + +`dev` is the default branch: branch from it and open PRs against it (squash-merged). `main` is production and only receives `dev` through a release PR (merge commit, never squash). See [CLAUDE.md § Branching & Release](CLAUDE.md#branching--release). + ### Key Dependencies | Package | Version | Purpose | |---------|---------|---------| -| next | ^16.1.7 | App Router, Turbopack, React Server Components | -| react | ^19.2.4 | UI framework | -| better-auth | ^1.5.5 | OAuth authentication with genericOAuth plugin | -| zod | ^4.3.6 | Runtime validation (v4 API) | -| react-hook-form | ^7.71.1 | Form state management | +| next | ^16.4.0 | App Router, Turbopack, React Server Components | +| react | ^19.3.0 | UI framework | +| better-auth | ^1.7.7 | OAuth authentication with genericOAuth plugin | +| jose | ^6.2.12 | id_token (JWT) verification | +| zod | ^4.6.5 | Runtime validation (v4 API) | +| react-hook-form | ^7.89.0 | Form state management | | tailwindcss | ^4.2.0 | Utility-first CSS | -| lucide-react | ^1.8.0 | Icon library | -| vitest | ^4.1.0 | Test framework | -| typescript | ^6.0.2 | Type safety | +| lucide-react | ^1.55.0 | Icon library | +| @react-pdf/renderer | ^4.9.0 | Label sheet PDFs | +| docx / docxtemplater | ^9.9.0 / ^3.71.0 | Word document output and mail merge | +| @dnd-kit/react | ^0.5.0 | Drag and drop (Field Management) | +| vitest | ^5.0.0 | Test framework | +| typescript | ^6.0.3 | Type safety | + +Dependency audits and known version holds are recorded in [`.claude/packages/`](.claude/packages/). ## Claude Code Commands -This project includes 5 custom [Claude Code](https://claude.ai/code) commands to streamline development: +This project includes 4 custom [Claude Code](https://claude.ai/code) commands in `.claude/commands/`: | Command | Description | |---------|-------------| -| `/audit-deps` | Security audit + dependency update analysis with CVE checks | -| `/branch-commit [args]` | Create branch and commit, optionally from GitHub issue | -| `/newtool [name]` | Scaffold a new tool with route, component, and homepage card | -| `/pr [args]` | Create a validated pull request with auto-detected issue links | -| `/release [args]` | Create a GitHub release with calver versioning and auto-generated changelog | - -### `/audit-deps` - -Runs `npm audit`, searches for CVEs affecting major dependencies, categorizes updates as safe (patch/minor) vs major, and generates a prioritized action plan. - -### `/branch-commit` - -```bash -/branch-commit # Prompts for branch name and message -/branch-commit #123 # Auto-generates from GitHub issue -/branch-commit feature/my-change: Add new feature -``` - -Branch naming: `fix/issue-<id>-<slug>` for bugs, `feature/issue-<id>-<slug>` for features. - -### `/newtool` - -```bash -/newtool TeamRoster # Creates route, component, and homepage card -/newtool EventCheckin, BatchEmail # Multiple tools at once -``` - -Generates: route page, client component with ToolContainer, and a card on the dashboard. - -### `/pr` - -```bash -/pr # Create PR to main -/pr --base dev # Target dev branch -/pr --draft # Draft PR -/pr #123 # Link to issue -``` - -Validates: not on main, no uncommitted changes, branch pushed, no duplicate PR. - -### `/release` - -```bash -/release # Auto-detect version, generate changelog -/release --draft # Create as draft release -/release --prerelease # Mark as pre-release -``` - -Uses calver format: `v{YYYY}.{MM}.{DD}.{HHmm}`. Categorizes PRs as features, fixes, docs, or maintenance. - -### Command Files - -``` -.claude/commands/ -├── audit-deps.md -├── branch-commit.md -├── newtool.md -├── pr.md -└── release.md -``` +| `/newtool <name>[, <name>…]` | Scaffold one or more tools: a self-gating route page, a client component in `ToolContainer`, and a dashboard card | +| `/newstoredproc <name>` | Scaffold an `api_*` stored procedure (with `@DomainID INT` as the first parameter) and add it to the SQL install bundle | +| `/release` | Promote `dev` → `main` with a merge commit, then tag `main` with calver `vYYYY.MM.DD.HHmm` and publish a GitHub release (`--draft`, `--prerelease`, `--tag`, `--target`, `--since`) | +| `/update-deps` | Apply in-range updates, evaluate each major separately, sweep OSV.dev for advisories, and write a record to `.claude/packages/` (`--security-only`, `--check`) | ## Documentation | Document | Description | |----------|-------------| -| **[CLAUDE.md](CLAUDE.md)** | Development guide — commands, architecture, code style, testing patterns | -| **[Ministry Platform Provider](src/lib/providers/ministry-platform/docs/README.md)** | Complete REST API client documentation | -| **[Type Generator](src/lib/providers/ministry-platform/scripts/README.md)** | CLI tool for generating TypeScript types from MP schema | -| **[Auth Reference](.claude/references/auth/README.md)** | Better Auth config, OAuth flow, session access, userGuid vs user.id | -| **[Components Reference](.claude/references/components/README.md)** | Component inventory with compliance status | -| **[Services Reference](.claude/references/services/README.md)** | Service layer docs, MP query patterns, DTOs | +| **[CLAUDE.md](CLAUDE.md)** | Development guide — commands, architecture, rules, testing patterns | +| **[Security Policy](.github/SECURITY.md)** | How to report a vulnerability; required steps for downstream forks | +| **[Reference Index](.claude/references/INDEX.md)** | Entry point to all agent-facing reference docs | +| **[Ministry Platform Provider](src/lib/providers/ministry-platform/docs/README.md)** | REST API client documentation | +| **[Type Generator](src/lib/providers/ministry-platform/scripts/README.md)** | CLI for generating TypeScript types from the MP schema | +| **[Auth Reference](.claude/references/auth/README.md)** | Better Auth config, OAuth flow, sessions, userGuid vs user.id | +| **[Security Reference](.claude/references/security/README.md)** | Authorization model, write attribution, logging rules, headers/CSP | +| **[Components Reference](.claude/references/components/README.md)** | Component inventory | +| **[Services Reference](.claude/references/services/README.md)** | Service layer, MP query patterns | | **[Testing Reference](.claude/references/testing/README.md)** | Vitest patterns, mock setup, coverage data | -| **[MP Schema Reference](.claude/references/ministryplatform.schema.md)** | Auto-generated database schema | -| **[MP Stored Procedures](.claude/references/ministryplatform.storedprocs.md)** | Auto-generated stored procedure reference | +| **[Decisions](.claude/references/DECISIONS.md)** / **[Gotchas](.claude/references/GOTCHAS.md)** | Architectural decisions and known traps | +| **[MP Schema Reference](.claude/references/mp-schema/README.md)** | Generated table and stored procedure catalogs | ## Code Style & Conventions -### Import Paths +See [CLAUDE.md](CLAUDE.md) for the full rules. The essentials: -Use the `@/*` path alias for all internal imports: -```typescript -import { MPHelper } from '@/lib/providers/ministry-platform'; -import { Button } from '@/components/ui/button'; -import { AuthWrapper } from '@/components/layout'; -import { ToolContainer } from '@/components/tool'; -import { useUser, useAppSession } from '@/contexts'; -``` - -### Naming Conventions - -| Convention | Usage | -|------------|-------| -| **PascalCase** | Components, types, interfaces | -| **camelCase** | Functions, variables | -| **kebab-case** | All files and folders | -| **snake_case** | Ministry Platform API fields | - -### Component Conventions - -- React Server Components by default; `"use client"` only when needed -- Named exports only (no default exports) -- Feature components organized in kebab-case folders with `index.ts` barrel exports -- Server actions co-located as `actions.ts` in feature folders -- Shared actions in `src/components/shared-actions/` - -### Component Organization - -``` -src/components/ -├── address-labels/ # Address label printing & mail merge (12 files) -├── 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 -└── dev-panel/ # Unified dev panel (localhost-only) -``` +- **`@/*` path alias** for all internal imports +- **Naming**: PascalCase for components/types, camelCase for functions/variables, kebab-case for all files and folders, snake_case for Ministry Platform API fields +- **React Server Components by default**; `"use client"` only when needed +- **Named exports only** (no default exports, except where Next.js requires them) +- **Feature folders** in kebab-case with `index.ts` barrels; server actions co-located as `actions.ts`; shared actions in `src/components/shared-actions/` +- **Server modules start with `import 'server-only'`** +- **Authorize, don't just authenticate**: MP-touching actions and service methods call `AuthorizationService.requireSecurityRole()`, reads included +- **Use `session.user.userGuid`** for MP lookups (not `session.user.id`) +- **Convert datetimes at the MP boundary** with `DomainTimezoneService` +- **Never edit generated files** in `src/lib/providers/ministry-platform/models/` — regenerate with `npm run mp:generate:models` +- Access MP fields with special characters using bracket notation: `event["Allow_Check-in"]` -### Best Practices +## Security -1. **Use service classes in server actions** — call `src/services/` singletons, not MPHelper directly -2. **Validate at API boundaries** — pass optional `schema` parameter in `createTableRecords()` / `updateTableRecords()` -3. **Regenerate types** after MP schema changes: `npm run mp:generate:models` -4. **Never edit generated files** in `src/lib/providers/ministry-platform/models/` -5. **Run tests** before committing: `npm run test:run` -6. **Use `session.user.userGuid`** for all MP API lookups (not `session.user.id`) -7. Access MP fields with special characters using bracket notation: `event["Allow_Check-in"]` +Please **do not** open a public issue for a security problem. Report it privately as described in [`.github/SECURITY.md`](.github/SECURITY.md), which also lists the steps every downstream fork must take. ## License -Private +No license has been chosen for this repository yet. Until one is added, all rights are reserved by default. (`"private": true` in `package.json` only prevents publishing to npm.) ## Support -For Ministry Platform API documentation, refer to your instance's API documentation portal. +For Ministry Platform API documentation, see your instance's API documentation portal. For bugs and feature requests, open a [GitHub issue](https://github.com/MinistryPlatform-Community/MPNext-Tools/issues). diff --git a/_INSTALL/.sql-hash b/_INSTALL/.sql-hash index e17814f..f6552c7 100644 --- a/_INSTALL/.sql-hash +++ b/_INSTALL/.sql-hash @@ -1 +1 @@ -f520e52514db5853536dff4afce89010a4e86d9cb9cbe5915bfde66d5dbb1904 \ No newline at end of file +de1ea26e0d700b7c869eba64e50c00e4d72e2ff672e8779dc7924bc84e007a05 \ No newline at end of file diff --git a/_INSTALL/ministryplatform-install.sql b/_INSTALL/ministryplatform-install.sql index 05afb6f..2c4a457 100644 --- a/_INSTALL/ministryplatform-install.sql +++ b/_INSTALL/ministryplatform-install.sql @@ -1,6 +1,6 @@ -- ============================================= -- Ministry Platform Database Install Script --- Generated: 2026-07-26T23:20:04.015Z +-- Generated: 2026-10-10T16:06:51.738Z -- Auto-generated - Do not edit manually -- ============================================= -- NOTE: Run this script against your Ministry Platform database @@ -890,10 +890,11 @@ GO -- api_dev_GetProcedureDefinition -- ============================================= -- Description: Returns the actual create SQL script for a stored procedure to enable LLM context --- Last Modified: 04/16/2026 +-- Last Modified: 10/10/2026 -- Chris Kehayias -- ============================================= CREATE PROCEDURE [dbo].[api_dev_GetProcedureDefinition] + @DomainID INT, -- required first parameter for MP API procs; MP injects it (unused here) @ProcedureName NVARCHAR(256), @SchemaName NVARCHAR(128) = 'dbo' AS diff --git a/next.config.ts b/next.config.ts index d1152b8..466a6d7 100644 --- a/next.config.ts +++ b/next.config.ts @@ -18,6 +18,12 @@ const securityHeaders = [ { key: "X-Frame-Options", value: "DENY" }, { key: "X-Content-Type-Options", value: "nosniff" }, { key: "Referrer-Policy", value: "strict-origin-when-cross-origin" }, + // Cross-origin isolation. COOP severs `window.opener` between this app and + // any page it opens or is opened by; sign-in is a full-page redirect to MP, + // not a popup, so nothing relies on that link. CORP stops other origins from + // embedding our responses (images, JSON, chunks) at all. + { key: "Cross-Origin-Opener-Policy", value: "same-origin" }, + { key: "Cross-Origin-Resource-Policy", value: "same-origin" }, { key: "Permissions-Policy", value: "camera=(), microphone=(), geolocation=(), payment=(), usb=()", @@ -37,7 +43,32 @@ const hstsHeader = { }; const nextConfig: NextConfig = { - serverExternalPackages: ['mjml', 'mjml-core', 'mjml-validator', 'uglify-js'], + // Drops `X-Powered-By: Next.js` — free version fingerprinting for scanners. + poweredByHeader: false, + // Nothing in `src/` uses `next/image`, so the `/_next/image` optimizer is + // pure attack surface (its URL-fetching has carried SSRF advisories). With + // `unoptimized` it never fetches anything; a future `<Image>` still renders, + // just served as-is. + images: { + unoptimized: true, + }, + logging: { + // `next dev` otherwise prints every server action's ARGUMENTS to the + // terminal — household lists, contact records — while a developer is often + // pointed at production MP. CLAUDE.md rule 14 forbids logging record + // content; this closes the one path that bypassed it. + serverFunctions: false, + }, + experimental: { + serverActions: { + // Default is 1 MB, which made the address-label mail merge's documented + // template limit unreachable. 4 MB stays under Vercel's 4.5 MB function + // request-body cap so Next rejects first, with a clear error. Sized + // with the limits in `src/components/address-labels/limits.ts` — change + // them together. + bodySizeLimit: "4mb", + }, + }, async headers() { return [ { diff --git a/package-lock.json b/package-lock.json index fde644f..7b89f5e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,62 +11,58 @@ "@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", - "@radix-ui/react-avatar": "^1.2.6", - "@radix-ui/react-checkbox": "^1.3.11", + "@radix-ui/react-alert-dialog": "^1.1.24", + "@radix-ui/react-avatar": "^1.2.7", + "@radix-ui/react-checkbox": "^1.3.12", "@radix-ui/react-dialog": "^1.1.15", - "@radix-ui/react-dropdown-menu": "^2.1.24", - "@radix-ui/react-label": "^2.1.15", - "@radix-ui/react-popover": "^1.1.23", - "@radix-ui/react-radio-group": "^1.4.7", - "@radix-ui/react-select": "^2.3.7", + "@radix-ui/react-dropdown-menu": "^2.1.25", + "@radix-ui/react-label": "^2.1.16", + "@radix-ui/react-popover": "^1.2.0", + "@radix-ui/react-radio-group": "^1.4.8", + "@radix-ui/react-select": "^2.3.8", "@radix-ui/react-slot": "^1.2.4", - "@radix-ui/react-switch": "^1.3.7", - "@radix-ui/react-tooltip": "^1.2.16", + "@radix-ui/react-switch": "^1.3.8", + "@radix-ui/react-tooltip": "^1.3.0", "@react-pdf/renderer": "^4.9.0", - "better-auth": "^1.7.4", + "better-auth": "^1.7.7", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", - "docx": "^9.7.1", - "docxtemplater": "^3.69.3", + "docx": "^9.9.0", + "docxtemplater": "^3.71.0", "docxtemplater-image": "^0.1.2", - "dotenv": "^17.4.2", - "grapesjs": "^0.22.16", - "grapesjs-mjml": "^1.0.8", - "lucide-react": "^1.45.0", - "mjml": "^5.4.1", - "next": "^16.3.5", - "pizzip": "^3.2.0", + "dotenv": "^18.0.7", + "jose": "^6.2.12", + "lucide-react": "^1.55.0", + "next": "^16.4.0", + "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", + "server-only": "^0.0.1", "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", + "@inquirer/prompts": "^8.7.3", "@tailwindcss/postcss": "^4.3.3", "@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.13.4", + "@types/node": "^24.19.2", "@types/react": "^19.3.0", "@types/react-dom": "^19.3.0", - "@vitejs/plugin-react": "^6.1.1", + "@vitejs/plugin-react": "^6.1.2", "@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", - "postcss": "^8.5.28", + "eslint-config-next": "^16.4.0", + "jsdom": "^30.1.2", + "postcss": "^8.5.29", "tailwindcss": "^4.2.0", "tw-animate-css": "^1.4.0", "typescript": "^6.0.3", @@ -97,26 +93,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.4", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-7.1.4.tgz", + "integrity": "sha512-KdNhWlAh2sbYB8Of0Yw84/a+LO/vsBOHJcIg8BpyXo2RGrFWVJvEMRviMON+ysQu0KpV7wxrWrq9jaX0P21GzA==", "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.3", + "@csstools/css-color-parser": "^4.2.6", + "@csstools/css-parser-algorithms": "^4.0.2", + "@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 +120,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.4", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-9.2.4.tgz", + "integrity": "sha512-YnVzxDxLaqL0t7Q4wfcgHZjg55Wm6y5EaO7Bhu3q9J7wq/8iw3PhsnWiOQhYekx4VexnbkgWpTT6n+QIV7kczw==", "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": { @@ -153,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", @@ -294,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" @@ -310,23 +308,23 @@ } }, "node_modules/@babel/helpers": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", - "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", + "version": "7.29.10", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.10.tgz", + "integrity": "sha512-EMCx3AGZ3WfAl6ht7AyQoPm2Lh+BGYEMdc4Q6Hf7uSCDOZFuLq44qNwL+iaim8VlyYhRAo4X43+weadK8ZB4Yg==", "dev": true, "license": "MIT", "dependencies": { "@babel/template": "^7.29.7", - "@babel/types": "^7.29.7" + "@babel/types": "^7.29.8" }, "engines": { "node": ">=6.9.0" } }, "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": { @@ -340,9 +338,9 @@ } }, "node_modules/@babel/runtime": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", - "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "version": "7.29.10", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.10.tgz", + "integrity": "sha512-NpugTzbKgGrNe45Ui32cN8vCFFMxVtP/9rtfwv0nP5FRDAjvYBxJcLVpYA9mr1jxP6cnIT7x9LUzuDTbWYYNRA==", "license": "MIT", "engines": { "node": ">=6.9.0" @@ -364,16 +362,16 @@ } }, "node_modules/@babel/traverse": { - "version": "7.29.8", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.8.tgz", - "integrity": "sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==", + "version": "7.29.10", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.10.tgz", + "integrity": "sha512-B3lYAs2nkKskbgQ1nfy8Ma9ao9Q1rntTAQHrzzyDuj+TSO+X3le6PfWwqDvodsb+OumE10eMFfuMCTQSQ5iuIQ==", "dev": true, "license": "MIT", "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.8", "@babel/helper-globals": "^7.29.7", - "@babel/parser": "^7.29.8", + "@babel/parser": "^7.29.9", "@babel/template": "^7.29.7", "@babel/types": "^7.29.8", "debug": "^4.3.1" @@ -407,9 +405,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.7", + "resolved": "https://registry.npmjs.org/@better-auth/core/-/core-1.7.7.tgz", + "integrity": "sha512-srgFzHEjB2WWlokFz1i4BfG41sNhsA105Pukgqu6RrEh5A+LSkzf1mCHydVipkjt9l5qswtBMi7IL6ouHbZL4Q==", "license": "MIT", "dependencies": { "@opentelemetry/semantic-conventions": "^1.41.1", @@ -418,8 +416,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 +424,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.7", + "resolved": "https://registry.npmjs.org/@better-auth/drizzle-adapter/-/drizzle-adapter-1.7.7.tgz", + "integrity": "sha512-qon0U94PR5FQysuyGoAlGVQpIIiNg5dtDpwjE63sN/HjoRWZadZzHPcS9mn7JsJyQZK6iTNhd7LD0AaXt2f78w==", "license": "MIT", "peerDependencies": { - "@better-auth/core": "^1.7.4", + "@better-auth/core": "^1.7.7", "@better-auth/utils": "0.4.2", "drizzle-orm": "^0.45.2 || >=1.0.0-rc.1 <2.0.0" }, @@ -452,12 +446,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.7", + "resolved": "https://registry.npmjs.org/@better-auth/kysely-adapter/-/kysely-adapter-1.7.7.tgz", + "integrity": "sha512-9FONOCgOcrQI9wJ5v1oDGIg9sT7pa9RjZZQ4fsCTudo8R87SrIGNUvUxDFTJhZvUblvFfGaIBR9Vb8LA+8bqLQ==", "license": "MIT", "peerDependencies": { - "@better-auth/core": "^1.7.4", + "@better-auth/core": "^1.7.7", "@better-auth/utils": "0.4.2", "kysely": "^0.28.17 || ^0.29.0" }, @@ -468,22 +462,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.7", + "resolved": "https://registry.npmjs.org/@better-auth/memory-adapter/-/memory-adapter-1.7.7.tgz", + "integrity": "sha512-FqKFEe+b5tXXV0gRbyirca+qXgMpLOeJ8Wk19yykb/scQKy0WJ4k22WzEV3TTPctQV9DZhwCXPlDtQhnlOv5Gg==", "license": "MIT", "peerDependencies": { - "@better-auth/core": "^1.7.4", + "@better-auth/core": "^1.7.7", "@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.7", + "resolved": "https://registry.npmjs.org/@better-auth/mongo-adapter/-/mongo-adapter-1.7.7.tgz", + "integrity": "sha512-ZnVDuRrXqbf0oHphrEN27oTImNH5NTO26dtWatq5cXyYAsH7goof9QIoTOmxTbBxUQahKBw9T6+9h6n+RtOE+g==", "license": "MIT", "peerDependencies": { - "@better-auth/core": "^1.7.4", + "@better-auth/core": "^1.7.7", "@better-auth/utils": "0.4.2", "mongodb": "^6.0.0 || ^7.0.0" }, @@ -494,12 +488,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.7", + "resolved": "https://registry.npmjs.org/@better-auth/prisma-adapter/-/prisma-adapter-1.7.7.tgz", + "integrity": "sha512-4YcnrcVdvWiAZw0sZl63tWUClPXg5r3QsLn7C2sG7kKEBY06zOng0ibecyxRBixKq1zE1ceHuEUqez5Rhi+nQA==", "license": "MIT", "peerDependencies": { - "@better-auth/core": "^1.7.4", + "@better-auth/core": "^1.7.7", "@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 +508,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.7", + "resolved": "https://registry.npmjs.org/@better-auth/telemetry/-/telemetry-1.7.7.tgz", + "integrity": "sha512-PaRA6i+kAioVYmza2gHPcdrxP0hAotph4KV9hft2GeNJq+AZXDiBu3FuLM9HxvcmiZeUT74Vufqd9ypmB8veTA==", "license": "MIT", "peerDependencies": { - "@better-auth/core": "^1.7.4", + "@better-auth/core": "^1.7.7", "@better-auth/utils": "0.4.2", - "@better-fetch/fetch": "1.3.1" + "@better-fetch/fetch": "1.3.2" } }, "node_modules/@better-auth/utils": { @@ -534,34 +528,28 @@ } }, "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": { - "version": "2.4.2", - "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", - "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", + "version": "2.4.3", + "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.3.tgz", + "integrity": "sha512-0GEz8qJWokkUVYQYRukmkSHIFNoBH8QTreeHrAFbvz1XFYc3Y+puhMs8PQ5x8GNugiC2l5jvDBXKLbBIByeSBw==", "devOptional": true, "license": "MIT", "dependencies": { - "css-tree": "^3.0.0" + "css-tree": "^3.2.1" }, "bin": { "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.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 +567,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.3", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.4.3.tgz", + "integrity": "sha512-iex20d8CHVkyvg6B7UKV7uHnI2Bqo9g+EFfT9E0y+GvTvhZ/DwONJ+9aKb1dlqm0ZiGsL5RXjp0fCoJYnkeDjA==", "devOptional": true, "funding": [ { @@ -598,14 +586,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.2", + "@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.6", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.2.6.tgz", + "integrity": "sha512-iiPQ3iRWwnJkeEn6RIu6SJPr7hYrLz6XZ9s/QZl+2/LI5KQVjpl2fdmDSZKuD4xP6GMmMPgHFFXg6k1Wkz0Trg==", "devOptional": true, "funding": [ { @@ -619,21 +607,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.3" }, "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.2", + "@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.2", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.2.tgz", + "integrity": "sha512-40cSKyMvK+tq4qz6Awrlye2WGuOKt3FwPgtGg6KTfbHOWNw+Rk1rzbAtZnZ6IBhsY491HLRnDXwoyBAijmmILA==", "devOptional": true, "funding": [ { @@ -650,13 +638,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.15", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.15.tgz", + "integrity": "sha512-J0u7HkVl2nzSlhsiTOp4AmwcUQ3D+mGEEKfBy/7To5/y7F2OHwyLrXfrhR0SMgr4p5Lo+eaMVSeai24zUcBIxA==", "devOptional": true, "funding": [ { @@ -679,9 +667,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 +1385,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": { @@ -1452,32 +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", - "integrity": "sha512-LMcepvRaS9LYHJGsF0zzmgKCUim/X3N/DQKc4jepAXJ7l8QxJ1PmxJzqplF2Z3FE4PqBAIGyJAQ/w4B5dsqbtQ==", - "license": "MIT", - "peerDependencies": { - "react": ">= 16 || ^19.0.0-rc" - } - }, "node_modules/@hookform/resolvers": { "version": "5.9.1", "resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-5.9.1.tgz", @@ -1665,9 +1627,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 +1645,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 +1667,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 +1690,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 +1706,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 +1722,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 +1741,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 +1760,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 +1779,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 +1798,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 +1817,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 +1836,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 +1855,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 +1874,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 +1895,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 +1920,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 +1945,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 +1970,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 +1995,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 +2020,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 +2045,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 +2070,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 +2090,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 +2109,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 +2128,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 +2147,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" ], @@ -2204,9 +2166,9 @@ } }, "node_modules/@inquirer/ansi": { - "version": "2.0.8", - "resolved": "https://registry.npmjs.org/@inquirer/ansi/-/ansi-2.0.8.tgz", - "integrity": "sha512-WpQM+Ti6Z40EFwwt+uL2p4UabT+W179zHp6HhLVOzfbwnVn05IPO/eXIZXGNqcT1jbQ15SujNLzQ39k4QPPxBQ==", + "version": "2.0.9", + "resolved": "https://registry.npmjs.org/@inquirer/ansi/-/ansi-2.0.9.tgz", + "integrity": "sha512-0Ywt3S8tqM6TWUcsRlBFQ1x+X3/o8kktX0ZiKAnByYb9GFDknnOhse1PVtfiD2BXPI8ar+BWx9PV9R0lQIcygw==", "dev": true, "license": "MIT", "engines": { @@ -2214,16 +2176,16 @@ } }, "node_modules/@inquirer/checkbox": { - "version": "5.2.5", - "resolved": "https://registry.npmjs.org/@inquirer/checkbox/-/checkbox-5.2.5.tgz", - "integrity": "sha512-bRt8J8m+Fot9CXv+zNQGXUq2ET0MggR1fPz7v6edN6MFYmsbfGnMmkmWZJEegMKqrAC8ej/o1sqisHZXZJMAfQ==", + "version": "5.2.6", + "resolved": "https://registry.npmjs.org/@inquirer/checkbox/-/checkbox-5.2.6.tgz", + "integrity": "sha512-XkOardciF3tow2NUlY6/Qjk1QqYa+6WpmVLTpxXl97Uv4erczDEZ+xg1j6LqBf0ekcRSUiICQA4nQXHHkVKQ9w==", "dev": true, "license": "MIT", "dependencies": { - "@inquirer/ansi": "^2.0.8", - "@inquirer/core": "^12.0.3", + "@inquirer/ansi": "^2.0.9", + "@inquirer/core": "^12.0.4", "@inquirer/figures": "^2.0.9", - "@inquirer/type": "4.1.1" + "@inquirer/type": "4.2.0" }, "engines": { "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" @@ -2238,14 +2200,14 @@ } }, "node_modules/@inquirer/confirm": { - "version": "6.3.2", - "resolved": "https://registry.npmjs.org/@inquirer/confirm/-/confirm-6.3.2.tgz", - "integrity": "sha512-Xvr/0HggjddPtGppuqVmxhTw+Hr8PvsZ/k0HmOEaAqQEt80OITNkFWnsdNmyT0/eM4Ab+iJLx2R8rctlEyfSVg==", + "version": "6.3.3", + "resolved": "https://registry.npmjs.org/@inquirer/confirm/-/confirm-6.3.3.tgz", + "integrity": "sha512-ImOBgpl/sJTFp8naeSkNu4S0Sf8F8pxv3/CUx+LoBAdQvCVqFz6rSB2A/oZYsJge3+yphbEDxEkfaRNYq1OU0A==", "dev": true, "license": "MIT", "dependencies": { - "@inquirer/core": "^12.0.3", - "@inquirer/type": "4.1.1" + "@inquirer/core": "^12.0.4", + "@inquirer/type": "4.2.0" }, "engines": { "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" @@ -2260,15 +2222,15 @@ } }, "node_modules/@inquirer/core": { - "version": "12.0.3", - "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-12.0.3.tgz", - "integrity": "sha512-wsSy0sznmXwkty+2PzZwx00Cazc/E0r0B7mAzdGROz2Ct+DFZXaK7WDjGZvgjRldxH5ZhFVfF2lgkYrqgOw2KA==", + "version": "12.0.4", + "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-12.0.4.tgz", + "integrity": "sha512-M5fB67y7jPObI45vhD82RskWwCSm0scOvenV/I6K8tIi2gfr9DXqQqJ+iw+Wi6SgapikdOiJibYzz1gW47fsDg==", "dev": true, "license": "MIT", "dependencies": { - "@inquirer/ansi": "^2.0.8", + "@inquirer/ansi": "^2.0.9", "@inquirer/figures": "^2.0.9", - "@inquirer/type": "4.1.1", + "@inquirer/type": "4.2.0", "cli-width": "^4.1.0", "fast-wrap-ansi": "^0.2.0", "mute-stream": "^3.0.0", @@ -2287,15 +2249,15 @@ } }, "node_modules/@inquirer/editor": { - "version": "5.3.3", - "resolved": "https://registry.npmjs.org/@inquirer/editor/-/editor-5.3.3.tgz", - "integrity": "sha512-YsKkS2q63IiLtaDK/9nqzdComN97SDQrmKiyNggN+ceP4ty+Z6VwyTz3FpjeUWeW1Efss2xHFKCC9sx7hnrsxg==", + "version": "5.3.4", + "resolved": "https://registry.npmjs.org/@inquirer/editor/-/editor-5.3.4.tgz", + "integrity": "sha512-imzxQDfw26NpcJAubMj/dE5RjU9Mp80IcT+FzsD6YoxXmL8L1QYnAFdCNv7ccGWh//73+0YP+1kfhwnExdl2VQ==", "dev": true, "license": "MIT", "dependencies": { - "@inquirer/core": "^12.0.3", - "@inquirer/external-editor": "^3.0.5", - "@inquirer/type": "4.1.1" + "@inquirer/core": "^12.0.4", + "@inquirer/external-editor": "^3.0.6", + "@inquirer/type": "4.2.0" }, "engines": { "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" @@ -2310,14 +2272,14 @@ } }, "node_modules/@inquirer/expand": { - "version": "5.1.5", - "resolved": "https://registry.npmjs.org/@inquirer/expand/-/expand-5.1.5.tgz", - "integrity": "sha512-uHuXLmXW+TtIfT/9vSBotypAkqn1n34Ul+CLGPos/xANyO4Ff5xZzkYhbKR4NEcfVK4a9mHQOpwVZzluSHFRGw==", + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/@inquirer/expand/-/expand-5.1.6.tgz", + "integrity": "sha512-JfiLKi7qGuHmcQO/DqR10UFEp054Y1FXZaszP5ZEfHhgHeuFoT5nNFQIXFhed/5/p07SbsV2tIJFwG/dg5+NmQ==", "dev": true, "license": "MIT", "dependencies": { - "@inquirer/core": "^12.0.3", - "@inquirer/type": "4.1.1" + "@inquirer/core": "^12.0.4", + "@inquirer/type": "4.2.0" }, "engines": { "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" @@ -2332,9 +2294,9 @@ } }, "node_modules/@inquirer/external-editor": { - "version": "3.0.5", - "resolved": "https://registry.npmjs.org/@inquirer/external-editor/-/external-editor-3.0.5.tgz", - "integrity": "sha512-f3QQJRIX5ZEneBHNUIuPjmbdzHnmRFJA8r2dkcb8q+OM5Uv5KtnuAttQumnrjcBVBM3mcTX1CkmtAkU58VRZxg==", + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@inquirer/external-editor/-/external-editor-3.0.6.tgz", + "integrity": "sha512-rWmw6WG8JZ7LMn/srXEylYGhL6EXXJeROE7/4x4GREh6NgzqJBDbIgpNGsYoLPfobeb4VZtEYzPGnT5EFNYhiQ==", "dev": true, "license": "MIT", "dependencies": { @@ -2364,14 +2326,14 @@ } }, "node_modules/@inquirer/input": { - "version": "5.1.6", - "resolved": "https://registry.npmjs.org/@inquirer/input/-/input-5.1.6.tgz", - "integrity": "sha512-HtcJhB2QFVXbLuJ5S3syhNbTUVxYvwqV4VRBDkQceBloC9bmTViUoRFP5PbSaDZb3HzfPmpuU/gG4ybVBz4FHA==", + "version": "5.1.7", + "resolved": "https://registry.npmjs.org/@inquirer/input/-/input-5.1.7.tgz", + "integrity": "sha512-Icrs9tuMBCxGI+lFYNO71VFOnqGO8IIrLIGqnTGh07X8jZ2la9QLAYxXcikHZQjjVhIZUtU2xyH89DVN/pPhVg==", "dev": true, "license": "MIT", "dependencies": { - "@inquirer/core": "^12.0.3", - "@inquirer/type": "4.1.1" + "@inquirer/core": "^12.0.4", + "@inquirer/type": "4.2.0" }, "engines": { "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" @@ -2386,14 +2348,14 @@ } }, "node_modules/@inquirer/number": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/@inquirer/number/-/number-4.2.3.tgz", - "integrity": "sha512-6Yuwh1NGSbu1Lo4N1EWjXs1jKRntLg/ZCwhmeorEHde90v1XxAozdbd4Iu30eOQLW+6h1hp2O9ujNfLSbTPJnA==", + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@inquirer/number/-/number-4.2.4.tgz", + "integrity": "sha512-C6g80U4U2+w2Vxa1CB0MN3a2WnQIfzLSUG0lduO3Au9GsirJsKATZkOwZPK+vZ1BlAlz+/Z5ParfQCS9E0svmg==", "dev": true, "license": "MIT", "dependencies": { - "@inquirer/core": "^12.0.3", - "@inquirer/type": "4.1.1" + "@inquirer/core": "^12.0.4", + "@inquirer/type": "4.2.0" }, "engines": { "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" @@ -2408,15 +2370,15 @@ } }, "node_modules/@inquirer/password": { - "version": "5.2.2", - "resolved": "https://registry.npmjs.org/@inquirer/password/-/password-5.2.2.tgz", - "integrity": "sha512-W9zYdyzogK+6110mqwaSJWCBu2yA5Q/OfnGSjjZB1bNpHlmUozXxTl0+QOZBNeVd6Qo81/qT75gW05gLAtITxw==", + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@inquirer/password/-/password-5.2.3.tgz", + "integrity": "sha512-1Uo2qfI2K3wXbfunMpvwAzoZPIUf5iEETwDGqRVbswoorXGqYQJ/bfIJFeLj+pxjq+e2UFPNOmx+fsIl4Urigw==", "dev": true, "license": "MIT", "dependencies": { - "@inquirer/ansi": "^2.0.8", - "@inquirer/core": "^12.0.3", - "@inquirer/type": "4.1.1" + "@inquirer/ansi": "^2.0.9", + "@inquirer/core": "^12.0.4", + "@inquirer/type": "4.2.0" }, "engines": { "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" @@ -2431,22 +2393,22 @@ } }, "node_modules/@inquirer/prompts": { - "version": "8.7.2", - "resolved": "https://registry.npmjs.org/@inquirer/prompts/-/prompts-8.7.2.tgz", - "integrity": "sha512-QoRB4wFIjgH5iOhSjoIKMkTvSHDuV+O3OITlIqAYO0oK5x364GJILXiMBvlPiE+klg7Xx9tq5XVqQHcGUDYYPA==", + "version": "8.7.3", + "resolved": "https://registry.npmjs.org/@inquirer/prompts/-/prompts-8.7.3.tgz", + "integrity": "sha512-DHK20kO+H5k/XcJQpHQUpVQbMYHHhZchlGWhZVMUV4WIuRuBW5744hipOtriE1vMqPLgnIyXFMHWKkIbTepa4Q==", "dev": true, "license": "MIT", "dependencies": { - "@inquirer/checkbox": "^5.2.5", - "@inquirer/confirm": "^6.3.2", - "@inquirer/editor": "^5.3.3", - "@inquirer/expand": "^5.1.5", - "@inquirer/input": "^5.1.6", - "@inquirer/number": "^4.2.3", - "@inquirer/password": "^5.2.2", - "@inquirer/rawlist": "^5.3.5", - "@inquirer/search": "^4.3.3", - "@inquirer/select": "^5.2.5" + "@inquirer/checkbox": "^5.2.6", + "@inquirer/confirm": "^6.3.3", + "@inquirer/editor": "^5.3.4", + "@inquirer/expand": "^5.1.6", + "@inquirer/input": "^5.1.7", + "@inquirer/number": "^4.2.4", + "@inquirer/password": "^5.2.3", + "@inquirer/rawlist": "^5.3.6", + "@inquirer/search": "^4.3.4", + "@inquirer/select": "^5.2.6" }, "engines": { "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" @@ -2461,14 +2423,14 @@ } }, "node_modules/@inquirer/rawlist": { - "version": "5.3.5", - "resolved": "https://registry.npmjs.org/@inquirer/rawlist/-/rawlist-5.3.5.tgz", - "integrity": "sha512-1oHky1ONfCOwNrnkQGDE1oaSij/3fI6HFMSf2H/WsGO2lEyDX9My82iggITSy9ddSZ8yk8j9v41OI0fVoSIoaA==", + "version": "5.3.6", + "resolved": "https://registry.npmjs.org/@inquirer/rawlist/-/rawlist-5.3.6.tgz", + "integrity": "sha512-2exExhQ5xTBqfN/rRlZkvM1Gm3b0eiLcWe2NqxWxe7qYmnU4M3j/n9TwzfPUb+Q7dAAz9TmBtDWWdF3jbp4ZLA==", "dev": true, "license": "MIT", "dependencies": { - "@inquirer/core": "^12.0.3", - "@inquirer/type": "4.1.1" + "@inquirer/core": "^12.0.4", + "@inquirer/type": "4.2.0" }, "engines": { "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" @@ -2483,15 +2445,15 @@ } }, "node_modules/@inquirer/search": { - "version": "4.3.3", - "resolved": "https://registry.npmjs.org/@inquirer/search/-/search-4.3.3.tgz", - "integrity": "sha512-fyuIU1Nbpvwlikjg3gXwJFDI11+EFjqQ7P+iByfmivIKQ1vmaykNrD/vy5unHuUqUpsOsnvJ25//tPF7E/RBRA==", + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/@inquirer/search/-/search-4.3.4.tgz", + "integrity": "sha512-xsk0UeFP04jsbEeFMzghjUaH2aHXyt+WykSajQzq/8qyBNHsgGYHea/sFwv5T08kW7Sp+KDX8aF124zPUIs1Ww==", "dev": true, "license": "MIT", "dependencies": { - "@inquirer/core": "^12.0.3", + "@inquirer/core": "^12.0.4", "@inquirer/figures": "^2.0.9", - "@inquirer/type": "4.1.1" + "@inquirer/type": "4.2.0" }, "engines": { "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" @@ -2506,16 +2468,16 @@ } }, "node_modules/@inquirer/select": { - "version": "5.2.5", - "resolved": "https://registry.npmjs.org/@inquirer/select/-/select-5.2.5.tgz", - "integrity": "sha512-9kc15hr8r/kI+3DO/xLog5nOzTz1jqsHXa6JBFzmQKhkoJ8Slda1I1L/uD8ZSZ9tF1yp79wwXe7mclvX1rqR2Q==", + "version": "5.2.6", + "resolved": "https://registry.npmjs.org/@inquirer/select/-/select-5.2.6.tgz", + "integrity": "sha512-jFu7CFghlr0vpi/326a4wz6BGkJH9X3H/iIu+ahyV2Ja52ktqQE3fC9BYP47JKeSPQYGUv5xquE2Vp9PbepZ3g==", "dev": true, "license": "MIT", "dependencies": { - "@inquirer/ansi": "^2.0.8", - "@inquirer/core": "^12.0.3", + "@inquirer/ansi": "^2.0.9", + "@inquirer/core": "^12.0.4", "@inquirer/figures": "^2.0.9", - "@inquirer/type": "4.1.1" + "@inquirer/type": "4.2.0" }, "engines": { "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" @@ -2530,9 +2492,9 @@ } }, "node_modules/@inquirer/type": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/@inquirer/type/-/type-4.1.1.tgz", - "integrity": "sha512-yJoHYrMnxIsJZCY+0Vb66Dy3he3kL3e2wOBKhoSwWWAzZAY82emlxwgprCtp6yRixvNRNq9ztfRWQYPNr3Go7A==", + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@inquirer/type/-/type-4.2.0.tgz", + "integrity": "sha512-2rnHX8RPo4glJYqnFc/v3nQeIhDcjyRRyaT7MGcfL/8xv/8K03SXYoA2tX06WVSKywWOE/ncbG3dtuzLjR5TCQ==", "dev": true, "license": "MIT", "engines": { @@ -2598,9 +2560,9 @@ } }, "node_modules/@napi-rs/wasm-runtime": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.2.4.tgz", - "integrity": "sha512-AJxoUD2/15ESHbvpcyjU274nsAPLuOtPHCk0vKJM5pj//Fg/B1FXNWjPnXTT9PymCYYiHo4zPj0ZomXBKhoy7g==", + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.2.5.tgz", + "integrity": "sha512-HshSvXzmBxNzqejd3k/KemgQ3hdREUYFRRrgxOZ+gPljDIsO2SDZrVZMd4Z/HsxudQbE62OVyVFE8z9BCurfjA==", "dev": true, "license": "MIT", "optional": true, @@ -2620,15 +2582,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.4.0", + "resolved": "https://registry.npmjs.org/@next/env/-/env-16.4.0.tgz", + "integrity": "sha512-pyLlT5oafGlX8X7xjgGNXz5dNIDSxREm+qD6i9VLSZiZFopA/XWl8NM/KPmLx/8EG/++c49iid3gNWSQW1+2dQ==", "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.4.0", + "resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-16.4.0.tgz", + "integrity": "sha512-ffeflB/dL8RrvWejQximw01pOpKHN692Y3FjDB82DCd0YVvXvBD6vY7mcZp27yvSVFVM6wA73xVodEKF4spizA==", "dev": true, "license": "MIT", "dependencies": { @@ -2669,9 +2631,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.4.0", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.4.0.tgz", + "integrity": "sha512-yyZ+9MgViAKLLb5l1gPBGjRwnq3teNLb5Zx6JEw8LzNSTjlsnp+baUHFxTgl7PpjCmKf48VQfyipG1eBflb48Q==", "cpu": [ "arm64" ], @@ -2685,9 +2647,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.4.0", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.4.0.tgz", + "integrity": "sha512-RePEEejEj1Auf5s9qEcG0JbcDP3ceNUS/ofNX8AhxzgIjTVlZIHMatlp16YplcSMpsVo/ZMQHJTjbMYJznjGUg==", "cpu": [ "x64" ], @@ -2701,9 +2663,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.4.0", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.4.0.tgz", + "integrity": "sha512-HhsyCqd+NpmvDDVY/LqkAN24f+QVvXh3ie+PfcwKinWL2Mu826JymQxJXGjAIfmJ4VlHjp4Pe/nZtXtUoEMAqw==", "cpu": [ "arm64" ], @@ -2720,9 +2682,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.4.0", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.4.0.tgz", + "integrity": "sha512-sN9Pf+Zh+RpZAOmbR7d8KAA8T61CVhawtbEJ2Bk6QrRzx6Z/6haaF4UiwBzytM96GRRRWZh9/WALIEO3npMamA==", "cpu": [ "arm64" ], @@ -2739,9 +2701,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.4.0", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.4.0.tgz", + "integrity": "sha512-D5M5xLWQVd1CvR+L7BickM1weRfmM41x3L1NWcnKY/oyJJqr1qwaCgmu3dMAhLBOMdZwSuDCRV6DJRRpiUkbDQ==", "cpu": [ "x64" ], @@ -2758,9 +2720,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.4.0", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.4.0.tgz", + "integrity": "sha512-8d5WLS6a9YalqpOgXdlbvonFu+sIgjA+CO6tGzetrpPVcUL56foa7B7664h41rAlbLi5fJwK4vWji3zf4QDy+w==", "cpu": [ "x64" ], @@ -2777,9 +2739,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.4.0", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.4.0.tgz", + "integrity": "sha512-sQuOIiW3snb48UwrBcDMvFA/u03qtw2LeuSIxFIQ9AJuW9UG/4Ddsfo+eV73PnZwBhyncMtsKVh7PrzMol2/fA==", "cpu": [ "arm64" ], @@ -2793,9 +2755,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.4.0", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.4.0.tgz", + "integrity": "sha512-6/33lCuxpdHUy27RegyGM/5uTpF7YZd3UR4D0gpkOA/tWY5YR7msbQjqPgCZmSmxrrTLGCXYA4A8vT2H5DPGTQ==", "cpu": [ "x64" ], @@ -2880,12 +2842,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", @@ -2896,9 +2852,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.153.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.153.0.tgz", + "integrity": "sha512-jSNrRIyvPVLL5E1z/tS5zLAprosGz/FuI1PWwEOz59j3E4TTTzSpqKb/RDGqJhhW0QHYISGXxq582wGx6T11Tw==", "devOptional": true, "license": "MIT", "peer": true, @@ -2929,16 +2885,16 @@ "license": "MIT" }, "node_modules/@radix-ui/react-alert-dialog": { - "version": "1.1.23", - "resolved": "https://registry.npmjs.org/@radix-ui/react-alert-dialog/-/react-alert-dialog-1.1.23.tgz", - "integrity": "sha512-VAYOiQRqj3GPpYJE0I9J+X8Ip05cyVlNdKOFeiGS2Ou1HHGfpl0BxOyZm6nmVDyU+W+NF3/XLzmjHmVGydhwgA==", + "version": "1.1.24", + "resolved": "https://registry.npmjs.org/@radix-ui/react-alert-dialog/-/react-alert-dialog-1.1.24.tgz", + "integrity": "sha512-dRvB59HhcjIWcCPKzK5Px1yQ8pIULQQrxqgyepCqin7tNXi/xvmC86WkyMOKhuCbjz3+v9Q6oE7/tUN5NQ9dCg==", "license": "MIT", "dependencies": { "@radix-ui/primitive": "1.1.7", "@radix-ui/react-compose-refs": "1.1.5", "@radix-ui/react-context": "1.2.2", - "@radix-ui/react-dialog": "1.1.23", - "@radix-ui/react-primitive": "2.1.10" + "@radix-ui/react-dialog": "1.2.0", + "@radix-ui/react-primitive": "2.1.11" }, "peerDependencies": { "@types/react": "*", @@ -2956,12 +2912,12 @@ } }, "node_modules/@radix-ui/react-arrow": { - "version": "1.1.15", - "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.15.tgz", - "integrity": "sha512-v4zggRcjadnI+ClKDuijlQEW4tw3NoaeHc/PwpKnLoLLKNUG4InLegkstooLcRIUWCs+8L22dGURCVuFfOKfnA==", + "version": "1.1.16", + "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.16.tgz", + "integrity": "sha512-nXPI1tzJsnQw8oXvB2r49rO9TIbbJ8VFeaWDiBsqY8+iwO5xb7WjUsrXDagjAgPKTdhW8WODQnAltUhBx8I+sg==", "license": "MIT", "dependencies": { - "@radix-ui/react-primitive": "2.1.10" + "@radix-ui/react-primitive": "2.1.11" }, "peerDependencies": { "@types/react": "*", @@ -2979,14 +2935,14 @@ } }, "node_modules/@radix-ui/react-avatar": { - "version": "1.2.6", - "resolved": "https://registry.npmjs.org/@radix-ui/react-avatar/-/react-avatar-1.2.6.tgz", - "integrity": "sha512-4ULOTJ/mqy2hT9GlWa/MFHxHSvH3nJzHnZM1waNsc5Bonv7i70aNenghXmD97S6OJ81ekXONGGt4nT1r0PfEdA==", + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-avatar/-/react-avatar-1.2.7.tgz", + "integrity": "sha512-6AizCpbU3Zngojp9vQak8Lt4AXwdKJ2lkRYAfpoETraT+qKVNeRqytiADzneHetFEB0Wu4+qeSfmZEq0PiVDmQ==", "license": "MIT", "dependencies": { "@radix-ui/primitive": "1.1.7", "@radix-ui/react-context": "1.2.2", - "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-primitive": "2.1.11", "@radix-ui/react-use-callback-ref": "1.1.4", "@radix-ui/react-use-is-hydrated": "0.1.3", "@radix-ui/react-use-layout-effect": "1.1.4" @@ -3007,18 +2963,18 @@ } }, "node_modules/@radix-ui/react-checkbox": { - "version": "1.3.11", - "resolved": "https://registry.npmjs.org/@radix-ui/react-checkbox/-/react-checkbox-1.3.11.tgz", - "integrity": "sha512-Gnptr9pDDQxD3hgq2dtPbtrp/c2qH1mBwIzw3X/ivrMb2e1t0jMTi606fVEqFPaQR1ggXIVQWKj3P2WW9v7zGQ==", + "version": "1.3.12", + "resolved": "https://registry.npmjs.org/@radix-ui/react-checkbox/-/react-checkbox-1.3.12.tgz", + "integrity": "sha512-gNOS2lLrpQxNLWHhCX0sayfVRiyB5gQp7nQIzpl+PM1Hkab52utsKtD27alAr78uqhpvX51kwqDp71hM5hNSwg==", "license": "MIT", "dependencies": { "@radix-ui/primitive": "1.1.7", "@radix-ui/react-compose-refs": "1.1.5", "@radix-ui/react-context": "1.2.2", - "@radix-ui/react-presence": "1.1.10", - "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-presence": "1.1.11", + "@radix-ui/react-primitive": "2.1.11", "@radix-ui/react-use-controllable-state": "1.2.6", - "@radix-ui/react-use-size": "1.1.4" + "@radix-ui/react-use-size": "1.1.5" }, "peerDependencies": { "@types/react": "*", @@ -3036,15 +2992,15 @@ } }, "node_modules/@radix-ui/react-collection": { - "version": "1.1.15", - "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.15.tgz", - "integrity": "sha512-9W+B9NPF0NaaPh/1NJd3+KqsnlLqU9H7T2rvww+fp+T/evVXdNAyYcnfRQZFOjkR1ajQp3yORlqnI8soawLvNA==", + "version": "1.1.16", + "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.16.tgz", + "integrity": "sha512-yEQrjfI5eBbeYEJXOASpm+gs9eNHY8+UjkZ81GzcuwdLrSMOlEM8jnGoGdeGo92oiBMkuwZUhHTsnsrDiJLcyA==", "license": "MIT", "dependencies": { "@radix-ui/react-compose-refs": "1.1.5", "@radix-ui/react-context": "1.2.2", - "@radix-ui/react-primitive": "2.1.10", - "@radix-ui/react-slot": "1.3.3" + "@radix-ui/react-primitive": "2.1.11", + "@radix-ui/react-slot": "1.4.0" }, "peerDependencies": { "@types/react": "*", @@ -3092,22 +3048,22 @@ } }, "node_modules/@radix-ui/react-dialog": { - "version": "1.1.23", - "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.23.tgz", - "integrity": "sha512-Ksw4WeROkO4rC9k/onilX/Ao2Cr1ku1unMNH+XSCcP4jSXYu7HDsg9n4ojMjVb22XpYjAQ9qfrFlVbru1vXDUA==", + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.2.0.tgz", + "integrity": "sha512-l5CX8HdNL+9cc4s7HkNyplumSA3FIx68sg9dttBHKr286fRBwq1MBniDVTeI/tkof92/APntMQvC1YG1Khb0Cg==", "license": "MIT", "dependencies": { "@radix-ui/primitive": "1.1.7", "@radix-ui/react-compose-refs": "1.1.5", "@radix-ui/react-context": "1.2.2", - "@radix-ui/react-dismissable-layer": "1.1.19", + "@radix-ui/react-dismissable-layer": "1.1.20", "@radix-ui/react-focus-guards": "1.1.6", - "@radix-ui/react-focus-scope": "1.1.16", + "@radix-ui/react-focus-scope": "1.2.0", "@radix-ui/react-id": "1.1.4", - "@radix-ui/react-portal": "1.1.17", - "@radix-ui/react-presence": "1.1.10", - "@radix-ui/react-primitive": "2.1.10", - "@radix-ui/react-slot": "1.3.3", + "@radix-ui/react-portal": "1.1.18", + "@radix-ui/react-presence": "1.1.11", + "@radix-ui/react-primitive": "2.1.11", + "@radix-ui/react-slot": "1.4.0", "@radix-ui/react-use-controllable-state": "1.2.6", "@radix-ui/react-use-layout-effect": "1.1.4", "aria-hidden": "^1.2.4", @@ -3129,9 +3085,9 @@ } }, "node_modules/@radix-ui/react-direction": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.4.tgz", - "integrity": "sha512-5pzg4FGQNpExhnhT2zlrP1wZFaYCd1K0nYWoFAdcYoYK868IEigqMX3B3f8yIoRlAhAeDWciLI6ZdCKHF9P4Vg==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.5.tgz", + "integrity": "sha512-/NlO/rOqQabmg+GGr86vCmbJVhqTv85NfM9g6Gknf/JVygXeZ6e6HM/RW1omerW9HsFtecwNqgEAKV6UqDsvpA==", "license": "MIT", "peerDependencies": { "@types/react": "*", @@ -3144,14 +3100,14 @@ } }, "node_modules/@radix-ui/react-dismissable-layer": { - "version": "1.1.19", - "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.19.tgz", - "integrity": "sha512-8g4pfOL9HoKKLWGiypT+dphVqjFfmcXO5GBnhsG6zI+lxAx/8feQpr+1LSN8Re3hiZ+XkLNS4O9ztK11/LzQ6w==", + "version": "1.1.20", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.20.tgz", + "integrity": "sha512-35WHRrycfnj2m4LvPiKMKHGLM3Y2WvUA744CkRdxjs1BGDX1c8PVMfmiSGTUwnHRbqdtIoxveYTXvcJSt7VfgQ==", "license": "MIT", "dependencies": { "@radix-ui/primitive": "1.1.7", "@radix-ui/react-compose-refs": "1.1.5", - "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-primitive": "2.1.11", "@radix-ui/react-use-callback-ref": "1.1.4", "@radix-ui/react-use-effect-event": "0.0.5" }, @@ -3171,17 +3127,17 @@ } }, "node_modules/@radix-ui/react-dropdown-menu": { - "version": "2.1.24", - "resolved": "https://registry.npmjs.org/@radix-ui/react-dropdown-menu/-/react-dropdown-menu-2.1.24.tgz", - "integrity": "sha512-geq8l2rJkxvkXsT9RMgtUE3P8pITFpTsvYpbySi1IH4fZEABD/Gp85myayFgxk0ktljGMJnCbeFkyTusvSvv7g==", + "version": "2.1.25", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dropdown-menu/-/react-dropdown-menu-2.1.25.tgz", + "integrity": "sha512-45QO76jn/a+cRl6OPgZKCKnIMdfYZaOa6SuAJeKHFiM3orX6bnscuFQJDx3qS4Agp/RE+eKv+9gmUL8t3Ma7bw==", "license": "MIT", "dependencies": { "@radix-ui/primitive": "1.1.7", "@radix-ui/react-compose-refs": "1.1.5", "@radix-ui/react-context": "1.2.2", "@radix-ui/react-id": "1.1.4", - "@radix-ui/react-menu": "2.1.24", - "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-menu": "2.1.25", + "@radix-ui/react-primitive": "2.1.11", "@radix-ui/react-use-controllable-state": "1.2.6" }, "peerDependencies": { @@ -3215,13 +3171,13 @@ } }, "node_modules/@radix-ui/react-focus-scope": { - "version": "1.1.16", - "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.16.tgz", - "integrity": "sha512-wmRZ2WWLvmt6KHy2rNPOdPUjwq5xOHY02+m+udwJTn0aNIox/rkskAvJTyTLGhPK6KgrUjlJUJpgmx/+wFiFIQ==", + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.2.0.tgz", + "integrity": "sha512-I5S7vsAOozx2lWSBAyuXjWIJ/A8qx4fjbv6115WoaRYQSb1yZDaeNHonUAQzXyE8oD+0whJHbQRl4HIE2gQ0Xw==", "license": "MIT", "dependencies": { "@radix-ui/react-compose-refs": "1.1.5", - "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-primitive": "2.1.11", "@radix-ui/react-use-callback-ref": "1.1.4" }, "peerDependencies": { @@ -3258,12 +3214,12 @@ } }, "node_modules/@radix-ui/react-label": { - "version": "2.1.15", - "resolved": "https://registry.npmjs.org/@radix-ui/react-label/-/react-label-2.1.15.tgz", - "integrity": "sha512-o/rdYEwZTTo5tjknnPeyQFU45kUC4i/XyeDPP+HGyi6XqpOP6Zf5Ya5vh/Yfe9Id5JiuWnnAx2XqIeD3UYZt0g==", + "version": "2.1.16", + "resolved": "https://registry.npmjs.org/@radix-ui/react-label/-/react-label-2.1.16.tgz", + "integrity": "sha512-2vTQj72YFmeoTxWKJEMbO/9uQt6zzLgbzi0+nWBffq49oo6KhIDP/D+fA2im0wsxBCAX7QfaF3xgsYBVdDbNFQ==", "license": "MIT", "dependencies": { - "@radix-ui/react-primitive": "2.1.10" + "@radix-ui/react-primitive": "2.1.11" }, "peerDependencies": { "@types/react": "*", @@ -3281,26 +3237,26 @@ } }, "node_modules/@radix-ui/react-menu": { - "version": "2.1.24", - "resolved": "https://registry.npmjs.org/@radix-ui/react-menu/-/react-menu-2.1.24.tgz", - "integrity": "sha512-uW7RVuU6Lp/ZtfeY4b3kL32zccgEWvPv1+cf17ubYzHa9cL8AHokmk36cG/XEiH/smbQvumnieXX9j/e9RqJWA==", + "version": "2.1.25", + "resolved": "https://registry.npmjs.org/@radix-ui/react-menu/-/react-menu-2.1.25.tgz", + "integrity": "sha512-e8JaQc0ijBbTx37YVI09Nfq8ThUWJ4L1rvZ3Hvaufehco9/ug9+U3PHz9ue6w3gKd91UcKf9fgDWejeSO+VSKQ==", "license": "MIT", "dependencies": { "@radix-ui/primitive": "1.1.7", - "@radix-ui/react-collection": "1.1.15", + "@radix-ui/react-collection": "1.1.16", "@radix-ui/react-compose-refs": "1.1.5", "@radix-ui/react-context": "1.2.2", - "@radix-ui/react-direction": "1.1.4", - "@radix-ui/react-dismissable-layer": "1.1.19", + "@radix-ui/react-direction": "1.1.5", + "@radix-ui/react-dismissable-layer": "1.1.20", "@radix-ui/react-focus-guards": "1.1.6", - "@radix-ui/react-focus-scope": "1.1.16", + "@radix-ui/react-focus-scope": "1.2.0", "@radix-ui/react-id": "1.1.4", - "@radix-ui/react-popper": "1.3.7", - "@radix-ui/react-portal": "1.1.17", - "@radix-ui/react-presence": "1.1.10", - "@radix-ui/react-primitive": "2.1.10", - "@radix-ui/react-roving-focus": "1.1.19", - "@radix-ui/react-slot": "1.3.3", + "@radix-ui/react-popper": "1.3.8", + "@radix-ui/react-portal": "1.1.18", + "@radix-ui/react-presence": "1.1.11", + "@radix-ui/react-primitive": "2.1.11", + "@radix-ui/react-roving-focus": "1.1.20", + "@radix-ui/react-slot": "1.4.0", "@radix-ui/react-use-callback-ref": "1.1.4", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.7.2" @@ -3321,24 +3277,25 @@ } }, "node_modules/@radix-ui/react-popover": { - "version": "1.1.23", - "resolved": "https://registry.npmjs.org/@radix-ui/react-popover/-/react-popover-1.1.23.tgz", - "integrity": "sha512-mw58MrBlyHWFisTOYignD0vf/3gdcgAR+9of1s9G/38CbFiUwH1nCDkc0AUM9IrXFgN5Ue8n45j9WCgyM1sbiQ==", + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-popover/-/react-popover-1.2.0.tgz", + "integrity": "sha512-e70Nh+0iQ+hRAIM2nAGib5BhaDL2Rjlzxw9kIzsU2nz+t3/t82Z5qtPhY5nTnfj8FbmrU6RRIKH8RF3ynCe+vw==", "license": "MIT", "dependencies": { "@radix-ui/primitive": "1.1.7", "@radix-ui/react-compose-refs": "1.1.5", "@radix-ui/react-context": "1.2.2", - "@radix-ui/react-dismissable-layer": "1.1.19", + "@radix-ui/react-dismissable-layer": "1.1.20", "@radix-ui/react-focus-guards": "1.1.6", - "@radix-ui/react-focus-scope": "1.1.16", + "@radix-ui/react-focus-scope": "1.2.0", "@radix-ui/react-id": "1.1.4", - "@radix-ui/react-popper": "1.3.7", - "@radix-ui/react-portal": "1.1.17", - "@radix-ui/react-presence": "1.1.10", - "@radix-ui/react-primitive": "2.1.10", - "@radix-ui/react-slot": "1.3.3", + "@radix-ui/react-popper": "1.3.8", + "@radix-ui/react-portal": "1.1.18", + "@radix-ui/react-presence": "1.1.11", + "@radix-ui/react-primitive": "2.1.11", + "@radix-ui/react-slot": "1.4.0", "@radix-ui/react-use-controllable-state": "1.2.6", + "@radix-ui/react-use-layout-effect": "1.1.4", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.7.2" }, @@ -3358,20 +3315,20 @@ } }, "node_modules/@radix-ui/react-popper": { - "version": "1.3.7", - "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.3.7.tgz", - "integrity": "sha512-UsJrrd7w4wuKKTdvd/DNERVlwSlUcyXzjhyDwBk+3aPOsCjOY6ZSbxuw8E6lZTjjfP8Cpd0J8VVkrYUWyGYXyg==", + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.3.8.tgz", + "integrity": "sha512-y92/9iIdG1FyNvow0Y0qP5WJZ7oRTAKDVKCy9kJnhZfItwIKYtt0KKQeOhBKfXz0ETizduKwDRbrQHrTQScs5A==", "license": "MIT", "dependencies": { "@floating-ui/react-dom": "^2.0.0", - "@radix-ui/react-arrow": "1.1.15", + "@radix-ui/react-arrow": "1.1.16", "@radix-ui/react-compose-refs": "1.1.5", "@radix-ui/react-context": "1.2.2", - "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-primitive": "2.1.11", "@radix-ui/react-use-callback-ref": "1.1.4", "@radix-ui/react-use-layout-effect": "1.1.4", "@radix-ui/react-use-rect": "1.1.4", - "@radix-ui/react-use-size": "1.1.4", + "@radix-ui/react-use-size": "1.1.5", "@radix-ui/rect": "1.1.3" }, "peerDependencies": { @@ -3390,12 +3347,12 @@ } }, "node_modules/@radix-ui/react-portal": { - "version": "1.1.17", - "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.17.tgz", - "integrity": "sha512-vKQLcWypUnwZVvfV7UkGahH2g6ySe8M8R+zYBwPrv5byZ9QAW6cQVvNKo7GgmD+p8aYb6D9JBuvy8/WhOno2wQ==", + "version": "1.1.18", + "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.18.tgz", + "integrity": "sha512-ygFIJ+QXNKf72AAq8x3hd22+ws7pYpmc1S0E4kW0Je+qD4qEb3FvxeKVQRGrVDMOkQaYfsB+Qs+KDh81a1gAOw==", "license": "MIT", "dependencies": { - "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-primitive": "2.1.11", "@radix-ui/react-use-layout-effect": "1.1.4" }, "peerDependencies": { @@ -3414,9 +3371,9 @@ } }, "node_modules/@radix-ui/react-presence": { - "version": "1.1.10", - "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.10.tgz", - "integrity": "sha512-3wyzCQ6+ubRA+D4uv9m95JYLXxmOHp05qjrkjeA7uKHHtjpPggQzc6DAb0URl7j67oR0K2foO4ip27TiX037Bw==", + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.11.tgz", + "integrity": "sha512-Z7/atlkab36Z8hm+V8zYc79r0W+tiOB1o0tun1By94JyadkekLQmtEAnBSP9XKXBNQsStoStS4+gX10JpS5akA==", "license": "MIT", "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.4" @@ -3437,12 +3394,12 @@ } }, "node_modules/@radix-ui/react-primitive": { - "version": "2.1.10", - "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.10.tgz", - "integrity": "sha512-MucOnzh6hR5mid6VpkbglRAMYMjKLqRnGBbjXkzjK52fuQDd1qbkx78a5P40mkcnVXJdEVxm26E9OPAiUq7nBg==", + "version": "2.1.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.11.tgz", + "integrity": "sha512-RzcaSw5aKl0OPhBJ1wbolKSF6nVvN+0o/QLeIVq+09npiPdnCkPggy2k6gI6yetxl4ZyrlBPYFgCB8BQJZwXcQ==", "license": "MIT", "dependencies": { - "@radix-ui/react-slot": "1.3.3" + "@radix-ui/react-slot": "1.4.0" }, "peerDependencies": { "@types/react": "*", @@ -3460,20 +3417,20 @@ } }, "node_modules/@radix-ui/react-radio-group": { - "version": "1.4.7", - "resolved": "https://registry.npmjs.org/@radix-ui/react-radio-group/-/react-radio-group-1.4.7.tgz", - "integrity": "sha512-cgYFEkntCxppHZgtSZ+7vh0wbZQ+IC7PPMw8DSnRG27B6kDd32/Zw0OJt7dGDigCoprMuWHjg2PvUn3PYvPFoQ==", + "version": "1.4.8", + "resolved": "https://registry.npmjs.org/@radix-ui/react-radio-group/-/react-radio-group-1.4.8.tgz", + "integrity": "sha512-MaOOZ1TlaYoOeSDnHeVIONss0rp5ngOTY5HJY+Xkq16PAopnj21iU9r9m0O/77cBYu8Ksaq3BcjlaOdpbfbzzg==", "license": "MIT", "dependencies": { "@radix-ui/primitive": "1.1.7", "@radix-ui/react-compose-refs": "1.1.5", "@radix-ui/react-context": "1.2.2", - "@radix-ui/react-direction": "1.1.4", - "@radix-ui/react-presence": "1.1.10", - "@radix-ui/react-primitive": "2.1.10", - "@radix-ui/react-roving-focus": "1.1.19", + "@radix-ui/react-direction": "1.1.5", + "@radix-ui/react-presence": "1.1.11", + "@radix-ui/react-primitive": "2.1.11", + "@radix-ui/react-roving-focus": "1.1.20", "@radix-ui/react-use-controllable-state": "1.2.6", - "@radix-ui/react-use-size": "1.1.4" + "@radix-ui/react-use-size": "1.1.5" }, "peerDependencies": { "@types/react": "*", @@ -3491,18 +3448,18 @@ } }, "node_modules/@radix-ui/react-roving-focus": { - "version": "1.1.19", - "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.19.tgz", - "integrity": "sha512-V9jI6hDjT7l3jsCQD9bLNvDLM3tH/gdbOTp7Tefp3hbbgCGQoK7tUvrWiRlcoBHIZ809ElXwNQwVo0B98LuTXQ==", + "version": "1.1.20", + "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.20.tgz", + "integrity": "sha512-+ZjbkU21fTSJdn4nq/IFKUdKS+JSZHORBZ8ma1f6YpOMNzRt0xf3sVAWVK/+ktHCajPy4HYDsyHXZIZmOMxzIA==", "license": "MIT", "dependencies": { "@radix-ui/primitive": "1.1.7", - "@radix-ui/react-collection": "1.1.15", + "@radix-ui/react-collection": "1.1.16", "@radix-ui/react-compose-refs": "1.1.5", "@radix-ui/react-context": "1.2.2", - "@radix-ui/react-direction": "1.1.4", + "@radix-ui/react-direction": "1.1.5", "@radix-ui/react-id": "1.1.4", - "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-primitive": "2.1.11", "@radix-ui/react-use-callback-ref": "1.1.4", "@radix-ui/react-use-controllable-state": "1.2.6", "@radix-ui/react-use-is-hydrated": "0.1.3", @@ -3524,31 +3481,31 @@ } }, "node_modules/@radix-ui/react-select": { - "version": "2.3.7", - "resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.3.7.tgz", - "integrity": "sha512-WFGImkmbzcfxeIwq/+4HvRN0pizBwbwQUED4I13ezQsDdfl38ZntN6TmR8XaSzPBqoCToe8rF75j6NPNDSzhbg==", + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.3.8.tgz", + "integrity": "sha512-Tz9xOpV9Rbca5WTk7pzmfAECV3zb++uScT9mP3Px7ziAhH7SlK53b0uwMw8VubPhRK4x7uDKCy5fwevT8I0fAg==", "license": "MIT", "dependencies": { "@radix-ui/number": "1.1.3", "@radix-ui/primitive": "1.1.7", - "@radix-ui/react-collection": "1.1.15", + "@radix-ui/react-collection": "1.1.16", "@radix-ui/react-compose-refs": "1.1.5", "@radix-ui/react-context": "1.2.2", - "@radix-ui/react-direction": "1.1.4", - "@radix-ui/react-dismissable-layer": "1.1.19", + "@radix-ui/react-direction": "1.1.5", + "@radix-ui/react-dismissable-layer": "1.1.20", "@radix-ui/react-focus-guards": "1.1.6", - "@radix-ui/react-focus-scope": "1.1.16", + "@radix-ui/react-focus-scope": "1.2.0", "@radix-ui/react-id": "1.1.4", - "@radix-ui/react-popper": "1.3.7", - "@radix-ui/react-portal": "1.1.17", - "@radix-ui/react-presence": "1.1.10", - "@radix-ui/react-primitive": "2.1.10", - "@radix-ui/react-slot": "1.3.3", + "@radix-ui/react-popper": "1.3.8", + "@radix-ui/react-portal": "1.1.18", + "@radix-ui/react-presence": "1.1.11", + "@radix-ui/react-primitive": "2.1.11", + "@radix-ui/react-slot": "1.4.0", "@radix-ui/react-use-callback-ref": "1.1.4", "@radix-ui/react-use-controllable-state": "1.2.6", "@radix-ui/react-use-layout-effect": "1.1.4", "@radix-ui/react-use-previous": "1.1.4", - "@radix-ui/react-visually-hidden": "1.2.11", + "@radix-ui/react-visually-hidden": "1.2.12", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.7.2" }, @@ -3568,9 +3525,9 @@ } }, "node_modules/@radix-ui/react-slot": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.3.3.tgz", - "integrity": "sha512-qx7oqnYbxnK9kYI9m317qmFmEgo6ywqWvbTogdj7cL9p3/yx4M48p7Rnw5z3H890cL/ow/EeWJsuTykeZVXP5Q==", + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.4.0.tgz", + "integrity": "sha512-IuMVHZ+Ah5fkctu3X3qeFrV/ZrBcuWuDTFUUPfS/L5hvoTjzhp/HdWGE14CsQombPdEidsa33HVlYZXi49PZ1Q==", "license": "MIT", "dependencies": { "@radix-ui/react-compose-refs": "1.1.5" @@ -3586,17 +3543,17 @@ } }, "node_modules/@radix-ui/react-switch": { - "version": "1.3.7", - "resolved": "https://registry.npmjs.org/@radix-ui/react-switch/-/react-switch-1.3.7.tgz", - "integrity": "sha512-48tB/4dn2UVLBCYhTu9AuR63IHl73l/qLbLgxd86noTUor4/K4LFDAcYjK+isP5313qxaFpjPVogE7+Y0/V3Kw==", + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/@radix-ui/react-switch/-/react-switch-1.3.8.tgz", + "integrity": "sha512-rr+zuoAeHWK3m6H4HgjOUiu3VsrQ/9JkjwY/uMnTgHJGPe9WNzDP/MOuIV7S+65p42DIxjGL0hAHktqBq82+2A==", "license": "MIT", "dependencies": { "@radix-ui/primitive": "1.1.7", "@radix-ui/react-compose-refs": "1.1.5", "@radix-ui/react-context": "1.2.2", - "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-primitive": "2.1.11", "@radix-ui/react-use-controllable-state": "1.2.6", - "@radix-ui/react-use-size": "1.1.4" + "@radix-ui/react-use-size": "1.1.5" }, "peerDependencies": { "@types/react": "*", @@ -3614,24 +3571,24 @@ } }, "node_modules/@radix-ui/react-tooltip": { - "version": "1.2.16", - "resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.2.16.tgz", - "integrity": "sha512-6EamKFRRnlpdadndbZ6LMwycfwkwPte1B42hs6QA0gYhjaOKqW4PZ4pjaW9UrlDX5eVt/OjncE7BFTPL5nmZhg==", + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.3.0.tgz", + "integrity": "sha512-4HM4b1Cft1CBXCGDRDG3S/fh8GedBuVOMNh145wMIfTbVqcuv+tyj03nZqYgFevWJMYo58H0s+8P6s6NaUHWtA==", "license": "MIT", "dependencies": { "@radix-ui/primitive": "1.1.7", "@radix-ui/react-compose-refs": "1.1.5", "@radix-ui/react-context": "1.2.2", - "@radix-ui/react-dismissable-layer": "1.1.19", + "@radix-ui/react-dismissable-layer": "1.1.20", "@radix-ui/react-id": "1.1.4", - "@radix-ui/react-popper": "1.3.7", - "@radix-ui/react-portal": "1.1.17", - "@radix-ui/react-presence": "1.1.10", - "@radix-ui/react-primitive": "2.1.10", - "@radix-ui/react-slot": "1.3.3", + "@radix-ui/react-popper": "1.3.8", + "@radix-ui/react-portal": "1.1.18", + "@radix-ui/react-presence": "1.1.11", + "@radix-ui/react-primitive": "2.1.11", + "@radix-ui/react-slot": "1.4.0", "@radix-ui/react-use-controllable-state": "1.2.6", "@radix-ui/react-use-layout-effect": "1.1.4", - "@radix-ui/react-visually-hidden": "1.2.11" + "@radix-ui/react-visually-hidden": "1.2.12" }, "peerDependencies": { "@types/react": "*", @@ -3765,9 +3722,9 @@ } }, "node_modules/@radix-ui/react-use-size": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-1.1.4.tgz", - "integrity": "sha512-D3anSY15EJoxrihpsXI6SMrmmonnQtR2ni7arO+Lfdg3O95b9hNXxONk8jA5C8ANdF/h5HMAxejgs8PWJ6rlhw==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-1.1.5.tgz", + "integrity": "sha512-V1McDQkMGzgivwqdu7u96APs7oi0Jvj9LTzgq3WWrDZqxWdAqU9yZnZQqk5OizGFR8badUQwZsrlao7MOlWYOA==", "license": "MIT", "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.4" @@ -3783,12 +3740,12 @@ } }, "node_modules/@radix-ui/react-visually-hidden": { - "version": "1.2.11", - "resolved": "https://registry.npmjs.org/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.2.11.tgz", - "integrity": "sha512-NFS86RYYZb4/exihaESBGOpMJFz8MGLAfu3mOBSGByVnVPC9JPASfYubxd/8KbkQK0sYAv8lVQDEQukDX/qXvQ==", + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.2.12.tgz", + "integrity": "sha512-/iPLVHRBzKWvekN+5xaDmXw80f+nleYYceomVORrGjuJe0LF2SCbSH4f/LG4hu32q92QSuPZx4hmSACYBVBfzQ==", "license": "MIT", "dependencies": { - "@radix-ui/react-primitive": "2.1.10" + "@radix-ui/react-primitive": "2.1.11" }, "peerDependencies": { "@types/react": "*", @@ -3981,9 +3938,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.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.13.tgz", + "integrity": "sha512-Vu/NICrgdRvUOeaJZIyB2FP63s6fO/4ud5l8dXmxDx9yDyn+mN0y+gsvsVviSli+T361Aqo0h46EuMoobrwawA==", "cpu": [ "arm" ], @@ -3998,9 +3955,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.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.13.tgz", + "integrity": "sha512-6LEoIrGy5vvCd2boNnv2soPSUZj/PRvhp8R/t9KlX+mIZxThIE/ZqxrGXxmuNX0FuvQFvLdZr7S4HrYpYBVhEQ==", "cpu": [ "arm64" ], @@ -4015,9 +3972,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.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.13.tgz", + "integrity": "sha512-CaJSRzlQ6Q+zkDMVA9OkVxBjVoPRCDEE3v0FSJ9kIQBgRjFApA3Si4ijhbNVUji7ZJt+PfO0e46qy4nPInHUPA==", "cpu": [ "arm64" ], @@ -4032,9 +3989,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.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.13.tgz", + "integrity": "sha512-e4r340I5hxSzJkD3YJBxgzq/rJ3wwxwdLLb2FPt1Zpyem20nrQPT11DDEEkazwxaHumH8wpglsp/Ozgj/wT6mw==", "cpu": [ "x64" ], @@ -4049,9 +4006,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.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.13.tgz", + "integrity": "sha512-hoBWDZ4/K/mJKVMbsgUd1jfpA2DiOrJ54mpegObU9TBHBBrtX4FLIXJJQRDPZ/2Dn91GvPob+Z2hwKJzf4E5SQ==", "cpu": [ "x64" ], @@ -4066,9 +4023,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.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.13.tgz", + "integrity": "sha512-oF9fZu3vTWnTiVAkDFxU9PJLt1nh20K9RRpuHsR7ZnE2L7XookBbDBVLojCVSLGbvtSlxeUNnjAj6DbNsKyzbg==", "cpu": [ "arm" ], @@ -4083,9 +4040,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.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.13.tgz", + "integrity": "sha512-DTpb/+jMiqWMPgphn4ksW1zW+XLJ6dJJ9FMvnIk9I291LhCctMPhMYJrP5t9p6C9qa3KXadDdUEZHBaPh3kNxQ==", "cpu": [ "arm64" ], @@ -4103,9 +4060,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.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.13.tgz", + "integrity": "sha512-xU+Kvv9N2A3SY35zFh1FWWNjxbadE/BKeqnkXe7vScU4GZsw757BlVmDLITZEq8DPpk6m7uoDYkK7P+x/uU5Ow==", "cpu": [ "arm64" ], @@ -4123,9 +4080,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.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.13.tgz", + "integrity": "sha512-XMk/hnhh25oQ756Lclb1Q11h4ll0xC0Esa5w4Vzp+mAE2S3AGJgeM0SonrIgY8fHhjarb6hcoTuwoFG0VVP2ig==", "cpu": [ "ppc64" ], @@ -4143,9 +4100,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.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.13.tgz", + "integrity": "sha512-U1BoTHYmlywE2n3HUWjzZ+8EQsKr+OwpxYuDWaubx42tUL8QSL7nOqRz/NVHanibbFP3M1Rxuprz/wNcbMKjqw==", "cpu": [ "s390x" ], @@ -4163,9 +4120,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.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.13.tgz", + "integrity": "sha512-J4cm80Pc6wEwV5AZ42o62sIQgDk+Y7R2s646jIwuvO+4V46QB58N4vVuMPWCBaNgursXLpmUWJV//WQAkPvHBQ==", "cpu": [ "x64" ], @@ -4183,9 +4140,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.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.13.tgz", + "integrity": "sha512-W0euZS40U4V6JkmEnVCIX9mcsL6cbXQ4X8c2w+MMOIjFg68TXUW+u3RT+2e1t1pv9mg/EP3pDtK3POXdQ7Rxyw==", "cpu": [ "x64" ], @@ -4203,9 +4160,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.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.13.tgz", + "integrity": "sha512-do/AF/4n9MRshia3a5aXoy8xM2c6mgfT6D7H/dY6my5z+hfCIR4ab7mk4IRj++nsEQpKy39kd0DQnDq+r692tw==", "cpu": [ "arm64" ], @@ -4220,9 +4177,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.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.13.tgz", + "integrity": "sha512-6FtUf2cJZobutJdxnGYX8z0vtbgqU0b2v8vfBhkXb9wp2MppMFtkunE+IaOntyi01rkIQoPW2GHABtfzsC0BRQ==", "cpu": [ "arm64" ], @@ -4237,9 +4194,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.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.13.tgz", + "integrity": "sha512-UKhFaZTeTcbKrb3iU82m17jVcPEAoqDkCN5MBqgPyZSwp/cQmib21crTS0DdH7T1B7wZRWEI89YQAOJMet5yhg==", "cpu": [ "x64" ], @@ -4572,9 +4529,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, @@ -4689,16 +4646,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", @@ -4724,12 +4671,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", @@ -4744,36 +4685,21 @@ "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.13.4", - "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.4.tgz", - "integrity": "sha512-YJ7EqCstVTzIr0fMr7qul/977en+pQHrfmuKIo6Zr9i75Be21dr3MovcfvGtyvi2HAUrRerWps5sMO9I7WaxDw==", + "version": "24.19.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.19.2.tgz", + "integrity": "sha512-93d49wCWJkCyN87MxLpNN6KXPCwFw7bVUg79mGUV1OS40JGWwVCr8n61DXCSqz7CV6/VfPkb7xec7uP2M+QHWQ==", "devOptional": true, "license": "MIT", "dependencies": { - "undici-types": "~7.18.0" + "undici-types": ">=7.24.0 <7.24.7" } }, "node_modules/@types/react": { "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" @@ -4783,35 +4709,24 @@ "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.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.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.71.1.tgz", + "integrity": "sha512-nNlBf6HuotqMgZvSkWD5hNdemiBIxQ2NmrlgfcUIeZRs+BbakjscCQRq/Hb74EFVnFiSWWdL12JruqwBy3ERDw==", "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.1", + "@typescript-eslint/type-utils": "8.71.1", + "@typescript-eslint/utils": "8.71.1", + "@typescript-eslint/visitor-keys": "8.71.1", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" @@ -4824,15 +4739,15 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^8.70.0", + "@typescript-eslint/parser": "^8.71.1", "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.12", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.12.tgz", + "integrity": "sha512-/8UvqAPU9DGTI9k4mxtf49U37Isfwr8Uts96+SBHIkFxPJnHS0Ew4f00sM4Scd8V8EjM0jUNtVDdv1kPdt35lg==", "dev": true, "license": "MIT", "engines": { @@ -4840,16 +4755,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.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.71.1.tgz", + "integrity": "sha512-oM/5sAqz/l1v/zYK1qTOPrn2+JIrBGC1V6uNYucRO5gh6eT9vWJ/RhPbGE59byvZEUQ4VP6XkLBaS5Jti5jpvw==", "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.1", + "@typescript-eslint/types": "8.71.1", + "@typescript-eslint/typescript-estree": "8.71.1", + "@typescript-eslint/visitor-keys": "8.71.1", "debug": "^4.4.3" }, "engines": { @@ -4865,14 +4780,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.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.71.1.tgz", + "integrity": "sha512-Oijc9RUsohHjncg6iGi7gCtjnEjYt9FWv9u6ygK8uKcHK5eLjLAkqERQ9BaytbpceOFdk6kifPCS/zAZE+A99g==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.70.0", - "@typescript-eslint/types": "^8.70.0", + "@typescript-eslint/tsconfig-utils": "^8.71.1", + "@typescript-eslint/types": "^8.71.1", "debug": "^4.4.3" }, "engines": { @@ -4887,14 +4802,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.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.71.1.tgz", + "integrity": "sha512-1YUDZXdTnXLlob03SFHBipdyMBsDVm7zoSz0ytTJ39dk5J1TgsTK03VlR/dLXG/RLLUrPcYbD4rp23BmHxHP0w==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.70.0", - "@typescript-eslint/visitor-keys": "8.70.0" + "@typescript-eslint/types": "8.71.1", + "@typescript-eslint/visitor-keys": "8.71.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -4905,9 +4820,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.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.71.1.tgz", + "integrity": "sha512-oTvkml5SxXhgg+WWKhod5qWQICDXtOB+/Hi9VdPKFAANJ2b7YOgMffgvzXuqog7R6A/JtrzRf128dsxummyWhg==", "dev": true, "license": "MIT", "engines": { @@ -4922,15 +4837,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.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.71.1.tgz", + "integrity": "sha512-kH3t3jZCYv2PZSBEWPKTariLrCaoF1CLoPitYFBJOhAGEwWnSRAtGEifrfrpBa4K9JlwWNZotgA5w3/wLSk/FQ==", "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.1", + "@typescript-eslint/typescript-estree": "8.71.1", + "@typescript-eslint/utils": "8.71.1", "debug": "^4.4.3", "ts-api-utils": "^2.5.0" }, @@ -4947,9 +4862,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.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.71.1.tgz", + "integrity": "sha512-ybYnPTUwg3VQQOFEiBPLs8Jxl1ry2vXdn1kVKguomBbvxpetdu3MKTNDqFNUrlG8ylP9MpZosmXMFYlGQHk1oQ==", "dev": true, "license": "MIT", "engines": { @@ -4961,16 +4876,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.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.71.1.tgz", + "integrity": "sha512-pSKSEK1JJpHs6KiKVtABCE4iJK5d7FLFzDXQNBQ2oRY7LEd3UwOt3TZemTgu9syTIf56dJWlTn/pdmrO9oKUXQ==", "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.1", + "@typescript-eslint/tsconfig-utils": "8.71.1", + "@typescript-eslint/types": "8.71.1", + "@typescript-eslint/visitor-keys": "8.71.1", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", @@ -4999,9 +4914,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 +4956,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.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.71.1.tgz", + "integrity": "sha512-CBjT6gfAz3DW2j9Q4moFnqt208Aq+506sVGXfpRcyAeqn0EpayL0mjdWoCO2t3RC7GewNrFxdeehuEaPg6DMzQ==", "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.1", + "@typescript-eslint/types": "8.71.1", + "@typescript-eslint/typescript-estree": "8.71.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -5065,13 +4980,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.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.71.1.tgz", + "integrity": "sha512-0GxiUGqMU0qXJt58cBsiG3DP8lUmBaFo2vChtUX+CT7PnR3v/VGLLrzVP4K9r1lNiZXjHYefMBbPunplr09v6w==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.70.0", + "@typescript-eslint/types": "8.71.1", "eslint-visitor-keys": "^5.0.0" }, "engines": { @@ -5450,9 +5365,9 @@ ] }, "node_modules/@vitejs/plugin-react": { - "version": "6.1.1", - "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.1.1.tgz", - "integrity": "sha512-yxLaQV9gkhS8ezJqCM6+ndU7mDY6gqAg75NQ+0IjwEI8IYOmQCgkRwHKVSfWXW076DsqMo0Dk+0FK1U+M5RgFw==", + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.1.2.tgz", + "integrity": "sha512-fhJNGUdscQa4HkI0xFxwonWyhnVReCPDbXI7FH7jmzpnVCrFQI7PRrhWecO263610vOrs9tr2Y4xcf8uAdFxbw==", "dev": true, "license": "MIT", "dependencies": { @@ -5464,7 +5379,7 @@ "peerDependencies": { "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", "babel-plugin-react-compiler": "^1.0.0", - "oxc-transform-react": "^0.145.0", + "oxc-transform-react": "^0.152.0", "vite": "^8.0.0" }, "peerDependenciesMeta": { @@ -5480,9 +5395,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.3", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-5.0.3.tgz", + "integrity": "sha512-+klsyz7BvT1vCU28Zkfzms1Ia78XD0V41U3FUtRaa3S+vOr/EXvK1O6BvVD7l1RzEjm6SONR2aybOvDDf9u0mQ==", "devOptional": true, "license": "MIT", "dependencies": { @@ -5499,8 +5414,8 @@ "url": "https://opencollective.com/vitest" }, "peerDependencies": { - "@vitest/browser": "5.0.0", - "vitest": "5.0.0" + "@vitest/browser": "5.0.3", + "vitest": "5.0.3" }, "peerDependenciesMeta": { "@vitest/browser": { @@ -5509,9 +5424,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 +5434,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.3", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-5.0.3.tgz", + "integrity": "sha512-T8sWAIbkSyAjkwTcaEc3Iu0o9A27X1/kdXrizhZkGuSKScRQtRzclfAMpOTcGdXCsqxeWlpGy3XjqaW8CpLORg==", "devOptional": true, "license": "MIT", "dependencies": { "@jridgewell/trace-mapping": "0.3.31", - "@vitest/spy": "5.0.0", + "@vitest/spy": "5.0.3", "estree-walker": "^3.0.3", "magic-string": "^1.2.3" }, @@ -5560,9 +5475,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.3", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.4.3.tgz", + "integrity": "sha512-z12OxmaPGE0F4xlpGdjuAUckipLpQlTAuAmyGhNoD7ODpYUzvDfvtxUbjM/jwznyP178oju/KDdyi220wNJ0RQ==", "devOptional": true, "license": "MIT", "dependencies": { @@ -5570,9 +5485,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.3", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-5.0.3.tgz", + "integrity": "sha512-XhFysQTB8AZ+P4gMi+Lpo99vg2AZi0qKpaB9yXQl37+CaMEAPO3iH/wGVnSyL5MPERiLezpqTVtrR6UZH5GCXg==", "devOptional": true, "license": "MIT", "funding": { @@ -5588,15 +5503,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", @@ -5604,9 +5510,9 @@ "license": "MIT" }, "node_modules/acorn": { - "version": "8.18.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz", - "integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==", + "version": "8.19.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.19.0.tgz", + "integrity": "sha512-oJlA3XiRm7Cyk6qFD2Jn8ak9B7jSy0qy00ADO3+8dpT0LSjFihQYv4C02LFCSYJV3Q37xYwwRy5m+IpIiUzqWw==", "dev": true, "license": "MIT", "bin": { @@ -5626,20 +5532,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" } @@ -5662,6 +5561,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": { @@ -5864,9 +5764,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": { @@ -5909,9 +5809,9 @@ } }, "node_modules/axe-core": { - "version": "4.13.0", - "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.13.0.tgz", - "integrity": "sha512-UzGt8zg7Ny8djbYMhxl2zuEevVa7r2gJjYY5Lwr1xM7+XU2nd6CkIWFTVcCIbAP63vSz71NaVyyuSk9lHKcy0A==", + "version": "4.14.0", + "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.14.0.tgz", + "integrity": "sha512-9WTZxEjsZ7b13TH8JPmbV2z8CHbl80/2hm3XPEG4JgNdQLK81IBRXmSxHfMAOkSqQeRxT/0dwNDz2GOm3zzpcQ==", "dev": true, "license": "MPL-2.0", "engines": { @@ -5928,26 +5828,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", @@ -5976,9 +5856,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.28", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.28.tgz", + "integrity": "sha512-br0LOmjJqr6Rt4AEkadbcNf67Zpo7EPwKsWbjnRrJIa1QYFHQBqM4C7F2WTbV8ahTEcg1Eo+Y0aWjjORInYFyA==", "license": "Apache-2.0", "bin": { "baseline-browser-mapping": "dist/cli.cjs" @@ -5988,20 +5868,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.7", + "resolved": "https://registry.npmjs.org/better-auth/-/better-auth-1.7.7.tgz", + "integrity": "sha512-Zhgxi/c5ylmfB/sX+nwnBbsFf/j6k0L8DvL6CJXZSrXgxc5pPMC/e7j812zNd4L4B4ELmdURCPw3X8nAOUdcjw==", + "license": "MIT", + "dependencies": { + "@better-auth/core": "1.7.7", + "@better-auth/drizzle-adapter": "1.7.7", + "@better-auth/kysely-adapter": "1.7.7", + "@better-auth/memory-adapter": "1.7.7", + "@better-auth/mongo-adapter": "1.7.7", + "@better-auth/prisma-adapter": "1.7.7", + "@better-auth/telemetry": "1.7.7", "@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 +5897,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 +5927,6 @@ "@tanstack/solid-start": { "optional": true }, - "better-sqlite3": { - "optional": true - }, "drizzle-kit": { "optional": true }, @@ -6130,16 +6006,10 @@ "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.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 +6040,10 @@ } }, "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==", + "dev": true, "funding": [ { "type": "opencollective", @@ -6189,11 +6060,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" @@ -6256,15 +6127,16 @@ "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" } }, "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.30001815", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001815.tgz", + "integrity": "sha512-uDybUEyfgbyJJ93J1vikTylBX31AkdVYeVPeffMQpf8XN7hSfr0oZOn4VFOIMNvtU5+rHvC5Udg2TgtJKHm23Q==", "funding": [ { "type": "opencollective", @@ -6282,9 +6154,9 @@ "license": "CC-BY-4.0" }, "node_modules/chai": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", - "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.3.0.tgz", + "integrity": "sha512-XWAtwJ6OHO+tj0EKCs0Y2UamnyOxseZWltU4x2U2wh8g4AigdjwvtUjvLP2tqkA/avxHEtzxNaqGq/YGNwckKg==", "devOptional": true, "license": "MIT", "engines": { @@ -6292,9 +6164,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": { @@ -6311,96 +6183,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/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", @@ -6429,20 +6211,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", @@ -6477,22 +6245,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" @@ -6505,6 +6262,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": { @@ -6528,15 +6286,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", @@ -6544,16 +6293,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", @@ -6567,32 +6306,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", @@ -6608,38 +6321,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", @@ -6649,18 +6335,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", @@ -6668,85 +6342,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": { @@ -6757,17 +6357,17 @@ "license": "BSD-2-Clause" }, "node_modules/data-urls": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", - "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-8.0.0.tgz", + "integrity": "sha512-JQUCCK+/CAGbGGx618PlFZMXH81Jtj7t73CHv9dd/zVWvxgGT2zLFRjnKb+Ng46GIwcy+f7f88qd4duqllK+WQ==", "devOptional": true, "license": "MIT", "dependencies": { "whatwg-mimetype": "^5.0.0", - "whatwg-url": "^16.0.0" + "whatwg-url": "^17.1.2" }, "engines": { - "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + "node": "^22.14.0 || >=24.0.0" } }, "node_modules/data-view-buffer": { @@ -6918,12 +6518,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", @@ -6950,15 +6544,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.9.0", + "resolved": "https://registry.npmjs.org/docx/-/docx-9.9.0.tgz", + "integrity": "sha512-qefptMMO31ZCfVnFANQWSLtnvUrYkqd7cLr+Bi4e+kl4LvYOmdk314iwp8XHHyQs3TPtPcQwqgZgN/MJxT7Ttw==", "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 +6561,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.5", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.6.5.tgz", + "integrity": "sha512-uMzsv9stDdofIZBx4UGh4Z9ZgXnGC9seRDscilBcTHAeOFk67MZg/k9bWVfO+bk4qZmG+l52hRnLwUiH2LnJTg==", "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" @@ -7011,66 +6605,14 @@ "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": "17.4.2", - "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz", - "integrity": "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==", + "version": "18.0.7", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-18.0.7.tgz", + "integrity": "sha512-QEvMEF8ca3GNZJJnAbOgxpj5ULhBvnMcURp+XKrtvqFNPgjBvYoswQ0Jl2cO7JzCjxZjJhr9ZuIEhlrRxMft/Q==", "license": "BSD-2-Clause", + "bin": { + "dotenv": "dist/index.cjs" + }, "engines": { "node": ">=12" }, @@ -7093,76 +6635,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.9", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", - "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", - "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.427", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.427.tgz", - "integrity": "sha512-n14zb3FdsChZ2BNobqNHAJMcP3ifFv4paox2LvCrfVAQcqGiSURgbJl+PfMpHVCNFkStnNc+RRVtPBTVW5PDgw==", + "version": "1.5.452", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.452.tgz", + "integrity": "sha512-cVvk1YBSj6571VmV6hdJTqj94Y79xSu8cj6Y9t6ae4Y4431z8/7EJFvu3rVp90xhfuv9BpPKUmaLfTo4+FOGBg==", + "dev": true, "license": "ISC" }, "node_modules/emoji-regex": { @@ -7178,35 +6655,10 @@ "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.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": { @@ -7218,35 +6670,18 @@ } }, "node_modules/entities": { - "version": "4.5.0", - "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", - "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.1.0.tgz", + "integrity": "sha512-kxL7msIffSuh9aaFAMD7rxAIuTRMAHMeBtgHW2yUdWw732ZNh4MehkF2gdjvtdmikkaIP9bFDDJOPlsvm7avrA==", + "devOptional": true, "license": "BSD-2-Clause", "engines": { - "node": ">=0.12" + "node": ">=20.19.0" }, "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", @@ -7498,23 +6933,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", @@ -7590,21 +7014,21 @@ } }, "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.4.0", + "resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-16.4.0.tgz", + "integrity": "sha512-2RHLYswzc+hNRktWAjIL6XAvwb1cKcjLawJ5KWywAxn/JivNHtG42ZigL1yobVXDqsimuuL2F9jcEcnHFzbppQ==", "dev": true, "license": "MIT", "dependencies": { - "@next/eslint-plugin-next": "16.3.5", + "@next/eslint-plugin-next": "16.4.0", "eslint-import-resolver-node": "^0.3.6", "eslint-import-resolver-typescript": "^3.5.2", "eslint-plugin-import": "^2.32.0", "eslint-plugin-jsx-a11y": "^6.10.0", "eslint-plugin-react": "^7.37.0", - "eslint-plugin-react-hooks": "^7.0.0", + "eslint-plugin-react-hooks": "^7.1.0", "globals": "16.4.0", - "typescript-eslint": "^8.46.0" + "typescript-eslint": "^8.56.0" }, "peerDependencies": { "eslint": ">=9.0.0", @@ -8299,15 +7723,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", @@ -8387,23 +7802,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", @@ -8417,64 +7815,28 @@ "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==", + "node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, "license": "MIT", "engines": { - "node": "18 || 20 || >=22" + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "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==", + "node_modules/globalthis": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", + "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==", + "dev": true, "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", - "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/globalthis": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", - "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "define-properties": "^1.2.1", - "gopd": "^1.0.1" + "define-properties": "^1.2.1", + "gopd": "^1.0.1" }, "engines": { "node": ">= 0.4" @@ -8503,41 +7865,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", @@ -8675,104 +8002,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" - }, - "engines": { - "node": "^20.19.0 || ^22.12.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" + "@exodus/bytes": "^1.15.1" }, - "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": "^22.13.0 || >=24.0.0" } }, "node_modules/hyphen": { @@ -8818,6 +8057,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", @@ -8856,12 +8096,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", @@ -8895,12 +8129,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", @@ -8991,13 +8219,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" @@ -9083,15 +8311,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", @@ -9125,12 +8344,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", @@ -9401,33 +8614,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", @@ -9438,6 +8624,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", @@ -9457,32 +8644,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.2", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-30.1.2.tgz", + "integrity": "sha512-0FFE/jE1rppmVfUrJUgxqXjcwolZYIGtAgj1pTussMhNZxIxi7W/PvfWaMNroGi2B36X1IKHbexpZ9DhkoOPiQ==", "devOptional": true, "license": "MIT", "dependencies": { - "@asamuzakjp/css-color": "^6.0.5", - "@asamuzakjp/dom-selector": "^8.3.0", + "@asamuzakjp/css-color": "^7.1.3", + "@asamuzakjp/dom-selector": "^9.2.4", "@bramus/specificity": "^2.4.2", - "@csstools/css-syntax-patches-for-csstree": "^1.1.7", - "@exodus/bytes": "^1.15.1", + "@csstools/css-syntax-patches-for-csstree": "^1.1.15", + "@exodus/bytes": "^1.16.0", "css-tree": "^3.2.1", - "data-urls": "^7.0.0", + "data-urls": "^8.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", + "lru-cache": "^11.5.3", "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.11.2", + "w3c-xmlserializer": "^6.0.0", "webidl-conversions": "^8.0.1", "whatwg-mimetype": "^5.0.0", - "whatwg-url": "^17.1.0", + "whatwg-url": "^17.2.0", "xml-name-validator": "^5.0.0" }, "engines": { @@ -9498,40 +8684,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", @@ -9552,12 +8713,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", @@ -9606,145 +8761,6 @@ "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==", - "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" - } - }, - "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==", - "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.28.1", - "resolved": "https://registry.npmjs.org/undici/-/undici-6.28.1.tgz", - "integrity": "sha512-zWpdTVD54H48CIybL0rWQ3ukpb9d23wM7eH5RtfdmeP70cWHNjtfo7P4vZX+5CoDcO53J4Pu5uXp7lNfjc6DRA==", - "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", @@ -9756,9 +8772,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" @@ -10080,18 +9096,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", @@ -10111,12 +9115,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", @@ -10133,18 +9131,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", @@ -10152,12 +9138,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", @@ -10181,12 +9161,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.55.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.55.0.tgz", + "integrity": "sha512-bSozQHiI3/8q7JvgoqVRGOYUi7fYT3siAZwmZsljjsOH2Husp/2ODdFf/leD+ZC/2yKfn7r8oh6yeoR3ITR5vA==", "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": { @@ -10236,6 +9222,7 @@ "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/media-engine": { @@ -10244,12 +9231,6 @@ "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", @@ -10274,973 +9255,43 @@ "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.9", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", - "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", - "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-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", - "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==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.28.4", - "lodash": "^4.17.21", - "mjml-core": "5.4.1" - } - }, - "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==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.28.4", - "detect-node": "2.1.0", - "htmlparser2": "^9.1.0", - "lodash": "^4.18.1" - } - }, - "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" - } - ], - "license": "MIT", - "dependencies": { - "domelementtype": "^2.3.0", - "domhandler": "^5.0.3", - "domutils": "^3.1.0", - "entities": "^4.5.0" - } - }, - "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==", - "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" - } - }, - "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/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/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==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.28.4", - "lodash": "^4.17.21", - "mjml-core": "5.4.1" - } - }, - "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==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.28.4", - "lodash": "^4.17.21", - "mjml-core": "5.4.1" - } - }, - "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": { @@ -11261,9 +9312,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.2", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-6.0.2.tgz", + "integrity": "sha512-hIvVOb9Qr1p/9CNNLOe0OlWvgx/ziTsVwKN/GDSsuArN2K0wDiafP3s0SWmsm7o2BdnyS8Z9Z64NFBUbn6ziBg==", "funding": [ { "type": "github", @@ -11275,13 +9326,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.5", + "resolved": "https://registry.npmjs.org/nanostores/-/nanostores-1.5.5.tgz", + "integrity": "sha512-FixtE239Gl6Gz0ZGCK0WzvNwObNCIwVT+L7zQcf1QgcA6AzBxblzqe9OT88qiucNkhsjSOqiogSaF5jVbJiqSA==", "funding": [ { "type": "github", @@ -11317,12 +9368,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.4.0", + "resolved": "https://registry.npmjs.org/next/-/next-16.4.0.tgz", + "integrity": "sha512-N2C+9q89Z/oLrsepSRS4jRMtwmUZAoZuAHU0e9T6+sAB5DdhADmrnOW+zjYijlrYFmVfmQhGVGy9eMFoB7UgKg==", "license": "MIT", "dependencies": { - "@next/env": "16.3.5", + "@next/env": "16.4.0", "@swc/helpers": "0.5.23", "baseline-browser-mapping": "^2.9.19", "caniuse-lite": "^1.0.30001579", @@ -11336,14 +9387,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.4.0", + "@next/swc-darwin-x64": "16.4.0", + "@next/swc-linux-arm64-gnu": "16.4.0", + "@next/swc-linux-arm64-musl": "16.4.0", + "@next/swc-linux-x64-gnu": "16.4.0", + "@next/swc-linux-x64-musl": "16.4.0", + "@next/swc-win32-arm64-msvc": "16.4.0", + "@next/swc-win32-x64-msvc": "16.4.0", "sharp": "^0.35.4" }, "peerDependencies": { @@ -11370,9 +9421,9 @@ } }, "node_modules/next/node_modules/nanoid": { - "version": "3.3.19", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.19.tgz", - "integrity": "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==", + "version": "3.3.20", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz", + "integrity": "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==", "funding": [ { "type": "github", @@ -11435,29 +9486,15 @@ } }, "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.58", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.58.tgz", + "integrity": "sha512-BdiKAgbfRlvfMq/XzO/AF9NPmaA8a+YpLAyAFTVFBj3gARrAIfWvNq7Q7NE4se3KgFu0KkrvQMxTS0XVnsGzHg==", + "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", @@ -11467,18 +9504,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", @@ -11694,6 +9719,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" @@ -11702,24 +9728,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", @@ -11739,92 +9747,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", - "integrity": "sha512-kxL7msIffSuh9aaFAMD7rxAIuTRMAHMeBtgHW2yUdWw732ZNh4MehkF2gdjvtdmikkaIP9bFDDJOPlsvm7avrA==", - "devOptional": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=20.19.0" - }, - "funding": { - "url": "https://github.com/fb55/entities?sponsor=1" - } - }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -11852,31 +9774,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.2", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", - "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", - "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", @@ -11935,9 +9832,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" @@ -11978,9 +9875,10 @@ } }, "node_modules/postcss": { - "version": "8.5.28", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz", - "integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==", + "version": "8.5.29", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.29.tgz", + "integrity": "sha512-49cGhUbXj8Qenv0iTMxA1cFBzxXoctpC9Ujd77t1WcbJIr6nF/eI7g/8MgxrYldFRuAXvja7xQRwavoW7kgrxQ==", + "devOptional": true, "funding": [ { "type": "opencollective", @@ -11995,242 +9893,39 @@ "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-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", - "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-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", - "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-value-parser": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", - "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", - "license": "MIT" - }, - "node_modules/postcss/node_modules/nanoid": { - "version": "3.3.19", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.19.tgz", - "integrity": "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "bin": { - "nanoid": "bin/nanoid.cjs" - }, - "engines": { - "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", + "license": "MIT", "dependencies": { - "dom-serializer": "^1.0.1", - "domelementtype": "^2.2.0", - "domhandler": "^4.2.0" + "nanoid": "^3.3.19", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.2" }, - "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": "^10 || ^12 || >=14" } }, - "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==", + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "license": "MIT" + }, + "node_modules/postcss/node_modules/nanoid": { + "version": "3.3.20", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz", + "integrity": "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==", + "devOptional": true, "funding": [ - "https://github.com/fb55/htmlparser2?sponsor=1", { "type": "github", - "url": "https://github.com/sponsors/fb55" + "url": "https://github.com/sponsors/ai" } ], "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" + "bin": { + "nanoid": "bin/nanoid.cjs" }, "engines": { - "node": ">=12" + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, "node_modules/prelude-ls": { @@ -12265,12 +9960,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", @@ -12288,12 +9977,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", @@ -12362,9 +10045,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 +10057,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": { @@ -12469,19 +10158,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", @@ -12540,15 +10216,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", @@ -12586,6 +10253,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" @@ -12619,14 +10287,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.13", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.13.tgz", + "integrity": "sha512-/5apOkwWxtACcQtZ5WlrAsKS4JwXVThQPJZ3RqIruRY9UG9M8e0VlXN068xum/JRKA4aIUoJ13ghe/kGCvidiQ==", "devOptional": true, "license": "MIT", "peer": true, "dependencies": { - "@oxc-project/types": "=0.149.0", + "@oxc-project/types": "=0.153.0", "@rolldown/pluginutils": "^1.0.0" }, "bin": { @@ -12636,21 +10304,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.13", + "@rolldown/binding-android-arm64": "1.2.13", + "@rolldown/binding-darwin-arm64": "1.2.13", + "@rolldown/binding-darwin-x64": "1.2.13", + "@rolldown/binding-freebsd-x64": "1.2.13", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.13", + "@rolldown/binding-linux-arm64-gnu": "1.2.13", + "@rolldown/binding-linux-arm64-musl": "1.2.13", + "@rolldown/binding-linux-ppc64-gnu": "1.2.13", + "@rolldown/binding-linux-s390x-gnu": "1.2.13", + "@rolldown/binding-linux-x64-gnu": "1.2.13", + "@rolldown/binding-linux-x64-musl": "1.2.13", + "@rolldown/binding-openharmony-arm64": "1.2.13", + "@rolldown/binding-win32-arm64-msvc": "1.2.13", + "@rolldown/binding-win32-x64-msvc": "1.2.13" } }, "node_modules/rou3": { @@ -12762,6 +10430,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": { @@ -12802,10 +10471,16 @@ "semver": "bin/semver.js" } }, + "node_modules/server-only": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/server-only/-/server-only-0.0.1.tgz", + "integrity": "sha512-qepMx2JxAa5jjfzxG79yPPq+8BuFToHd1hm7kI+Z4zAq1ftQiP7HcxMhDDItrbtwVeLg/cY2JnKnrcFkmiswNA==", + "license": "MIT" + }, "node_modules/set-cookie-parser": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-3.1.2.tgz", - "integrity": "sha512-5/r/lTwbJ3zQ+qwdUFZYeRNqda7P5HD8zQKqlSjdGt1/S0cjLAphHusj4Y58ahDtWn/g32xrIS58/ikOvwl0Lw==", + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-3.1.3.tgz", + "integrity": "sha512-xletSdAI5efyVJjsatBbzDkXl3M/Zmad3xfnQe7ZXuBB0yNwW7C2WFsNGjdRNfZunmNHQuX79xs/ACMTLCdcew==", "license": "MIT" }, "node_modules/set-function-length": { @@ -12864,9 +10539,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 +10556,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 +10700,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", @@ -13045,15 +10713,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", @@ -13071,9 +10730,9 @@ } }, "node_modules/source-map-js": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", - "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", + "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", "license": "BSD-3-Clause", "engines": { "node": ">=0.10.0" @@ -13086,17 +10745,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" }, @@ -13123,26 +10775,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", @@ -13257,18 +10889,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", @@ -13360,75 +10980,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/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 +11018,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.1", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-6.2.1.tgz", + "integrity": "sha512-IAbQaPJIIdhVxi0mqy9lez1wEwje0UQf9F1vwtbelEG5Nbgn0nPOxCvU+mNeNS9gyJTAhrHp74CbUaSynQpZcQ==", "devOptional": true, "license": "MIT", "engines": { @@ -13477,9 +11028,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": { @@ -13535,9 +11086,9 @@ } }, "node_modules/tinyrainbow": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.1.tgz", - "integrity": "sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==", + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.2.0.tgz", + "integrity": "sha512-LgO3D9yZJjApUiuUfl9iFAwrtaX4+lok3wJIqttGoKCHlWUqHqbQpnxCf82L8FjgKsh4iGo78hqJwgL8F6To2A==", "devOptional": true, "license": "MIT", "engines": { @@ -13545,22 +11096,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.18", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.18.tgz", + "integrity": "sha512-somxA8xKAUzlAxl0TX/jMSYIbRNFIVGnI96pVGJPeMc5BsHmfVXn6mOJT6U++ELnl04eGfzpfcEuZe8WiASkXg==", "devOptional": true, "license": "MIT", "dependencies": { - "tldts-core": "^7.4.12" + "tldts-core": "^7.4.18" }, "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.18", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.18.tgz", + "integrity": "sha512-KI2PKvCmvQI/xD6ypPqkU0i7oxNXR/O58RLRaHR4svsgVUQ2JE/XC8BtMbG6PSehD27I8sZJRcC1ASnCy6lp3g==", "devOptional": true, "license": "MIT" }, @@ -13591,16 +11142,16 @@ } }, "node_modules/tr46": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", - "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-7.0.0.tgz", + "integrity": "sha512-BcDSDW+SIGWVOns1Zm6Yg91q9AEj7eaID0QDY9oIqJD1A8dscNwMcl9Sms2DeuWCNov1X0/oXanHGYQBSLwZsg==", "devOptional": true, "license": "MIT", "dependencies": { "punycode": "^2.3.1" }, "engines": { - "node": ">=20" + "node": "^22.14.0 || >=24.0.0" } }, "node_modules/ts-api-utils": { @@ -13649,9 +11200,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 +11276,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" @@ -13771,7 +11321,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,16 +11332,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.1", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.71.1.tgz", + "integrity": "sha512-oiNVPC3/NbV1FpaHU07l8O6ynqG9v4PAiW2WvTrAmZdR0f2HI/gE668ElMLCK7Xbsu5k2fK5C4fXNiq05j7/nQ==", "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.1", + "@typescript-eslint/parser": "8.71.1", + "@typescript-eslint/typescript-estree": "8.71.1", + "@typescript-eslint/utils": "8.71.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -13824,25 +11374,20 @@ "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": "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" }, @@ -13911,9 +11456,10 @@ } }, "node_modules/update-browserslist-db": { - "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==", + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.4.tgz", + "integrity": "sha512-t8wonIOdQyOjtPd2VktlBu6QjTVTks6v8bSi7kd0r2Vll8TK1JYDZtRRdbmCHOAMphkZgrzK+L+vXK4pyZ8t4A==", + "dev": true, "funding": [ { "type": "opencollective", @@ -13999,15 +11545,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", @@ -14022,17 +11559,17 @@ } }, "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.4", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.4.tgz", + "integrity": "sha512-IftLE8phJhdNRIGNEV4JCCvfNScoZQVfcXaN8TQropWGJtOHcu+iAB9pHcuGvmEb/kddw0LBe/jS8ztdC6xdiQ==", "devOptional": true, "license": "MIT", "peer": true, "dependencies": { "lightningcss": "^1.33.0", "picomatch": "^4.0.7", - "postcss": "^8.5.28", - "rolldown": "~1.2.6", + "postcss": "^8.5.29", + "rolldown": "~1.2.12", "tinyglobby": "^0.2.17" }, "bin": { @@ -14389,14 +11926,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.3", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-5.0.3.tgz", + "integrity": "sha512-xMw97S3rjdtj5dkVat7jCsqWBpvchs3RlpQctUqwJD0KkERk40vz2fJ77lDwW/Vzh/pk18eItYAzkodhSes3jQ==", "devOptional": true, "license": "MIT", "dependencies": { "@types/chai": "^5.2.2", - "@vitest/mocker": "5.0.0", + "@vitest/mocker": "5.0.3", "chai": "^6.2.2", "es-module-lexer": "^2.3.2", "expect-type": "^1.4.0", @@ -14404,10 +11941,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 +11959,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.3", + "@vitest/browser-preview": "5.0.3", "@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.3", + "@vitest/coverage-v8": "5.0.3", + "@vitest/ui": "5.0.3", "happy-dom": "*", "jsdom": "*", "vite": "^6.4.0 || ^7.0.0 || ^8.0.0" @@ -14472,9 +12009,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.3", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.4.3.tgz", + "integrity": "sha512-z12OxmaPGE0F4xlpGdjuAUckipLpQlTAuAmyGhNoD7ODpYUzvDfvtxUbjM/jwznyP178oju/KDdyi220wNJ0RQ==", "devOptional": true, "license": "MIT", "dependencies": { @@ -14495,51 +12032,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_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": "^22.22.2 || ^24.15.0 || >=26.0.0" } }, "node_modules/webidl-conversions": { @@ -14552,31 +12054,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", @@ -14588,18 +12065,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.2.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-17.2.0.tgz", + "integrity": "sha512-IhLIHNcBrzIixC5XXsbr4DxdjajyA5vQ5RwGZMtrUp+GbtE5Nrsge9QKau7jXrC0aTXLYGV0xiW7Tb4YS2aomw==", "devOptional": true, "license": "MIT", "dependencies": { - "@exodus/bytes": "^1.11.0", - "tr46": "^6.0.0", + "@exodus/bytes": "^1.16.0", + "tr46": "^7.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 +12170,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 +12192,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.1", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-3.2.1.tgz", + "integrity": "sha512-Tb2FUhB4vUsGQlfSquQLYkApkuPAFQXGFzxWKHHumVz2dK+X1RUm/HnID4+TfIGYJ1kTcwOaCk/buYCEJr6YjQ==", "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": { @@ -14741,38 +12214,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", @@ -14808,15 +12249,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", @@ -14824,33 +12256,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", @@ -14871,9 +12276,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..6adcc6a 100644 --- a/package.json +++ b/package.json @@ -9,78 +9,76 @@ "dev": "next dev", "build": "tsx src/lib/providers/ministry-platform/scripts/build-sql-install.ts && next build", "start": "next start", + "check:prerender": "node scripts/check-prerender.mjs", "lint": "eslint .", "typecheck": "tsc --noEmit", "test": "vitest", "test:run": "vitest run", "test:coverage": "vitest run --coverage", - "mp:generate": "tsx src/lib/providers/ministry-platform/scripts/generate-types.ts", - "mp:generate:models": "tsx src/lib/providers/ministry-platform/scripts/generate-types.ts -o src/lib/providers/ministry-platform/models --zod --clean", - "mp:generate:storedprocs": "tsx src/lib/providers/ministry-platform/scripts/generate-storedprocs.ts", + "mp:generate": "tsx --conditions=react-server src/lib/providers/ministry-platform/scripts/generate-types.ts", + "mp:generate:models": "tsx --conditions=react-server src/lib/providers/ministry-platform/scripts/generate-types.ts -o src/lib/providers/ministry-platform/models --zod --clean", + "mp:generate:storedprocs": "tsx --conditions=react-server 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", "@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", - "@radix-ui/react-avatar": "^1.2.6", - "@radix-ui/react-checkbox": "^1.3.11", + "@radix-ui/react-alert-dialog": "^1.1.24", + "@radix-ui/react-avatar": "^1.2.7", + "@radix-ui/react-checkbox": "^1.3.12", "@radix-ui/react-dialog": "^1.1.15", - "@radix-ui/react-dropdown-menu": "^2.1.24", - "@radix-ui/react-label": "^2.1.15", - "@radix-ui/react-popover": "^1.1.23", - "@radix-ui/react-radio-group": "^1.4.7", - "@radix-ui/react-select": "^2.3.7", + "@radix-ui/react-dropdown-menu": "^2.1.25", + "@radix-ui/react-label": "^2.1.16", + "@radix-ui/react-popover": "^1.2.0", + "@radix-ui/react-radio-group": "^1.4.8", + "@radix-ui/react-select": "^2.3.8", "@radix-ui/react-slot": "^1.2.4", - "@radix-ui/react-switch": "^1.3.7", - "@radix-ui/react-tooltip": "^1.2.16", + "@radix-ui/react-switch": "^1.3.8", + "@radix-ui/react-tooltip": "^1.3.0", "@react-pdf/renderer": "^4.9.0", - "better-auth": "^1.7.4", + "better-auth": "^1.7.7", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", - "docx": "^9.7.1", - "docxtemplater": "^3.69.3", + "docx": "^9.9.0", + "docxtemplater": "^3.71.0", "docxtemplater-image": "^0.1.2", - "dotenv": "^17.4.2", - "grapesjs": "^0.22.16", - "grapesjs-mjml": "^1.0.8", - "lucide-react": "^1.45.0", - "mjml": "^5.4.1", - "next": "^16.3.5", - "pizzip": "^3.2.0", + "dotenv": "^18.0.7", + "jose": "^6.2.12", + "lucide-react": "^1.55.0", + "next": "^16.4.0", + "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", + "server-only": "^0.0.1", "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", + "@inquirer/prompts": "^8.7.3", "@tailwindcss/postcss": "^4.3.3", "@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.13.4", + "@types/node": "^24.19.2", "@types/react": "^19.3.0", "@types/react-dom": "^19.3.0", - "@vitejs/plugin-react": "^6.1.1", + "@vitejs/plugin-react": "^6.1.2", "@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", - "postcss": "^8.5.28", + "eslint-config-next": "^16.4.0", + "jsdom": "^30.1.2", + "postcss": "^8.5.29", "tailwindcss": "^4.2.0", "tw-animate-css": "^1.4.0", "typescript": "^6.0.3", diff --git a/scripts/check-prerender.mjs b/scripts/check-prerender.mjs new file mode 100644 index 0000000..af90ecc --- /dev/null +++ b/scripts/check-prerender.mjs @@ -0,0 +1,55 @@ +#!/usr/bin/env node +/** + * Fails when `next build` statically prerendered a route that has no business + * being static. Run after `npm run build` (CI does). + * + * WHY: the CSP in `src/proxy.ts` is nonce-based, and Next reads the nonce off + * the incoming request at render time. A route prerendered at build time has no + * request and therefore no nonce, so under the enforced CSP its scripts are + * blocked and it never hydrates — silently, with no build error. A static page + * also skips every server-side session and role check. `src/app/signin` was + * bitten by exactly this; see the `force-dynamic` note in its `page.tsx`. + * + * ALLOWED are Next's two built-in shells, both static by design and neither + * dependent on hydration to be useful: + * /_not-found plain 404 markup; its links work without JavaScript + * /_global-error only ever rendered client-side, after a crash + * Anything else showing up here means a route lost its dynamic signal (a + * `headers()`/session read, or `export const dynamic = "force-dynamic"` in a + * SERVER module). Fix the route; do not widen this list without the same kind + * of written justification. + */ +import { readFileSync } from "node:fs"; +import path from "node:path"; + +const ALLOWED_STATIC = new Set(["/_not-found", "/_global-error"]); + +const manifestPath = path.join(process.cwd(), ".next", "prerender-manifest.json"); + +let manifest; +try { + manifest = JSON.parse(readFileSync(manifestPath, "utf8")); +} catch { + console.error(`check-prerender: cannot read ${manifestPath} — run \`npm run build\` first.`); + process.exit(1); +} + +const prerendered = [ + ...Object.keys(manifest.routes ?? {}), + ...Object.keys(manifest.dynamicRoutes ?? {}), +]; +const unexpected = prerendered.filter((route) => !ALLOWED_STATIC.has(route)); + +if (unexpected.length > 0) { + console.error("check-prerender: these routes were statically prerendered:"); + for (const route of unexpected) console.error(` ${route}`); + console.error( + "A prerendered page has no CSP nonce (it will not hydrate under the enforced policy) " + + "and skips server-side auth. Make the route dynamic; see scripts/check-prerender.mjs.", + ); + process.exit(1); +} + +console.log( + `check-prerender: OK (${prerendered.length} prerendered, all allowed: ${prerendered.join(", ") || "none"})`, +); diff --git a/scripts/setup.ts b/scripts/setup.ts index d6aed33..45e96b6 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', @@ -1058,6 +1068,15 @@ async function runInteractiveSetup(options: SetupOptions): Promise<number> { console.log(chalk.green(` ✓ MINISTRY_PLATFORM_CLIENT_SECRET = ********`)); } + // The same client handles user sign-in (OIDC), so its MP registration needs + // the sign-in redirect URIs too. See getMpClient in src/lib/env.ts. + console.log( + chalk.gray( + ' This client also signs users in. In MP, register its redirect URI\n' + + ' <BETTER_AUTH_URL>/api/auth/callback/ministryplatform and post-logout redirect URI <BETTER_AUTH_URL>.' + ) + ); + // Variables handled specially (skip in regular loop) const speciallyHandledVars = [ ...mpDerivedVars, @@ -1220,7 +1239,7 @@ async function runInteractiveSetup(options: SetupOptions): Promise<number> { printStepHeader(9, totalSteps, 'Generating stored procedure reference'); console.log( chalk.gray( - ' Produces .claude/references/ministryplatform.storedprocs.md — used by Claude Code' + ' Produces .claude/references/mp-schema/stored-procs.md — used by Claude Code' ) ); console.log(chalk.gray(' for stored-proc query suggestions and authoring assistance.')); diff --git a/src/app/(web)/error.tsx b/src/app/(web)/error.tsx index 353e583..fc099bb 100644 --- a/src/app/(web)/error.tsx +++ b/src/app/(web)/error.tsx @@ -6,9 +6,9 @@ import { useEffect } from "react"; * Error boundary for everything below the `(web)` layout. * * Placement is the whole design: because this sits INSIDE the `(web)` segment, - * the shell — header, user menu and critically the SIGN-OUT control — survives - * the error. A single boundary at `src/app/error.tsx` would replace the shell - * and strand the user with no way out. + * the `(web)` layout (AuthWrapper + Providers) survives the error. A single + * boundary at `src/app/error.tsx` would replace that shell. (There is no + * in-shell sign-out control; sign-out lives on `/no-access` and `/session-error`.) * * Two details that bite: * diff --git a/src/app/(web)/no-access/page.test.tsx b/src/app/(web)/no-access/page.test.tsx index ddb53f4..0264e06 100644 --- a/src/app/(web)/no-access/page.test.tsx +++ b/src/app/(web)/no-access/page.test.tsx @@ -1,16 +1,26 @@ -import { describe, it, expect, afterEach } from 'vitest'; +import { describe, it, expect, vi, afterEach } from 'vitest'; import { render, screen, cleanup } from '@testing-library/react'; +const { mockHandleSignOut } = vi.hoisted(() => ({ + mockHandleSignOut: vi.fn(), +})); + +vi.mock('@/components/shared-actions/sign-out', () => ({ + handleSignOut: mockHandleSignOut, +})); + import NoAccessPage from './page'; /** * This page is the landing spot for a signed-in user with no MP security role. - * It lives inside the (web) route group specifically so the app shell — and - * therefore the sign-out control — stays reachable. These tests pin the copy - * that tells the user what to do, since that copy IS the feature. + * Sign-in is not role-gated, so the user has a session; these tests pin the copy + * that tells them what to do and the sign-out form that lets them end it. */ -afterEach(cleanup); +afterEach(() => { + cleanup(); + vi.clearAllMocks(); +}); describe('NoAccessPage', () => { it('renders a heading explaining the user lacks access', () => { @@ -33,4 +43,14 @@ describe('NoAccessPage', () => { expect(screen.getByText(/ask your ministry platform administrator/i)).toBeInTheDocument(); }); + + it('renders a sign-out submit button wired to the handleSignOut action', () => { + // Without it a role-less user is stuck in their session until the 12 h + // lifetime ends — there is no other sign-out control on this page. + const { container } = render(<NoAccessPage />); + + const button = screen.getByRole('button', { name: /^sign out$/i }); + expect(button).toHaveAttribute('type', 'submit'); + expect(button.closest('form')).toBe(container.querySelector('form')); + }); }); diff --git a/src/app/(web)/no-access/page.tsx b/src/app/(web)/no-access/page.tsx index d694261..223601f 100644 --- a/src/app/(web)/no-access/page.tsx +++ b/src/app/(web)/no-access/page.tsx @@ -1,21 +1,34 @@ +import { handleSignOut } from "@/components/shared-actions/sign-out"; + /** * Explaining page for signed-in users who hold no MP security role. * - * Deliberately inside the `(web)` route group so it renders WITH the app shell: - * the header, user menu and — critically — the sign-out control stay available. - * Refusing these users at sign-in instead would strand them in the app with no - * way out, which is why sign-in is not role-gated (see AuthorizationService). + * Sign-in is deliberately not role-gated (see AuthorizationService), so these + * users DO have a session — and this page must give them a way to end it, e.g. + * to sign in again as a different MP user. The form below is that way out; the + * app has no other sign-out control outside `/session-error`. + * + * It sits inside the `(web)` group, so AuthWrapper renders it per request (it + * reads the session), which keeps the CSP nonce available for the form. */ export default function NoAccessPage() { return ( <div className="flex items-center justify-center min-h-[60vh] px-4"> <div className="max-w-md text-center"> <h1 className="text-2xl font-semibold mb-3">You don't have access to this tool</h1> - <p className="text-gray-600"> + <p className="text-gray-600 mb-6"> Your Ministry Platform account signed in successfully, but it doesn't have a security role that grants access to these tools. Ask your Ministry Platform administrator to assign one. </p> + <form action={handleSignOut}> + <button + type="submit" + className="inline-flex items-center justify-center rounded-md bg-[#344767] px-5 py-2.5 text-white font-medium hover:bg-[#2d3a5f] focus:outline-none focus:ring-2 focus:ring-blue-300" + > + Sign out + </button> + </form> </div> </div> ); 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() { </CardContent> </Card> - <Card className="flex flex-col"> - <CardHeader> - <CardTitle>Template Editor</CardTitle> - <CardDescription> - Create and edit templates in Ministry Platform - </CardDescription> - </CardHeader> - <CardContent className="mt-auto"> - <Link href="/tools/templateeditor"> - <Button className="w-full">Open Tool</Button> - </Link> - </CardContent> - </Card> - <Card className="flex flex-col"> <CardHeader> <CardTitle>Address Labels</CardTitle> diff --git a/src/app/(web)/tools/addeditfamily/actions.test.ts b/src/app/(web)/tools/addeditfamily/actions.test.ts index 07bb76c..678d2cb 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" }); }); }); @@ -391,8 +445,6 @@ describe("addeditfamily actions", () => { * `FamilyService` interpolates `envelopeNo` and `donorId` into MP `$filter` * strings and uses `donorId` to target a `Donors` update, so the payload has * to be parsed, not merely typed. - * - * See `.claude/TODO/2026-09-13-unvalidated-envelope-donor-ids-in-filter.md`. */ describe("saveFamily payload validation", () => { beforeEach(() => { 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/add-edit-family.test.tsx b/src/app/(web)/tools/addeditfamily/add-edit-family.test.tsx index c0a1897..d1ea455 100644 --- a/src/app/(web)/tools/addeditfamily/add-edit-family.test.tsx +++ b/src/app/(web)/tools/addeditfamily/add-edit-family.test.tsx @@ -305,13 +305,7 @@ describe("AddEditFamily — search bar", () => { expect(await screen.findByDisplayValue("Jones")).toBeInTheDocument(); }); - it("finds no CommandEmpty text when the search returns nothing (see TODO: cmdk always counts the '+ New Family' item)", async () => { - // NOTE: the source renders <CommandEmpty>No contacts found.</CommandEmpty> - // whenever query.length >= 2 && results.length === 0, but cmdk's - // CommandEmpty only paints when its *global* registered-item count is 0. - // The "+ New Family" CommandItem is always registered once the query is - // >= 2 chars, so that count is never 0 and this message never renders. - // See .claude/TODO/2026-09-13-search-empty-state-never-renders.md + it("shows 'No contacts found.' alongside the new-family option when the search returns nothing", async () => { const user = userEvent.setup(); mockSearchContacts.mockResolvedValue([]); setup(); @@ -319,8 +313,25 @@ describe("AddEditFamily — search bar", () => { await user.click(screen.getByRole("combobox", { name: "" })); await user.type(screen.getByPlaceholderText("Type a name…"), "zz"); await waitFor(() => expect(mockSearchContacts).toHaveBeenCalledWith("zz")); + expect(await screen.findByText("No contacts found.")).toBeInTheDocument(); + expect(screen.getByText(/\+ New Family with last name/)).toBeInTheDocument(); + }); + + it("does not claim 'No contacts found.' before the debounced search for the current term has run", async () => { + const user = userEvent.setup(); + mockSearchContacts.mockResolvedValue([]); + setup(); + await screen.findByText(/Search to find an existing household/i); + await user.click(screen.getByRole("combobox", { name: "" })); + const input = screen.getByPlaceholderText("Type a name…"); + await user.type(input, "zz"); + expect(await screen.findByText("No contacts found.")).toBeInTheDocument(); + + // A new term invalidates the previous answer until it has been searched. + await user.type(input, "y"); expect(screen.queryByText("No contacts found.")).not.toBeInTheDocument(); - expect(await screen.findByText(/\+ New Family with last name/)).toBeInTheDocument(); + await waitFor(() => expect(mockSearchContacts).toHaveBeenCalledWith("zzy")); + expect(await screen.findByText("No contacts found.")).toBeInTheDocument(); }); it("clears results silently when the search action throws", async () => { @@ -331,8 +342,9 @@ describe("AddEditFamily — search bar", () => { await user.click(screen.getByRole("combobox", { name: "" })); await user.type(screen.getByPlaceholderText("Type a name…"), "zz"); await waitFor(() => expect(mockSearchContacts).toHaveBeenCalledWith("zz")); - // No crash, and no stale results rendered. + // No crash, no stale results rendered, and no false "not found" claim. expect(screen.queryByText("Existing households")).not.toBeInTheDocument(); + expect(screen.queryByText("No contacts found.")).not.toBeInTheDocument(); }); it("creates a new family from the search box", async () => { diff --git a/src/app/(web)/tools/addeditfamily/add-edit-family.tsx b/src/app/(web)/tools/addeditfamily/add-edit-family.tsx index 2164938..516f2f0 100644 --- a/src/app/(web)/tools/addeditfamily/add-edit-family.tsx +++ b/src/app/(web)/tools/addeditfamily/add-edit-family.tsx @@ -24,7 +24,6 @@ import { Command, CommandInput, CommandList, - CommandEmpty, CommandGroup, CommandItem, } from "@/components/ui/command"; @@ -392,18 +391,26 @@ function FamilySearchBar({ onLoadExisting, onCreateNew, disabled }: FamilySearch const [query, setQuery] = useState(""); const [results, setResults] = useState<ContactSearchResult[]>([]); const [isSearching, setIsSearching] = useState(false); + // The term the current `results` belong to. While the debounce is pending, + // `results` still describes an older term, so "No contacts found" must wait + // until the term on screen has actually been searched. + const [searchedTerm, setSearchedTerm] = useState<string | null>(null); const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null); const doSearch = useCallback(async (term: string) => { - if (term.trim().length < 2) { + const trimmed = term.trim(); + if (trimmed.length < 2) { setResults([]); + setSearchedTerm(null); return; } setIsSearching(true); try { setResults(await searchContacts(term)); + setSearchedTerm(trimmed); } catch { setResults([]); + setSearchedTerm(null); } finally { setIsSearching(false); } @@ -454,9 +461,17 @@ function FamilySearchBar({ onLoadExisting, onCreateNew, disabled }: FamilySearch Searching… </div> )} - {!isSearching && query.trim().length >= 2 && results.length === 0 && ( - <CommandEmpty>No contacts found.</CommandEmpty> - )} + {/* Not <CommandEmpty>: cmdk shows that only when the whole + Command has zero items, and the "+ New Family" item below + is always registered once the query is >= 2 characters. */} + {!isSearching && + query.trim().length >= 2 && + searchedTerm === query.trim() && + results.length === 0 && ( + <div className="py-4 text-center text-sm text-muted-foreground"> + No contacts found. + </div> + )} {!isSearching && query.trim().length < 2 && ( <div className="py-4 text-center text-sm text-muted-foreground"> Type at least 2 characters 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<string, string | string[] | undefined>) { @@ -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 <AddressLabels params={params} />; diff --git a/src/app/(web)/tools/fieldmanagement/field-management.test.tsx b/src/app/(web)/tools/fieldmanagement/field-management.test.tsx index d038be0..102b3a0 100644 --- a/src/app/(web)/tools/fieldmanagement/field-management.test.tsx +++ b/src/app/(web)/tools/fieldmanagement/field-management.test.tsx @@ -170,7 +170,7 @@ describe('FieldManagement', () => { it('shows column count when tableMetadata has Columns', async () => { mockFetchPageFieldData.mockResolvedValueOnce({ fields: [{ Page_Field_ID: 1 }], - tableMetadata: { Table_Name: 'Contacts', Columns: [{ Name: 'A' }, { Name: 'B' }] }, + tableMetadata: { Name: 'Contacts', Columns: [{ Name: 'A' }, { Name: 'B' }] }, }); await selectPage(); 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 <FieldManagement params={params} />; diff --git a/src/app/(web)/tools/groupwizard/group-wizard.tsx b/src/app/(web)/tools/groupwizard/group-wizard.tsx index 873cfc2..d387806 100644 --- a/src/app/(web)/tools/groupwizard/group-wizard.tsx +++ b/src/app/(web)/tools/groupwizard/group-wizard.tsx @@ -118,14 +118,9 @@ export function GroupWizard({ params, mpTimezone }: GroupWizardProps) { }, []); const handleNext = useCallback(async () => { - const fields = STEP_FIELDS[currentStep]; - if (!fields || fields.length === 0) { - setCompletedSteps((prev) => new Set(prev).add(currentStep)); - setCurrentStep((prev) => Math.min(prev + 1, WIZARD_STEPS.length - 1)); - return; - } - - const isValid = await form.trigger(fields as (keyof GroupWizardFormData)[]); + // No special case for a step without fields: `trigger([])` resolves true, + // and a missing entry (`undefined`) validates the whole form — fails safe. + const isValid = await form.trigger(STEP_FIELDS[currentStep]); if (isValid) { setCompletedSteps((prev) => new Set(prev).add(currentStep)); setCurrentStep((prev) => Math.min(prev + 1, WIZARD_STEPS.length - 1)); 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/<tool>/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..a6bdf93 --- /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']; + + 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/<tool>/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<number> { + 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 <TemplateTool params={params} />; 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 ( - <div className="flex flex-col items-center justify-center h-full p-8 space-y-4"> - <Skeleton className="h-8 w-64" /> - <Skeleton className="h-4 w-48" /> - <Skeleton className="h-64 w-full max-w-2xl" /> - </div> - ); -} 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 e539270..0000000 --- a/src/app/(web)/tools/templateeditor/page.test.tsx +++ /dev/null @@ -1,38 +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, -})); - -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 }); - }); -}); diff --git a/src/app/(web)/tools/templateeditor/page.tsx b/src/app/(web)/tools/templateeditor/page.tsx deleted file mode 100644 index fbaaad8..0000000 --- a/src/app/(web)/tools/templateeditor/page.tsx +++ /dev/null @@ -1,12 +0,0 @@ -import { TemplateEditor } from "./template-editor"; -import { parseToolParams } from "@/lib/tool-params.server"; - -interface TemplateEditorPageProps { - searchParams: Promise<{ [key: string]: string | string[] | undefined }>; -} - -export default async function TemplateEditorPage({ searchParams }: TemplateEditorPageProps) { - const params = await parseToolParams(await searchParams); - - return <TemplateEditor params={params} />; -} 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; - }) => ( - <div data-testid="tool-container"> - <div data-testid="title">{title}</div> - <div data-testid="info-content">{infoContent}</div> - {children} - </div> - ), -})); - -vi.mock('@/components/template-editor', () => ({ - TemplateEditorForm: ({ onClose }: { onClose: () => void }) => ( - <button data-testid="template-editor-form" onClick={onClose}> - form - </button> - ), -})); - -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(<TemplateEditor params={params} />); - - 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(<TemplateEditor params={{}} />); - - 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(<TemplateEditor params={{}} />); - expect(screen.queryByText(/Page ID:/)).not.toBeInTheDocument(); - }); - - it('shows pageID (without recordID) in the info popover when only pageID is set', () => { - render(<TemplateEditor params={{ pageID: 292 }} />); - expect(screen.getByText('Page ID: 292')).toBeInTheDocument(); - }); - - it('shows pageID and recordID together in the info popover when both are set', () => { - render(<TemplateEditor params={{ pageID: 292, recordID: 7 }} />); - expect(screen.getByText('Page ID: 292 | Record: 7')).toBeInTheDocument(); - }); - - it('shows the recordID line even when recordID is 0 (falsy but defined)', () => { - render(<TemplateEditor params={{ pageID: 292, recordID: 0 }} />); - 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 ( - <ToolContainer - params={params} - title="Template Editor" - infoContent={ - <div className="space-y-2"> - <p className="font-semibold">Template Editor</p> - <p className="text-sm"> - Create and edit email templates using a drag-and-drop MJML editor. - </p> - {params.pageID && ( - <p className="text-xs text-gray-400 mt-2"> - Page ID: {params.pageID} - {params.recordID !== undefined && ` | Record: ${params.recordID}`} - </p> - )} - </div> - } - hideFooter - > - <TemplateEditorForm onClose={handleClose} /> - </ToolContainer> - ); -} 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<string, string> = { 'Content-Type': 'application/json' }, + ) { + return new Request(SIGN_IN, { method: 'POST', headers, body }); + } + + function chunked(bytes: Uint8Array, chunkSize: number) { + const stream = new ReadableStream<Uint8Array>({ + 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..a5c44ee 100644 --- a/src/app/api/auth/[...all]/route.ts +++ b/src/app/api/auth/[...all]/route.ts @@ -24,7 +24,7 @@ import { toNextJsHandler } from "better-auth/next-js"; * behaviour to want, but it is still an outage. * * `/sign-out` is deliberately absent too: sign-out runs server-side through - * `auth.api.signOut` in `src/components/user-menu/actions.ts`, which calls the + * `auth.api.signOut` in `src/components/shared-actions/sign-out.ts`, which calls the * handler directly and never crosses this HTTP boundary. If a future change * moves sign-out to `authClient.signOut()` in the browser, it will 404 here — * loudly, which is the point. @@ -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 + * `<NBSP>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<Uint8Array>, + limit: number, +): Promise<Uint8Array | null> { + 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<boolean> { + 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<Response> => { 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/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<string, string>(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/app/global-error.test.tsx b/src/app/global-error.test.tsx index 29445aa..debb23d 100644 --- a/src/app/global-error.test.tsx +++ b/src/app/global-error.test.tsx @@ -1,6 +1,7 @@ import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest'; import { render, screen, cleanup } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; +import { renderToString } from 'react-dom/server'; import GlobalError from './global-error'; @@ -44,6 +45,25 @@ describe('GlobalError', () => { expect(retry).toHaveBeenCalledTimes(1); }); + it('offers a plain link home that works without JavaScript', () => { + render(<GlobalError error={makeError()} retry={vi.fn()} />); + + expect(screen.getByRole('link', { name: /go to home page/i })).toHaveAttribute('href', '/'); + }); + + it('enables the retry button once hydrated', () => { + render(<GlobalError error={makeError()} retry={vi.fn()} />); + + expect(screen.getByRole('button', { name: /try again/i })).toBeEnabled(); + }); + + it('server-renders the retry button disabled, so a pre-hydration click is not swallowed', () => { + const html = renderToString(<GlobalError error={makeError()} retry={vi.fn()} />); + + expect(html).toMatch(/<button[^>]*disabled=""[^>]*>Try again<\/button>/); + expect(html).toContain('href="/"'); + }); + it('shows the digest reference when one is present', () => { render(<GlobalError error={makeError({ digest: 'abc123' })} retry={vi.fn()} />); diff --git a/src/app/global-error.tsx b/src/app/global-error.tsx index d56160d..b62a3f3 100644 --- a/src/app/global-error.tsx +++ b/src/app/global-error.tsx @@ -1,6 +1,6 @@ "use client"; -import { useEffect } from "react"; +import { useEffect, useSyncExternalStore } from "react"; /** * Last-resort boundary: catches a throw in the ROOT LAYOUT itself, which it @@ -15,7 +15,20 @@ import { useEffect } from "react"; * The two are coupled: a nonce-based `style-src` would silently drop all of * this and render an unstyled page at the worst possible moment. If you change * one, check the other. + * + * Two controls, because this page can be the server-rendered HTML a user sees + * BEFORE (or without) hydration: + * - "Try again" calls `retry`, which needs React. It is disabled until the + * page hydrates, so a pre-hydration click is not silently swallowed. + * - "Go to home page" is a plain `<a href="/">`, which works with no JS at all. */ + +const subscribeNoop = () => () => {}; + +/** `false` in the server render and during hydration, `true` once hydrated. */ +function useHydrated(): boolean { + return useSyncExternalStore(subscribeNoop, () => true, () => false); +} export default function GlobalError({ error, retry, @@ -23,6 +36,8 @@ export default function GlobalError({ error: Error & { digest?: string }; retry: () => void; }) { + const hydrated = useHydrated(); + useEffect(() => { console.error("ui.render.error", { boundary: "global", @@ -61,7 +76,9 @@ export default function GlobalError({ The application failed to load. Please try again. </p> <button + type="button" onClick={retry} + disabled={!hydrated} style={{ backgroundColor: "#2563eb", color: "#ffffff", @@ -70,11 +87,21 @@ export default function GlobalError({ padding: "0.5rem 1rem", fontSize: "0.875rem", fontWeight: 500, - cursor: "pointer", + cursor: hydrated ? "pointer" : "default", + opacity: hydrated ? 1 : 0.6, }} > Try again </button> + <p style={{ fontSize: "0.875rem", marginTop: "1rem" }}> + {/* A plain <a>, not next/link, on purpose: it must work before or + without hydration, and a full document load is the recovery we + want when the root layout itself has failed. */} + {/* eslint-disable-next-line @next/next/no-html-link-for-pages */} + <a href="/" style={{ color: "#2563eb", textDecoration: "underline" }}> + Go to home page + </a> + </p> {error.digest ? ( <p style={{ fontSize: "0.75rem", color: "#9ca3af", marginTop: "1.5rem" }}> Reference: <span style={{ fontFamily: "monospace" }}>{error.digest}</span> diff --git a/src/app/session-error/page.test.tsx b/src/app/session-error/page.test.tsx index 66c2739..be4985c 100644 --- a/src/app/session-error/page.test.tsx +++ b/src/app/session-error/page.test.tsx @@ -12,7 +12,7 @@ const { mockHandleSignOut } = vi.hoisted(() => ({ mockHandleSignOut: vi.fn(), })); -vi.mock('@/components/user-menu/actions', () => ({ +vi.mock('@/components/shared-actions/sign-out', () => ({ handleSignOut: mockHandleSignOut, })); diff --git a/src/app/session-error/page.tsx b/src/app/session-error/page.tsx index 2483223..16c1785 100644 --- a/src/app/session-error/page.tsx +++ b/src/app/session-error/page.tsx @@ -1,12 +1,11 @@ -import { handleSignOut } from "@/components/user-menu/actions"; +import { handleSignOut } from "@/components/shared-actions/sign-out"; /** * Recovery page for authenticated-but-unusable sessions. * * AuthWrapper redirects here when a session exists but has no `userGuid`. Such a - * session can't load an MP profile, so the header avatar/menu — and therefore - * the normal sign-out control — never render. This page gives the user an - * unconditional way out via `handleSignOut`. It lives outside the (web) route + * session can't load an MP profile, so no tool can serve it. This page gives the + * user an unconditional way out via `handleSignOut`. It lives outside the (web) route * group, so it is NOT wrapped by AuthWrapper and cannot cause a redirect loop. */ /** 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(<SignIn />); + + 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(<SignIn />); + + 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(<SignIn />); + + 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(<SignIn />); - 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', '<b>urgent</b>'], + ])('never renders free text from ?error= (%s)', async (_label, value) => { + setSearchParams({ error: value }); + + const { container } = render(<SignIn />); + + 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<string, string> = 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..b5d068f --- /dev/null +++ b/src/auth.id-token-sign-in.test.ts @@ -0,0 +1,333 @@ +// @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. 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 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 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`) — 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. + * - 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 () => { + 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<string, string> = { + '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); + } + 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<Response> }, + 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<Response> }, 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<GenericOAuthConfig>) { + 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 }] }); + }); +} + +/** The structured (JSON) events written to console.error. */ +function loggedEvents(): Array<Record<string, unknown>> { + return vi.mocked(console.error).mock.calls.flatMap(([line]) => { + try { + return [JSON.parse(String(line)) as Record<string, unknown>]; + } catch { + return []; + } + }); +} + +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, 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, + idToken: { + token: mockOidc.signIdToken(mockOidc.subs.victim), + accessToken: 'access-token-victim', + }, + }); + + expect(response.status).toBe(200); + expect(sessionCookies(response)).not.toEqual([]); + const body = (await response.json()) as { user: { userGuid: string } }; + expect(body.user.userGuid).toBe(mockOidc.subs.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); + }); +}); 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<string, string>, env: Record<string, string>) => + 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<string, string>) { + return betterAuth({ + secret: 'x'.repeat(40), + baseURL: ORIGIN, + rateLimit: { enabled: true }, + advanced: { ipAddress: parseIpAddressOptions(env) }, + logger: { disabled: true }, + }); + } + + const signIn = (instance: ReturnType<typeof buildInstance>, 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.oidc-discovery.test.ts b/src/auth.oidc-discovery.test.ts new file mode 100644 index 0000000..f8b8629 --- /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<AuthModule> { + 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<Record<string, unknown>> { + return vi.mocked(console.error).mock.calls.flatMap(([line]) => { + try { + return [JSON.parse(String(line)) as Record<string, unknown>]; + } 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.MINISTRY_PLATFORM_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<Response>; + jwks?: () => Response | Promise<Response>; + } + + /** 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<string, unknown> = {}) => + 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<Response>((_, 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<string, unknown>]>([ + ['a 404', () => new Response('nope', { status: 404 }), { reason: 'http_status', status: 404 }], + ['a non-JSON 200', () => new Response('<html>proxy</html>', { 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 new file mode 100644 index 0000000..88dcd5e --- /dev/null +++ b/src/auth.secret-guard.test.ts @@ -0,0 +1,242 @@ +// @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 (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 +// 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', + '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<string, string | undefined>; + +const SHARED_AUTH_KEY = Symbol.for('mpnext-tools.auth'); +const clearSharedAuth = () => { + delete (globalThis as Record<symbol, unknown>)[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) => { + throw new Error(`Blocked unexpected fetch in test: ${String(input)}`); + })); + vi.spyOn(console, 'error').mockImplementation(() => {}); + vi.spyOn(console, 'warn').mockImplementation(() => {}); +}); + +afterEach(() => { + clearSharedAuth(); + setEnv(saved); + vi.unstubAllGlobals(); + vi.restoreAllMocks(); +}); + +/** Sets the env for one import; `undefined` deletes the variable. */ +function setEnv(values: Partial<Record<(typeof KEYS)[number], string | undefined>>) { + 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.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 MP client at module load (owner decision: one client for the app)', () => { + it('signs in as MINISTRY_PLATFORM_CLIENT_ID / _SECRET and ignores the retired MP_OIDC_* pair', async () => { + setEnv({ + MINISTRY_PLATFORM_CLIENT_ID: 'svc', + MINISTRY_PLATFORM_CLIENT_SECRET: 'svc-secret', + MP_OIDC_CLIENT_ID: 'oidc-app', + MP_OIDC_CLIENT_SECRET: 'oidc-secret', + }); + const { ministryPlatformProviderConfig } = await importAuthAsIfNotVitest(); + expect(ministryPlatformProviderConfig.clientId).toBe('svc'); + expect(ministryPlatformProviderConfig.clientSecret).toBe('svc-secret'); + }); + + it('refuses to import without the client secret', async () => { + setEnv({ MINISTRY_PLATFORM_CLIENT_SECRET: undefined }); + await expect(importAuthAsIfNotVitest()).rejects.toThrow( + /MINISTRY_PLATFORM_CLIENT_ID and MINISTRY_PLATFORM_CLIENT_SECRET must both be set/, + ); + }); +}); + +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<string, { value: string; attrs: string[] }> { + const out = new Map<string, { value: string; attrs: string[] }>(); + 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<string, unknown>; user: Record<string, unknown> } | 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<CookieJar> { + 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<string | null> { + 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.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<AuthModule> { + 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<symbol, unknown>)[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<symbol, unknown>)[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<symbol, unknown>)[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<symbol, unknown>)[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 45f7a92..852d918 100644 --- a/src/auth.test.ts +++ b/src/auth.test.ts @@ -9,6 +9,7 @@ import { extractUserGuid, buildDisplayName, ministryPlatformProviderConfig, + refuseIdTokenSignIn, } from '@/lib/auth'; /** @@ -16,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. */ /** @@ -153,60 +158,18 @@ 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 app's one MP client (MINISTRY_PLATFORM_CLIENT_ID / _SECRET)", () => { + expect(ministryPlatformProviderConfig.clientId).toBe(process.env.MINISTRY_PLATFORM_CLIENT_ID); + expect(ministryPlatformProviderConfig.clientSecret).toBe(process.env.MINISTRY_PLATFORM_CLIENT_SECRET); }); /** @@ -450,24 +413,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', () => { @@ -477,22 +461,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('uses discovery rather than hardcoded endpoints', () => { - expect(ministryPlatformProviderConfig.discoveryUrl).toContain( - '/oauth/.well-known/openid-configuration', - ); + it('wires refuseIdTokenSignIn as the hooks.before (F12)', () => { + expect(auth.options.hooks?.before).toBe(refuseIdTokenSignIn); + expect(disabledAuthPaths).toContain('/link-social'); }); - 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'); }); }); @@ -511,7 +491,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<string, unknown>) { @@ -521,98 +500,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<string, unknown>; - - 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', () => { @@ -629,115 +534,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<string, unknown>; - - 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<string, unknown>; - - 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..e419914 --- /dev/null +++ b/src/auth.user-info.test.ts @@ -0,0 +1,376 @@ +// @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.MINISTRY_PLATFORM_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<Response>) { + const userinfo = vi.fn<(url: string, init?: RequestInit) => Promise<Response>>(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<OAuth2Tokens>) { + const fn = ministryPlatformProviderConfig.getUserInfo; + if (!fn) throw new Error('getUserInfo must be declared'); + return fn(tokens as OAuth2Tokens) as Promise<Record<string, unknown> | null>; +} + +/** The structured (JSON) events written to console.error. */ +function loggedEvents(): Array<Record<string, unknown>> { + return vi.mocked(console.error).mock.calls.flatMap(([line]) => { + try { + return [JSON.parse(String(line)) as Record<string, unknown>]; + } 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<string, string>).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('<html>Gateway says hi to member@example.com</html>', { 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<string, unknown>) { + 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' }], + ])('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<string, unknown>, 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<string, unknown>) => + `${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: the client secret is not a verification key here. + ['HS256 signed with the client 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 new file mode 100644 index 0000000..23b6dca --- /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<Array<Record<string, unknown>>> } }>; +}; + +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; it is the id_token_hint on MP + // logout (src/components/shared-actions/sign-out.ts). 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', () => { + // src/components/shared-actions/sign-out.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({ + 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<string, unknown> }>)(row)).data; + expect(result).toMatchObject({ accessToken: null, refreshToken: null, idToken: 'i' }); + } + }); +}); diff --git a/src/components/address-labels/actions.test.ts b/src/components/address-labels/actions.test.ts index f3dc4e3..eb6c8d3 100644 --- a/src/components/address-labels/actions.test.ts +++ b/src/components/address-labels/actions.test.ts @@ -8,6 +8,7 @@ const mockToBlob = vi.hoisted(() => vi.fn()); const mockDocxtemplaterRender = vi.hoisted(() => vi.fn()); const mockDocxtemplaterGetZip = vi.hoisted(() => vi.fn()); const mockImageModuleCtor = vi.hoisted(() => vi.fn()); +const mockAssertZip = vi.hoisted(() => vi.fn()); vi.mock('@/lib/auth', () => ({ auth: { @@ -91,6 +92,13 @@ vi.mock('pizzip', () => ({ default: class {}, })); +// The zip guard has its own tests against real archives (src/lib/zip-guard.test.ts); +// here it is a seam so the fake templates below need not be valid ZIPs. +vi.mock('@/lib/zip-guard', () => ({ + assertZipWithinLimits: mockAssertZip, + ZipLimitError: class ZipLimitError extends Error {}, +})); + vi.mock('docxtemplater-image', () => ({ default: class { constructor(...args: unknown[]) { @@ -111,6 +119,9 @@ vi.mock('@/lib/barcode-image', () => ({ })); import { fetchAddressLabels, generateLabelPdf, mergeTemplate } from './actions'; +import { preEncodeBarcodes } from '@/lib/barcode-helpers'; +import { ZipLimitError } from '@/lib/zip-guard'; +import { MAX_LABELS } from './limits'; import type { LabelConfig, LabelData } from '@/lib/dto'; import type { ToolParams } from '@/lib/tool-params'; @@ -463,11 +474,12 @@ describe('mergeTemplate', () => { }); it('should return error for oversized template', async () => { - const hugeBase64 = Buffer.alloc(6 * 1024 * 1024).toString('base64'); + const hugeBase64 = Buffer.alloc(3 * 1024 * 1024).toString('base64'); const labels: LabelData[] = [{ name: 'T', addressLine1: 'A', city: 'C', state: 'S', postalCode: '12345' }]; const result = await mergeTemplate(hugeBase64, labels, mergeConfig); expect(result.success).toBe(false); - if (!result.success) expect(result.error).toContain('5MB'); + if (!result.success) expect(result.error).toContain('2MB'); + expect(mockAssertZip).not.toHaveBeenCalled(); }); it('returns error wrapped for template tag mismatch', async () => { @@ -568,6 +580,84 @@ describe('mergeTemplate', () => { }); }); +describe('label input validation (shared by every render action)', () => { + const config: LabelConfig = { + stockId: '5160', + addressMode: 'household', + startPosition: 1, + includeMissingBarcodes: true, + barcodeFormat: 'postnet', + mailerId: '', + serviceType: '040', + }; + const label: LabelData = { name: 'T', addressLine1: 'A', city: 'C', state: 'S', postalCode: '12345' }; + const template = Buffer.from('x').toString('base64'); + + beforeEach(() => { + mockAssertZip.mockReset(); + mockDocxtemplaterRender.mockClear(); + vi.mocked(preEncodeBarcodes).mockClear(); + }); + + it('rejects more than MAX_LABELS labels before rendering', async () => { + const labels = Array.from({ length: MAX_LABELS + 1 }, () => label); + const result = await generateLabelPdf(labels, config); + expect(result).toEqual({ success: false, error: expect.stringContaining('Too many labels') }); + expect(preEncodeBarcodes).not.toHaveBeenCalled(); + }); + + it('rejects a label with an oversized field', async () => { + const result = await generateLabelPdf([{ ...label, name: 'x'.repeat(10_000) }], config); + expect(result).toEqual({ success: false, error: 'Invalid label data' }); + }); + + it('rejects a non-array labels payload', async () => { + const result = await generateLabelPdf('nope' as unknown as LabelData[], config); + expect(result).toEqual({ success: false, error: 'Invalid label data' }); + }); + + it('rejects an unknown service type', async () => { + const result = await generateLabelPdf([label], { ...config, serviceType: '999' }); + expect(result).toEqual({ success: false, error: 'Invalid label settings' }); + }); + + it.each([0, 31, 1e9, 1.5])('rejects start position %s outside the 30-up sheet', async (startPosition) => { + const result = await generateLabelPdf([label], { ...config, startPosition }); + expect(result.success).toBe(false); + expect(preEncodeBarcodes).not.toHaveBeenCalled(); + }); + + it('drops caller-supplied barStates/barType so only server-encoded bars render', async () => { + const hostile = { ...label, barStates: 'FFFF', barType: 'imb' as const }; + await mergeTemplate(template, [hostile], config); + const passed = vi.mocked(preEncodeBarcodes).mock.calls[0][0]; + expect(passed[0]).not.toHaveProperty('barStates'); + expect(passed[0]).not.toHaveProperty('barType'); + }); + + it('runs the zip guard before PizZip and returns its message on a limit breach', async () => { + mockAssertZip.mockImplementationOnce(() => { + throw new ZipLimitError('Template is too large when uncompressed'); + }); + const result = await mergeTemplate(template, [label], config); + expect(result).toEqual({ success: false, error: 'Template is too large when uncompressed' }); + expect(mockDocxtemplaterRender).not.toHaveBeenCalled(); + }); + + it('maps any other zip-guard failure to a generic invalid-file error', async () => { + mockAssertZip.mockImplementationOnce(() => { + throw new RangeError('out of bounds'); + }); + const result = await mergeTemplate(template, [label], config); + expect(result).toEqual({ success: false, error: 'Not a valid .docx file' }); + }); + + it('rejects a non-string template', async () => { + const result = await mergeTemplate(42 as unknown as string, [label], config); + expect(result).toEqual({ success: false, error: 'Template file exceeds 2MB limit' }); + }); +}); + describe('generateLabelDocx', () => { const docxConfig: LabelConfig = { stockId: '5160', @@ -678,8 +768,6 @@ describe('generateLabelPdf error branches', () => { * `console.error('mergeTemplate error:', error)` wrote every printable name * and mailing address for the batch into server logs — exactly what CLAUDE.md * rule 14 forbids. - * - * See `.claude/TODO/2026-09-13-mergetemplate-logs-address-pii-on-error.md`. */ describe('address-label actions redact errors before logging', () => { const config: LabelConfig = { diff --git a/src/components/address-labels/actions.ts b/src/components/address-labels/actions.ts index 7f509a0..01d8686 100644 --- a/src/components/address-labels/actions.ts +++ b/src/components/address-labels/actions.ts @@ -19,6 +19,10 @@ import { buildWordDocument } from './word-document'; import { getLabelStock } from '@/lib/label-stock'; import { preEncodeBarcodes } from '@/lib/barcode-helpers'; import { validateMailerId } from '@/lib/validation'; +import { assertZipWithinLimits, ZipLimitError } from '@/lib/zip-guard'; +import type { LabelStockConfig } from '@/lib/label-stock'; +import { parseLabelInput } from './schemas'; +import { MAX_TEMPLATE_BYTES, MAX_TEMPLATE_LABEL, TEMPLATE_ZIP_LIMITS } from './limits'; import Docxtemplater from 'docxtemplater'; import PizZip from 'pizzip'; import ImageModule from 'docxtemplater-image'; @@ -172,19 +176,36 @@ function describeError(error: unknown): Record<string, string> { return described; } -export async function generateLabelPdf( - labels: LabelData[], - config: LabelConfig -): Promise<{ success: true; data: string } | { success: false; error: string }> { - await requireAccess('Contacts', 'read'); +type ActionResult = { success: true; data: string } | { success: false; error: string }; + +type PreparedLabels = + | { ok: true; labels: LabelData[]; config: LabelConfig; stock: LabelStockConfig } + | { ok: false; result: ActionResult }; + +/** + * Validate the caller-supplied labels and config shared by every render + * action: schema + count cap (`parseLabelInput`), a known stock, a start + * position that fits on one sheet, and a well-formed Mailer ID for IMb. + * + * `startPosition` matters: it is a count of blank cells to emit, so an + * unbounded value is an unbounded number of pages. + */ +function prepareLabels(rawLabels: unknown, rawConfig: unknown, emptyMessage: string): PreparedLabels { + const parsed = parseLabelInput(rawLabels, rawConfig); + if (!parsed.success) return { ok: false, result: parsed }; + const { labels, config } = parsed; const stock = getLabelStock(config.stockId); if (!stock) { - return { success: false, error: `Unknown label stock: ${config.stockId}` }; + return { ok: false, result: { success: false, error: `Unknown label stock: ${config.stockId}` } }; + } + + if (config.startPosition > stock.columns * stock.rows) { + return { ok: false, result: { success: false, error: 'Start position is beyond the end of the sheet' } }; } if (labels.length === 0) { - return { success: false, error: 'No labels to print' }; + return { ok: false, result: { success: false, error: emptyMessage } }; } if (config.barcodeFormat === 'imb' && config.mailerId) { @@ -192,12 +213,25 @@ export async function generateLabelPdf( validateMailerId(config.mailerId); } catch (error) { return { - success: false, - error: error instanceof Error ? error.message : 'Invalid Mailer ID', + ok: false, + result: { success: false, error: error instanceof Error ? error.message : 'Invalid Mailer ID' }, }; } } + return { ok: true, labels, config, stock }; +} + +export async function generateLabelPdf( + rawLabels: LabelData[], + rawConfig: LabelConfig +): Promise<ActionResult> { + await requireAccess('Contacts', 'read'); + + const prepared = prepareLabels(rawLabels, rawConfig, 'No labels to print'); + if (!prepared.ok) return prepared.result; + const { labels, config, stock } = prepared; + try { const labelsWithBars = preEncodeBarcodes(labels, config); @@ -226,30 +260,14 @@ export async function generateLabelPdf( } export async function generateLabelDocx( - labels: LabelData[], - config: LabelConfig -): Promise<{ success: true; data: string } | { success: false; error: string }> { + rawLabels: LabelData[], + rawConfig: LabelConfig +): Promise<ActionResult> { await requireAccess('Contacts', 'read'); - const stock = getLabelStock(config.stockId); - if (!stock) { - return { success: false, error: `Unknown label stock: ${config.stockId}` }; - } - - if (labels.length === 0) { - return { success: false, error: 'No labels to export' }; - } - - if (config.barcodeFormat === 'imb' && config.mailerId) { - try { - validateMailerId(config.mailerId); - } catch (error) { - return { - success: false, - error: error instanceof Error ? error.message : 'Invalid Mailer ID', - }; - } - } + const prepared = prepareLabels(rawLabels, rawConfig, 'No labels to export'); + if (!prepared.ok) return prepared.result; + const { labels, config, stock } = prepared; try { const labelsWithBars = preEncodeBarcodes(labels, config); @@ -268,34 +286,32 @@ export async function generateLabelDocx( } } -const MAX_TEMPLATE_SIZE = 5 * 1024 * 1024; // 5MB - export async function mergeTemplate( templateBase64: string, - labels: LabelData[], - config: LabelConfig -): Promise<{ success: true; data: string } | { success: false; error: string }> { + rawLabels: LabelData[], + rawConfig: LabelConfig +): Promise<ActionResult> { await requireAccess('Contacts', 'read'); - if (labels.length === 0) { - return { success: false, error: 'No addresses to merge' }; + // Size check on the encoded string first, before decoding anything. + if (typeof templateBase64 !== 'string' || Math.ceil(templateBase64.length * 0.75) > MAX_TEMPLATE_BYTES) { + return { success: false, error: `Template file exceeds ${MAX_TEMPLATE_LABEL} limit` }; } - // Validate template size - const templateSize = Math.ceil(templateBase64.length * 0.75); - if (templateSize > MAX_TEMPLATE_SIZE) { - return { success: false, error: 'Template file exceeds 5MB limit' }; - } + const prepared = prepareLabels(rawLabels, rawConfig, 'No addresses to merge'); + if (!prepared.ok) return prepared.result; + const { labels, config } = prepared; - if (config.barcodeFormat === 'imb' && config.mailerId) { - try { - validateMailerId(config.mailerId); - } catch (error) { - return { - success: false, - error: error instanceof Error ? error.message : 'Invalid Mailer ID', - }; - } + // Bound decompression BEFORE PizZip sees the archive (zip-bomb guard). + const templateBuffer = Buffer.from(templateBase64, 'base64'); + try { + assertZipWithinLimits(templateBuffer, TEMPLATE_ZIP_LIMITS); + } catch (error) { + // Anything else is a malformed archive tripping a bounds check. + return { + success: false, + error: error instanceof ZipLimitError ? error.message : 'Not a valid .docx file', + }; } try { @@ -326,8 +342,6 @@ export async function mergeTemplate( }; }); - // Parse template - const templateBuffer = Buffer.from(templateBase64, 'base64'); const zip = new PizZip(templateBuffer); // Configure image module diff --git a/src/components/address-labels/limits.ts b/src/components/address-labels/limits.ts new file mode 100644 index 0000000..0c027cb --- /dev/null +++ b/src/components/address-labels/limits.ts @@ -0,0 +1,22 @@ +/** + * Size limits for the address-label server actions, shared by the client + * (early, friendly errors) and the server (the actual enforcement). + * + * They are sized against the request-body ceilings, not picked freely: + * Vercel rejects function request bodies over 4.5 MB, and + * `experimental.serverActions.bodySizeLimit` in `next.config.ts` is set to + * 4 MB so Next fails first with a clear error. A 2 MB template is ~2.7 MB as + * base64, which leaves ~1.3 MB for the label list on a mail merge. + * Raising any of these means re-checking all three together. + */ +export const MAX_TEMPLATE_BYTES = 2 * 1024 * 1024; +export const MAX_TEMPLATE_LABEL = '2MB'; + +/** Upper bound on labels per request. Bounds PDF/DOCX render memory. */ +export const MAX_LABELS = 10_000; + +/** Bounds PizZip's decompression of an uploaded template — see `src/lib/zip-guard.ts`. */ +export const TEMPLATE_ZIP_LIMITS = { + maxEntries: 1_000, + maxTotalBytes: 64 * 1024 * 1024, +} as const; diff --git a/src/components/address-labels/mail-merge-tab.test.tsx b/src/components/address-labels/mail-merge-tab.test.tsx index ed73768..fd8fbaa 100644 --- a/src/components/address-labels/mail-merge-tab.test.tsx +++ b/src/components/address-labels/mail-merge-tab.test.tsx @@ -103,14 +103,14 @@ describe('MailMergeTab', () => { expect(await screen.findByText('Please select a .docx file')).toBeInTheDocument(); }); - it('rejects a file over the 5MB limit', async () => { + it('rejects a file over the 2MB limit', async () => { render(<MailMergeTab printable={printable} skipped={skipped} config={baseConfig} />); const input = screen.getByLabelText(/upload your template/i); - const file = makeDocxFile('big.docx', 6 * 1024 * 1024); + const file = makeDocxFile('big.docx', 3 * 1024 * 1024); fireEvent.change(input, { target: { files: [file] } }); - expect(await screen.findByText('Template file must be under 5MB')).toBeInTheDocument(); + expect(await screen.findByText('Template file must be under 2MB')).toBeInTheDocument(); }); it('accepts a valid .docx file, shows its name, and enables Merge', async () => { diff --git a/src/components/address-labels/mail-merge-tab.tsx b/src/components/address-labels/mail-merge-tab.tsx index 0f2d4d9..c66d991 100644 --- a/src/components/address-labels/mail-merge-tab.tsx +++ b/src/components/address-labels/mail-merge-tab.tsx @@ -8,9 +8,9 @@ import { Loader2, Download, Upload, FileText } from 'lucide-react'; import { AddressLabelsSummary } from './address-labels-summary'; import { generateSampleTemplate } from './sample-template'; import { mergeTemplate } from './actions'; +import { MAX_TEMPLATE_BYTES, MAX_TEMPLATE_LABEL } from './limits'; import type { LabelData, SkipRecord, LabelConfig } from '@/lib/dto'; -const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB interface MailMergeTabProps { printable: LabelData[]; @@ -88,8 +88,8 @@ export function MailMergeTab({ printable, skipped, config }: MailMergeTabProps) return; } - if (file.size > MAX_FILE_SIZE) { - setError('Template file must be under 5MB'); + if (file.size > MAX_TEMPLATE_BYTES) { + setError(`Template file must be under ${MAX_TEMPLATE_LABEL}`); return; } @@ -154,7 +154,7 @@ export function MailMergeTab({ printable, skipped, config }: MailMergeTabProps) </Button> </div> <div className="space-y-2"> - <Label htmlFor="template-upload">Upload your template (.docx, max 5MB)</Label> + <Label htmlFor="template-upload">Upload your template (.docx, max {MAX_TEMPLATE_LABEL})</Label> <Input id="template-upload" type="file" diff --git a/src/components/address-labels/merge-template.integration.test.ts b/src/components/address-labels/merge-template.integration.test.ts new file mode 100644 index 0000000..5c141f0 --- /dev/null +++ b/src/components/address-labels/merge-template.integration.test.ts @@ -0,0 +1,54 @@ +import { describe, it, expect, vi } from 'vitest'; + +vi.mock('@/services/authorizationService', () => ({ + AuthorizationService: { + getInstance: () => ({ requireSecurityRole: vi.fn(async () => 1) }), + }, + UnauthorizedError: class UnauthorizedError extends Error {}, +})); + +import PizZip from 'pizzip'; +import { generateSampleTemplate } from './sample-template'; +import { mergeTemplate } from './actions'; +import type { LabelConfig } from '@/lib/dto'; + +/** + * End-to-end through the REAL zip guard, PizZip and docxtemplater (actions.test.ts + * mocks all three). Proves the guard accepts a genuine Word archive and that a + * bomb is stopped before PizZip is reached. + */ +const config: LabelConfig = { + stockId: '5160', + addressMode: 'household', + startPosition: 1, + includeMissingBarcodes: true, + barcodeFormat: 'none', + mailerId: '', + serviceType: '040', +}; +const labels = [{ name: 'Jane Doe', addressLine1: '1 Main St', city: 'Springfield', state: 'IL', postalCode: '62704' }]; + +describe('mergeTemplate (integration)', () => { + it('merges the bundled sample template', async () => { + const template = await generateSampleTemplate(); + const result = await mergeTemplate(template, labels, config); + expect(result.success).toBe(true); + if (!result.success) return; + const xml = new PizZip(Buffer.from(result.data, 'base64')).file('word/document.xml')!.asText(); + expect(xml).toContain('Jane Doe'); + }); + + it('rejects a zip bomb before decompressing it', async () => { + // An entry whose header understates its size: the shape that gets past a + // declared-size check and would make PizZip inflate it in full. (The + // honest-but-huge case is covered in src/lib/zip-guard.test.ts.) + const zip = new PizZip(); + zip.file('word/document.xml', Buffer.alloc(4 * 1024 * 1024)); + const buf = zip.generate({ type: 'nodebuffer', compression: 'DEFLATE' }); + const central = buf.indexOf(Buffer.from([0x50, 0x4b, 0x01, 0x02])); + buf.writeUInt32LE(100, central + 24); + const template = buf.toString('base64'); + const result = await mergeTemplate(template, labels, config); + expect(result).toEqual({ success: false, error: 'Template is too large when uncompressed' }); + }); +}); diff --git a/src/components/address-labels/schemas.ts b/src/components/address-labels/schemas.ts new file mode 100644 index 0000000..cc7d58e --- /dev/null +++ b/src/components/address-labels/schemas.ts @@ -0,0 +1,63 @@ +import { z } from 'zod'; +import { SERVICE_TYPES } from '@/lib/dto'; +import type { LabelConfig, LabelData } from '@/lib/dto'; +import { MAX_LABELS } from './limits'; + +/** + * Server-side validation for what the address-label actions receive. + * + * Labels come back from the client after `fetchAddressLabels`, so they are + * caller-shaped input: a server action is a POST endpoint anyone with access + * can call with any payload. This bounds the count and every string, and + * DROPS `barStates`/`barType` — those are computed server-side by + * `preEncodeBarcodes` and must never be accepted from the caller. + * + * Field caps are generous (well above MP's column sizes) so real data never + * trips them; they exist to bound memory, not to re-validate addresses. + */ +const text = (max: number) => z.string().max(max); + +export const labelDataSchema = z.object({ + name: text(256), + addressLine1: text(256), + addressLine2: text(256).optional(), + city: text(128), + state: text(64), + postalCode: text(32), + barCode: text(64).optional(), + deliveryPointCode: text(16).optional(), +}); + +export const labelsSchema = z.array(labelDataSchema).max(MAX_LABELS); + +const serviceTypeIds = SERVICE_TYPES.map((t) => t.id) as [string, ...string[]]; + +export const labelConfigSchema = z.object({ + stockId: text(64), + addressMode: z.enum(['household', 'individual']), + // Upper bound is the stock's labels-per-sheet, checked once the stock is known. + startPosition: z.number().int().min(1), + includeMissingBarcodes: z.boolean(), + barcodeFormat: z.enum(['imb', 'postnet', 'none']), + mailerId: text(9), + serviceType: z.enum(serviceTypeIds), +}); + +export type ParsedLabelInput = + | { success: true; labels: LabelData[]; config: LabelConfig } + | { success: false; error: string }; + +/** Parse labels + config; the error names the problem, never the payload. */ +export function parseLabelInput(labels: unknown, config: unknown): ParsedLabelInput { + if (Array.isArray(labels) && labels.length > MAX_LABELS) { + return { + success: false, + error: `Too many labels (${labels.length.toLocaleString('en-US')}); the limit is ${MAX_LABELS.toLocaleString('en-US')} per request`, + }; + } + const parsedLabels = labelsSchema.safeParse(labels); + if (!parsedLabels.success) return { success: false, error: 'Invalid label data' }; + const parsedConfig = labelConfigSchema.safeParse(config); + if (!parsedConfig.success) return { success: false, error: 'Invalid label settings' }; + return { success: true, labels: parsedLabels.data, config: parsedConfig.data }; +} diff --git a/src/components/dev-panel/panels/contact-records-actions.test.ts b/src/components/dev-panel/panels/contact-records-actions.test.ts index 30d9651..75c2eaa 100644 --- a/src/components/dev-panel/panels/contact-records-actions.test.ts +++ b/src/components/dev-panel/panels/contact-records-actions.test.ts @@ -34,10 +34,10 @@ describe('resolveContactRecords', () => { }; mockResolveContactIds.mockResolvedValueOnce(expected); - const result = await resolveContactRecords('Contacts', 'Contact_ID', 'Contact_ID', [1, 2, 3]); + const result = await resolveContactRecords(7, [1, 2, 3]); expect(mockRequireDevSession).toHaveBeenCalledWith('Dev panel'); - expect(mockResolveContactIds).toHaveBeenCalledWith('Contacts', 'Contact_ID', 'Contact_ID', [1, 2, 3]); + expect(mockResolveContactIds).toHaveBeenCalledWith(7, [1, 2, 3]); expect(result).toEqual(expected); }); @@ -45,7 +45,7 @@ describe('resolveContactRecords', () => { mockRequireDevSession.mockRejectedValueOnce(new Error('Unauthorized - Missing user session data')); await expect( - resolveContactRecords('Contacts', 'Contact_ID', 'Contact_ID', [1]) + resolveContactRecords(7, [1]) ).rejects.toThrow('Unauthorized - Missing user session data'); expect(mockResolveContactIds).not.toHaveBeenCalled(); }); @@ -54,7 +54,7 @@ describe('resolveContactRecords', () => { mockResolveContactIds.mockRejectedValueOnce(new Error('Not authorized')); await expect( - resolveContactRecords('Contacts', 'Contact_ID', 'Contact_ID', [1]) + resolveContactRecords(7, [1]) ).rejects.toThrow('Not authorized'); }); }); diff --git a/src/components/dev-panel/panels/contact-records-actions.ts b/src/components/dev-panel/panels/contact-records-actions.ts index e7a9697..56cd6de 100644 --- a/src/components/dev-panel/panels/contact-records-actions.ts +++ b/src/components/dev-panel/panels/contact-records-actions.ts @@ -4,14 +4,17 @@ import { requireDevSession } from './require-dev-session'; import { ToolService } from '@/services/toolService'; import type { ContactRecordResult } from '@/services/toolService'; +/** + * Takes the page ID, not table/column names: the service resolves the page's + * metadata server-side, so the caller never chooses what the service account + * reads. + */ export async function resolveContactRecords( - tableName: string, - primaryKey: string, - contactIdField: string, + pageId: number, recordIds: number[] -): Promise<ContactRecordResult> { +): Promise<ContactRecordResult | null> { await requireDevSession('Dev panel'); const toolService = await ToolService.getInstance(); - return toolService.resolveContactIds(tableName, primaryKey, contactIdField, recordIds); + return toolService.resolveContactIds(pageId, recordIds); } diff --git a/src/components/dev-panel/panels/contact-records-panel.test.tsx b/src/components/dev-panel/panels/contact-records-panel.test.tsx index 43472fd..a3a437c 100644 --- a/src/components/dev-panel/panels/contact-records-panel.test.tsx +++ b/src/components/dev-panel/panels/contact-records-panel.test.tsx @@ -37,13 +37,30 @@ describe("ContactRecordsPanel", () => { }); it("renders nothing when there is neither a single record nor a selection", () => { - const params: ToolParams = { pageData }; + const params: ToolParams = { pageID: 292, pageData }; const { container } = render(<ContactRecordsPanel params={params} />); expect(container.firstChild).toBeNull(); }); + it("does not resolve when there is no page ID", () => { + const params: ToolParams = { pageData, recordID: 1 }; + render(<ContactRecordsPanel params={params} />); + expect(mockResolveContactRecords).not.toHaveBeenCalled(); + }); + + it("renders nothing when the service returns null (page has no contact metadata)", async () => { + mockResolveContactRecords.mockResolvedValueOnce(null); + + const { container } = render(<ContactRecordsPanel params={{ pageID: 292, pageData, recordID: 1 }} />); + + await waitFor(() => { + expect(screen.queryByText("Development Mode - Loading Contact Records...")).not.toBeInTheDocument(); + }); + expect(container.firstChild).toBeNull(); + }); + it("renders nothing when recordID is 0 (falsy for hasSingleRecord) and no selection", () => { - const params: ToolParams = { pageData, recordID: 0 }; + const params: ToolParams = { pageID: 292, pageData, recordID: 0 }; const { container } = render(<ContactRecordsPanel params={params} />); expect(container.firstChild).toBeNull(); }); @@ -56,7 +73,7 @@ describe("ContactRecordsPanel", () => { records: [{ recordId: 1, contactId: 100 }], }); - const params: ToolParams = { pageData, recordID: 1 }; + const params: ToolParams = { pageID: 292, pageData, recordID: 1 }; render(<ContactRecordsPanel params={params} />); expect( @@ -68,7 +85,7 @@ describe("ContactRecordsPanel", () => { expect(screen.getByText("Development Mode - Contact Records")).toBeInTheDocument(); }); expect(screen.getByText("1 contact resolved")).toBeInTheDocument(); - expect(mockResolveContactRecords).toHaveBeenCalledWith("Contacts", "Contact_ID", "Contact_ID", [1]); + expect(mockResolveContactRecords).toHaveBeenCalledWith(292, [1]); }); it("resolves a selection of record IDs and pluralizes the count", async () => { @@ -82,7 +99,7 @@ describe("ContactRecordsPanel", () => { ], }); - const params: ToolParams = { pageData }; + const params: ToolParams = { pageID: 292, pageData }; render(<ContactRecordsPanel params={params} selectionRecordIds={[1, 2]} />); expect(screen.getByText(/Resolving Contact IDs from selection/)).toBeInTheDocument(); @@ -90,13 +107,13 @@ describe("ContactRecordsPanel", () => { await waitFor(() => { expect(screen.getByText("2 contacts resolved")).toBeInTheDocument(); }); - expect(mockResolveContactRecords).toHaveBeenCalledWith("Contacts", "Contact_ID", "Contact_ID", [1, 2]); + expect(mockResolveContactRecords).toHaveBeenCalledWith(292, [1, 2]); }); it("shows an error state when resolution fails", async () => { mockResolveContactRecords.mockRejectedValueOnce(new Error("resolve failed")); - render(<ContactRecordsPanel params={{ pageData, recordID: 1 }} />); + render(<ContactRecordsPanel params={{ pageID: 292, pageData, recordID: 1 }} />); await waitFor(() => { expect(screen.getByText("Development Mode - Contact Records Error")).toBeInTheDocument(); @@ -107,7 +124,7 @@ describe("ContactRecordsPanel", () => { it("shows a generic error message for a non-Error rejection", async () => { mockResolveContactRecords.mockRejectedValueOnce("nope"); - render(<ContactRecordsPanel params={{ pageData, recordID: 1 }} />); + render(<ContactRecordsPanel params={{ pageID: 292, pageData, recordID: 1 }} />); await waitFor(() => { expect(screen.getByText("Failed to resolve contact records")).toBeInTheDocument(); @@ -122,7 +139,7 @@ describe("ContactRecordsPanel", () => { records: Array.from({ length: 7 }, (_, i) => ({ recordId: i + 1, contactId: 100 + i })), }); - render(<ContactRecordsPanel params={{ pageData }} selectionRecordIds={[1, 2, 3, 4, 5, 6, 7]} />); + render(<ContactRecordsPanel params={{ pageID: 292, pageData }} selectionRecordIds={[1, 2, 3, 4, 5, 6, 7]} />); await waitFor(() => { expect(screen.getByText("+2 more")).toBeInTheDocument(); @@ -137,7 +154,7 @@ describe("ContactRecordsPanel", () => { records: [{ recordId: 1, contactId: 100 }], }); - render(<ContactRecordsPanel params={{ pageData, recordID: 1 }} />); + render(<ContactRecordsPanel params={{ pageID: 292, pageData, recordID: 1 }} />); await waitFor(() => { expect(screen.getByText("View Raw JSON")).toBeInTheDocument(); diff --git a/src/components/dev-panel/panels/contact-records-panel.tsx b/src/components/dev-panel/panels/contact-records-panel.tsx index 62ed852..b6960b7 100644 --- a/src/components/dev-panel/panels/contact-records-panel.tsx +++ b/src/components/dev-panel/panels/contact-records-panel.tsx @@ -17,12 +17,11 @@ export function ContactRecordsPanel({ params, selectionRecordIds }: ContactRecor const [error, setError] = useState<string | null>(null); const contactIdField = params.pageData?.Contact_ID_Field; - const tableName = params.pageData?.Table_Name; - const primaryKey = params.pageData?.Primary_Key; + const pageId = params.pageID; const hasSingleRecord = params.recordID !== undefined && params.recordID > 0; const hasSelection = selectionRecordIds !== undefined && selectionRecordIds.length > 0; - const canResolve = contactIdField && tableName && primaryKey && (hasSingleRecord || hasSelection); + const canResolve = contactIdField && pageId && (hasSingleRecord || hasSelection); useEffect(() => { if (!canResolve) return; @@ -37,7 +36,7 @@ export function ContactRecordsPanel({ params, selectionRecordIds }: ContactRecor async function fetchContacts() { try { - const data = await resolveContactRecords(tableName!, primaryKey!, contactIdField!, recordIds); + const data = await resolveContactRecords(pageId!, recordIds); setResult(data); } catch (err) { setError(err instanceof Error ? err.message : "Failed to resolve contact records"); @@ -47,7 +46,7 @@ export function ContactRecordsPanel({ params, selectionRecordIds }: ContactRecor } fetchContacts(); - }, [canResolve, hasSingleRecord, params.recordID, selectionRecordIds, tableName, primaryKey, contactIdField]); + }, [canResolve, hasSingleRecord, params.recordID, selectionRecordIds, pageId]); if (!contactIdField) return null; if (!hasSingleRecord && !hasSelection) return null; diff --git a/src/components/field-management/actions.test.ts b/src/components/field-management/actions.test.ts index f345f9a..e78597e 100644 --- a/src/components/field-management/actions.test.ts +++ b/src/components/field-management/actions.test.ts @@ -168,7 +168,7 @@ describe('field-management actions', () => { }, ]); mockGetTableMetadata.mockResolvedValueOnce({ - Table_Name: 'Contacts', + Name: 'Contacts', Columns: [ { Name: 'Contact_ID', IsPrimaryKey: true, IsRequired: true }, { Name: 'First_Name', IsPrimaryKey: false, IsRequired: true }, @@ -188,7 +188,7 @@ describe('field-management actions', () => { mockGetSession.mockResolvedValueOnce(authedSession); mockGetPageFields.mockResolvedValueOnce([]); mockGetTableMetadata.mockResolvedValueOnce({ - Table_Name: 'Contacts', + Name: 'Contacts', Columns: [ { Name: 'First_Name', IsPrimaryKey: false, IsRequired: true }, { Name: 'Last_Name', IsPrimaryKey: false, IsRequired: false }, @@ -220,7 +220,7 @@ describe('field-management actions', () => { }, ]); mockGetTableMetadata.mockResolvedValueOnce({ - Table_Name: 'Contacts', + Name: 'Contacts', Columns: [ { Name: 'First_Name', IsPrimaryKey: false, IsRequired: true }, { Name: 'Last_Name', IsPrimaryKey: false, IsRequired: false }, @@ -237,7 +237,7 @@ describe('field-management actions', () => { mockGetSession.mockResolvedValueOnce(authedSession); mockGetPageFields.mockResolvedValueOnce([]); mockGetTableMetadata.mockResolvedValueOnce({ - Table_Name: 'Contacts', + Name: 'Contacts', Columns: [ { Name: 'Contact_ID', IsPrimaryKey: true, IsRequired: true }, { Name: 'First_Name', IsPrimaryKey: false, IsRequired: true }, @@ -283,7 +283,7 @@ describe('field-management actions', () => { }, ]); mockGetTableMetadata.mockResolvedValueOnce({ - Table_Name: 'Contacts', + Name: 'Contacts', Columns: [ { Name: 'General_Section', DataType: 'Separator', IsPrimaryKey: false, IsRequired: false }, { Name: 'First_Name', DataType: 'String', IsPrimaryKey: false, IsRequired: true }, @@ -318,7 +318,7 @@ describe('field-management actions', () => { }, ]); mockGetTableMetadata.mockResolvedValueOnce({ - Table_Name: 'Contacts', + Name: 'Contacts', Columns: [ { Name: 'First_Name', DataType: 'String', IsPrimaryKey: false, IsRequired: false }, { Name: 'Address_Section', DataType: 'Separator', IsPrimaryKey: false, IsRequired: false }, @@ -344,7 +344,7 @@ describe('field-management actions', () => { mockGetSession.mockResolvedValueOnce(authedSession); mockGetPageFields.mockResolvedValueOnce([]); mockGetTableMetadata.mockResolvedValueOnce({ - Table_Name: 'Contacts', + Name: 'Contacts', Columns: [ { Name: 'Weird_Separator', DataType: 'Separator', IsPrimaryKey: false, IsRequired: true }, ], @@ -376,7 +376,7 @@ describe('field-management actions', () => { }, ]); mockGetTableMetadata.mockResolvedValueOnce({ - Table_Name: 'Contacts', + Name: 'Contacts', Columns: [], }); @@ -405,7 +405,7 @@ describe('field-management actions', () => { }, ]); mockGetTableMetadata.mockResolvedValueOnce({ - Table_Name: 'Contacts', + Name: 'Contacts', Columns: [ { Name: 'First_Name', IsPrimaryKey: false, IsRequired: true }, { Name: 'Last_Name', IsPrimaryKey: false, IsRequired: false }, diff --git a/src/components/field-management/field-order-editor.test.tsx b/src/components/field-management/field-order-editor.test.tsx index 1db1dfb..7bb7107 100644 --- a/src/components/field-management/field-order-editor.test.tsx +++ b/src/components/field-management/field-order-editor.test.tsx @@ -193,7 +193,7 @@ describe('FieldOrderEditor', () => { <FieldOrderEditor fields={[]} tableMetadata={{ - Table_Name: 'Contacts', + Name: 'Contacts', Columns: [ { Name: 'First_Name', IsRequired: true }, { Name: 'Last_Name', IsRequired: false }, diff --git a/src/components/field-management/use-field-order-state.test.ts b/src/components/field-management/use-field-order-state.test.ts index 2eb3c0e..1721616 100644 --- a/src/components/field-management/use-field-order-state.test.ts +++ b/src/components/field-management/use-field-order-state.test.ts @@ -389,7 +389,6 @@ describe('useFieldOrderState > removeGroup', () => { * `buildSavePayload()` iterates `groupOrder`, those fields silently * disappeared from the payload — live Ministry Platform page configuration * lost with no error shown. - * See `.claude/TODO/2026-09-13-removegroup-order-guard-mismatch.md`. */ it('is a complete no-op when the group still has fields', () => { const fields: PageField[] = [ 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<string, unknown>; + 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<GroupWizardLookups> { await requireAccess('Groups', 'read'); @@ -79,8 +96,10 @@ export async function createGroup( ): Promise<CreateGroupResult | ActionError> { 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<UpdateGroupResult | ActionError> { 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/group-wizard/schema.test.ts b/src/components/group-wizard/schema.test.ts index d04333f..12eb32d 100644 --- a/src/components/group-wizard/schema.test.ts +++ b/src/components/group-wizard/schema.test.ts @@ -6,6 +6,7 @@ import { STEP_FIELDS, type GroupWizardFormData, } from './schema'; +import { WIZARD_STEPS } from './types'; /** Helper: fill all required IDs + Start_Date so schema passes base validation */ function validBase(): GroupWizardFormData { @@ -113,6 +114,17 @@ describe('STEP_FIELDS', () => { expect(Object.keys(STEP_FIELDS)).toEqual(['0', '1', '2', '3', '4', '5']); }); + // GroupWizard.handleNext calls form.trigger(STEP_FIELDS[currentStep]) with no + // special case, and the last step shows Submit, not Next. So every step + // before Review must list fields to validate, or Next would skip validation. + it('every step before the Review step has fields, and only Review is empty', () => { + expect(Object.keys(STEP_FIELDS)).toHaveLength(WIZARD_STEPS.length); + WIZARD_STEPS.slice(0, -1).forEach((_, i) => { + expect(STEP_FIELDS[i].length).toBeGreaterThan(0); + }); + expect(STEP_FIELDS[WIZARD_STEPS.length - 1]).toEqual([]); + }); + it('STEP_FIELDS[4] includes the hyphenated Secure_Check-in field name', () => { expect(STEP_FIELDS[4]).toContain('Secure_Check-in'); }); diff --git a/src/components/layout/auth-wrapper.tsx b/src/components/layout/auth-wrapper.tsx index 91ed6de..99c4eb9 100644 --- a/src/components/layout/auth-wrapper.tsx +++ b/src/components/layout/auth-wrapper.tsx @@ -17,8 +17,8 @@ export async function AuthWrapper({ children }: { children: React.ReactNode }) { } // A session without a userGuid is unusable: every MP lookup keys off userGuid, - // and without it the header avatar/menu never renders — which leaves the user - // with no way to even sign out (the trap behind the better-auth 1.6 regression). + // so the app would render dead with no way to sign out (the trap behind the + // better-auth 1.6 regression). // Route these broken sessions to a recovery page that CAN sign them out, // rather than rendering a dead app. /session-error lives outside the (web) // route group, so it is not wrapped by AuthWrapper and cannot redirect-loop. diff --git a/src/components/shared-actions/README.md b/src/components/shared-actions/README.md index d0ba1d1..3bcec59 100644 --- a/src/components/shared-actions/README.md +++ b/src/components/shared-actions/README.md @@ -17,14 +17,15 @@ Keep actions with their component folder when: ## Examples -**Shared Actions (place here):** -- `user.ts` - Current-user profile lookup used by contexts and components -- `auth.ts` - Authentication actions used across the app -- `analytics.ts` - Analytics tracking used by multiple features -- `notifications.ts` - Notification system used app-wide +**Shared Actions (in this folder):** +- `user.ts` - `getCurrentUserProfile`: the calling user's profile (used by `UserProvider` in `src/contexts/user-context.tsx`) +- `domain.ts` - `getMpTimezone`: the MP domain's IANA time zone (used by the group wizard page) +- `sign-out.ts` - `handleSignOut`: app sign-out plus MP RP-initiated logout (used by the sign-out forms on `/no-access` and `/session-error`) + +`user.ts` and `domain.ts` are documented authorization carve-outs (CLAUDE.md rule 12), each justified in the file; `sign-out.ts` reads no MP data. A new shared action that touches MP data must call `AuthorizationService` instead, or carry its own written justification. **Feature-Specific Actions (keep co-located):** -- `components/user-menu/actions.ts` - Sign-out and user menu operations +- `components/address-labels/actions.ts` - Address-label fetch and mail merge ## Usage 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<string> { + 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/components/shared-actions/sign-out.test.ts b/src/components/shared-actions/sign-out.test.ts new file mode 100644 index 0000000..2ecb0e3 --- /dev/null +++ b/src/components/shared-actions/sign-out.test.ts @@ -0,0 +1,153 @@ +import { describe, it, expect, vi, beforeEach, afterAll } from 'vitest'; + +const { mockSignOut, mockRedirect } = vi.hoisted(() => ({ + mockSignOut: vi.fn(), + mockRedirect: vi.fn(), +})); + +vi.mock('@/lib/auth', () => ({ + auth: { + api: { + signOut: mockSignOut, + }, + }, +})); + +vi.mock('next/headers', () => ({ + headers: vi.fn().mockResolvedValue(new Headers()), +})); + +vi.mock('next/navigation', () => ({ + redirect: mockRedirect, +})); + +import { handleSignOut } from './sign-out'; + +/** + * `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.MINISTRY_PLATFORM_CLIENT_ID = 'app-client'; + process.env.MINISTRY_PLATFORM_CLIENT_SECRET = 'app-secret'; + mockSignOut.mockResolvedValue({ success: true }); + }); + + afterAll(() => { + process.env = originalEnv; + }); + + 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('redirects to MP end-session with post_logout_redirect_uri and the MP 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-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(); + + 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-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('still clears the session, then throws, when MINISTRY_PLATFORM_BASE_URL is missing', async () => { + delete process.env.MINISTRY_PLATFORM_BASE_URL; + + await expect(handleSignOut()).rejects.toThrow('MINISTRY_PLATFORM_BASE_URL is not set'); + expect(mockSignOut).toHaveBeenCalledTimes(1); + expect(mockRedirect).not.toHaveBeenCalled(); + }); + + it('throws when neither BETTER_AUTH_URL nor NEXTAUTH_URL is set', async () => { + delete process.env.BETTER_AUTH_URL; + delete process.env.NEXTAUTH_URL; + + await expect(handleSignOut()).rejects.toThrow('BETTER_AUTH_URL is not set (NEXTAUTH_URL is accepted as a fallback)'); + expect(mockRedirect).not.toHaveBeenCalled(); + }); + + it('throws when the MP client secret is missing rather than send a half-configured client_id', async () => { + delete process.env.MINISTRY_PLATFORM_CLIENT_SECRET; + + await expect(handleSignOut()).rejects.toThrow(/must both be set/); + 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'; + + await handleSignOut(); + + 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'; + + 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/'; + + await handleSignOut(); + + expect(mockRedirect).toHaveBeenCalledWith( + 'https://mp.example.com/api/oauth/connect/endsession?post_logout_redirect_uri=https%3A%2F%2Fmyapp.example.com&client_id=app-client', + ); + }); +}); diff --git a/src/components/shared-actions/sign-out.ts b/src/components/shared-actions/sign-out.ts new file mode 100644 index 0000000..d67872d --- /dev/null +++ b/src/components/shared-actions/sign-out.ts @@ -0,0 +1,79 @@ +'use server'; + +import { auth } from "@/lib/auth"; +import { getAuthBaseUrl, getMpBaseUrl, getMpClient } 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 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 }, + }); + + // 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 } = getMpClient(); + + const params = new URLSearchParams({ + 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(`${mpBaseUrl}/oauth/connect/endsession?${params.toString()}`); +} diff --git a/src/components/shared-actions/user.test.ts b/src/components/shared-actions/user.test.ts index 28ccd74..a2c57c3 100644 --- a/src/components/shared-actions/user.test.ts +++ b/src/components/shared-actions/user.test.ts @@ -35,17 +35,10 @@ describe('getCurrentUserProfile', () => { it("should look up the profile using the session's userGuid", async () => { const mockProfile = { - User_ID: 1, - User_GUID: SESSION_GUID, - Contact_ID: 100, First_Name: 'John', Nickname: 'Johnny', Last_Name: 'Doe', Email_Address: 'john@example.com', - Mobile_Phone: null, - Image_GUID: null, - roles: ['Admin'], - userGroups: ['Staff'], }; mockGetUserProfile.mockResolvedValueOnce(mockProfile); 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 = '<mjml><mj-body><mj-section><mj-column><mj-text>Hi</mj-text></mj-column></mj-section></mj-body></mjml>'; - -describe('compileMjml', () => { - beforeEach(() => { - vi.clearAllMocks(); - mockRequireSecurityRole.mockResolvedValue(42); - mockMjml2html.mockResolvedValue({ - html: '<html>compiled</html>', - 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: '<html>compiled</html>', - errors: [], - }); - }); - - it('maps mjml compile errors into the MjmlCompileError shape', async () => { - mockMjml2html.mockResolvedValueOnce({ - html: '<html>partial</html>', - 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<number> { - return AuthorizationService.getInstance().requireSecurityRole({ table, operation }); -} - - -const MAX_MJML_SIZE = 512_000; // 500KB - -export async function compileMjml(mjmlSource: string): Promise<MjmlCompileResult> { - // 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 }) => ( - <button data-testid="toolbar-close" onClick={onClose}> - close - </button> - ), -})); - -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. - <div data-testid="canvas" className={`gjs-cv-canvas ${className ?? ''}`} /> - ), - 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(<EditorCanvas onClose={onClose} />); - expect(screen.getByTestId('canvas')).toBeInTheDocument(); - expect(screen.getByTestId('toolbar-close')).toBeInTheDocument(); - }); - - it('registers merge field blocks and removes navbar blocks on ready', () => { - render(<EditorCanvas onClose={onClose} />); - - 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(<EditorCanvas onClose={onClose} />); - 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(<EditorCanvas onClose={onClose} />); - 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(<EditorCanvas onClose={onClose} />); - expect(mockSetComponents).toHaveBeenCalledWith(DEFAULT_MJML_TEMPLATE); - }); - - it('invokes the onEditorReady callback prop with the editor instance', () => { - const onEditorReady = vi.fn(); - render(<EditorCanvas onClose={onClose} onEditorReady={onEditorReady} />); - expect(onEditorReady).toHaveBeenCalledTimes(1); - expect(onEditorReady).toHaveBeenCalledWith(expect.objectContaining({ setComponents: mockSetComponents })); - }); - - it('passes onClose through to the toolbar', () => { - render(<EditorCanvas onClose={onClose} />); - 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<Editor | null>(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 ( - <GjsEditor - className="flex flex-col h-full w-full" - grapesjs={grapesjs} - grapesjsCss="" - plugins={[grapesjsMjml]} - options={editorConfig} - onReady={handleEditorReady} - > - <WithEditor> - <EditorToolbar onClose={onClose} /> - </WithEditor> - <div className="flex-1 overflow-hidden"> - <Canvas className="h-full w-full" /> - </div> - </GjsEditor> - ); -} 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('<mjml><mj-body/></mjml>'); - mockCompileMjml.mockResolvedValue({ html: '<html>ok</html>', errors: [] }); - Object.assign(navigator, { clipboard: { writeText: mockWriteText } }); - mockWriteText.mockResolvedValue(undefined); - }); - - afterEach(() => { - cleanup(); - vi.restoreAllMocks(); - }); - - it('renders nothing when closed', () => { - render(<EditorCodeDialog open={false} onOpenChange={onOpenChange} />); - expect(screen.queryByText('Code Editor')).not.toBeInTheDocument(); - }); - - it('seeds the MJML textarea from editor.getHtml() when opened', () => { - render(<EditorCodeDialog open onOpenChange={onOpenChange} />); - expect(screen.getByDisplayValue('<mjml><mj-body/></mjml>')).toBeInTheDocument(); - }); - - it('applies edited MJML to the editor and closes the dialog', () => { - render(<EditorCodeDialog open onOpenChange={onOpenChange} />); - - const textarea = screen.getByDisplayValue('<mjml><mj-body/></mjml>'); - fireEvent.change(textarea, { target: { value: '<mjml><mj-body>edited</mj-body></mjml>' } }); - fireEvent.click(screen.getByRole('button', { name: 'Apply to Editor' })); - - expect(mockSetComponents).toHaveBeenCalledWith('<mjml><mj-body>edited</mj-body></mjml>'); - expect(onOpenChange).toHaveBeenCalledWith(false); - }); - - it('copies the MJML source to the clipboard and shows Copied! feedback', async () => { - render(<EditorCodeDialog open onOpenChange={onOpenChange} />); - - fireEvent.click(screen.getByRole('button', { name: 'Copy MJML' })); - - expect(mockWriteText).toHaveBeenCalledWith('<mjml><mj-body/></mjml>'); - expect(await screen.findByText('Copied!')).toBeInTheDocument(); - }); - - it('compiles HTML automatically on first switch to the HTML tab', async () => { - render(<EditorCodeDialog open onOpenChange={onOpenChange} />); - - fireEvent.click(screen.getByRole('button', { name: 'HTML Preview' })); - - expect(screen.getByText(/Compiling MJML/)).toBeInTheDocument(); - await waitFor(() => expect(mockCompileMjml).toHaveBeenCalledWith('<mjml><mj-body/></mjml>')); - expect(await screen.findByDisplayValue('<html>ok</html>')).toBeInTheDocument(); - }); - - it('does not recompile automatically the second time the HTML tab is opened', async () => { - render(<EditorCodeDialog open onOpenChange={onOpenChange} />); - - fireEvent.click(screen.getByRole('button', { name: 'HTML Preview' })); - await screen.findByDisplayValue('<html>ok</html>'); - 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: '<html>partial</html>', - errors: [{ line: 1, message: 'bad', tagName: 'mj-x', formattedMessage: 'Line 1: bad' }], - }); - - render(<EditorCodeDialog open onOpenChange={onOpenChange} />); - 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(<EditorCodeDialog open onOpenChange={onOpenChange} />); - 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(<EditorCodeDialog open onOpenChange={onOpenChange} />); - 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(<EditorCodeDialog open onOpenChange={onOpenChange} />); - fireEvent.click(screen.getByRole('button', { name: 'HTML Preview' })); - await screen.findByDisplayValue('<html>ok</html>'); - - fireEvent.click(screen.getByRole('button', { name: 'Recompile' })); - await waitFor(() => expect(mockCompileMjml).toHaveBeenCalledTimes(2)); - - fireEvent.click(screen.getByRole('button', { name: 'Copy HTML' })); - expect(mockWriteText).toHaveBeenCalledWith('<html>ok</html>'); - 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<string[]>([]); - 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 ( - <Dialog open={open} onOpenChange={onOpenChange}> - <DialogContent className="max-w-4xl max-h-[80vh] flex flex-col"> - <DialogHeader> - <DialogTitle>Code Editor</DialogTitle> - <DialogDescription> - View and edit the MJML source or preview the compiled HTML output. - </DialogDescription> - </DialogHeader> - - {/* Tabs */} - <div className="flex gap-2 border-b pb-2"> - <Button - variant={activeTab === 'mjml' ? 'default' : 'outline'} - size="sm" - onClick={() => setActiveTab('mjml')} - > - MJML Source - </Button> - <Button - variant={activeTab === 'html' ? 'default' : 'outline'} - size="sm" - onClick={() => { - setActiveTab('html'); - if (!htmlPreview) handleCompileHtml(); - }} - > - HTML Preview - </Button> - </div> - - {/* Content */} - <div className="flex-1 min-h-0 overflow-auto"> - {activeTab === 'mjml' && ( - <div className="space-y-3"> - <Textarea - value={mjmlSource} - onChange={(e) => setMjmlSource(e.target.value)} - className="font-mono text-xs min-h-[400px] resize-none" - placeholder="MJML source code..." - /> - <div className="flex gap-2"> - <Button size="sm" onClick={handleApplyMjml}> - Apply to Editor - </Button> - <Button - size="sm" - variant="outline" - onClick={() => handleCopy(mjmlSource, 'mjml')} - > - {copiedSource === 'mjml' ? 'Copied!' : 'Copy MJML'} - </Button> - </div> - </div> - )} - - {activeTab === 'html' && ( - <div className="space-y-3"> - {isCompiling ? ( - <div className="flex items-center justify-center py-12 text-sm text-muted-foreground"> - Compiling MJML... - </div> - ) : ( - <> - {compileErrors.length > 0 && ( - <div className="bg-red-50 border border-red-200 rounded-md p-3"> - <p className="text-sm font-medium text-red-800 mb-1"> - Compilation warnings: - </p> - {compileErrors.map((err, i) => ( - <p key={i} className="text-xs text-red-600 font-mono"> - {err} - </p> - ))} - </div> - )} - <Textarea - value={htmlPreview} - readOnly - className="font-mono text-xs min-h-[400px] resize-none bg-gray-50" - placeholder="Compiled HTML will appear here..." - /> - <div className="flex gap-2"> - <Button - size="sm" - variant="outline" - onClick={handleCompileHtml} - disabled={isCompiling} - > - Recompile - </Button> - <Button - size="sm" - variant="outline" - onClick={() => handleCopy(htmlPreview, 'html')} - disabled={!htmlPreview} - > - {copiedSource === 'html' ? 'Copied!' : 'Copy HTML'} - </Button> - </div> - </> - )} - </div> - )} - </div> - </DialogContent> - </Dialog> - ); -} diff --git a/src/components/template-editor/editor-export-dialog.test.tsx b/src/components/template-editor/editor-export-dialog.test.tsx deleted file mode 100644 index 38f84a3..0000000 --- a/src/components/template-editor/editor-export-dialog.test.tsx +++ /dev/null @@ -1,153 +0,0 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { render, screen, cleanup, fireEvent, waitFor } from '@testing-library/react'; - -const { mockGetHtml, mockGetProjectData, mockEditor } = vi.hoisted(() => { - const mockGetHtml = vi.fn(); - const mockGetProjectData = 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, - mockGetProjectData, - mockEditor: { getHtml: mockGetHtml, getProjectData: mockGetProjectData }, - }; -}); - -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 { EditorExportDialog } from './editor-export-dialog'; - -describe('EditorExportDialog', () => { - const onOpenChange = vi.fn(); - let createObjectURL: ReturnType<typeof vi.fn>; - let revokeObjectURL: ReturnType<typeof vi.fn>; - let clickSpy: ReturnType<typeof vi.spyOn>; - - beforeEach(() => { - vi.clearAllMocks(); - mockGetHtml.mockReturnValue('<mjml><mj-body/></mjml>'); - mockGetProjectData.mockReturnValue({ pages: [] }); - mockCompileMjml.mockResolvedValue({ html: '<html>ok</html>', errors: [] }); - Object.assign(navigator, { clipboard: { writeText: mockWriteText } }); - mockWriteText.mockResolvedValue(undefined); - - createObjectURL = vi.fn().mockReturnValue('blob:mock-url'); - revokeObjectURL = vi.fn(); - // jsdom does not implement these. - (URL as unknown as { createObjectURL: typeof createObjectURL }).createObjectURL = createObjectURL; - (URL as unknown as { revokeObjectURL: typeof revokeObjectURL }).revokeObjectURL = revokeObjectURL; - clickSpy = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {}); - }); - - afterEach(() => { - cleanup(); - vi.restoreAllMocks(); - }); - - it('renders nothing when closed', () => { - render(<EditorExportDialog open={false} onOpenChange={onOpenChange} />); - expect(screen.queryByText('Export Template')).not.toBeInTheDocument(); - }); - - it('seeds MJML and JSON state from the editor when opened', () => { - render(<EditorExportDialog open onOpenChange={onOpenChange} />); - - expect(screen.getByDisplayValue('<mjml><mj-body/></mjml>')).toBeInTheDocument(); - - fireEvent.click(screen.getByRole('button', { name: 'JSON' })); - const textarea = screen.getByRole('textbox') as HTMLTextAreaElement; - expect(textarea.value).toBe(JSON.stringify({ pages: [] }, null, 2)); - }); - - it('compiles HTML automatically the first time the HTML tab is opened', async () => { - render(<EditorExportDialog open onOpenChange={onOpenChange} />); - - fireEvent.click(screen.getByRole('button', { name: 'HTML' })); - expect(screen.getByText('Compiling...')).toBeInTheDocument(); - - await waitFor(() => expect(mockCompileMjml).toHaveBeenCalledWith('<mjml><mj-body/></mjml>')); - expect(await screen.findByDisplayValue('<html>ok</html>')).toBeInTheDocument(); - }); - - it('shows a generic message for non-Error rejections', async () => { - mockCompileMjml.mockRejectedValueOnce('nope'); - - render(<EditorExportDialog open onOpenChange={onOpenChange} />); - fireEvent.click(screen.getByRole('button', { name: 'HTML' })); - - expect(await screen.findByText('Compilation failed')).toBeInTheDocument(); - }); - - it('shows compile warnings returned alongside a successful compile', async () => { - mockCompileMjml.mockResolvedValueOnce({ - html: '<html>partial</html>', - errors: [{ line: 2, message: 'bad', tagName: 'mj-x', formattedMessage: 'Line 2: bad' }], - }); - - render(<EditorExportDialog open onOpenChange={onOpenChange} />); - fireEvent.click(screen.getByRole('button', { name: 'HTML' })); - - expect(await screen.findByText('Line 2: bad')).toBeInTheDocument(); - }); - - it('clicking the already-active MJML tab is a no-op that stays on MJML', () => { - render(<EditorExportDialog open onOpenChange={onOpenChange} />); - fireEvent.click(screen.getByRole('button', { name: 'MJML' })); - expect(screen.getByDisplayValue('<mjml><mj-body/></mjml>')).toBeInTheDocument(); - }); - - it('copies MJML source to the clipboard', async () => { - render(<EditorExportDialog open onOpenChange={onOpenChange} />); - fireEvent.click(screen.getByRole('button', { name: 'Copy MJML' })); - - expect(mockWriteText).toHaveBeenCalledWith('<mjml><mj-body/></mjml>'); - expect(await screen.findByText('Copied!')).toBeInTheDocument(); - }); - - it('recompile button is disabled while compiling and copy HTML disabled until output exists', async () => { - render(<EditorExportDialog open onOpenChange={onOpenChange} />); - fireEvent.click(screen.getByRole('button', { name: 'HTML' })); - - // While compiling, Recompile/Copy controls are not present (spinner shown instead). - expect(screen.queryByRole('button', { name: 'Recompile' })).not.toBeInTheDocument(); - - await screen.findByDisplayValue('<html>ok</html>'); - fireEvent.click(screen.getByRole('button', { name: 'Copy HTML' })); - expect(mockWriteText).toHaveBeenCalledWith('<html>ok</html>'); - - fireEvent.click(screen.getByRole('button', { name: 'Recompile' })); - await waitFor(() => expect(mockCompileMjml).toHaveBeenCalledTimes(2)); - }); - - it('copies JSON state to the clipboard', async () => { - render(<EditorExportDialog open onOpenChange={onOpenChange} />); - fireEvent.click(screen.getByRole('button', { name: 'JSON' })); - fireEvent.click(screen.getByRole('button', { name: 'Copy JSON' })); - - expect(mockWriteText).toHaveBeenCalledWith(JSON.stringify({ pages: [] }, null, 2)); - expect(await screen.findByText('Copied!')).toBeInTheDocument(); - }); - - it('downloads the JSON state as a file via an anchor click, then revokes the object URL', () => { - render(<EditorExportDialog open onOpenChange={onOpenChange} />); - fireEvent.click(screen.getByRole('button', { name: 'JSON' })); - fireEvent.click(screen.getByRole('button', { name: 'Download JSON' })); - - expect(createObjectURL).toHaveBeenCalledTimes(1); - expect(clickSpy).toHaveBeenCalledTimes(1); - expect(revokeObjectURL).toHaveBeenCalledWith('blob:mock-url'); - }); -}); diff --git a/src/components/template-editor/editor-export-dialog.tsx b/src/components/template-editor/editor-export-dialog.tsx deleted file mode 100644 index 9670afc..0000000 --- a/src/components/template-editor/editor-export-dialog.tsx +++ /dev/null @@ -1,217 +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 EditorExportDialogProps { - open: boolean; - onOpenChange: (open: boolean) => void; -} - -export function EditorExportDialog({ open, onOpenChange }: EditorExportDialogProps) { - const editor = useEditor(); - const [activeTab, setActiveTab] = useState<'mjml' | 'html' | 'json'>('mjml'); - const [mjmlSource, setMjmlSource] = useState(''); - const [htmlOutput, setHtmlOutput] = useState(''); - const [jsonState, setJsonState] = useState(''); - const [isCompiling, setIsCompiling] = useState(false); - const [compileErrors, setCompileErrors] = useState<string[]>([]); - const [copiedSource, setCopiedSource] = useState<'mjml' | 'html' | 'json' | null>(null); - - useEffect(() => { - if (open) { - // Sync editor's current MJML/project data into dialog state when opened. - const mjml = editor.getHtml(); - // eslint-disable-next-line react-hooks/set-state-in-effect - setMjmlSource(mjml); - - const projectData = editor.getProjectData(); - - setJsonState(JSON.stringify(projectData, null, 2)); - - - setHtmlOutput(''); - - setCompileErrors([]); - - setCopiedSource(null); - } - }, [open, editor]); - - const handleCompile = async () => { - setIsCompiling(true); - setCompileErrors([]); - try { - const result = await compileMjml(mjmlSource); - setHtmlOutput(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 handleCopy = async (text: string, source: 'mjml' | 'html' | 'json') => { - await navigator.clipboard.writeText(text); - setCopiedSource(source); - setTimeout(() => setCopiedSource(null), 2000); - }; - - const handleDownloadJson = () => { - const blob = new Blob([jsonState], { type: 'application/json' }); - const url = URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - a.download = 'template-state.json'; - a.click(); - URL.revokeObjectURL(url); - }; - - return ( - <Dialog open={open} onOpenChange={onOpenChange}> - <DialogContent className="max-w-4xl max-h-[80vh] flex flex-col"> - <DialogHeader> - <DialogTitle>Export Template</DialogTitle> - <DialogDescription> - Export the template as MJML source, compiled HTML, or editor JSON state. - </DialogDescription> - </DialogHeader> - - {/* Tabs */} - <div className="flex gap-2 border-b pb-2"> - <Button - variant={activeTab === 'mjml' ? 'default' : 'outline'} - size="sm" - onClick={() => setActiveTab('mjml')} - > - MJML - </Button> - <Button - variant={activeTab === 'html' ? 'default' : 'outline'} - size="sm" - onClick={() => { - setActiveTab('html'); - if (!htmlOutput) handleCompile(); - }} - > - HTML - </Button> - <Button - variant={activeTab === 'json' ? 'default' : 'outline'} - size="sm" - onClick={() => setActiveTab('json')} - > - JSON - </Button> - </div> - - {/* Content */} - <div className="flex-1 min-h-0 overflow-auto"> - {activeTab === 'mjml' && ( - <div className="space-y-3"> - <Textarea - value={mjmlSource} - readOnly - className="font-mono text-xs min-h-[400px] resize-none bg-gray-50" - /> - <Button - size="sm" - variant="outline" - onClick={() => handleCopy(mjmlSource, 'mjml')} - > - {copiedSource === 'mjml' ? 'Copied!' : 'Copy MJML'} - </Button> - </div> - )} - - {activeTab === 'html' && ( - <div className="space-y-3"> - {isCompiling ? ( - <div className="flex items-center justify-center py-12 text-sm text-muted-foreground"> - Compiling... - </div> - ) : ( - <> - {compileErrors.length > 0 && ( - <div className="bg-red-50 border border-red-200 rounded-md p-3"> - <p className="text-sm font-medium text-red-800 mb-1"> - Compilation warnings: - </p> - {compileErrors.map((err, i) => ( - <p key={i} className="text-xs text-red-600 font-mono"> - {err} - </p> - ))} - </div> - )} - <Textarea - value={htmlOutput} - readOnly - className="font-mono text-xs min-h-[400px] resize-none bg-gray-50" - /> - <div className="flex gap-2"> - <Button - size="sm" - variant="outline" - onClick={() => handleCopy(htmlOutput, 'html')} - disabled={!htmlOutput} - > - {copiedSource === 'html' ? 'Copied!' : 'Copy HTML'} - </Button> - <Button - size="sm" - variant="outline" - onClick={handleCompile} - disabled={isCompiling} - > - Recompile - </Button> - </div> - </> - )} - </div> - )} - - {activeTab === 'json' && ( - <div className="space-y-3"> - <Textarea - value={jsonState} - readOnly - className="font-mono text-xs min-h-[400px] resize-none bg-gray-50" - /> - <div className="flex gap-2"> - <Button - size="sm" - variant="outline" - onClick={() => handleCopy(jsonState, 'json')} - > - {copiedSource === 'json' ? 'Copied!' : 'Copy JSON'} - </Button> - <Button - size="sm" - variant="outline" - onClick={handleDownloadJson} - > - Download JSON - </Button> - </div> - </div> - )} - </div> - </DialogContent> - </Dialog> - ); -} diff --git a/src/components/template-editor/editor-import-dialog.test.tsx b/src/components/template-editor/editor-import-dialog.test.tsx deleted file mode 100644 index 190ca47..0000000 --- a/src/components/template-editor/editor-import-dialog.test.tsx +++ /dev/null @@ -1,106 +0,0 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { render, screen, cleanup, fireEvent } from '@testing-library/react'; - -const { mockSetComponents, mockLoadData } = vi.hoisted(() => ({ - mockSetComponents: vi.fn(), - mockLoadData: vi.fn(), -})); - -vi.mock('@grapesjs/react', () => ({ - useEditor: () => ({ - setComponents: mockSetComponents, - loadData: mockLoadData, - }), -})); - -import { EditorImportDialog } from './editor-import-dialog'; - -describe('EditorImportDialog', () => { - const onOpenChange = vi.fn(); - - beforeEach(() => { - vi.clearAllMocks(); - }); - - afterEach(() => { - cleanup(); - vi.restoreAllMocks(); - }); - - it('renders nothing meaningful when closed', () => { - render(<EditorImportDialog open={false} onOpenChange={onOpenChange} />); - expect(screen.queryByText('Import Template')).not.toBeInTheDocument(); - }); - - it('disables the Import button until source text is entered', () => { - render(<EditorImportDialog open onOpenChange={onOpenChange} />); - expect(screen.getByRole('button', { name: 'Import' })).toBeDisabled(); - - fireEvent.change(screen.getByPlaceholderText(/mjml/i), { target: { value: '<mjml></mjml>' } }); - expect(screen.getByRole('button', { name: 'Import' })).not.toBeDisabled(); - }); - - it('imports MJML source via editor.setComponents by default', () => { - render(<EditorImportDialog open onOpenChange={onOpenChange} />); - - fireEvent.change(screen.getByPlaceholderText(/mjml/i), { target: { value: '<mjml><mj-body/></mjml>' } }); - fireEvent.click(screen.getByRole('button', { name: 'Import' })); - - expect(mockSetComponents).toHaveBeenCalledWith('<mjml><mj-body/></mjml>'); - expect(mockLoadData).not.toHaveBeenCalled(); - expect(onOpenChange).toHaveBeenCalledWith(false); - }); - - it('switches to JSON mode and loads valid JSON via editor.loadData', () => { - render(<EditorImportDialog open onOpenChange={onOpenChange} />); - - fireEvent.click(screen.getByRole('button', { name: 'JSON State' })); - const textarea = screen.getByPlaceholderText(/assets/i); - fireEvent.change(textarea, { target: { value: '{"assets":[]}' } }); - fireEvent.click(screen.getByRole('button', { name: 'Import' })); - - expect(mockLoadData).toHaveBeenCalledWith({ assets: [] }); - expect(mockSetComponents).not.toHaveBeenCalled(); - expect(onOpenChange).toHaveBeenCalledWith(false); - }); - - it('shows an error and does not close when JSON is invalid', () => { - render(<EditorImportDialog open onOpenChange={onOpenChange} />); - - fireEvent.click(screen.getByRole('button', { name: 'JSON State' })); - fireEvent.change(screen.getByPlaceholderText(/assets/i), { target: { value: 'not json' } }); - fireEvent.click(screen.getByRole('button', { name: 'Import' })); - - expect(screen.getByText(/Invalid JSON data/)).toBeInTheDocument(); - expect(mockLoadData).not.toHaveBeenCalled(); - expect(onOpenChange).not.toHaveBeenCalled(); - }); - - it('does nothing when Import is clicked with only whitespace source', () => { - render(<EditorImportDialog open onOpenChange={onOpenChange} />); - - fireEvent.change(screen.getByPlaceholderText(/mjml/i), { target: { value: ' ' } }); - // Button stays disabled for whitespace-only source. - expect(screen.getByRole('button', { name: 'Import' })).toBeDisabled(); - }); - - it('switches from JSON mode back to MJML mode via the toggle', () => { - render(<EditorImportDialog open onOpenChange={onOpenChange} />); - - fireEvent.click(screen.getByRole('button', { name: 'JSON State' })); - fireEvent.click(screen.getByRole('button', { name: 'MJML Source' })); - - fireEvent.change(screen.getByPlaceholderText(/mjml/i), { target: { value: '<mjml/>' } }); - fireEvent.click(screen.getByRole('button', { name: 'Import' })); - - expect(mockSetComponents).toHaveBeenCalledWith('<mjml/>'); - }); - - it('cancel closes without importing', () => { - render(<EditorImportDialog open onOpenChange={onOpenChange} />); - fireEvent.click(screen.getByRole('button', { name: 'Cancel' })); - - expect(onOpenChange).toHaveBeenCalledWith(false); - expect(mockSetComponents).not.toHaveBeenCalled(); - }); -}); diff --git a/src/components/template-editor/editor-import-dialog.tsx b/src/components/template-editor/editor-import-dialog.tsx deleted file mode 100644 index b2e9b9e..0000000 --- a/src/components/template-editor/editor-import-dialog.tsx +++ /dev/null @@ -1,101 +0,0 @@ -"use client"; - -import { useState } from 'react'; -import { useEditor } from '@grapesjs/react'; -import { - Dialog, - DialogContent, - DialogHeader, - DialogTitle, - DialogDescription, -} from '@/components/ui/dialog'; -import { Button } from '@/components/ui/button'; -import { Textarea } from '@/components/ui/textarea'; - -interface EditorImportDialogProps { - open: boolean; - onOpenChange: (open: boolean) => void; -} - -export function EditorImportDialog({ open, onOpenChange }: EditorImportDialogProps) { - const editor = useEditor(); - const [source, setSource] = useState(''); - const [importType, setImportType] = useState<'mjml' | 'json'>('mjml'); - const [error, setError] = useState(''); - - const handleImport = () => { - if (!source.trim()) return; - setError(''); - - if (importType === 'mjml') { - editor.setComponents(source); - } else { - try { - const data = JSON.parse(source); - editor.loadData(data); - } catch { - setError('Invalid JSON data. Please check the format and try again.'); - return; - } - } - - setSource(''); - onOpenChange(false); - }; - - return ( - <Dialog open={open} onOpenChange={onOpenChange}> - <DialogContent className="max-w-3xl max-h-[80vh] flex flex-col"> - <DialogHeader> - <DialogTitle>Import Template</DialogTitle> - <DialogDescription> - Paste MJML source code or a JSON editor state to load into the editor. - This will replace the current content. - </DialogDescription> - </DialogHeader> - - {/* Type Toggle */} - <div className="flex gap-2"> - <Button - variant={importType === 'mjml' ? 'default' : 'outline'} - size="sm" - onClick={() => setImportType('mjml')} - > - MJML Source - </Button> - <Button - variant={importType === 'json' ? 'default' : 'outline'} - size="sm" - onClick={() => setImportType('json')} - > - JSON State - </Button> - </div> - - <Textarea - value={source} - onChange={(e) => setSource(e.target.value)} - className="font-mono text-xs min-h-[350px] resize-none flex-1" - placeholder={ - importType === 'mjml' - ? '<mjml>\n <mj-body>\n <mj-section>\n <mj-column>\n <mj-text>Hello World</mj-text>\n </mj-column>\n </mj-section>\n </mj-body>\n</mjml>' - : '{ "assets": [], "styles": [], "pages": [...] }' - } - /> - - {error && ( - <p className="text-sm text-red-600">{error}</p> - )} - - <div className="flex justify-end gap-2"> - <Button variant="outline" onClick={() => onOpenChange(false)}> - Cancel - </Button> - <Button onClick={handleImport} disabled={!source.trim()}> - Import - </Button> - </div> - </DialogContent> - </Dialog> - ); -} diff --git a/src/components/template-editor/editor-toolbar.test.tsx b/src/components/template-editor/editor-toolbar.test.tsx deleted file mode 100644 index 43d3119..0000000 --- a/src/components/template-editor/editor-toolbar.test.tsx +++ /dev/null @@ -1,327 +0,0 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { render, screen, cleanup, fireEvent, act } from '@testing-library/react'; - -// Sub-dialogs / picker are exercised in their own test files — stub them here -// so EditorToolbar tests focus on the toolbar's own wiring (undo/redo state, -// device/panel toggles, and the Clear-canvas confirmation). -vi.mock('./editor-code-dialog', () => ({ - EditorCodeDialog: ({ open }: { open: boolean }) => - open ? <div data-testid="code-dialog" /> : null, -})); -vi.mock('./editor-import-dialog', () => ({ - EditorImportDialog: ({ open }: { open: boolean }) => - open ? <div data-testid="import-dialog" /> : null, -})); -vi.mock('./editor-export-dialog', () => ({ - EditorExportDialog: ({ open }: { open: boolean }) => - open ? <div data-testid="export-dialog" /> : null, -})); -vi.mock('./merge-field-picker', () => ({ - MergeFieldPicker: () => <div data-testid="merge-field-picker" />, -})); - -const { - mockHasUndo, - mockHasRedo, - mockUndo, - mockRedo, - mockOn, - mockOff, - mockSetDevice, - mockDomComponentsClear, - mockCssComposerClear, - mockUndoManagerClear, - mockCommandsHas, - mockRunCommand, - mockStopCommand, - mockEditor, -} = vi.hoisted(() => { - const mockHasUndo = vi.fn(() => false); - const mockHasRedo = vi.fn(() => false); - const mockUndo = vi.fn(); - const mockRedo = vi.fn(); - const mockOn = vi.fn(); - const mockOff = vi.fn(); - const mockSetDevice = vi.fn(); - const mockDomComponentsClear = vi.fn(); - const mockCssComposerClear = vi.fn(); - const mockUndoManagerClear = vi.fn(); - const mockCommandsHas = vi.fn(() => false); - const mockRunCommand = vi.fn(); - const mockStopCommand = vi.fn(); - - // Stable object identity — EditorToolbar's effects depend on `editor` by - // reference, so recreating the object per useEditor() call would retrigger - // effects every render and infinite-loop the test. - const mockEditor = { - UndoManager: { - hasUndo: mockHasUndo, - hasRedo: mockHasRedo, - undo: mockUndo, - redo: mockRedo, - clear: mockUndoManagerClear, - }, - on: mockOn, - off: mockOff, - setDevice: mockSetDevice, - DomComponents: { clear: mockDomComponentsClear }, - CssComposer: { clear: mockCssComposerClear }, - Commands: { has: mockCommandsHas }, - runCommand: mockRunCommand, - stopCommand: mockStopCommand, - }; - - return { - mockHasUndo, - mockHasRedo, - mockUndo, - mockRedo, - mockOn, - mockOff, - mockSetDevice, - mockDomComponentsClear, - mockCssComposerClear, - mockUndoManagerClear, - mockCommandsHas, - mockRunCommand, - mockStopCommand, - mockEditor, - }; -}); - -vi.mock('@grapesjs/react', () => ({ - useEditor: () => mockEditor, -})); - -import { EditorToolbar } from './editor-toolbar'; - -/** - * `ToolbarButton` renders icon-only buttons: the tooltip text is not wired as - * an accessible name (Radix Tooltip only links `aria-describedby` while the - * tooltip is actually open), so `getByRole('button', { name })` cannot find - * them. Locate them instead by the lucide icon's stable CSS class - * (`lucide-<kebab-case-icon-name>`), which each toolbar button renders - * exactly one of. - */ -function getIconButton(iconClass: string): HTMLElement { - const button = screen - .getAllByRole('button') - .find((b) => b.querySelector(`svg.lucide-${iconClass}`)); - if (!button) { - throw new Error(`No button found containing an svg.lucide-${iconClass}`); - } - return button; -} - -describe('EditorToolbar', () => { - const onClose = vi.fn(); - - beforeEach(() => { - vi.clearAllMocks(); - mockHasUndo.mockReturnValue(false); - mockHasRedo.mockReturnValue(false); - mockCommandsHas.mockReturnValue(false); - }); - - afterEach(() => { - cleanup(); - vi.restoreAllMocks(); - }); - - it('subscribes to change:changesCount on mount and unsubscribes on unmount', () => { - const { unmount } = render(<EditorToolbar onClose={onClose} />); - expect(mockOn).toHaveBeenCalledWith('change:changesCount', expect.any(Function)); - - unmount(); - expect(mockOff).toHaveBeenCalledWith('change:changesCount', expect.any(Function)); - }); - - it('disables Undo/Redo buttons when the UndoManager reports none available', () => { - render(<EditorToolbar onClose={onClose} />); - expect(getIconButton('undo-2')).toBeDisabled(); - expect(getIconButton('redo-2')).toBeDisabled(); - }); - - it('enables and wires Undo/Redo when the UndoManager reports availability', () => { - mockHasUndo.mockReturnValue(true); - mockHasRedo.mockReturnValue(true); - render(<EditorToolbar onClose={onClose} />); - - const undoBtn = getIconButton('undo-2'); - const redoBtn = getIconButton('redo-2'); - expect(undoBtn).not.toBeDisabled(); - expect(redoBtn).not.toBeDisabled(); - - fireEvent.click(undoBtn); - expect(mockUndo).toHaveBeenCalledTimes(1); - - fireEvent.click(redoBtn); - expect(mockRedo).toHaveBeenCalledTimes(1); - }); - - it('re-syncs undo/redo state when the subscribed change event fires', () => { - render(<EditorToolbar onClose={onClose} />); - expect(getIconButton('undo-2')).toBeDisabled(); - - mockHasUndo.mockReturnValue(true); - const onUpdate = mockOn.mock.calls.find(([event]) => event === 'change:changesCount')?.[1]; - act(() => { - onUpdate?.(); - }); - - expect(getIconButton('undo-2')).not.toBeDisabled(); - }); - - it('switches active device and calls editor.setDevice', () => { - render(<EditorToolbar onClose={onClose} />); - - fireEvent.click(getIconButton('smartphone')); - expect(mockSetDevice).toHaveBeenCalledWith('Mobile'); - - fireEvent.click(getIconButton('monitor')); - expect(mockSetDevice).toHaveBeenCalledWith('Desktop'); - }); - - it('resizes the views container and canvas elements when they exist in the DOM', () => { - // Simulate the real GrapesJS-rendered nodes the toolbar's effect looks - // up by class name via document.querySelector. - const viewsContainer = document.createElement('div'); - viewsContainer.className = 'gjs-pn-views-container'; - const canvas = document.createElement('div'); - canvas.className = 'gjs-cv-canvas'; - document.body.append(viewsContainer, canvas); - - mockCommandsHas.mockReturnValue(true); - render(<EditorToolbar onClose={onClose} />); - expect(viewsContainer.style.display).toBe('block'); - expect(canvas.style.width).toBe('calc(100% - 240px)'); - - // Toggling the active panel closed hides the sidebar again. - fireEvent.click(getIconButton('layout-grid')); - expect(viewsContainer.style.display).toBe('none'); - expect(canvas.style.width).toBe('100%'); - - viewsContainer.remove(); - canvas.remove(); - }); - - it('opens the Blocks panel command by default on mount when the command exists', () => { - mockCommandsHas.mockReturnValue(true); - render(<EditorToolbar onClose={onClose} />); - expect(mockRunCommand).toHaveBeenCalledWith('core:open-blocks'); - }); - - it('toggles a panel closed when its already-active button is clicked again', () => { - mockCommandsHas.mockReturnValue(true); - render(<EditorToolbar onClose={onClose} />); - mockRunCommand.mockClear(); - mockStopCommand.mockClear(); - - fireEvent.click(getIconButton('layout-grid')); - - // Active panel becomes null: all panel-close commands run, none (re)open. - expect(mockStopCommand).toHaveBeenCalledWith('core:open-blocks'); - expect(mockStopCommand).toHaveBeenCalledWith('core:open-layers'); - expect(mockStopCommand).toHaveBeenCalledWith('core:open-styles-manager'); - expect(mockRunCommand).not.toHaveBeenCalled(); - }); - - it('switches to the Layers panel when its button is clicked', () => { - mockCommandsHas.mockReturnValue(true); - render(<EditorToolbar onClose={onClose} />); - mockRunCommand.mockClear(); - - fireEvent.click(getIconButton('layers')); - expect(mockRunCommand).toHaveBeenCalledWith('core:open-layers'); - }); - - it('switches to the Styles panel when its button is clicked', () => { - mockCommandsHas.mockReturnValue(true); - render(<EditorToolbar onClose={onClose} />); - mockRunCommand.mockClear(); - - fireEvent.click(getIconButton('paintbrush')); - expect(mockRunCommand).toHaveBeenCalledWith('core:open-styles-manager'); - }); - - it('does not run a panel command when the editor reports it does not exist', () => { - mockCommandsHas.mockReturnValue(false); - render(<EditorToolbar onClose={onClose} />); - - expect(mockRunCommand).not.toHaveBeenCalled(); - expect(mockStopCommand).not.toHaveBeenCalled(); - }); - - it('opens the code dialog from the toolbar button', () => { - render(<EditorToolbar onClose={onClose} />); - fireEvent.click(getIconButton('code')); - expect(screen.getByTestId('code-dialog')).toBeInTheDocument(); - }); - - it('opens the import dialog from the toolbar button', () => { - render(<EditorToolbar onClose={onClose} />); - fireEvent.click(getIconButton('upload')); - expect(screen.getByTestId('import-dialog')).toBeInTheDocument(); - }); - - it('opens the export dialog from the toolbar button', () => { - render(<EditorToolbar onClose={onClose} />); - fireEvent.click(getIconButton('download')); - expect(screen.getByTestId('export-dialog')).toBeInTheDocument(); - }); - - it('renders the merge field picker', () => { - render(<EditorToolbar onClose={onClose} />); - expect(screen.getByTestId('merge-field-picker')).toBeInTheDocument(); - }); - - it('calls onClose when the close button is clicked', () => { - render(<EditorToolbar onClose={onClose} />); - fireEvent.click(getIconButton('x')); - expect(onClose).toHaveBeenCalledTimes(1); - }); - - it('opens a confirmation dialog for the Clear-canvas button and does nothing until confirmed', () => { - render(<EditorToolbar onClose={onClose} />); - - fireEvent.click(getIconButton('trash-2')); - - expect(screen.getByText('Clear canvas?')).toBeInTheDocument(); - expect(mockDomComponentsClear).not.toHaveBeenCalled(); - }); - - it('clears the canvas and removes the localStorage draft when confirmed', () => { - const removeItemSpy = vi.spyOn(Storage.prototype, 'removeItem'); - render(<EditorToolbar onClose={onClose} />); - - fireEvent.click(getIconButton('trash-2')); - fireEvent.click(screen.getByRole('button', { name: 'Clear' })); - - expect(mockDomComponentsClear).toHaveBeenCalledTimes(1); - expect(mockCssComposerClear).toHaveBeenCalledTimes(1); - expect(mockUndoManagerClear).toHaveBeenCalledTimes(1); - expect(removeItemSpy).toHaveBeenCalledWith('mp-template-editor'); - }); - - it('swallows a localStorage.removeItem failure during Clear', () => { - vi.spyOn(Storage.prototype, 'removeItem').mockImplementation(() => { - throw new Error('unavailable'); - }); - render(<EditorToolbar onClose={onClose} />); - - fireEvent.click(getIconButton('trash-2')); - - expect(() => fireEvent.click(screen.getByRole('button', { name: 'Clear' }))).not.toThrow(); - expect(mockDomComponentsClear).toHaveBeenCalledTimes(1); - }); - - it('canceling the clear confirmation leaves the canvas untouched', () => { - render(<EditorToolbar onClose={onClose} />); - - fireEvent.click(getIconButton('trash-2')); - fireEvent.click(screen.getByRole('button', { name: 'Cancel' })); - - expect(mockDomComponentsClear).not.toHaveBeenCalled(); - expect(screen.queryByText('Clear canvas?')).not.toBeInTheDocument(); - }); -}); diff --git a/src/components/template-editor/editor-toolbar.tsx b/src/components/template-editor/editor-toolbar.tsx deleted file mode 100644 index 358b14c..0000000 --- a/src/components/template-editor/editor-toolbar.tsx +++ /dev/null @@ -1,340 +0,0 @@ -"use client"; - -import { useState, useEffect, useCallback } from 'react'; -import { useEditor } from '@grapesjs/react'; -import { - Undo2, - Redo2, - Monitor, - Smartphone, - LayoutGrid, - Layers, - Paintbrush, - Code, - Upload, - Download, - Trash2, - X, -} from 'lucide-react'; -import { cn } from '@/lib/utils'; -import { Button } from '@/components/ui/button'; -import { - Tooltip, - TooltipContent, - TooltipProvider, - TooltipTrigger, -} from '@/components/ui/tooltip'; -import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, - AlertDialogTrigger, -} from '@/components/ui/alert-dialog'; -import { EditorCodeDialog } from '@/components/template-editor/editor-code-dialog'; -import { EditorImportDialog } from '@/components/template-editor/editor-import-dialog'; -import { EditorExportDialog } from '@/components/template-editor/editor-export-dialog'; -import { MergeFieldPicker } from '@/components/template-editor/merge-field-picker'; -import { STORAGE_KEY } from '@/components/template-editor/grapes-config'; -import type { EditorToolbarProps } from '@/components/template-editor/types'; - -type PanelName = 'blocks' | 'layers' | 'styles'; - -export function EditorToolbar({ onClose }: EditorToolbarProps) { - const editor = useEditor(); - const [activeDevice, setActiveDevice] = useState('Desktop'); - const [canUndo, setCanUndo] = useState(false); - const [canRedo, setCanRedo] = useState(false); - const [activePanel, setActivePanel] = useState<PanelName | null>('blocks'); - const [codeDialogOpen, setCodeDialogOpen] = useState(false); - const [importDialogOpen, setImportDialogOpen] = useState(false); - const [exportDialogOpen, setExportDialogOpen] = useState(false); - - const updateUndoRedo = useCallback(() => { - const um = editor.UndoManager; - setCanUndo(um.hasUndo()); - setCanRedo(um.hasRedo()); - }, [editor]); - - useEffect(() => { - // Sync undo/redo from GrapesJS UndoManager and subscribe to its change events. - // eslint-disable-next-line react-hooks/set-state-in-effect - updateUndoRedo(); - - const onUpdate = () => updateUndoRedo(); - editor.on('change:changesCount', onUpdate); - - return () => { - editor.off('change:changesCount', onUpdate); - }; - }, [editor, updateUndoRedo]); - - // Render the active panel content into the views container - useEffect(() => { - const commands = editor.Commands; - const runIfExists = (cmd: string) => { - if (commands.has(cmd)) editor.runCommand(cmd); - }; - const stopIfExists = (cmd: string) => { - if (commands.has(cmd)) editor.stopCommand(cmd); - }; - - const panelCommands: Record<PanelName, string> = { - blocks: 'core:open-blocks', - layers: 'core:open-layers', - styles: 'core:open-styles-manager', - }; - - // Close all panels first - for (const cmd of Object.values(panelCommands)) { - stopIfExists(cmd); - } - - // Open the active one - if (activePanel) { - runIfExists(panelCommands[activePanel]); - } - - // Toggle sidebar visibility - const viewsContainer = document.querySelector('.gjs-pn-views-container') as HTMLElement; - const canvas = document.querySelector('.gjs-cv-canvas') as HTMLElement; - if (viewsContainer && canvas) { - if (activePanel) { - viewsContainer.style.display = 'block'; - canvas.style.width = 'calc(100% - 240px)'; - } else { - viewsContainer.style.display = 'none'; - canvas.style.width = '100%'; - } - } - }, [editor, activePanel]); - - const handleUndo = () => { - editor.UndoManager.undo(); - updateUndoRedo(); - }; - - const handleRedo = () => { - editor.UndoManager.redo(); - updateUndoRedo(); - }; - - const handleDeviceChange = (device: string) => { - editor.setDevice(device); - setActiveDevice(device); - }; - - const togglePanel = (panel: PanelName) => { - setActivePanel(activePanel === panel ? null : panel); - }; - - const handleClear = () => { - editor.DomComponents.clear(); - editor.CssComposer.clear(); - editor.UndoManager.clear(); - try { - localStorage.removeItem(STORAGE_KEY); - } catch { - // localStorage may be unavailable - } - }; - - return ( - <> - <TooltipProvider delayDuration={300}> - <div className="flex items-center gap-1 px-3 py-1.5 bg-slate-800 border-b border-slate-700 flex-shrink-0"> - {/* Undo/Redo */} - <ToolbarGroup> - <ToolbarButton - icon={<Undo2 className="h-4 w-4" />} - tooltip="Undo" - onClick={handleUndo} - disabled={!canUndo} - /> - <ToolbarButton - icon={<Redo2 className="h-4 w-4" />} - tooltip="Redo" - onClick={handleRedo} - disabled={!canRedo} - /> - </ToolbarGroup> - - <ToolbarSeparator /> - - {/* Device Toggle */} - <ToolbarGroup> - <ToolbarButton - icon={<Monitor className="h-4 w-4" />} - tooltip="Desktop (600px)" - onClick={() => handleDeviceChange('Desktop')} - active={activeDevice === 'Desktop'} - /> - <ToolbarButton - icon={<Smartphone className="h-4 w-4" />} - tooltip="Mobile (320px)" - onClick={() => handleDeviceChange('Mobile')} - active={activeDevice === 'Mobile'} - /> - </ToolbarGroup> - - <ToolbarSeparator /> - - {/* Panel Toggles */} - <ToolbarGroup> - <ToolbarButton - icon={<LayoutGrid className="h-4 w-4" />} - tooltip="Blocks" - onClick={() => togglePanel('blocks')} - active={activePanel === 'blocks'} - /> - <ToolbarButton - icon={<Layers className="h-4 w-4" />} - tooltip="Layers" - onClick={() => togglePanel('layers')} - active={activePanel === 'layers'} - /> - <ToolbarButton - icon={<Paintbrush className="h-4 w-4" />} - tooltip="Styles" - onClick={() => togglePanel('styles')} - active={activePanel === 'styles'} - /> - </ToolbarGroup> - - <ToolbarSeparator /> - - {/* Actions */} - <ToolbarGroup> - <ToolbarButton - icon={<Code className="h-4 w-4" />} - tooltip="View/Edit Code" - onClick={() => setCodeDialogOpen(true)} - /> - <MergeFieldPicker /> - <ToolbarButton - icon={<Upload className="h-4 w-4" />} - tooltip="Import Template" - onClick={() => setImportDialogOpen(true)} - /> - <ToolbarButton - icon={<Download className="h-4 w-4" />} - tooltip="Export Template" - onClick={() => setExportDialogOpen(true)} - /> - </ToolbarGroup> - - <ToolbarSeparator /> - - {/* Clear with confirmation */} - <AlertDialog> - <Tooltip> - <TooltipTrigger asChild> - <AlertDialogTrigger asChild> - <Button - variant="ghost" - size="icon" - className="h-7 w-7 text-slate-300 hover:text-white hover:bg-red-900/50 hover:text-red-300" - > - <Trash2 className="h-4 w-4" /> - </Button> - </AlertDialogTrigger> - </TooltipTrigger> - <TooltipContent side="bottom" className="text-xs"> - Clear Canvas - </TooltipContent> - </Tooltip> - <AlertDialogContent> - <AlertDialogHeader> - <AlertDialogTitle>Clear canvas?</AlertDialogTitle> - <AlertDialogDescription> - This will remove all content from the editor. This action cannot be undone. - </AlertDialogDescription> - </AlertDialogHeader> - <AlertDialogFooter> - <AlertDialogCancel>Cancel</AlertDialogCancel> - <AlertDialogAction onClick={handleClear}> - Clear - </AlertDialogAction> - </AlertDialogFooter> - </AlertDialogContent> - </AlertDialog> - - {/* Spacer */} - <div className="flex-1" /> - - {/* Close */} - <ToolbarButton - icon={<X className="h-4 w-4" />} - tooltip="Close Editor" - onClick={onClose} - /> - </div> - </TooltipProvider> - - {/* Dialogs */} - <EditorCodeDialog - open={codeDialogOpen} - onOpenChange={setCodeDialogOpen} - /> - <EditorImportDialog - open={importDialogOpen} - onOpenChange={setImportDialogOpen} - /> - <EditorExportDialog - open={exportDialogOpen} - onOpenChange={setExportDialogOpen} - /> - </> - ); -} - -function ToolbarGroup({ children }: { children: React.ReactNode }) { - return <div className="flex items-center gap-0.5">{children}</div>; -} - -function ToolbarSeparator() { - return <div className="w-px h-5 bg-slate-600 mx-1.5" />; -} - -interface ToolbarButtonProps { - icon: React.ReactNode; - tooltip: string; - onClick?: () => void; - disabled?: boolean; - active?: boolean; -} - -function ToolbarButton({ - icon, - tooltip, - onClick, - disabled = false, - active = false, -}: ToolbarButtonProps) { - return ( - <Tooltip> - <TooltipTrigger asChild> - <Button - variant="ghost" - size="icon" - className={cn( - 'h-7 w-7 text-slate-300 hover:text-white hover:bg-slate-700', - active && 'bg-slate-600 text-white', - disabled && 'opacity-40 cursor-not-allowed', - )} - onClick={onClick} - disabled={disabled} - > - {icon} - </Button> - </TooltipTrigger> - <TooltipContent side="bottom" className="text-xs"> - {tooltip} - </TooltipContent> - </Tooltip> - ); -} diff --git a/src/components/template-editor/grapes-config.test.ts b/src/components/template-editor/grapes-config.test.ts deleted file mode 100644 index 5a4bcf4..0000000 --- a/src/components/template-editor/grapes-config.test.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { createEditorConfig, DEFAULT_MJML_TEMPLATE, STORAGE_KEY } from './grapes-config'; - -describe('grapes-config', () => { - it('exports the shared storage key', () => { - expect(STORAGE_KEY).toBe('mp-template-editor'); - }); - - it('exports a default MJML template that is well-formed enough to contain mjml/mj-body tags', () => { - expect(DEFAULT_MJML_TEMPLATE).toContain('<mjml>'); - expect(DEFAULT_MJML_TEMPLATE).toContain('<mj-body'); - expect(DEFAULT_MJML_TEMPLATE).toContain('</mjml>'); - }); - - it('createEditorConfig returns a config wired to the shared storage key', () => { - const config = createEditorConfig(); - - expect(config.fromElement).toBe(false); - expect(config.height).toBe('100%'); - expect(config.storageManager).toMatchObject({ - type: 'local', - autosave: true, - autoload: true, - stepsBeforeSave: 1, - options: { - local: { - key: STORAGE_KEY, - }, - }, - }); - }); - - it('createEditorConfig includes Desktop and Mobile devices', () => { - const config = createEditorConfig(); - const devices = config.deviceManager?.devices ?? []; - const names = devices.map((d) => d.name); - expect(names).toEqual(['Desktop', 'Mobile']); - }); - - it('createEditorConfig disables default panels (custom React toolbar is used instead)', () => { - const config = createEditorConfig(); - expect(config.panels).toEqual({ defaults: [] }); - }); - - it('createEditorConfig returns a fresh object on each call', () => { - const first = createEditorConfig(); - const second = createEditorConfig(); - expect(first).not.toBe(second); - expect(first).toEqual(second); - }); -}); diff --git a/src/components/template-editor/grapes-config.ts b/src/components/template-editor/grapes-config.ts deleted file mode 100644 index 7046743..0000000 --- a/src/components/template-editor/grapes-config.ts +++ /dev/null @@ -1,73 +0,0 @@ -import type { EditorConfig } from 'grapesjs'; - -export const STORAGE_KEY = 'mp-template-editor'; - -export const DEFAULT_MJML_TEMPLATE = `<mjml> - <mj-body background-color="#f4f4f4"> - <mj-section background-color="#ffffff" padding="20px"> - <mj-column> - <mj-text font-size="14px" color="#333333" font-family="Arial, sans-serif"> - <p>Start building your email by dragging blocks from the right panel.</p> - </mj-text> - </mj-column> - </mj-section> - </mj-body> -</mjml>`; - -export function createEditorConfig(): EditorConfig { - return { - fromElement: false, - - // Canvas height managed by parent container - height: '100%', - width: 'auto', - - // Storage: localStorage for Phase 1 - storageManager: { - type: 'local', - autosave: true, - autoload: true, - stepsBeforeSave: 1, - options: { - local: { - key: STORAGE_KEY, - }, - }, - }, - - // Device manager for desktop/mobile preview - deviceManager: { - devices: [ - { - name: 'Desktop', - width: '600px', - }, - { - name: 'Mobile', - width: '320px', - widthMedia: '480px', - }, - ], - }, - - // Disable default panels — we use a custom React toolbar - panels: { defaults: [] }, - - // Asset manager — URL input only for Phase 1 - assetManager: { - autoAdd: true, - }, - - // Canvas config - canvas: { - styles: [ - 'https://fonts.googleapis.com/css2?family=Open+Sans:wght@300;400;600;700&display=swap', - ], - }, - - // Undo manager - undoManager: { - trackSelection: false, - }, - }; -} diff --git a/src/components/template-editor/index.ts b/src/components/template-editor/index.ts deleted file mode 100644 index 77bcea4..0000000 --- a/src/components/template-editor/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { TemplateEditorForm } from './template-editor-form'; diff --git a/src/components/template-editor/merge-field-picker.test.tsx b/src/components/template-editor/merge-field-picker.test.tsx deleted file mode 100644 index 8542008..0000000 --- a/src/components/template-editor/merge-field-picker.test.tsx +++ /dev/null @@ -1,136 +0,0 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { render, screen, cleanup, fireEvent } from '@testing-library/react'; - -const { mockGetSelected, mockGetWrapper } = vi.hoisted(() => ({ - mockGetSelected: vi.fn(), - mockGetWrapper: vi.fn(), -})); - -vi.mock('@grapesjs/react', () => ({ - useEditor: () => ({ - getSelected: mockGetSelected, - getWrapper: mockGetWrapper, - }), -})); - -import { MergeFieldPicker } from './merge-field-picker'; - -describe('MergeFieldPicker', () => { - beforeEach(() => { - vi.clearAllMocks(); - mockGetSelected.mockReturnValue(null); - mockGetWrapper.mockReturnValue(null); - }); - - afterEach(() => { - cleanup(); - vi.restoreAllMocks(); - }); - - async function openPicker() { - render(<MergeFieldPicker />); - const trigger = screen.getByRole('button'); - fireEvent.click(trigger); - } - - it('renders every category and field once opened', async () => { - await openPicker(); - - expect(await screen.findByText('Merge Fields')).toBeInTheDocument(); - expect(screen.getByText('Contact')).toBeInTheDocument(); - expect(screen.getByText('Household')).toBeInTheDocument(); - expect(screen.getByText('Church')).toBeInTheDocument(); - expect(screen.getByText('System')).toBeInTheDocument(); - expect(screen.getByText('First Name')).toBeInTheDocument(); - expect(screen.getByText('{{First_Name}}')).toBeInTheDocument(); - }); - - it('appends the token to the selected mj-text component content instead of inserting a new block', async () => { - const set = vi.fn(); - const selected = { - get: (key: string) => (key === 'type' ? 'mj-text' : key === 'content' ? '<p>Hi </p>' : undefined), - set, - }; - mockGetSelected.mockReturnValue(selected); - - await openPicker(); - fireEvent.click(await screen.findByText('First Name')); - - expect(set).toHaveBeenCalledWith('content', '<p>Hi </p>{{First_Name}}'); - expect(mockGetWrapper).not.toHaveBeenCalled(); - }); - - it('treats an empty existing content as empty string, not the literal "undefined"', async () => { - const set = vi.fn(); - const selected = { - get: (key: string) => (key === 'type' ? 'mj-text' : key === 'content' ? undefined : undefined), - set, - }; - mockGetSelected.mockReturnValue(selected); - - await openPicker(); - fireEvent.click(await screen.findByText('First Name')); - - expect(set).toHaveBeenCalledWith('content', '{{First_Name}}'); - }); - - it('inserts a new mj-section under the mj-body when nothing is selected', async () => { - const append = vi.fn(); - const body = { append }; - const wrapper = { - find: (selector: string) => (selector === 'mj-body' ? [body] : []), - }; - mockGetSelected.mockReturnValue(null); - mockGetWrapper.mockReturnValue(wrapper); - - await openPicker(); - fireEvent.click(await screen.findByText('Unsubscribe Link')); - - expect(append).toHaveBeenCalledWith({ - type: 'mj-section', - components: [ - { - type: 'mj-column', - components: [ - { - type: 'mj-text', - content: '<p>{{Unsubscribe_URL}}</p>', - }, - ], - }, - ], - }); - }); - - it('falls back to appending directly on the wrapper when no mj-body is found', async () => { - const append = vi.fn(); - const wrapper = { - find: () => [], - append, - }; - mockGetSelected.mockReturnValue(null); - mockGetWrapper.mockReturnValue(wrapper); - - await openPicker(); - fireEvent.click(await screen.findByText('First Name')); - - expect(append).toHaveBeenCalledTimes(1); - }); - - it('does nothing (no throw) when selected is not mj-text and no wrapper exists', async () => { - mockGetSelected.mockReturnValue(null); - mockGetWrapper.mockReturnValue(null); - - await openPicker(); - const field = await screen.findByText('First Name'); - expect(() => fireEvent.click(field)).not.toThrow(); - }); - - it('closes the popover after inserting a field', async () => { - await openPicker(); - expect(await screen.findByText('Merge Fields')).toBeInTheDocument(); - fireEvent.click(screen.getByText('First Name')); - - expect(screen.queryByText('Merge Fields')).not.toBeInTheDocument(); - }); -}); diff --git a/src/components/template-editor/merge-field-picker.tsx b/src/components/template-editor/merge-field-picker.tsx deleted file mode 100644 index e876063..0000000 --- a/src/components/template-editor/merge-field-picker.tsx +++ /dev/null @@ -1,110 +0,0 @@ -"use client"; - -import { useState } from 'react'; -import { useEditor } from '@grapesjs/react'; -import { Braces } from 'lucide-react'; -import { Button } from '@/components/ui/button'; -import { - Popover, - PopoverContent, - PopoverTrigger, -} from '@/components/ui/popover'; -import { - Tooltip, - TooltipContent, - TooltipTrigger, -} from '@/components/ui/tooltip'; -import { - MERGE_FIELD_CATEGORIES, - getFieldsByCategory, - type MergeField, -} from '@/components/template-editor/merge-fields'; - -export function MergeFieldPicker() { - const editor = useEditor(); - const [open, setOpen] = useState(false); - - const handleInsertField = (field: MergeField) => { - const selected = editor.getSelected(); - - if (selected && selected.get('type') === 'mj-text') { - // Append to existing text component - const currentContent = selected.get('content') || ''; - selected.set('content', currentContent + field.value); - } else { - // Add new mj-text block with the merge field - const wrapper = editor.getWrapper(); - if (wrapper) { - // Try to find an mj-body or add to the first available container - const body = wrapper.find('mj-body')[0] || wrapper; - body.append({ - type: 'mj-section', - components: [ - { - type: 'mj-column', - components: [ - { - type: 'mj-text', - content: `<p>${field.value}</p>`, - }, - ], - }, - ], - }); - } - } - - setOpen(false); - }; - - return ( - <Popover open={open} onOpenChange={setOpen}> - <Tooltip> - <TooltipTrigger asChild> - <PopoverTrigger asChild> - <Button - variant="ghost" - size="icon" - className="h-7 w-7 text-slate-300 hover:text-white hover:bg-slate-700" - > - <Braces className="h-4 w-4" /> - </Button> - </PopoverTrigger> - </TooltipTrigger> - <TooltipContent side="bottom" className="text-xs"> - Insert Merge Field - </TooltipContent> - </Tooltip> - - <PopoverContent className="w-64 p-0" align="start"> - <div className="p-2 border-b"> - <p className="text-sm font-medium">Merge Fields</p> - <p className="text-xs text-muted-foreground"> - Click to insert at cursor or as new block - </p> - </div> - <div className="max-h-64 overflow-auto p-1"> - {MERGE_FIELD_CATEGORIES.map((category) => ( - <div key={category} className="mb-1"> - <p className="text-xs font-medium text-muted-foreground px-2 py-1"> - {category} - </p> - {getFieldsByCategory(category).map((field) => ( - <button - key={field.value} - className="w-full text-left px-2 py-1.5 text-sm rounded hover:bg-accent flex items-center justify-between gap-2" - onClick={() => handleInsertField(field)} - > - <span>{field.label}</span> - <span className="text-xs text-muted-foreground font-mono"> - {field.value} - </span> - </button> - ))} - </div> - ))} - </div> - </PopoverContent> - </Popover> - ); -} diff --git a/src/components/template-editor/merge-fields.test.ts b/src/components/template-editor/merge-fields.test.ts deleted file mode 100644 index feaad17..0000000 --- a/src/components/template-editor/merge-fields.test.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { describe, it, expect, vi } from 'vitest'; -import { - MERGE_FIELDS, - MERGE_FIELD_CATEGORIES, - getFieldsByCategory, - registerMergeFieldBlocks, -} from './merge-fields'; -import type { Editor } from 'grapesjs'; - -describe('MERGE_FIELD_CATEGORIES', () => { - it('dedupes categories preserving first-seen order', () => { - expect(MERGE_FIELD_CATEGORIES).toEqual(['Contact', 'Household', 'Church', 'System']); - }); - - it('matches the set of categories present in MERGE_FIELDS', () => { - const categoriesInFields = new Set(MERGE_FIELDS.map((f) => f.category)); - expect(new Set(MERGE_FIELD_CATEGORIES)).toEqual(categoriesInFields); - }); -}); - -describe('getFieldsByCategory', () => { - it('returns only fields matching the given category', () => { - const contactFields = getFieldsByCategory('Contact'); - expect(contactFields.length).toBeGreaterThan(0); - expect(contactFields.every((f) => f.category === 'Contact')).toBe(true); - expect(contactFields.map((f) => f.value)).toContain('{{First_Name}}'); - }); - - it('returns an empty array for an unknown category', () => { - expect(getFieldsByCategory('Nonexistent')).toEqual([]); - }); -}); - -describe('registerMergeFieldBlocks', () => { - it('calls editor.Blocks.add twice with the expected block ids and categories', () => { - const add = vi.fn(); - const editor = { Blocks: { add } } as unknown as Editor; - - registerMergeFieldBlocks(editor); - - expect(add).toHaveBeenCalledTimes(2); - expect(add).toHaveBeenNthCalledWith( - 1, - 'merge-field-contact', - expect.objectContaining({ - label: 'Contact Fields', - category: 'Merge Fields', - content: expect.objectContaining({ type: 'mj-text' }), - }), - ); - expect(add).toHaveBeenNthCalledWith( - 2, - 'merge-field-unsubscribe', - expect.objectContaining({ - label: 'Unsubscribe Link', - category: 'Merge Fields', - }), - ); - }); -}); diff --git a/src/components/template-editor/merge-fields.ts b/src/components/template-editor/merge-fields.ts deleted file mode 100644 index f86d44a..0000000 --- a/src/components/template-editor/merge-fields.ts +++ /dev/null @@ -1,59 +0,0 @@ -import type { Editor } from 'grapesjs'; - -export interface MergeField { - label: string; - value: string; - category: string; -} - -export const MERGE_FIELDS: MergeField[] = [ - // Contact - { label: 'First Name', value: '{{First_Name}}', category: 'Contact' }, - { label: 'Last Name', value: '{{Last_Name}}', category: 'Contact' }, - { label: 'Nickname', value: '{{Nickname}}', category: 'Contact' }, - { label: 'Email Address', value: '{{Email_Address}}', category: 'Contact' }, - { label: 'Mobile Phone', value: '{{Mobile_Phone}}', category: 'Contact' }, - - // Household - { label: 'Household Name', value: '{{Household_Name}}', category: 'Household' }, - { label: 'Home Address', value: '{{Home_Address}}', category: 'Household' }, - - // Church - { label: 'Congregation', value: '{{Congregation_Name}}', category: 'Church' }, - - // System - { label: 'Unsubscribe Link', value: '{{Unsubscribe_URL}}', category: 'System' }, - { label: 'View in Browser', value: '{{View_In_Browser_URL}}', category: 'System' }, - { label: 'Current Date', value: '{{Current_Date}}', category: 'System' }, -]; - -export const MERGE_FIELD_CATEGORIES = [ - ...new Set(MERGE_FIELDS.map((f) => f.category)), -]; - -export function getFieldsByCategory(category: string): MergeField[] { - return MERGE_FIELDS.filter((f) => f.category === category); -} - -export function registerMergeFieldBlocks(editor: Editor) { - const bm = editor.Blocks; - - bm.add('merge-field-contact', { - label: 'Contact Fields', - category: 'Merge Fields', - content: { - type: 'mj-text', - content: '<p>{{First_Name}} {{Last_Name}}</p>', - }, - }); - - bm.add('merge-field-unsubscribe', { - label: 'Unsubscribe Link', - category: 'Merge Fields', - content: { - type: 'mj-text', - content: - '<p style="font-size:12px;color:#999;text-align:center;"><a href="{{Unsubscribe_URL}}">Unsubscribe</a> | <a href="{{View_In_Browser_URL}}">View in Browser</a></p>', - }, - }); -} diff --git a/src/components/template-editor/template-editor-form.test.tsx b/src/components/template-editor/template-editor-form.test.tsx deleted file mode 100644 index c29d3d0..0000000 --- a/src/components/template-editor/template-editor-form.test.tsx +++ /dev/null @@ -1,85 +0,0 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { render, screen, cleanup } from '@testing-library/react'; - -/** - * `next/dynamic` is mocked directly so both the resolved component and the - * `loading` fallback (`EditorSkeleton`) are independently testable and - * covered, without depending on the real dynamic-import/suspense timing. - */ -const { mockDynamic } = vi.hoisted(() => ({ - mockDynamic: vi.fn(), -})); - -vi.mock('next/dynamic', () => ({ - default: mockDynamic, -})); - -vi.mock('./editor-canvas', () => ({ - EditorCanvas: ({ onClose }: { onClose: () => void }) => ( - <div data-testid="editor-canvas" onClick={onClose} /> - ), -})); - -describe('TemplateEditorForm', () => { - beforeEach(() => { - vi.resetModules(); - }); - - afterEach(() => { - cleanup(); - vi.restoreAllMocks(); - }); - - it('calls next/dynamic with a loader resolving to EditorCanvas and ssr disabled', async () => { - let capturedOptions: { ssr?: boolean; loading?: () => React.ReactNode } | undefined; - mockDynamic.mockImplementation((loader, options) => { - capturedOptions = options; - return function DynamicResult() { - return <div data-testid="dynamic-result" />; - }; - }); - - const { TemplateEditorForm } = await import('./template-editor-form'); - render(<TemplateEditorForm onClose={vi.fn()} />); - - expect(mockDynamic).toHaveBeenCalledTimes(1); - expect(capturedOptions?.ssr).toBe(false); - - const loaderFn = mockDynamic.mock.calls[0][0] as () => Promise<{ default: unknown }>; - const mod = await loaderFn(); - const editorCanvasModule = await import('./editor-canvas'); - expect(mod.default).toBe(editorCanvasModule.EditorCanvas); - }); - - it('renders the loading fallback (EditorSkeleton) via the loading option', async () => { - let capturedOptions: { loading?: () => React.ReactNode } | undefined; - mockDynamic.mockImplementation((loader, options) => { - capturedOptions = options; - return function DynamicResult() { - return <div data-testid="dynamic-result" />; - }; - }); - - await import('./template-editor-form'); - const loadingNode = capturedOptions?.loading?.(); - - // EditorSkeleton renders Skeleton placeholders inside a flex row plus a - // header skeleton — assert the structural shape rather than text, since - // Skeleton has no visible copy. - const { container } = render(<>{loadingNode}</>); - expect(container.querySelectorAll('[class*="animate-pulse"]').length).toBeGreaterThan(0); - }); - - it('renders the resolved dynamic component (EditorCanvas) with onClose wired through', async () => { - const { EditorCanvas } = await import('./editor-canvas'); - mockDynamic.mockImplementation(() => EditorCanvas); - - const { TemplateEditorForm } = await import('./template-editor-form'); - const onClose = vi.fn(); - render(<TemplateEditorForm onClose={onClose} />); - - const canvas = await screen.findByTestId('editor-canvas'); - canvas.click(); - expect(onClose).toHaveBeenCalledTimes(1); - }); -}); diff --git a/src/components/template-editor/template-editor-form.tsx b/src/components/template-editor/template-editor-form.tsx deleted file mode 100644 index 455ed8e..0000000 --- a/src/components/template-editor/template-editor-form.tsx +++ /dev/null @@ -1,40 +0,0 @@ -"use client"; - -import dynamic from 'next/dynamic'; -import { Skeleton } from '@/components/ui/skeleton'; - -const EditorCanvas = dynamic( - () => - import('@/components/template-editor/editor-canvas').then((mod) => ({ - default: mod.EditorCanvas, - })), - { - ssr: false, - loading: () => <EditorSkeleton />, - } -); - -interface TemplateEditorFormProps { - onClose: () => void; -} - -export function TemplateEditorForm({ onClose }: TemplateEditorFormProps) { - return ( - <div className="h-full w-full overflow-hidden flex flex-col"> - <EditorCanvas onClose={onClose} /> - </div> - ); -} - -function EditorSkeleton() { - return ( - <div className="h-full w-full flex flex-col gap-2 p-4"> - <Skeleton className="h-10 w-full" /> - <div className="flex flex-1 gap-2"> - <Skeleton className="h-full w-48" /> - <Skeleton className="h-full flex-1" /> - <Skeleton className="h-full w-64" /> - </div> - </div> - ); -} diff --git a/src/components/template-editor/types.ts b/src/components/template-editor/types.ts deleted file mode 100644 index 3e3979d..0000000 --- a/src/components/template-editor/types.ts +++ /dev/null @@ -1,22 +0,0 @@ -import type { Editor } from 'grapesjs'; - -export interface MjmlCompileResult { - html: string; - errors: MjmlCompileError[]; -} - -export interface MjmlCompileError { - line: number; - message: string; - tagName: string; - formattedMessage: string; -} - -export interface EditorCanvasProps { - onEditorReady?: (editor: Editor) => void; - onClose: () => void; -} - -export interface EditorToolbarProps { - onClose: () => void; -} diff --git a/src/components/user-menu/actions.test.ts b/src/components/user-menu/actions.test.ts deleted file mode 100644 index c0c45c7..0000000 --- a/src/components/user-menu/actions.test.ts +++ /dev/null @@ -1,90 +0,0 @@ -import { describe, it, expect, vi, beforeEach, afterAll } from 'vitest'; - -const { mockSignOut, mockRedirect } = vi.hoisted(() => ({ - mockSignOut: vi.fn(), - mockRedirect: vi.fn(), -})); - -vi.mock('@/lib/auth', () => ({ - auth: { - api: { - signOut: mockSignOut, - }, - }, -})); - -vi.mock('next/headers', () => ({ - headers: vi.fn().mockResolvedValue(new Headers()), -})); - -vi.mock('next/navigation', () => ({ - redirect: mockRedirect, -})); - -import { handleSignOut } from './actions'; - -describe('handleSignOut', () => { - const originalEnv = process.env; - - 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'; - }); - - afterAll(() => { - process.env = originalEnv; - }); - - it('should call auth.api.signOut', async () => { - mockSignOut.mockResolvedValueOnce(undefined); - - await handleSignOut(); - - expect(mockSignOut).toHaveBeenCalledWith({ - headers: expect.any(Headers), - }); - }); - - it('should redirect to MP end session URL', async () => { - mockSignOut.mockResolvedValueOnce(undefined); - - 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') - ); - }); - - it('should throw 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 configured'); - }); - - it('should throw 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 (or NEXTAUTH_URL) is not configured'); - expect(mockRedirect).not.toHaveBeenCalled(); - }); - - it('should fall 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') - ); - }); -}); diff --git a/src/components/user-menu/actions.ts b/src/components/user-menu/actions.ts deleted file mode 100644 index 5597f13..0000000 --- a/src/components/user-menu/actions.ts +++ /dev/null @@ -1,29 +0,0 @@ -'use server'; - -import { auth } from "@/lib/auth"; -import { headers } from "next/headers"; -import { redirect } from "next/navigation"; - -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 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`; - const params = new URLSearchParams({ - post_logout_redirect_uri: postLogoutRedirectUri, - }); - - redirect(`${endSessionUrl}?${params.toString()}`); -} diff --git a/src/components/user-menu/index.ts b/src/components/user-menu/index.ts deleted file mode 100644 index 2d660ff..0000000 --- a/src/components/user-menu/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { UserMenu } from './user-menu'; diff --git a/src/components/user-menu/user-menu.test.tsx b/src/components/user-menu/user-menu.test.tsx deleted file mode 100644 index 35941e2..0000000 --- a/src/components/user-menu/user-menu.test.tsx +++ /dev/null @@ -1,167 +0,0 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; -import { render, screen, cleanup, act } from "@testing-library/react"; -import type { MPUserProfile } from "@/lib/providers/ministry-platform/types"; - -/** - * UserMenu Tests - * - * Covers the client component behavior that `actions.test.ts` does not: - * 1. Name display fallback (Nickname || First_Name). - * 2. Email_Address rendering including null-safe render. - * 3. `handleItemClick` wiring: `onClose?.()` runs before `handleSignOut()`. - * 4. Sign-out still works when `onClose` is omitted. - * - * 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 - * to render content inline. This keeps the focus on UserMenu's own - * logic rather than Radix internals. - */ - -const { mockHandleSignOut } = vi.hoisted(() => ({ - mockHandleSignOut: vi.fn(), -})); - -vi.mock("./actions", () => ({ - handleSignOut: mockHandleSignOut, -})); - -vi.mock("@/components/ui/dropdown-menu", () => { - const React = require("react"); - const passthrough = (tag: string) => - function Passthrough({ children, ...rest }: any) { - return React.createElement(tag, rest, children); - }; - return { - DropdownMenu: passthrough("div"), - DropdownMenuTrigger: function Trigger({ children }: any) { - // `asChild` means Radix clones into the child; for tests we just - // render the child directly. - return React.createElement(React.Fragment, null, children); - }, - DropdownMenuContent: passthrough("div"), - DropdownMenuLabel: passthrough("div"), - DropdownMenuSeparator: passthrough("hr"), - DropdownMenuItem: function Item({ children, onClick, className }: any) { - return React.createElement( - "button", - { type: "button", onClick, className }, - children - ); - }, - }; -}); - -import { UserMenu } from "./user-menu"; - -function makeProfile(overrides: Partial<MPUserProfile> = {}): MPUserProfile { - return { - User_ID: 1, - User_GUID: "guid-1", - Contact_ID: 101, - First_Name: "Jonathan", - Nickname: "Jon", - Last_Name: "Doe", - Email_Address: "jon@example.com", - Mobile_Phone: null, - Image_GUID: null, - roles: [], - userGroups: [], - ...overrides, - }; -} - -describe("UserMenu", () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - afterEach(() => { - cleanup(); - }); - - it("renders Nickname when present", () => { - render( - <UserMenu userProfile={makeProfile({ Nickname: "Jon" })}> - <button>trigger</button> - </UserMenu> - ); - - // Nickname + Last_Name rendered together with a literal space between. - expect(screen.getByText(/Jon\s+Doe/)).toBeInTheDocument(); - // First_Name should not appear when Nickname is present. - expect(screen.queryByText(/Jonathan\s+Doe/)).not.toBeInTheDocument(); - }); - - it("falls back to First_Name when Nickname is empty", () => { - render( - <UserMenu - userProfile={makeProfile({ Nickname: "", First_Name: "Jonathan" })} - > - <button>trigger</button> - </UserMenu> - ); - - expect(screen.getByText(/Jonathan\s+Doe/)).toBeInTheDocument(); - }); - - it("renders Email_Address when present and does not crash when null", () => { - const { unmount } = render( - <UserMenu userProfile={makeProfile({ Email_Address: "jon@example.com" })}> - <button>trigger</button> - </UserMenu> - ); - expect(screen.getByText("jon@example.com")).toBeInTheDocument(); - unmount(); - - // Null Email_Address must not throw during render. - expect(() => - render( - <UserMenu userProfile={makeProfile({ Email_Address: null })}> - <button>trigger</button> - </UserMenu> - ) - ).not.toThrow(); - }); - - it("invokes onClose before handleSignOut when Sign out is clicked", async () => { - const callOrder: string[] = []; - const onClose = vi.fn(() => { - callOrder.push("onClose"); - }); - mockHandleSignOut.mockImplementation(async () => { - callOrder.push("handleSignOut"); - }); - - render( - <UserMenu userProfile={makeProfile()} onClose={onClose}> - <button>trigger</button> - </UserMenu> - ); - - const signOut = screen.getByRole("button", { name: /sign out/i }); - await act(async () => { - signOut.click(); - }); - - expect(onClose).toHaveBeenCalledTimes(1); - expect(mockHandleSignOut).toHaveBeenCalledTimes(1); - expect(callOrder).toEqual(["onClose", "handleSignOut"]); - }); - - it("still signs out when onClose is omitted", async () => { - mockHandleSignOut.mockResolvedValueOnce(undefined); - - render( - <UserMenu userProfile={makeProfile()}> - <button>trigger</button> - </UserMenu> - ); - - const signOut = screen.getByRole("button", { name: /sign out/i }); - await act(async () => { - signOut.click(); - }); - - expect(mockHandleSignOut).toHaveBeenCalledTimes(1); - }); -}); diff --git a/src/components/user-menu/user-menu.tsx b/src/components/user-menu/user-menu.tsx deleted file mode 100644 index 9f3359b..0000000 --- a/src/components/user-menu/user-menu.tsx +++ /dev/null @@ -1,69 +0,0 @@ -"use client"; - -import { ArrowRightOnRectangleIcon } from "@heroicons/react/24/outline"; -import { MPUserProfile } from "@/lib/providers/ministry-platform/types"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuLabel, - DropdownMenuSeparator, - DropdownMenuTrigger, -} from "@/components/ui/dropdown-menu"; -import { handleSignOut } from "./actions"; - -interface UserMenuProps { - onClose?: () => void; - userProfile: MPUserProfile; - children: React.ReactNode; -} - -const userMenuItems = [ - { - name: "Sign out", - action: "signout", - icon: ArrowRightOnRectangleIcon, - }, -]; - -export function UserMenu({ onClose, userProfile, children }: UserMenuProps) { - const handleItemClick = async (action: string) => { - if (onClose) { - onClose(); - } - if (action === "signout") { - await handleSignOut(); - } - }; - - return ( - <DropdownMenu> - <DropdownMenuTrigger asChild>{children}</DropdownMenuTrigger> - <DropdownMenuContent - className="w-48 bg-[#344767] border-[#344767]" - align="end" - > - <DropdownMenuLabel className="text-white"> - <div className="flex flex-col space-y-1"> - <p className="font-medium text-white"> - {userProfile.Nickname || userProfile.First_Name}{" "} - {userProfile.Last_Name} - </p> - <p className="text-sm text-gray-300">{userProfile.Email_Address}</p> - </div> - </DropdownMenuLabel> - <DropdownMenuSeparator className="bg-gray-500" /> - {userMenuItems.map((item) => ( - <DropdownMenuItem - key={item.name} - onClick={() => handleItemClick(item.action)} - className="cursor-pointer text-white hover:bg-[#2d3a5f] focus:bg-[#2d3a5f]" - > - <item.icon className="mr-2 h-4 w-4 text-white" /> - {item.name} - </DropdownMenuItem> - ))} - </DropdownMenuContent> - </DropdownMenu> - ); -} diff --git a/src/contexts/user-context.test.tsx b/src/contexts/user-context.test.tsx index ec171b1..2eabcd9 100644 --- a/src/contexts/user-context.test.tsx +++ b/src/contexts/user-context.test.tsx @@ -1,5 +1,5 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { renderHook, waitFor, act } from '@testing-library/react'; +import { renderHook, waitFor, act, render, screen } from '@testing-library/react'; import { ReactNode } from 'react'; const { mockUseSession, mockGetCurrentUserProfile } = vi.hoisted(() => ({ @@ -233,3 +233,81 @@ describe('UserProvider edge branches', () => { expect(result.current.userProfile).toEqual({ Contact_ID: 7 }); }); }); + +/** + * Lazy fetch: `UserProvider` makes no MP call until a component calls + * `useUser()`, and every consumer shares the one load. + */ +describe('UserProvider lazy fetch', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('makes no profile call when nothing calls useUser()', async () => { + mockUseSession.mockReturnValue({ + data: { user: { id: 'internal-id', userGuid: 'guid-123' } }, + isPending: false, + }); + + render( + <UserProvider> + <p>no consumer</p> + </UserProvider> + ); + + expect(screen.getByText('no consumer')).toBeInTheDocument(); + // Let any (incorrectly) scheduled effects and microtasks flush. + await act(async () => {}); + expect(mockGetCurrentUserProfile).not.toHaveBeenCalled(); + }); + + it('makes exactly one profile call shared by two consumers', async () => { + mockUseSession.mockReturnValue({ + data: { user: { id: 'internal-id', userGuid: 'guid-123' } }, + isPending: false, + }); + mockGetCurrentUserProfile.mockResolvedValue({ First_Name: 'John' }); + + function Consumer({ label }: { label: string }) { + const { userProfile, isLoading } = useUser(); + return <p>{isLoading ? `${label}: loading` : `${label}: ${userProfile?.First_Name}`}</p>; + } + + render( + <UserProvider> + <Consumer label="a" /> + <Consumer label="b" /> + </UserProvider> + ); + + expect(await screen.findByText('a: John')).toBeInTheDocument(); + expect(screen.getByText('b: John')).toBeInTheDocument(); + expect(mockGetCurrentUserProfile).toHaveBeenCalledTimes(1); + }); + + it('fetches only once a consumer mounts later', async () => { + mockUseSession.mockReturnValue({ + data: { user: { id: 'internal-id', userGuid: 'guid-123' } }, + isPending: false, + }); + mockGetCurrentUserProfile.mockResolvedValue({ First_Name: 'John' }); + + function Consumer() { + const { userProfile } = useUser(); + return <p>name: {userProfile?.First_Name ?? '-'}</p>; + } + + const { rerender } = render(<UserProvider>{null}</UserProvider>); + await act(async () => {}); + expect(mockGetCurrentUserProfile).not.toHaveBeenCalled(); + + rerender( + <UserProvider> + <Consumer /> + </UserProvider> + ); + + expect(await screen.findByText('name: John')).toBeInTheDocument(); + expect(mockGetCurrentUserProfile).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/contexts/user-context.tsx b/src/contexts/user-context.tsx index 967cae2..a24dae8 100644 --- a/src/contexts/user-context.tsx +++ b/src/contexts/user-context.tsx @@ -14,12 +14,26 @@ interface UserContextValue { const UserContext = createContext<UserContextValue | undefined>(undefined); +/** + * Internal: lets `useUser()` tell the provider that a consumer exists. + * Kept in its own context so the public `useUser()` return shape is unchanged + * and the stable callback never re-renders consumers. + */ +const UserProfileRequestContext = createContext<(() => void) | undefined>(undefined); + interface UserProviderProps { children: ReactNode; } +/** + * Loads the signed-in user's MP display profile **lazily**: no + * `getCurrentUserProfile()` call is made until a component calls `useUser()`. + * The first consumer flips `requested`; later consumers share the same load + * (setting `requested` to `true` again is a no-op re-render bail-out). + */ export function UserProvider({ children }: UserProviderProps) { const { data: session, isPending } = authClient.useSession(); + const [requested, setRequested] = useState(false); const [userProfile, setUserProfile] = useState<MPUserProfile | null>(null); const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState<Error | null>(null); @@ -28,6 +42,8 @@ export function UserProvider({ children }: UserProviderProps) { // Better Auth generates its own internal user.id, so we use userGuid for MP lookups. const userGuid = (session?.user as { userGuid?: string } | undefined)?.userGuid; + const requestProfile = useCallback(() => setRequested(true), []); + const loadUserProfile = useCallback(async () => { if (!userGuid) { setUserProfile(null); @@ -49,6 +65,7 @@ export function UserProvider({ children }: UserProviderProps) { }, [userGuid]); useEffect(() => { + if (!requested) return; if (!isPending && userGuid) { // eslint-disable-next-line react-hooks/set-state-in-effect loadUserProfile(); @@ -56,7 +73,7 @@ export function UserProvider({ children }: UserProviderProps) { setUserProfile(null); setIsLoading(false); } - }, [userGuid, isPending, loadUserProfile, session]); + }, [requested, userGuid, isPending, loadUserProfile, session]); const refreshUserProfile = useCallback(async () => { await loadUserProfile(); @@ -68,14 +85,23 @@ export function UserProvider({ children }: UserProviderProps) { ); return ( - <UserContext.Provider value={value}> - {children} - </UserContext.Provider> + <UserProfileRequestContext.Provider value={requestProfile}> + <UserContext.Provider value={value}> + {children} + </UserContext.Provider> + </UserProfileRequestContext.Provider> ); } export function useUser() { const context = useContext(UserContext); + const requestProfile = useContext(UserProfileRequestContext); + + // The first mounted consumer triggers the provider's one profile load. + useEffect(() => { + requestProfile?.(); + }, [requestProfile]); + if (context === undefined) { throw new Error("useUser must be used within a UserProvider"); } diff --git a/src/lib/auth.ts b/src/lib/auth.ts index cbaeb4b..147874e 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -1,4 +1,6 @@ +import "server-only"; import { betterAuth, BetterAuthOptions } from "better-auth"; +import { APIError, createAuthMiddleware } from "better-auth/api"; import { genericOAuth, type GenericOAuthConfig, @@ -6,9 +8,182 @@ import { } from "better-auth/plugins"; import { customSession } from "better-auth/plugins"; import { nextCookies } from "better-auth/next-js"; +import { isIP } from "node:net"; +import { createRemoteJWKSet, jwtVerify, type JWTPayload, type JWTVerifyGetKey } from "jose"; +import { getAuthBaseUrl, getMpBaseUrl, getMpClient } 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<Record<string, string | undefined>>, +): 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(); + +// The app's one MP OAuth client (`MINISTRY_PLATFORM_CLIENT_ID` / `_SECRET`): +// the service account AND the client user sign-in runs as. See `getMpClient` +// in src/lib/env.ts. +const mpClient = getMpClient(); + +/** + * 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<Record<string, string | undefined>>, +): { 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 @@ -84,8 +259,73 @@ 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`). + * + * 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 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` + * 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`, 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; + 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. * @@ -93,8 +333,8 @@ export const disabledAuthPaths = [ * OAuth profile via `mapProfileToUser`, and as of better-auth 1.6 * `parseAdditionalUserInputFromProviderProfile` strips any additional field * declared `input: false` BEFORE the user record is created — so `input: false` - * silently drops the value, which breaks every MP profile lookup (avatar, user - * menu, User_ID resolution). `src/auth.test.ts` guards this. + * silently drops the value, which breaks every MP profile lookup (UserProvider, + * User_ID resolution). `src/auth.test.ts` guards this. * * `input: true` also means these fields are writable through Better Auth's * `/update-user` endpoint. That endpoint is closed at the route layer (see @@ -147,7 +387,7 @@ export function extractUserGuid(profile: unknown): string | null { * resolved name is empty, so this must always return a non-empty string. The * previous template-literal form produced the string "undefined undefined" * whenever MP omitted the name claims, which satisfied that check by accident - * while rendering as literal "undefined undefined" in the user menu. + * while rendering as literal "undefined undefined" wherever the name is shown. */ export function buildDisplayName(profile: unknown, fallbackEmail: string | null): string { const p = (profile ?? {}) as Record<string, unknown>; @@ -168,21 +408,332 @@ 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<string, unknown>) { + 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<IdTokenVerifier> { + 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<string, unknown>; + 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<IdTokenVerifier> { + let pending: Promise<IdTokenVerifier> | 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<string, unknown>; + 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<JWTPayload | null> { + 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: mpClient.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; + } +} + /** * 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: mpClient.clientId, + clientSecret: mpClient.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 + * 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", ], /** @@ -194,89 +745,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: exact redirect URIs registered on the MP client + * (`MINISTRY_PLATFORM_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, 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, mpClient.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<string, unknown>; + 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<string, unknown>; + } 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; } @@ -287,18 +870,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 @@ -341,10 +917,128 @@ 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` needs it (`src/components/shared-actions/sign-out.ts`). Exported + * for tests. + */ +export function stripUserOAuthTokens<T extends object>(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<string, unknown>; + 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<S, WithheldSessionField>, + }; +} + 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 + // `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. * @@ -359,16 +1053,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. * @@ -384,6 +1093,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], @@ -391,28 +1108,53 @@ const options = { ], } satisfies BetterAuthOptions; -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, - ), - nextCookies(), - ], -}); +function createAuth() { + 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<T>( + key: symbol, + create: () => T, + env: Readonly<Record<string, string | undefined>> = process.env, +): T { + if (env.VITEST) return create(); + const store = globalThis as unknown as Record<symbol, T | undefined>; + return (store[key] ??= create()); +} + +export const auth = sharedInstance(SHARED_AUTH_KEY, createAuth); export type Session = typeof auth.$Infer.Session; diff --git a/src/lib/barcode-helpers.test.ts b/src/lib/barcode-helpers.test.ts index 60a047a..90cb9a9 100644 --- a/src/lib/barcode-helpers.test.ts +++ b/src/lib/barcode-helpers.test.ts @@ -101,7 +101,9 @@ describe('barcode-helpers', () => { expect(result[0].barStates).toBeDefined(); expect(warnSpy).toHaveBeenCalledTimes(1); expect(warnSpy.mock.calls[0][0]).toContain('IMb encode failed'); - expect(warnSpy.mock.calls[0][0]).toContain('Test'); + // Identifiers and shape only — never the label's name or address (rule 14). + expect(warnSpy.mock.calls[0][1]).toEqual({ labelIndex: 0, error: 'Error' }); + expect(JSON.stringify(warnSpy.mock.calls)).not.toContain(baseLabel.name); }); it('should log warning and produce no barcode when IMb fails and POSTNET also fails (invalid ZIP)', () => { @@ -124,6 +126,7 @@ describe('barcode-helpers', () => { expect(warnSpy.mock.calls[0][0]).toContain('IMb encode failed'); expect(warnSpy.mock.calls[1][0]).toContain('POSTNET encode failed'); expect(warnSpy.mock.calls[2][0]).toContain('POSTNET encode also failed'); + expect(JSON.stringify(warnSpy.mock.calls)).not.toContain('ABCDE'); }); it('should log warning and fall back to 5-digit ZIP when full-routing POSTNET throws', () => { diff --git a/src/lib/barcode-helpers.ts b/src/lib/barcode-helpers.ts index e53f823..879b0ff 100644 --- a/src/lib/barcode-helpers.ts +++ b/src/lib/barcode-helpers.ts @@ -32,14 +32,23 @@ export function buildImbTrackingCode(mailerId: string, serviceType: string, seri * Attempts IMb first (if configured), falls back to POSTNET. * Returns a new array with `barStates` and `barType` populated where possible. */ +/** + * Log shape for an encode failure. CLAUDE.md rule 14: identifiers and shape + * only. The label's position in the batch identifies it; its name, address + * and ZIP/routing digits are record content and stay out of logs. The error + * message is dropped too, since encoder errors can echo their input. + */ +function describeFailure(index: number, error: unknown): { labelIndex: number; error: string } { + return { labelIndex: index, error: error instanceof Error ? error.name : typeof error }; +} + export function preEncodeBarcodes(labels: LabelData[], config: LabelConfig): LabelData[] { let serialCounter = 0; - return labels.map((label) => { + return labels.map((label, index) => { if (config.barcodeFormat === 'none') return label; const routingCode = buildRoutingCode(label.postalCode, label.deliveryPointCode); - const labelId = label.name || label.addressLine1 || '(unnamed)'; if (config.barcodeFormat === 'imb' && config.mailerId) { try { @@ -50,10 +59,7 @@ export function preEncodeBarcodes(labels: LabelData[], config: LabelConfig): Lab return { ...label, barStates: bars.join(''), barType: 'imb' as const }; } catch (err) { // Fall through to POSTNET — log the downgrade so operators see misconfigured mailer IDs / ZIPs. - console.warn( - `[preEncodeBarcodes] IMb encode failed for "${labelId}", falling back to POSTNET:`, - err instanceof Error ? err.message : err - ); + console.warn('[preEncodeBarcodes] IMb encode failed, falling back to POSTNET', describeFailure(index, err)); } } @@ -64,16 +70,16 @@ export function preEncodeBarcodes(labels: LabelData[], config: LabelConfig): Lab return { ...label, barStates: JSON.stringify(bars), barType: 'postnet' as const }; } catch (err) { console.warn( - `[preEncodeBarcodes] POSTNET encode failed for "${labelId}" with routing "${routingCode}", retrying with 5-digit ZIP:`, - err instanceof Error ? err.message : err + '[preEncodeBarcodes] POSTNET encode failed, retrying with 5-digit ZIP', + { ...describeFailure(index, err), routingLength: routingCode.length } ); try { const bars = postnetEncode(routingCode.substring(0, 5)); return { ...label, barStates: JSON.stringify(bars), barType: 'postnet' as const }; } catch (err2) { console.warn( - `[preEncodeBarcodes] POSTNET encode also failed with 5-digit ZIP for "${labelId}", no barcode will be rendered:`, - err2 instanceof Error ? err2.message : err2 + '[preEncodeBarcodes] POSTNET encode also failed with 5-digit ZIP, no barcode will be rendered', + describeFailure(index, err2) ); } } diff --git a/src/lib/env.test.ts b/src/lib/env.test.ts new file mode 100644 index 0000000..15367ef --- /dev/null +++ b/src/lib/env.test.ts @@ -0,0 +1,154 @@ +import { describe, it, expect } from 'vitest'; +import { getAuthBaseUrl, getMpBaseUrl, getMpClient } from '@/lib/env'; + +const dev = (extra: Record<string, string | undefined>) => ({ NODE_ENV: 'development', ...extra }); +const prod = (extra: Record<string, string | undefined>) => ({ 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'); + }); +}); + +describe('getMpClient', () => { + it('returns the trimmed MINISTRY_PLATFORM_CLIENT_ID / _SECRET pair', () => { + expect( + getMpClient({ MINISTRY_PLATFORM_CLIENT_ID: ' svc ', MINISTRY_PLATFORM_CLIENT_SECRET: ' svc-secret ' }), + ).toEqual({ clientId: 'svc', clientSecret: 'svc-secret' }); + }); + + it.each([ + ['nothing', {}], + ['only the id', { MINISTRY_PLATFORM_CLIENT_ID: 'svc' }], + ['only the secret', { MINISTRY_PLATFORM_CLIENT_SECRET: 'svc-secret' }], + ['blank values', { MINISTRY_PLATFORM_CLIENT_ID: ' ', MINISTRY_PLATFORM_CLIENT_SECRET: '' }], + ])('refuses %s, without echoing a value', (_label, env) => { + expect(() => getMpClient(env)).toThrow(/MINISTRY_PLATFORM_CLIENT_ID and MINISTRY_PLATFORM_CLIENT_SECRET must both be set/); + expect(() => getMpClient(env)).toThrow( + expect.objectContaining({ message: expect.not.stringMatching(/svc/) }), + ); + }); + + it('ignores the retired MP_OIDC_CLIENT_ID / _SECRET entirely', () => { + expect( + getMpClient({ + MINISTRY_PLATFORM_CLIENT_ID: 'svc', + MINISTRY_PLATFORM_CLIENT_SECRET: 'svc-secret', + MP_OIDC_CLIENT_ID: 'other', + MP_OIDC_CLIENT_SECRET: 'other-secret', + }), + ).toEqual({ clientId: 'svc', clientSecret: 'svc-secret' }); + }); + + it('reads process.env by default', () => { + expect(getMpClient()).toEqual({ clientId: 'test-client-id', clientSecret: 'test-client-secret' }); + }); +}); diff --git a/src/lib/env.ts b/src/lib/env.ts new file mode 100644 index 0000000..958a314 --- /dev/null +++ b/src/lib/env.ts @@ -0,0 +1,146 @@ +/** + * Validated readers for the two auth-critical URLs (and, at the end of the + * file, the app's MP OAuth 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 (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 + * 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. + */ + +import "server-only"; + +type Env = Readonly<Record<string, string | undefined>>; + +/** 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; +} + +/** The app's MP OAuth client. See `getMpClient`. */ +export interface MpClient { + clientId: string; + clientSecret: string; +} + +/** + * The app's single MP OAuth client (`MINISTRY_PLATFORM_CLIENT_ID` / + * `MINISTRY_PLATFORM_CLIENT_SECRET`). It is both the client-credentials + * service account every MP data call uses AND the client user sign-in (OIDC) + * runs as, so its id is also the id_token audience and the sign-out + * `client_id`. + * + * One client by owner decision (2026-10-10): a single registration and a + * single secret per deployment. The consequence to keep in mind is that this + * one secret is both full MP data access and user sign-in, so its MP client + * record must carry the sign-in redirect URIs + * (`<BETTER_AUTH_URL>/api/auth/callback/ministryplatform`, post-logout + * `<BETTER_AUTH_URL>`) and nothing broader. + * + * Throws when either value is unset and never echoes a value. + */ +export function getMpClient(env: Env = process.env): MpClient { + const clientId = env.MINISTRY_PLATFORM_CLIENT_ID?.trim(); + const clientSecret = env.MINISTRY_PLATFORM_CLIENT_SECRET?.trim(); + if (!clientId || !clientSecret) { + throw new Error( + "[env] MINISTRY_PLATFORM_CLIENT_ID and MINISTRY_PLATFORM_CLIENT_SECRET must both be set (the app's MP OAuth client).", + ); + } + return { clientId, clientSecret }; +} 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<string, string>) { + 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<typeof vi.fn>; 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 3d79cf8..ba9ecb2 100644 --- a/src/lib/providers/ministry-platform/auth/client-credentials.ts +++ b/src/lib/providers/ministry-platform/auth/client-credentials.ts @@ -1,7 +1,35 @@ +import "server-only"; +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') { - const mpBaseUrl = process.env.MINISTRY_PLATFORM_BASE_URL!; +/** + * 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<ClientCredentialsToken> { + // 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); @@ -13,19 +41,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<Partial<ClientCredentialsToken> | 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<typeof vi.fn>; + + 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<void> } }).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<typeof vi.fn>; + 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 72adfbf..72a3d9c 100644 --- a/src/lib/providers/ministry-platform/client.ts +++ b/src/lib/providers/ministry-platform/client.ts @@ -1,16 +1,88 @@ -import { getClientCredentialsToken } from "./auth/client-credentials"; +import "server-only"; +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<void> | 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<void> { + 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<void> { + 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<void> { + // 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 @@ -19,59 +91,27 @@ 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<void> | 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<void> | null = null; - - private baseUrl: string; + private readonly main: TokenPipeline; + private readonly dev: TokenPipeline; /** * Creates a new MinistryPlatformClient instance * Initializes both the default and dev HTTP clients and sets up token management */ 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); + // Validated and normalized (see src/lib/env.ts); throws on a bad value. + 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<void> { - - 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(); } /** @@ -80,28 +120,7 @@ export class MinistryPlatformClient { * @throws Error if token refresh fails or dev credentials are not configured */ public async ensureValidDevToken(): Promise<void> { - - 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(); } /** @@ -109,7 +128,7 @@ export class MinistryPlatformClient { * @returns HttpClient bound to the default token */ public getHttpClient(): HttpClient { - return this.httpClient; + return this.main.httpClient; } /** @@ -118,6 +137,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/db/api_dev_GetProcedureDefinition.sql b/src/lib/providers/ministry-platform/db/api_dev_GetProcedureDefinition.sql index f4c6e83..abe6119 100644 --- a/src/lib/providers/ministry-platform/db/api_dev_GetProcedureDefinition.sql +++ b/src/lib/providers/ministry-platform/db/api_dev_GetProcedureDefinition.sql @@ -12,10 +12,11 @@ GO -- api_dev_GetProcedureDefinition -- ============================================= -- Description: Returns the actual create SQL script for a stored procedure to enable LLM context --- Last Modified: 04/16/2026 +-- Last Modified: 10/10/2026 -- Chris Kehayias -- ============================================= CREATE PROCEDURE [dbo].[api_dev_GetProcedureDefinition] + @DomainID INT, -- required first parameter for MP API procs; MP injects it (unused here) @ProcedureName NVARCHAR(256), @SchemaName NVARCHAR(128) = 'dbo' AS diff --git a/src/lib/providers/ministry-platform/docs/README.md b/src/lib/providers/ministry-platform/docs/README.md index de2ed50..9b09ffe 100644 --- a/src/lib/providers/ministry-platform/docs/README.md +++ b/src/lib/providers/ministry-platform/docs/README.md @@ -32,6 +32,7 @@ ministry-platform/ │ └── types.ts # Auth-related types ├── services/ # Service layer │ ├── index.ts +│ ├── guards.ts # Path-segment validation (table/proc names, IDs, GUIDs) │ ├── table.service.ts │ ├── procedure.service.ts │ ├── communication.service.ts @@ -39,13 +40,15 @@ ministry-platform/ │ ├── domain.service.ts │ └── file.service.ts ├── models/ # Generated data models (PascalCase, one per MP table) +│ ├── index.ts # Barrel — re-exports interfaces only, NOT the *Schema files │ ├── <Table>.ts # TypeScript interface -│ └── <Table>Schema.ts # Matching Zod schema +│ └── <Table>Schema.ts # Matching Zod schema (import by path) ├── types/ # Hand-written type definitions │ ├── index.ts │ ├── provider.types.ts │ └── user-profile.types.ts ├── utils/ # Utilities +│ ├── index.ts │ ├── http-client.ts │ └── logger.ts ├── scripts/ # Type generation CLI (see scripts/README.md) @@ -85,7 +88,7 @@ await mp.createTableRecords('Contact_Log', [{ ## Environment Variables ```env -MINISTRY_PLATFORM_BASE_URL=https://your-instance.ministryplatform.com +MINISTRY_PLATFORM_BASE_URL=https://your-instance.ministryplatform.com/ministryplatformapi MINISTRY_PLATFORM_CLIENT_ID=your_client_id MINISTRY_PLATFORM_CLIENT_SECRET=your_client_secret @@ -122,14 +125,15 @@ mp.deleteTableRecords<T>(table, ids, params?) ```typescript mp.executeProcedure(procedure, params?) -mp.executeProcedureWithBody(procedure, parameters) +mp.getProcedures(search?) +mp.executeProcedureWithBody(procedure, parameters, queryParams?) ``` ### Communications ```typescript -mp.createCommunication(communication, attachments?) -mp.sendMessage(message, attachments?) +mp.createCommunication(communication, attachments?, params?) // params: { $userId } +mp.sendMessage(message, attachments?, params?) ``` ### Files @@ -139,6 +143,9 @@ mp.uploadFiles(params) mp.getFilesByRecord(params) mp.updateFile(params) mp.deleteFile(params) +mp.getFileContentByUniqueId(params) +mp.getFileMetadata(params) +mp.getFileMetadataByUniqueId(params) ``` ### Metadata @@ -170,7 +177,7 @@ npm run mp:generate npm run mp:generate:models # Or use directly with options -npx tsx src/lib/providers/ministry-platform/scripts/generate-types.ts --help +npx tsx --conditions=react-server src/lib/providers/ministry-platform/scripts/generate-types.ts --help ``` See [scripts/README.md](../scripts/README.md) for full documentation. diff --git a/src/lib/providers/ministry-platform/helper.test.ts b/src/lib/providers/ministry-platform/helper.test.ts index 2feb593..28facf8 100644 --- a/src/lib/providers/ministry-platform/helper.test.ts +++ b/src/lib/providers/ministry-platform/helper.test.ts @@ -496,7 +496,7 @@ describe('MPHelper', () => { it('should get tables list', async () => { const tables = [ - { Table_ID: 1, Table_Name: 'Contacts', Display_Name: 'Contacts' }, + { Name: 'Contacts', Columns: [] }, ]; mockGetTables.mockResolvedValueOnce(tables); @@ -508,8 +508,8 @@ describe('MPHelper', () => { it('should search tables by name', async () => { const tables = [ - { Table_ID: 1, Table_Name: 'Contacts', Display_Name: 'Contacts' }, - { Table_ID: 2, Table_Name: 'Contact_Log', Display_Name: 'Contact Log' }, + { Name: 'Contacts', Columns: [] }, + { Name: 'Contact_Log', Columns: [] }, ]; mockGetTables.mockResolvedValueOnce(tables); diff --git a/src/lib/providers/ministry-platform/helper.ts b/src/lib/providers/ministry-platform/helper.ts index c78a001..c5e6c45 100644 --- a/src/lib/providers/ministry-platform/helper.ts +++ b/src/lib/providers/ministry-platform/helper.ts @@ -1,3 +1,4 @@ +import "server-only"; import { MinistryPlatformProvider } from "./provider"; import { TableQueryParams, @@ -175,9 +176,10 @@ export class MPHelper { * }); * * @example - * // Create with Zod validation (recommended) - * import { ContactLogSchema } from '@/lib/providers/ministry-platform/models'; + * // Create with a datetime, validated against the generated schema + * // (its datetime rule accepts MP wall-clock `YYYY-MM-DD HH:MM:SS`) * import { DomainTimezoneService } from '@/services/domainTimezoneService'; + * import { ContactLogSchema } from '@/lib/providers/ministry-platform/models/ContactLogSchema'; * * // MP stores datetimes as wall-clock in the domain's TZ, not UTC. * // Route every datetime write through DomainTimezoneService. @@ -267,7 +269,7 @@ export class MPHelper { * * @example * // Update with Zod validation (recommended) - * import { ContactLogSchema } from '@/lib/providers/ministry-platform/models'; + * import { ContactLogSchema } from '@/lib/providers/ministry-platform/models/ContactLogSchema'; * * const updatedLogs = await mp.updateTableRecords('Contact_Log', [{ * Contact_Log_ID: 67890, @@ -385,7 +387,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/models/AccountingCompaniesSchema.ts b/src/lib/providers/ministry-platform/models/AccountingCompaniesSchema.ts index 8912953..f12cd88 100644 --- a/src/lib/providers/ministry-platform/models/AccountingCompaniesSchema.ts +++ b/src/lib/providers/ministry-platform/models/AccountingCompaniesSchema.ts @@ -12,7 +12,7 @@ export const AccountingCompaniesSchema = z.object({ List_Non_Cash_Gifts: z.boolean(), Statement_Footer: z.string().max(500).nullable(), Statement_Letter: z.string().max(2147483647).nullable(), - Statement_Cutoff_Date: z.string().datetime().nullable(), + Statement_Cutoff_Date: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)").nullable(), Statement_Cutoff_Automation_ID: z.number().int().nullable(), Standard_Statement: z.number().int().nullable(), Formal_Salutation: z.boolean(), diff --git a/src/lib/providers/ministry-platform/models/ActivityLogSchema.ts b/src/lib/providers/ministry-platform/models/ActivityLogSchema.ts index 8af1cb3..7eb0c64 100644 --- a/src/lib/providers/ministry-platform/models/ActivityLogSchema.ts +++ b/src/lib/providers/ministry-platform/models/ActivityLogSchema.ts @@ -2,7 +2,7 @@ import { z } from 'zod'; export const ActivityLogSchema = z.object({ Activity_Log_ID: z.number().int(), - Activity_Date: z.string().datetime(), + Activity_Date: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)"), Activity_Type: z.string().max(75), Record_Name: z.string().max(75), Contact_ID: z.number().int(), diff --git a/src/lib/providers/ministry-platform/models/ActivityLogStagingSchema.ts b/src/lib/providers/ministry-platform/models/ActivityLogStagingSchema.ts index 23c00b3..9c9e6b4 100644 --- a/src/lib/providers/ministry-platform/models/ActivityLogStagingSchema.ts +++ b/src/lib/providers/ministry-platform/models/ActivityLogStagingSchema.ts @@ -4,7 +4,7 @@ export const ActivityLogStagingSchema = z.object({ Activity_Log_Staging_ID: z.number().int(), Page_ID: z.number().int(), Last_PK: z.number().int(), - Last_Date: z.string().datetime(), + Last_Date: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)"), }); export type ActivityLogStagingInput = z.infer<typeof ActivityLogStagingSchema>; diff --git a/src/lib/providers/ministry-platform/models/AddressesSchema.ts b/src/lib/providers/ministry-platform/models/AddressesSchema.ts index a82fd08..6087972 100644 --- a/src/lib/providers/ministry-platform/models/AddressesSchema.ts +++ b/src/lib/providers/ministry-platform/models/AddressesSchema.ts @@ -19,11 +19,11 @@ export const AddressesSchema = z.object({ Time_Zone: z.string().max(50).nullable(), Bar_Code: z.string().max(50).nullable(), Area_Code: z.string().max(50).nullable(), - Last_Validation_Attempt: z.string().datetime().nullable(), + Last_Validation_Attempt: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)").nullable(), County: z.string().max(50).nullable(), Validated: z.boolean().nullable(), Do_Not_Validate: z.boolean(), - Last_GeoCode_Attempt: z.string().datetime().nullable(), + Last_GeoCode_Attempt: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)").nullable(), Country: z.string().max(100).nullable(), }); diff --git a/src/lib/providers/ministry-platform/models/AudienceMembersSchema.ts b/src/lib/providers/ministry-platform/models/AudienceMembersSchema.ts index 7149796..1f03058 100644 --- a/src/lib/providers/ministry-platform/models/AudienceMembersSchema.ts +++ b/src/lib/providers/ministry-platform/models/AudienceMembersSchema.ts @@ -4,11 +4,11 @@ export const AudienceMembersSchema = z.object({ Audience_Member_ID: z.number().int(), Audience_ID: z.number().int(), Contact_ID: z.number().int(), - Start_Date: z.string().datetime(), - End_Date: z.string().datetime().nullable(), + Start_Date: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)"), + End_Date: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)").nullable(), Forced_Filter: z.boolean(), Active: z.boolean(), - Last_Update_Date: z.string().datetime(), + Last_Update_Date: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)"), }); export type AudienceMembersInput = z.infer<typeof AudienceMembersSchema>; diff --git a/src/lib/providers/ministry-platform/models/AudiencesSchema.ts b/src/lib/providers/ministry-platform/models/AudiencesSchema.ts index f165108..fd6fb08 100644 --- a/src/lib/providers/ministry-platform/models/AudiencesSchema.ts +++ b/src/lib/providers/ministry-platform/models/AudiencesSchema.ts @@ -6,9 +6,9 @@ export const AudiencesSchema = z.object({ Description: z.string().max(255).nullable(), Processing_Order: z.number().int(), Active: z.boolean(), - Last_Update_Date: z.string().datetime().nullable(), + Last_Update_Date: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)").nullable(), Last_Update_Status: z.string().max(256).nullable(), - Next_Update_Date: z.string().datetime().nullable(), + Next_Update_Date: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)").nullable(), }); export type AudiencesInput = z.infer<typeof AudiencesSchema>; diff --git a/src/lib/providers/ministry-platform/models/BackgroundChecks.ts b/src/lib/providers/ministry-platform/models/BackgroundChecks.ts index 258f554..eae88c8 100644 --- a/src/lib/providers/ministry-platform/models/BackgroundChecks.ts +++ b/src/lib/providers/ministry-platform/models/BackgroundChecks.ts @@ -133,6 +133,12 @@ export interface BackgroundChecks { * Max length: 45 characters */ IP_Address?: string /* max 45 chars */ | null; + + Background_Check_InProgress?: string /* ISO datetime */ | null; + + Background_Check_Cancelled?: string /* ISO datetime */ | null; + + Invitation_Guid?: string /* GUID/UUID */ | null; } export type BackgroundChecksRecord = BackgroundChecks; diff --git a/src/lib/providers/ministry-platform/models/BackgroundChecksSchema.ts b/src/lib/providers/ministry-platform/models/BackgroundChecksSchema.ts index 1578ce8..8e3e92b 100644 --- a/src/lib/providers/ministry-platform/models/BackgroundChecksSchema.ts +++ b/src/lib/providers/ministry-platform/models/BackgroundChecksSchema.ts @@ -4,8 +4,8 @@ export const BackgroundChecksSchema = z.object({ Background_Check_ID: z.number().int(), Contact_ID: z.number().int(), Requesting_Ministry: z.number().int().nullable(), - Background_Check_Submitted: z.string().datetime().nullable(), - Background_Check_Returned: z.string().datetime().nullable(), + Background_Check_Submitted: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)").nullable(), + Background_Check_Returned: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)").nullable(), All_Clear: z.boolean().nullable(), Notes: z.string().max(500).nullable(), Theft: z.boolean().nullable(), @@ -15,7 +15,7 @@ export const BackgroundChecksSchema = z.object({ Battery: z.boolean().nullable(), Traffic: z.boolean().nullable(), Other: z.boolean().nullable(), - Background_Check_Started: z.string().datetime(), + Background_Check_Started: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)"), Reference_Number: z.string().max(50).nullable(), Report_Url: z.string().url().nullable(), Background_Check_Type_ID: z.number().int().nullable(), @@ -24,7 +24,7 @@ export const BackgroundChecksSchema = z.object({ Middle_Name: z.string().max(50).nullable(), Last_Name: z.string().max(50).nullable(), Maiden_Name: z.string().max(50).nullable(), - Date_Of_Birth: z.string().datetime().nullable(), + Date_Of_Birth: z.string().regex(/^\d{4}-\d{2}-\d{2}([ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?)?$/, "Expected YYYY-MM-DD (optionally with a time)").nullable(), Gender: z.string().max(50).nullable(), Race: z.string().max(50).nullable(), Address_Line_1: z.string().max(128).nullable(), @@ -35,10 +35,13 @@ export const BackgroundChecksSchema = z.object({ DL_Number: z.string().max(50).nullable(), Jurisdiction: z.string().max(128).nullable(), DL_State: z.string().max(50).nullable(), - Background_Check_Expires: z.string().datetime().nullable(), + Background_Check_Expires: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)").nullable(), Background_Check_Status_ID: z.number().int().nullable(), - Status_Date: z.string().datetime().nullable(), + Status_Date: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)").nullable(), IP_Address: z.string().max(45).nullable(), + Background_Check_InProgress: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)").nullable(), + Background_Check_Cancelled: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)").nullable(), + Invitation_Guid: z.string().uuid().nullable(), }); export type BackgroundChecksInput = z.infer<typeof BackgroundChecksSchema>; diff --git a/src/lib/providers/ministry-platform/models/BatchesSchema.ts b/src/lib/providers/ministry-platform/models/BatchesSchema.ts index bfdc574..490cd8a 100644 --- a/src/lib/providers/ministry-platform/models/BatchesSchema.ts +++ b/src/lib/providers/ministry-platform/models/BatchesSchema.ts @@ -3,7 +3,7 @@ import { z } from 'zod'; export const BatchesSchema = z.object({ Batch_ID: z.number().int(), Batch_Name: z.string().max(75), - Setup_Date: z.string().datetime(), + Setup_Date: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)"), Batch_Total: z.number(), Item_Count: z.number().int(), Batch_Entry_Type_ID: z.number().int(), @@ -11,7 +11,7 @@ export const BatchesSchema = z.object({ Default_Program: z.number().int().nullable(), Source_Event: z.number().int().nullable(), Deposit_ID: z.number().int().nullable(), - Finalize_Date: z.string().datetime().nullable(), + Finalize_Date: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)").nullable(), Congregation_ID: z.number().int().nullable(), _Import_Counter: z.number().int().nullable(), _Source_File: z.string().max(50).nullable(), diff --git a/src/lib/providers/ministry-platform/models/BookCheckoutsSchema.ts b/src/lib/providers/ministry-platform/models/BookCheckoutsSchema.ts index e5f71e6..02f5c27 100644 --- a/src/lib/providers/ministry-platform/models/BookCheckoutsSchema.ts +++ b/src/lib/providers/ministry-platform/models/BookCheckoutsSchema.ts @@ -4,9 +4,9 @@ export const BookCheckoutsSchema = z.object({ Book_Checkout_ID: z.number().int(), Book_ID: z.number().int(), Contact_ID: z.number().int(), - Checkout_Date: z.string().datetime(), - Due_Date: z.string().datetime().nullable(), - Date_Returned: z.string().datetime().nullable(), + Checkout_Date: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)"), + Due_Date: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)").nullable(), + Date_Returned: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)").nullable(), Notes: z.string().max(2147483647).nullable(), Is_Returned: z.number().int(), }); diff --git a/src/lib/providers/ministry-platform/models/BooksSchema.ts b/src/lib/providers/ministry-platform/models/BooksSchema.ts index df2164d..9097f32 100644 --- a/src/lib/providers/ministry-platform/models/BooksSchema.ts +++ b/src/lib/providers/ministry-platform/models/BooksSchema.ts @@ -6,7 +6,7 @@ export const BooksSchema = z.object({ ISBN: z.string().max(15).nullable(), Description: z.string().max(255).nullable(), Cost: z.number().nullable(), - Start_Date: z.string().datetime().nullable(), + Start_Date: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)").nullable(), Genre_ID: z.number().int().nullable(), }); diff --git a/src/lib/providers/ministry-platform/models/BuildingsSchema.ts b/src/lib/providers/ministry-platform/models/BuildingsSchema.ts index f46cad5..053486d 100644 --- a/src/lib/providers/ministry-platform/models/BuildingsSchema.ts +++ b/src/lib/providers/ministry-platform/models/BuildingsSchema.ts @@ -5,11 +5,11 @@ export const BuildingsSchema = z.object({ Building_Name: z.string().max(50), Location_ID: z.number().int(), Description: z.string().max(255).nullable(), - Next_Inspection_Date: z.string().datetime().nullable(), + Next_Inspection_Date: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)").nullable(), Building_Coordinator: z.number().int().nullable(), - Date_Acquired: z.string().datetime().nullable(), - Date_Constructed: z.string().datetime().nullable(), - Date_Retired: z.string().datetime().nullable(), + Date_Acquired: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)").nullable(), + Date_Constructed: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)").nullable(), + Date_Retired: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)").nullable(), }); export type BuildingsInput = z.infer<typeof BuildingsSchema>; diff --git a/src/lib/providers/ministry-platform/models/CareCasesSchema.ts b/src/lib/providers/ministry-platform/models/CareCasesSchema.ts index 910276b..795a0bb 100644 --- a/src/lib/providers/ministry-platform/models/CareCasesSchema.ts +++ b/src/lib/providers/ministry-platform/models/CareCasesSchema.ts @@ -6,8 +6,8 @@ export const CareCasesSchema = z.object({ Description: z.string().max(512).nullable(), Household_ID: z.number().int(), Contact_ID: z.number().int().nullable(), - Start_Date: z.string().datetime(), - End_Date: z.string().datetime().nullable(), + Start_Date: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)"), + End_Date: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)").nullable(), Care_Case_Type_ID: z.number().int(), Location_ID: z.number().int().nullable(), Case_Manager: z.number().int().nullable(), diff --git a/src/lib/providers/ministry-platform/models/ChurchAssociationsSchema.ts b/src/lib/providers/ministry-platform/models/ChurchAssociationsSchema.ts index 0c59bab..4856a4a 100644 --- a/src/lib/providers/ministry-platform/models/ChurchAssociationsSchema.ts +++ b/src/lib/providers/ministry-platform/models/ChurchAssociationsSchema.ts @@ -8,8 +8,8 @@ export const ChurchAssociationsSchema = z.object({ Address_ID: z.number().int().nullable(), Website: z.string().url().nullable(), Year_Established: z.number().int().nullable(), - End_Date: z.string().datetime().nullable(), - Review_Date: z.string().datetime().nullable(), + End_Date: z.string().regex(/^\d{4}-\d{2}-\d{2}([ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?)?$/, "Expected YYYY-MM-DD (optionally with a time)").nullable(), + Review_Date: z.string().regex(/^\d{4}-\d{2}-\d{2}([ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?)?$/, "Expected YYYY-MM-DD (optionally with a time)").nullable(), }); export type ChurchAssociationsInput = z.infer<typeof ChurchAssociationsSchema>; diff --git a/src/lib/providers/ministry-platform/models/CongregationAuditsSchema.ts b/src/lib/providers/ministry-platform/models/CongregationAuditsSchema.ts index 61ed434..e463ef3 100644 --- a/src/lib/providers/ministry-platform/models/CongregationAuditsSchema.ts +++ b/src/lib/providers/ministry-platform/models/CongregationAuditsSchema.ts @@ -3,7 +3,7 @@ import { z } from 'zod'; export const CongregationAuditsSchema = z.object({ Congregation_Audit_ID: z.number().int(), Household_ID: z.number().int(), - Change_Date: z.string().datetime(), + Change_Date: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)"), Prior_Congregation: z.number().int(), New_Congregation: z.number().int(), Notes: z.string().max(300).nullable(), diff --git a/src/lib/providers/ministry-platform/models/Congregations.ts b/src/lib/providers/ministry-platform/models/Congregations.ts index d29eb8a..4957e8c 100644 --- a/src/lib/providers/ministry-platform/models/Congregations.ts +++ b/src/lib/providers/ministry-platform/models/Congregations.ts @@ -112,11 +112,6 @@ export interface Congregations { */ Vanco_Giving_PCCT?: string /* max 50 chars */ | null; - /** - * Max length: 50 characters - */ - Vanco_Signing_Key?: string /* max 50 chars */ | null; - /** * Max length: 16 characters */ diff --git a/src/lib/providers/ministry-platform/models/CongregationsSchema.ts b/src/lib/providers/ministry-platform/models/CongregationsSchema.ts index 99d9294..6e0db79 100644 --- a/src/lib/providers/ministry-platform/models/CongregationsSchema.ts +++ b/src/lib/providers/ministry-platform/models/CongregationsSchema.ts @@ -4,8 +4,8 @@ export const CongregationsSchema = z.object({ Congregation_ID: z.number().int(), Congregation_Name: z.string().max(50), Description: z.string().max(255).nullable(), - Start_Date: z.string().datetime(), - End_Date: z.string().datetime().nullable(), + Start_Date: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)"), + End_Date: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)").nullable(), Accounting_Company_ID: z.number().int(), Available_Online: z.boolean(), Location_ID: z.number().int().nullable(), @@ -34,7 +34,6 @@ export const CongregationsSchema = z.object({ Event_Registration_Handoff_URL: z.string().url().nullable(), Vanco_Tenant: z.string().max(50).nullable(), Vanco_Giving_PCCT: z.string().max(50).nullable(), - Vanco_Signing_Key: z.string().max(50).nullable(), Organization_Code: z.string().max(16).nullable(), Sacrament_Place_ID: z.number().int().nullable(), Dark_Mode_Logo_URL: z.string().max(500).nullable(), diff --git a/src/lib/providers/ministry-platform/models/ContactAttributesSchema.ts b/src/lib/providers/ministry-platform/models/ContactAttributesSchema.ts index c2955ef..07c44b2 100644 --- a/src/lib/providers/ministry-platform/models/ContactAttributesSchema.ts +++ b/src/lib/providers/ministry-platform/models/ContactAttributesSchema.ts @@ -4,8 +4,8 @@ export const ContactAttributesSchema = z.object({ Contact_Attribute_ID: z.number().int(), Contact_ID: z.number().int(), Attribute_ID: z.number().int(), - Start_Date: z.string().datetime(), - End_Date: z.string().datetime().nullable(), + Start_Date: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)"), + End_Date: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)").nullable(), Notes: z.string().max(2000).nullable(), }); diff --git a/src/lib/providers/ministry-platform/models/ContactHouseholdsSchema.ts b/src/lib/providers/ministry-platform/models/ContactHouseholdsSchema.ts index f2e83b8..5d5c763 100644 --- a/src/lib/providers/ministry-platform/models/ContactHouseholdsSchema.ts +++ b/src/lib/providers/ministry-platform/models/ContactHouseholdsSchema.ts @@ -8,7 +8,7 @@ export const ContactHouseholdsSchema = z.object({ Household_Type_ID: z.number().int().nullable(), Primary_Family: z.boolean(), Notes: z.string().max(2000).nullable(), - End_Date: z.string().datetime().nullable(), + End_Date: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)").nullable(), }); export type ContactHouseholdsInput = z.infer<typeof ContactHouseholdsSchema>; diff --git a/src/lib/providers/ministry-platform/models/ContactIdentifierLogSchema.ts b/src/lib/providers/ministry-platform/models/ContactIdentifierLogSchema.ts index ebfb3e2..8f887dd 100644 --- a/src/lib/providers/ministry-platform/models/ContactIdentifierLogSchema.ts +++ b/src/lib/providers/ministry-platform/models/ContactIdentifierLogSchema.ts @@ -6,7 +6,7 @@ export const ContactIdentifierLogSchema = z.object({ Source_System_Name: z.string().max(50), Source_Type_Name: z.string().max(128), Identifier_Value: z.string().max(4000), - _Date_Inserted: z.string().datetime(), + _Date_Inserted: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)"), }); export type ContactIdentifierLogInput = z.infer<typeof ContactIdentifierLogSchema>; diff --git a/src/lib/providers/ministry-platform/models/ContactLogSchema.ts b/src/lib/providers/ministry-platform/models/ContactLogSchema.ts index 52323ff..f74c311 100644 --- a/src/lib/providers/ministry-platform/models/ContactLogSchema.ts +++ b/src/lib/providers/ministry-platform/models/ContactLogSchema.ts @@ -3,7 +3,7 @@ import { z } from 'zod'; export const ContactLogSchema = z.object({ Contact_Log_ID: z.number().int(), Contact_ID: z.number().int(), - Contact_Date: z.string().datetime(), + Contact_Date: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)"), Contact_Log_Type_ID: z.number().int().nullable(), Made_By: z.number().int(), Notes: z.string().max(2000), diff --git a/src/lib/providers/ministry-platform/models/ContactPrivateNotesSchema.ts b/src/lib/providers/ministry-platform/models/ContactPrivateNotesSchema.ts index 9fdbb6a..bec75e4 100644 --- a/src/lib/providers/ministry-platform/models/ContactPrivateNotesSchema.ts +++ b/src/lib/providers/ministry-platform/models/ContactPrivateNotesSchema.ts @@ -5,7 +5,7 @@ export const ContactPrivateNotesSchema = z.object({ User_ID: z.number().int(), Contact_ID: z.number().int().nullable(), Notes: z.string().max(2000), - Start_Date: z.string().datetime(), + Start_Date: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)"), }); export type ContactPrivateNotesInput = z.infer<typeof ContactPrivateNotesSchema>; diff --git a/src/lib/providers/ministry-platform/models/ContactRelationshipsSchema.ts b/src/lib/providers/ministry-platform/models/ContactRelationshipsSchema.ts index dd4a514..b9469a7 100644 --- a/src/lib/providers/ministry-platform/models/ContactRelationshipsSchema.ts +++ b/src/lib/providers/ministry-platform/models/ContactRelationshipsSchema.ts @@ -5,8 +5,8 @@ export const ContactRelationshipsSchema = z.object({ Contact_ID: z.number().int(), Relationship_ID: z.number().int(), Related_Contact_ID: z.number().int(), - Start_Date: z.string().datetime().nullable(), - End_Date: z.string().datetime().nullable(), + Start_Date: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)").nullable(), + End_Date: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)").nullable(), Notes: z.string().max(255).nullable(), _Triggered_By: z.number().int().nullable(), }); diff --git a/src/lib/providers/ministry-platform/models/ContactStagingSchema.ts b/src/lib/providers/ministry-platform/models/ContactStagingSchema.ts index 25369b1..3a91b26 100644 --- a/src/lib/providers/ministry-platform/models/ContactStagingSchema.ts +++ b/src/lib/providers/ministry-platform/models/ContactStagingSchema.ts @@ -17,14 +17,14 @@ export const ContactStagingSchema = z.object({ Last_Name: z.string().max(50).nullable(), Suffix_ID: z.number().int().nullable(), Nickname: z.string().max(50).nullable(), - Date_of_Birth: z.string().datetime().nullable(), + Date_of_Birth: z.string().regex(/^\d{4}-\d{2}-\d{2}([ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?)?$/, "Expected YYYY-MM-DD (optionally with a time)").nullable(), Gender_ID: z.number().int().nullable(), Marital_Status_ID: z.number().int().nullable(), Contact_Status_ID: z.number().int(), Existing_Household_Record: z.number().int().nullable(), Household_Position_ID: z.number().int().nullable(), Existing_Participant_Record: z.number().int().nullable(), - Participant_Start_Date: z.string().datetime().nullable(), + Participant_Start_Date: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)").nullable(), Participant_Type_ID: z.number().int().nullable(), Participant_Notes: z.string().max(1000).nullable(), Existing_Donor_Record: z.number().int().nullable(), @@ -36,7 +36,7 @@ export const ContactStagingSchema = z.object({ Fax_Phone: z.string().nullable(), Existing_User_Account: z.number().int().nullable(), Web_Page: z.string().url().nullable(), - Anniversary_Date: z.string().datetime().nullable(), + Anniversary_Date: z.string().regex(/^\d{4}-\d{2}-\d{2}([ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?)?$/, "Expected YYYY-MM-DD (optionally with a time)").nullable(), HS_Graduation_Year: z.number().int().nullable(), Home_Phone: z.string().nullable(), Congregation_ID: z.number().int().nullable(), @@ -48,8 +48,8 @@ export const ContactStagingSchema = z.object({ Postal_Code: z.string().max(15).nullable(), Foreign_Country: z.string().max(50).nullable(), ID_Card: z.string().max(50).nullable(), - Date_of_Death: z.string().datetime().nullable(), - _Contact_Setup_Date: z.string().datetime().nullable(), + Date_of_Death: z.string().regex(/^\d{4}-\d{2}-\d{2}([ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?)?$/, "Expected YYYY-MM-DD (optionally with a time)").nullable(), + _Contact_Setup_Date: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)").nullable(), }); export type ContactStagingInput = z.infer<typeof ContactStagingSchema>; diff --git a/src/lib/providers/ministry-platform/models/ContactsSchema.ts b/src/lib/providers/ministry-platform/models/ContactsSchema.ts index 2a704c9..a38b93a 100644 --- a/src/lib/providers/ministry-platform/models/ContactsSchema.ts +++ b/src/lib/providers/ministry-platform/models/ContactsSchema.ts @@ -11,14 +11,14 @@ export const ContactsSchema = z.object({ Last_Name: z.string().max(50).nullable(), Suffix_ID: z.number().int().nullable(), Nickname: z.string().max(50).nullable(), - Date_of_Birth: z.string().datetime().nullable(), + Date_of_Birth: z.string().regex(/^\d{4}-\d{2}-\d{2}([ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?)?$/, "Expected YYYY-MM-DD (optionally with a time)").nullable(), Gender_ID: z.number().int().nullable(), Marital_Status_ID: z.number().int().nullable(), Contact_Status_ID: z.number().int(), Household_ID: z.number().int().nullable(), Household_Position_ID: z.number().int().nullable(), - Anniversary_Date: z.string().datetime().nullable(), - Date_of_Death: z.string().datetime().nullable(), + Anniversary_Date: z.string().regex(/^\d{4}-\d{2}-\d{2}([ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?)?$/, "Expected YYYY-MM-DD (optionally with a time)").nullable(), + Date_of_Death: z.string().regex(/^\d{4}-\d{2}-\d{2}([ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?)?$/, "Expected YYYY-MM-DD (optionally with a time)").nullable(), Participant_Record: z.number().int().nullable(), Donor_Record: z.number().int().nullable(), Email_Address: z.string().email().max(254).nullable(), @@ -41,7 +41,7 @@ export const ContactsSchema = z.object({ User_Account: z.number().int().nullable(), ID_Card: z.string().max(50).nullable(), Contact_GUID: z.string().uuid(), - _Contact_Setup_Date: z.string().datetime(), + _Contact_Setup_Date: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)"), Email_Verified: z.boolean(), Mobile_Phone_Verified: z.boolean(), Maiden_Name: z.string().max(50).nullable(), diff --git a/src/lib/providers/ministry-platform/models/ContributionStatementsSchema.ts b/src/lib/providers/ministry-platform/models/ContributionStatementsSchema.ts index 3791af3..868a2e8 100644 --- a/src/lib/providers/ministry-platform/models/ContributionStatementsSchema.ts +++ b/src/lib/providers/ministry-platform/models/ContributionStatementsSchema.ts @@ -12,8 +12,8 @@ export const ContributionStatementsSchema = z.object({ Archived: z.boolean(), Archived_Campaign: z.number().int().nullable(), Alternate_Archived_Campaign: z.number().int().nullable(), - Last_Change_By_Routine: z.string().datetime(), - Last_Statement_File: z.string().datetime().nullable(), + Last_Change_By_Routine: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)"), + Last_Statement_File: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)").nullable(), }); export type ContributionStatementsInput = z.infer<typeof ContributionStatementsSchema>; diff --git a/src/lib/providers/ministry-platform/models/DeploymentLogs.ts b/src/lib/providers/ministry-platform/models/DeploymentLogs.ts new file mode 100644 index 0000000..8507f54 --- /dev/null +++ b/src/lib/providers/ministry-platform/models/DeploymentLogs.ts @@ -0,0 +1,29 @@ +/** + * Interface for _Deployment_Logs +* Table: _Deployment_Logs + * Access Level: ReadWriteAssignDelete + * Special Permissions: None + * Generated from column metadata + */ +export interface DeploymentLogs { + + Log_ID: number /* 32-bit integer */; // Primary Key + + /** + * Max length: 128 characters + */ + Log_Title: string /* max 128 chars */; + + Log_Date: string /* ISO datetime */; + + /** + * Max length: 2147483647 characters + */ + Log_Content?: string /* max 2147483647 chars */ | null; + + Log_Code?: number /* 32-bit integer */ | null; + + Deployment_ID?: number /* 32-bit integer */ | null; // Foreign Key -> _Deployments.Deployment_ID +} + +export type DeploymentLogsRecord = DeploymentLogs; diff --git a/src/lib/providers/ministry-platform/models/DeploymentLogsSchema.ts b/src/lib/providers/ministry-platform/models/DeploymentLogsSchema.ts new file mode 100644 index 0000000..dcf4de3 --- /dev/null +++ b/src/lib/providers/ministry-platform/models/DeploymentLogsSchema.ts @@ -0,0 +1,12 @@ +import { z } from 'zod'; + +export const DeploymentLogsSchema = z.object({ + Log_ID: z.number().int(), + Log_Title: z.string().max(128), + Log_Date: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)"), + Log_Content: z.string().max(2147483647).nullable(), + Log_Code: z.number().int().nullable(), + Deployment_ID: z.number().int().nullable(), +}); + +export type DeploymentLogsInput = z.infer<typeof DeploymentLogsSchema>; diff --git a/src/lib/providers/ministry-platform/models/DeploymentsSchema.ts b/src/lib/providers/ministry-platform/models/DeploymentsSchema.ts index 36c73b9..b6c0f93 100644 --- a/src/lib/providers/ministry-platform/models/DeploymentsSchema.ts +++ b/src/lib/providers/ministry-platform/models/DeploymentsSchema.ts @@ -2,7 +2,7 @@ import { z } from 'zod'; export const DeploymentsSchema = z.object({ Deployment_ID: z.number().int(), - Deployment_Date: z.string().datetime(), + Deployment_Date: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)"), Application_Code: z.string().max(16), Build_Version: z.string().max(16), Notes: z.string().max(255).nullable(), diff --git a/src/lib/providers/ministry-platform/models/DepositsSchema.ts b/src/lib/providers/ministry-platform/models/DepositsSchema.ts index e2c4a0b..0780f5d 100644 --- a/src/lib/providers/ministry-platform/models/DepositsSchema.ts +++ b/src/lib/providers/ministry-platform/models/DepositsSchema.ts @@ -4,7 +4,7 @@ export const DepositsSchema = z.object({ Deposit_ID: z.number().int(), Deposit_Name: z.string().max(75), Deposit_Total: z.number(), - Deposit_Date: z.string().datetime(), + Deposit_Date: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)"), Account_Number: z.string().max(30), Batch_Count: z.number().int(), Exported: z.boolean(), diff --git a/src/lib/providers/ministry-platform/models/DonationDistributionsSchema.ts b/src/lib/providers/ministry-platform/models/DonationDistributionsSchema.ts index 9f6c605..6dc1a18 100644 --- a/src/lib/providers/ministry-platform/models/DonationDistributionsSchema.ts +++ b/src/lib/providers/ministry-platform/models/DonationDistributionsSchema.ts @@ -13,7 +13,7 @@ export const DonationDistributionsSchema = z.object({ Donation_Source_ID: z.number().int().nullable(), _Vendor_Pledge_Code: z.string().max(16).nullable(), Projected_Gift_Frequency: z.number().int().nullable(), - _Last_Statement_Review: z.string().datetime().nullable(), + _Last_Statement_Review: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)").nullable(), Soft_Credit_Statement_ID: z.number().int().nullable(), }); diff --git a/src/lib/providers/ministry-platform/models/DonationsSchema.ts b/src/lib/providers/ministry-platform/models/DonationsSchema.ts index 9b0da6a..123448a 100644 --- a/src/lib/providers/ministry-platform/models/DonationsSchema.ts +++ b/src/lib/providers/ministry-platform/models/DonationsSchema.ts @@ -4,7 +4,7 @@ export const DonationsSchema = z.object({ Donation_ID: z.number().int(), Donor_ID: z.number().int(), Donation_Amount: z.number(), - Donation_Date: z.string().datetime(), + Donation_Date: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)"), Payment_Type_ID: z.number().int(), Item_Number: z.string().max(15).nullable(), Batch_ID: z.number().int().nullable(), @@ -24,7 +24,7 @@ export const DonationsSchema = z.object({ OCR_Data: z.string().max(1000).nullable(), Third_Party_Note: z.string().max(2147483647).nullable(), Statement_ID: z.number().int().nullable(), - _Last_Statement_Review: z.string().datetime().nullable(), + _Last_Statement_Review: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)").nullable(), Multiple_Donor_Match: z.boolean(), _Is_Check_Scan: z.boolean().nullable(), }); diff --git a/src/lib/providers/ministry-platform/models/DonorsSchema.ts b/src/lib/providers/ministry-platform/models/DonorsSchema.ts index 5b712e2..5ef31c2 100644 --- a/src/lib/providers/ministry-platform/models/DonorsSchema.ts +++ b/src/lib/providers/ministry-platform/models/DonorsSchema.ts @@ -11,10 +11,10 @@ export const DonorsSchema = z.object({ Notes: z.string().max(500).nullable(), Always_Soft_Credit: z.number().int().nullable(), Always_Pledge_Credit: z.number().int().nullable(), - Setup_Date: z.string().datetime(), + Setup_Date: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)"), First_Contact_Made: z.boolean().nullable(), - _First_Donation_Date: z.string().datetime().nullable(), - _Last_Donation_Date: z.string().datetime().nullable(), + _First_Donation_Date: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)").nullable(), + _Last_Donation_Date: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)").nullable(), Donation_Frequency_ID: z.number().int().nullable(), Donation_Level_ID: z.number().int().nullable(), Donor_Code: z.string().max(16).nullable(), diff --git a/src/lib/providers/ministry-platform/models/DpApiClients.ts b/src/lib/providers/ministry-platform/models/DpApiClients.ts index 9206817..e2b78be 100644 --- a/src/lib/providers/ministry-platform/models/DpApiClients.ts +++ b/src/lib/providers/ministry-platform/models/DpApiClients.ts @@ -2,7 +2,7 @@ * Interface for dp_API_Clients * Table: dp_API_Clients * Access Level: ReadWriteAssignDelete - * Special Permissions: FileAttach, DataExport + * Special Permissions: FileAttach, DataExport, SecureRecord * Generated from column metadata */ export interface DpApiClients { diff --git a/src/lib/providers/ministry-platform/models/DpAuditRetentionMessagesSchema.ts b/src/lib/providers/ministry-platform/models/DpAuditRetentionMessagesSchema.ts index 84d87d2..3328b1c 100644 --- a/src/lib/providers/ministry-platform/models/DpAuditRetentionMessagesSchema.ts +++ b/src/lib/providers/ministry-platform/models/DpAuditRetentionMessagesSchema.ts @@ -4,7 +4,7 @@ export const DpAuditRetentionMessagesSchema = z.object({ Audit_Retention_Message_ID: z.number().int(), _Audit_Retention_Policy_ID: z.number().int().nullable(), _Message_Code: z.number().int().nullable(), - _Message_Time: z.string().datetime().nullable(), + _Message_Time: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)").nullable(), _Error_Severity: z.number().int().nullable(), _Message: z.string().max(2000).nullable(), _Message_Sql: z.string().max(1000).nullable(), diff --git a/src/lib/providers/ministry-platform/models/DpAuditRetentionPolicies.ts b/src/lib/providers/ministry-platform/models/DpAuditRetentionPolicies.ts index b490adc..f0616ec 100644 --- a/src/lib/providers/ministry-platform/models/DpAuditRetentionPolicies.ts +++ b/src/lib/providers/ministry-platform/models/DpAuditRetentionPolicies.ts @@ -19,7 +19,7 @@ export interface DpAuditRetentionPolicies { Is_Active: boolean; // Has Default - Detail_Retention_Days?: number /* 32-bit integer */ | null; + Max_Audits_Per_Run: number /* 32-bit integer */; // Has Default } export type DpAuditRetentionPoliciesRecord = DpAuditRetentionPolicies; diff --git a/src/lib/providers/ministry-platform/models/DpAuditRetentionPoliciesSchema.ts b/src/lib/providers/ministry-platform/models/DpAuditRetentionPoliciesSchema.ts index 7573622..d02156e 100644 --- a/src/lib/providers/ministry-platform/models/DpAuditRetentionPoliciesSchema.ts +++ b/src/lib/providers/ministry-platform/models/DpAuditRetentionPoliciesSchema.ts @@ -7,7 +7,7 @@ export const DpAuditRetentionPoliciesSchema = z.object({ Audit_Type_ID: z.number().int().nullable(), Audit_Retention_Days: z.number().int(), Is_Active: z.boolean(), - Detail_Retention_Days: z.number().int().nullable(), + Max_Audits_Per_Run: z.number().int(), }); export type DpAuditRetentionPoliciesInput = z.infer<typeof DpAuditRetentionPoliciesSchema>; diff --git a/src/lib/providers/ministry-platform/models/DpAuditRetentionRuns.ts b/src/lib/providers/ministry-platform/models/DpAuditRetentionRuns.ts new file mode 100644 index 0000000..41eb4ea --- /dev/null +++ b/src/lib/providers/ministry-platform/models/DpAuditRetentionRuns.ts @@ -0,0 +1,33 @@ +/** + * Interface for dp_Audit_Retention_Runs +* Table: dp_Audit_Retention_Runs + * Access Level: ReadWriteAssignDelete + * Special Permissions: DataExport + * Generated from column metadata + */ +export interface DpAuditRetentionRuns { + + Audit_Retention_Run_ID: number /* 32-bit integer */; // Primary Key + + Run_Date: string /* ISO datetime */; + + Active_Erase_Policies: number /* 32-bit integer */; + + Active_Retain_Policies: number /* 32-bit integer */; + + Max_Minutes: number /* 32-bit integer */; + + Policies_Run: number /* 32-bit integer */; + + Duration_Minutes: number /* 32-bit integer */; + + Deleted_Audits: number /* 32-bit integer */; + + Deleted_Details: number /* 32-bit integer */; + + Remaining_Audits: number /* 32-bit integer */; + + Remaining_Details: number /* 32-bit integer */; +} + +export type DpAuditRetentionRunsRecord = DpAuditRetentionRuns; diff --git a/src/lib/providers/ministry-platform/models/DpAuditRetentionRunsSchema.ts b/src/lib/providers/ministry-platform/models/DpAuditRetentionRunsSchema.ts new file mode 100644 index 0000000..51c7539 --- /dev/null +++ b/src/lib/providers/ministry-platform/models/DpAuditRetentionRunsSchema.ts @@ -0,0 +1,17 @@ +import { z } from 'zod'; + +export const DpAuditRetentionRunsSchema = z.object({ + Audit_Retention_Run_ID: z.number().int(), + Run_Date: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)"), + Active_Erase_Policies: z.number().int(), + Active_Retain_Policies: z.number().int(), + Max_Minutes: z.number().int(), + Policies_Run: z.number().int(), + Duration_Minutes: z.number().int(), + Deleted_Audits: z.number().int(), + Deleted_Details: z.number().int(), + Remaining_Audits: z.number().int(), + Remaining_Details: z.number().int(), +}); + +export type DpAuditRetentionRunsInput = z.infer<typeof DpAuditRetentionRunsSchema>; diff --git a/src/lib/providers/ministry-platform/models/DpAuthenticationLogSchema.ts b/src/lib/providers/ministry-platform/models/DpAuthenticationLogSchema.ts index 47fa7d8..2991cd3 100644 --- a/src/lib/providers/ministry-platform/models/DpAuthenticationLogSchema.ts +++ b/src/lib/providers/ministry-platform/models/DpAuthenticationLogSchema.ts @@ -5,11 +5,11 @@ export const DpAuthenticationLogSchema = z.object({ User_Name: z.string().max(256), Server_Name: z.string().max(75), IP_Address: z.string().max(50), - Date_Time: z.string().datetime(), + Date_Time: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)"), User_ID: z.number().int().nullable(), Referrer: z.string().max(2048).nullable(), Keep_Signed_In: z.boolean(), - Last_Token_Request: z.string().datetime().nullable(), + Last_Token_Request: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)").nullable(), }); export type DpAuthenticationLogInput = z.infer<typeof DpAuthenticationLogSchema>; diff --git a/src/lib/providers/ministry-platform/models/DpCharts.ts b/src/lib/providers/ministry-platform/models/DpCharts.ts new file mode 100644 index 0000000..44e0f40 --- /dev/null +++ b/src/lib/providers/ministry-platform/models/DpCharts.ts @@ -0,0 +1,45 @@ +/** + * Interface for dp_Charts +* Table: dp_Charts + * Access Level: Read + * Special Permissions: None + * Generated from column metadata + */ +export interface DpCharts { + + Chart_ID: number /* 32-bit integer */; // Primary Key + + Page_ID: number /* 32-bit integer */; // Foreign Key -> dp_Pages.Page_ID + + /** + * Max length: 50 characters + */ + Chart_Name: string /* max 50 chars */; + + /** + * Max length: 2000 characters + */ + Label_Field: string /* max 2000 chars */; + + /** + * Max length: 2000 characters + */ + Data_Field: string /* max 2000 chars */; + + /** + * Max length: 256 characters + */ + Sort_Field?: string /* max 256 chars */ | null; + + Aggregation_Type_ID: number /* 32-bit integer */; // Foreign Key -> dp_Aggregation_Types.Aggregation_Type_ID + + Default_Chart_Type_ID: number /* 32-bit integer */; // Foreign Key -> dp_Chart_Types.Chart_Type_ID + + Position: number /* 32-bit integer */; // Has Default + + Active: boolean; // Has Default + + Cache_Minutes: number /* 16-bit integer */; // Has Default +} + +export type DpChartsRecord = DpCharts; diff --git a/src/lib/providers/ministry-platform/models/DpChartsSchema.ts b/src/lib/providers/ministry-platform/models/DpChartsSchema.ts new file mode 100644 index 0000000..390e719 --- /dev/null +++ b/src/lib/providers/ministry-platform/models/DpChartsSchema.ts @@ -0,0 +1,17 @@ +import { z } from 'zod'; + +export const DpChartsSchema = z.object({ + Chart_ID: z.number().int(), + Page_ID: z.number().int(), + Chart_Name: z.string().max(50), + Label_Field: z.string().max(2000), + Data_Field: z.string().max(2000), + Sort_Field: z.string().max(256).nullable(), + Aggregation_Type_ID: z.number().int(), + Default_Chart_Type_ID: z.number().int(), + Position: z.number().int(), + Active: z.boolean(), + Cache_Minutes: z.number().int(), +}); + +export type DpChartsInput = z.infer<typeof DpChartsSchema>; diff --git a/src/lib/providers/ministry-platform/models/DpCommunicationMessagesArchived.ts b/src/lib/providers/ministry-platform/models/DpCommunicationMessagesArchived.ts new file mode 100644 index 0000000..875ce82 --- /dev/null +++ b/src/lib/providers/ministry-platform/models/DpCommunicationMessagesArchived.ts @@ -0,0 +1,58 @@ +/** + * Interface for dp_Communication_Messages_Archived +* Table: dp_Communication_Messages_Archived + * Access Level: ReadWriteAssign + * Special Permissions: DataExport + * Generated from column metadata + */ +export interface DpCommunicationMessagesArchived { + + Communication_Message_Archived_ID: number /* 32-bit integer */; // Primary Key + + Original_Communication_Message_ID: number /* 32-bit integer */; + + Original_Communication_ID: number /* 32-bit integer */; // Foreign Key -> dp_Communications.Communication_ID + + Action_Status_ID: number /* 32-bit integer */; // Foreign Key -> dp_Communication_Action_Statuses.Action_Status_ID + + Action_Status_Time: string /* ISO datetime */; + + Message_Sent_Time?: string /* ISO datetime */ | null; + + /** + * Max length: 1024 characters + */ + Action_Text?: string /* max 1024 chars */ | null; + + Contact_ID?: number /* 32-bit integer */ | null; // Foreign Key -> Contacts.Contact_ID + + /** + * Max length: 256 characters + */ + From: string /* max 256 chars */; + + /** + * Max length: 256 characters + */ + To: string /* max 256 chars */; + + /** + * Max length: 256 characters + */ + Reply_To?: string /* max 256 chars */ | null; + + /** + * Max length: 1000 characters + */ + Subject?: string /* max 1000 chars */ | null; + + Deleted: boolean; // Has Default + + _Date_Submitted?: string /* ISO datetime */ | null; // Read Only + + _Priority: number /* 32-bit integer */; // Read Only, Has Default + + _Text_Segments?: number /* 32-bit integer */ | null; // Read Only +} + +export type DpCommunicationMessagesArchivedRecord = DpCommunicationMessagesArchived; diff --git a/src/lib/providers/ministry-platform/models/DpCommunicationMessagesArchivedSchema.ts b/src/lib/providers/ministry-platform/models/DpCommunicationMessagesArchivedSchema.ts new file mode 100644 index 0000000..f7e3358 --- /dev/null +++ b/src/lib/providers/ministry-platform/models/DpCommunicationMessagesArchivedSchema.ts @@ -0,0 +1,22 @@ +import { z } from 'zod'; + +export const DpCommunicationMessagesArchivedSchema = z.object({ + Communication_Message_Archived_ID: z.number().int(), + Original_Communication_Message_ID: z.number().int(), + Original_Communication_ID: z.number().int(), + Action_Status_ID: z.number().int(), + Action_Status_Time: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)"), + Message_Sent_Time: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)").nullable(), + Action_Text: z.string().max(1024).nullable(), + Contact_ID: z.number().int().nullable(), + From: z.string().max(256), + To: z.string().max(256), + Reply_To: z.string().max(256).nullable(), + Subject: z.string().max(1000).nullable(), + Deleted: z.boolean(), + _Date_Submitted: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)").nullable(), + _Priority: z.number().int(), + _Text_Segments: z.number().int().nullable(), +}); + +export type DpCommunicationMessagesArchivedInput = z.infer<typeof DpCommunicationMessagesArchivedSchema>; diff --git a/src/lib/providers/ministry-platform/models/DpCommunicationMessagesSchema.ts b/src/lib/providers/ministry-platform/models/DpCommunicationMessagesSchema.ts index a6b7739..a07fef1 100644 --- a/src/lib/providers/ministry-platform/models/DpCommunicationMessagesSchema.ts +++ b/src/lib/providers/ministry-platform/models/DpCommunicationMessagesSchema.ts @@ -4,7 +4,7 @@ export const DpCommunicationMessagesSchema = z.object({ Communication_Message_ID: z.number().int(), Communication_ID: z.number().int(), Action_Status_ID: z.number().int(), - Action_Status_Time: z.string().datetime(), + Action_Status_Time: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)"), Action_Text: z.string().max(1024).nullable(), Contact_ID: z.number().int().nullable(), From: z.string().max(256), @@ -13,7 +13,7 @@ export const DpCommunicationMessagesSchema = z.object({ Subject: z.string().max(500).nullable(), Body: z.string().max(2147483647).nullable(), Deleted: z.boolean(), - _Date_Submitted: z.string().datetime().nullable(), + _Date_Submitted: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)").nullable(), _Priority: z.number().int(), _Text_Segments: z.number().int().nullable(), }); diff --git a/src/lib/providers/ministry-platform/models/DpCommunicationsSchema.ts b/src/lib/providers/ministry-platform/models/DpCommunicationsSchema.ts index b4f2ce0..0ff6d0c 100644 --- a/src/lib/providers/ministry-platform/models/DpCommunicationsSchema.ts +++ b/src/lib/providers/ministry-platform/models/DpCommunicationsSchema.ts @@ -15,13 +15,13 @@ export const DpCommunicationsSchema = z.object({ From_SMS_Number: z.number().int().nullable(), From_Contact: z.number().int(), Reply_to_Contact: z.number().int(), - Start_Date: z.string().datetime(), + Start_Date: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)"), Time_Zone: z.unknown().nullable(), Locale: z.unknown().nullable(), Bulk_Email: z.boolean(), Template: z.boolean(), Active: z.boolean(), - Expire_Date: z.string().datetime().nullable(), + Expire_Date: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)").nullable(), Template_User: z.number().int().nullable(), Template_User_Group: z.number().int().nullable(), Communication_GUID: z.string().uuid(), diff --git a/src/lib/providers/ministry-platform/models/DpExportLogSchema.ts b/src/lib/providers/ministry-platform/models/DpExportLogSchema.ts index 5f7ae21..237acc8 100644 --- a/src/lib/providers/ministry-platform/models/DpExportLogSchema.ts +++ b/src/lib/providers/ministry-platform/models/DpExportLogSchema.ts @@ -2,7 +2,7 @@ import { z } from 'zod'; export const DpExportLogSchema = z.object({ Export_ID: z.number().int(), - Date_Time: z.string().datetime(), + Date_Time: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)"), User_Name: z.string().max(256).nullable(), User_ID: z.number().int(), Table_Name: z.unknown(), diff --git a/src/lib/providers/ministry-platform/models/DpInboundSmsSchema.ts b/src/lib/providers/ministry-platform/models/DpInboundSmsSchema.ts index 859a87b..7d3d64a 100644 --- a/src/lib/providers/ministry-platform/models/DpInboundSmsSchema.ts +++ b/src/lib/providers/ministry-platform/models/DpInboundSmsSchema.ts @@ -2,7 +2,7 @@ import { z } from 'zod'; export const DpInboundSmsSchema = z.object({ Inbound_SMS_ID: z.number().int(), - Event_Time: z.string().datetime(), + Event_Time: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)"), Message_ID: z.string().max(64), Message_From: z.string().max(13), Message_To: z.string().max(1024), diff --git a/src/lib/providers/ministry-platform/models/DpNotificationsSchema.ts b/src/lib/providers/ministry-platform/models/DpNotificationsSchema.ts index a36fc3d..d94da34 100644 --- a/src/lib/providers/ministry-platform/models/DpNotificationsSchema.ts +++ b/src/lib/providers/ministry-platform/models/DpNotificationsSchema.ts @@ -4,8 +4,8 @@ export const DpNotificationsSchema = z.object({ Notification_ID: z.number().int(), User_ID: z.number().int(), Recurrence_Name: z.string().max(256), - Last_Execution: z.string().datetime().nullable(), - Next_Execution: z.string().datetime().nullable(), + Last_Execution: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)").nullable(), + Next_Execution: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)").nullable(), Notification_Type: z.unknown(), Template_ID: z.number().int().nullable(), User_Group_ID: z.number().int().nullable(), diff --git a/src/lib/providers/ministry-platform/models/DpProcessSubmissionsSchema.ts b/src/lib/providers/ministry-platform/models/DpProcessSubmissionsSchema.ts index d1ef4d1..f59a128 100644 --- a/src/lib/providers/ministry-platform/models/DpProcessSubmissionsSchema.ts +++ b/src/lib/providers/ministry-platform/models/DpProcessSubmissionsSchema.ts @@ -3,7 +3,7 @@ import { z } from 'zod'; export const DpProcessSubmissionsSchema = z.object({ Process_Submission_ID: z.number().int(), Submitted_By: z.number().int(), - Date_Submitted: z.string().datetime(), + Date_Submitted: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)"), Process_ID: z.number().int(), Process_Submission_Status_ID: z.number().int(), _Record_ID: z.number().int(), diff --git a/src/lib/providers/ministry-platform/models/DpPublicationsSchema.ts b/src/lib/providers/ministry-platform/models/DpPublicationsSchema.ts index 3dc2e88..7444099 100644 --- a/src/lib/providers/ministry-platform/models/DpPublicationsSchema.ts +++ b/src/lib/providers/ministry-platform/models/DpPublicationsSchema.ts @@ -9,7 +9,7 @@ export const DpPublicationsSchema = z.object({ Available_Online: z.boolean().nullable(), Online_Sort_Order: z.number().int().nullable(), MailChimp_List: z.string().max(50).nullable(), - Last_Sync_Date: z.string().datetime().nullable(), + Last_Sync_Date: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)").nullable(), Sync_Nightly: z.boolean(), Name: z.string().max(25), On_Connection_Card: z.boolean(), diff --git a/src/lib/providers/ministry-platform/models/DpRoles.ts b/src/lib/providers/ministry-platform/models/DpRoles.ts index bcba1c3..104b562 100644 --- a/src/lib/providers/ministry-platform/models/DpRoles.ts +++ b/src/lib/providers/ministry-platform/models/DpRoles.ts @@ -28,6 +28,8 @@ export interface DpRoles { Parish_Assignable: boolean; // Has Default Texting_Override: boolean; // Has Default + + Texting_Character_Limit: number /* 32-bit integer */; // Has Default } export type DpRolesRecord = DpRoles; diff --git a/src/lib/providers/ministry-platform/models/DpRolesSchema.ts b/src/lib/providers/ministry-platform/models/DpRolesSchema.ts index 2aed07d..769c62a 100644 --- a/src/lib/providers/ministry-platform/models/DpRolesSchema.ts +++ b/src/lib/providers/ministry-platform/models/DpRolesSchema.ts @@ -9,6 +9,7 @@ export const DpRolesSchema = z.object({ Role_Type_ID: z.number().int().nullable(), Parish_Assignable: z.boolean(), Texting_Override: z.boolean(), + Texting_Character_Limit: z.number().int(), }); export type DpRolesInput = z.infer<typeof DpRolesSchema>; diff --git a/src/lib/providers/ministry-platform/models/DpTasksSchema.ts b/src/lib/providers/ministry-platform/models/DpTasksSchema.ts index 5742e97..02b07cd 100644 --- a/src/lib/providers/ministry-platform/models/DpTasksSchema.ts +++ b/src/lib/providers/ministry-platform/models/DpTasksSchema.ts @@ -5,8 +5,8 @@ export const DpTasksSchema = z.object({ Title: z.string().max(75).nullable(), Author_User_ID: z.number().int(), Assigned_User_ID: z.number().int().nullable(), - Start_Date: z.string().datetime().nullable(), - End_Date: z.string().datetime().nullable(), + Start_Date: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)").nullable(), + End_Date: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)").nullable(), Completed: z.boolean(), Description: z.string().max(2147483647).nullable(), _Record_ID: z.number().int().nullable(), diff --git a/src/lib/providers/ministry-platform/models/DpWebhookInvocationsSchema.ts b/src/lib/providers/ministry-platform/models/DpWebhookInvocationsSchema.ts index 26ae882..074f1e6 100644 --- a/src/lib/providers/ministry-platform/models/DpWebhookInvocationsSchema.ts +++ b/src/lib/providers/ministry-platform/models/DpWebhookInvocationsSchema.ts @@ -4,8 +4,8 @@ export const DpWebhookInvocationsSchema = z.object({ Webhook_Invocation_ID: z.number().int(), Webhook_ID: z.number().int(), Record_ID: z.number().int(), - Created: z.string().datetime(), - Updated: z.string().datetime().nullable(), + Created: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)"), + Updated: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)").nullable(), Status_ID: z.number().int(), Retries_Left: z.unknown(), Uri: z.string().url().nullable(), diff --git a/src/lib/providers/ministry-platform/models/EquipmentSchema.ts b/src/lib/providers/ministry-platform/models/EquipmentSchema.ts index ac8db2f..7ac6edc 100644 --- a/src/lib/providers/ministry-platform/models/EquipmentSchema.ts +++ b/src/lib/providers/ministry-platform/models/EquipmentSchema.ts @@ -3,7 +3,7 @@ import { z } from 'zod'; export const EquipmentSchema = z.object({ Equipment_ID: z.number().int(), Equipment_Name: z.string().max(50), - Date_Acquired: z.string().datetime(), + Date_Acquired: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)"), Equipment_Type_ID: z.number().int().nullable(), Room_ID: z.number().int(), Model_Name: z.string().max(50).nullable(), @@ -12,7 +12,7 @@ export const EquipmentSchema = z.object({ Bookable: z.boolean(), Separately_Insured: z.boolean(), Purchase_Price: z.number().nullable(), - Date_Retired: z.string().datetime().nullable(), + Date_Retired: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)").nullable(), Equipment_Coordinator: z.number().int().nullable(), Auto_Approve: z.boolean(), Quantity: z.number().int(), diff --git a/src/lib/providers/ministry-platform/models/EventParticipantsSchema.ts b/src/lib/providers/ministry-platform/models/EventParticipantsSchema.ts index ee29fbe..926ecc4 100644 --- a/src/lib/providers/ministry-platform/models/EventParticipantsSchema.ts +++ b/src/lib/providers/ministry-platform/models/EventParticipantsSchema.ts @@ -5,13 +5,13 @@ export const EventParticipantsSchema = z.object({ Event_ID: z.number().int(), Participant_ID: z.number().int(), Participation_Status_ID: z.number().int(), - Time_In: z.string().datetime().nullable(), - Time_Confirmed: z.string().datetime().nullable(), - Time_Out: z.string().datetime().nullable(), + Time_In: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)").nullable(), + Time_Confirmed: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)").nullable(), + Time_Out: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)").nullable(), Notes: z.string().max(4000).nullable(), Group_Participant_ID: z.number().int().nullable(), "Check-in_Station": z.string().max(50).nullable(), - _Setup_Date: z.string().datetime().nullable(), + _Setup_Date: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)").nullable(), Group_ID: z.number().int().nullable(), Room_ID: z.number().int().nullable(), Call_Parents: z.boolean().nullable(), diff --git a/src/lib/providers/ministry-platform/models/EventsSchema.ts b/src/lib/providers/ministry-platform/models/EventsSchema.ts index eec398a..202ced3 100644 --- a/src/lib/providers/ministry-platform/models/EventsSchema.ts +++ b/src/lib/providers/ministry-platform/models/EventsSchema.ts @@ -12,8 +12,8 @@ export const EventsSchema = z.object({ Primary_Contact: z.number().int(), Participants_Expected: z.number().int().nullable(), Minutes_for_Setup: z.number().int(), - Event_Start_Date: z.string().datetime(), - Event_End_Date: z.string().datetime(), + Event_Start_Date: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)"), + Event_End_Date: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)"), Minutes_for_Cleanup: z.number().int(), Cancelled: z.boolean(), _Approved: z.boolean().nullable(), @@ -21,8 +21,8 @@ export const EventsSchema = z.object({ Featured_On_Calendar: z.boolean(), Online_Registration_Product: z.number().int().nullable(), Registration_Form: z.number().int().nullable(), - Registration_Start: z.string().datetime().nullable(), - Registration_End: z.string().datetime().nullable(), + Registration_Start: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)").nullable(), + Registration_End: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)").nullable(), Registration_Active: z.boolean(), Register_Into_Series: z.boolean(), External_Registration_URL: z.string().url().nullable(), diff --git a/src/lib/providers/ministry-platform/models/FeedbackEntriesSchema.ts b/src/lib/providers/ministry-platform/models/FeedbackEntriesSchema.ts index 7a2cfc4..4812725 100644 --- a/src/lib/providers/ministry-platform/models/FeedbackEntriesSchema.ts +++ b/src/lib/providers/ministry-platform/models/FeedbackEntriesSchema.ts @@ -6,13 +6,13 @@ export const FeedbackEntriesSchema = z.object({ Entry_Title: z.string().max(50), Feedback_Type_ID: z.number().int(), Program_ID: z.number().int().nullable(), - Date_Submitted: z.string().datetime(), + Date_Submitted: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)"), Visibility_Level_ID: z.number().int(), Description: z.string().max(2000).nullable(), Ongoing_Need: z.boolean(), Assigned_To: z.number().int().nullable(), Care_Outcome_ID: z.number().int().nullable(), - Outcome_Date: z.string().datetime().nullable(), + Outcome_Date: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)").nullable(), Approved: z.boolean(), Care_Case_ID: z.number().int().nullable(), }); diff --git a/src/lib/providers/ministry-platform/models/FormResponsesSchema.ts b/src/lib/providers/ministry-platform/models/FormResponsesSchema.ts index 9d9665e..8b2f50d 100644 --- a/src/lib/providers/ministry-platform/models/FormResponsesSchema.ts +++ b/src/lib/providers/ministry-platform/models/FormResponsesSchema.ts @@ -3,7 +3,7 @@ import { z } from 'zod'; export const FormResponsesSchema = z.object({ Form_Response_ID: z.number().int(), Form_ID: z.number().int(), - Response_Date: z.string().datetime(), + Response_Date: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)"), IP_Address: z.string().max(45).nullable(), Contact_ID: z.number().int().nullable(), First_Name: z.string().max(50).nullable(), @@ -21,7 +21,7 @@ export const FormResponsesSchema = z.object({ Opportunity_Response: z.number().int().nullable(), Congregation_ID: z.number().int().nullable(), Notification_Sent: z.boolean(), - Expires: z.string().datetime().nullable(), + Expires: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)").nullable(), Event_Participant_ID: z.number().int().nullable(), }); diff --git a/src/lib/providers/ministry-platform/models/Forms.ts b/src/lib/providers/ministry-platform/models/Forms.ts index 284c627..a2d4593 100644 --- a/src/lib/providers/ministry-platform/models/Forms.ts +++ b/src/lib/providers/ministry-platform/models/Forms.ts @@ -45,6 +45,10 @@ export interface Forms { Expiring_Soon_Days?: number /* 32-bit integer */ | null; Force_Login: boolean; // Has Default + + Product_ID?: number /* 32-bit integer */ | null; // Foreign Key -> Products.Product_ID + + Program_ID?: number /* 32-bit integer */ | null; // Foreign Key -> Programs.Program_ID } export type FormsRecord = Forms; diff --git a/src/lib/providers/ministry-platform/models/FormsSchema.ts b/src/lib/providers/ministry-platform/models/FormsSchema.ts index f4632d9..3bd0d47 100644 --- a/src/lib/providers/ministry-platform/models/FormsSchema.ts +++ b/src/lib/providers/ministry-platform/models/FormsSchema.ts @@ -8,7 +8,7 @@ export const FormsSchema = z.object({ Get_Contact_Info: z.boolean(), Get_Address_Info: z.boolean(), Form_GUID: z.string().uuid(), - End_Date: z.string().datetime().nullable(), + End_Date: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)").nullable(), Complete_Message: z.string().max(2147483647).nullable(), Primary_Contact: z.number().int().nullable(), Notify: z.boolean(), @@ -16,6 +16,8 @@ export const FormsSchema = z.object({ Months_Till_Expires: z.number().int().nullable(), Expiring_Soon_Days: z.number().int().nullable(), Force_Login: z.boolean(), + Product_ID: z.number().int().nullable(), + Program_ID: z.number().int().nullable(), }); export type FormsInput = z.infer<typeof FormsSchema>; diff --git a/src/lib/providers/ministry-platform/models/GroupAttributesSchema.ts b/src/lib/providers/ministry-platform/models/GroupAttributesSchema.ts index a4f844f..0459339 100644 --- a/src/lib/providers/ministry-platform/models/GroupAttributesSchema.ts +++ b/src/lib/providers/ministry-platform/models/GroupAttributesSchema.ts @@ -4,8 +4,8 @@ export const GroupAttributesSchema = z.object({ Group_Attribute_ID: z.number().int(), Attribute_ID: z.number().int(), Group_ID: z.number().int(), - Start_Date: z.string().datetime(), - End_Date: z.string().datetime().nullable(), + Start_Date: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)"), + End_Date: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)").nullable(), Notes: z.string().max(255).nullable(), }); diff --git a/src/lib/providers/ministry-platform/models/GroupInquiriesSchema.ts b/src/lib/providers/ministry-platform/models/GroupInquiriesSchema.ts index 5276455..e71b293 100644 --- a/src/lib/providers/ministry-platform/models/GroupInquiriesSchema.ts +++ b/src/lib/providers/ministry-platform/models/GroupInquiriesSchema.ts @@ -4,7 +4,7 @@ export const GroupInquiriesSchema = z.object({ Group_Inquiry_ID: z.number().int(), Group_ID: z.number().int(), Contact_ID: z.number().int().nullable(), - Inquiry_Date: z.string().datetime(), + Inquiry_Date: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)"), First_Name: z.string().max(50).nullable(), Last_Name: z.string().max(50).nullable(), Phone: z.string().nullable(), diff --git a/src/lib/providers/ministry-platform/models/GroupParticipantsSchema.ts b/src/lib/providers/ministry-platform/models/GroupParticipantsSchema.ts index 498a1d6..92797f0 100644 --- a/src/lib/providers/ministry-platform/models/GroupParticipantsSchema.ts +++ b/src/lib/providers/ministry-platform/models/GroupParticipantsSchema.ts @@ -5,8 +5,8 @@ export const GroupParticipantsSchema = z.object({ Group_ID: z.number().int(), Participant_ID: z.number().int(), Group_Role_ID: z.number().int(), - Start_Date: z.string().datetime(), - End_Date: z.string().datetime().nullable(), + Start_Date: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)"), + End_Date: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)").nullable(), Employee_Role: z.boolean(), Hours_Per_Week: z.number().int().nullable(), Notes: z.string().max(500).nullable(), @@ -14,10 +14,10 @@ export const GroupParticipantsSchema = z.object({ Email_Opt_Out: z.boolean(), Share_With_Group: z.boolean(), Auto_Promote: z.boolean(), - _First_Attendance: z.string().datetime().nullable(), - _Second_Attendance: z.string().datetime().nullable(), - _Third_Attendance: z.string().datetime().nullable(), - _Last_Attendance: z.string().datetime().nullable(), + _First_Attendance: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)").nullable(), + _Second_Attendance: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)").nullable(), + _Third_Attendance: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)").nullable(), + _Last_Attendance: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)").nullable(), Show_Email: z.boolean(), Show_Phone: z.boolean(), Show_Last_Name: z.boolean(), diff --git a/src/lib/providers/ministry-platform/models/GroupsSchema.ts b/src/lib/providers/ministry-platform/models/GroupsSchema.ts index c16c3fd..f23dcb4 100644 --- a/src/lib/providers/ministry-platform/models/GroupsSchema.ts +++ b/src/lib/providers/ministry-platform/models/GroupsSchema.ts @@ -8,8 +8,8 @@ export const GroupsSchema = z.object({ Congregation_ID: z.number().int(), Primary_Contact: z.number().int(), Description: z.string().max(2000).nullable(), - Start_Date: z.string().datetime(), - End_Date: z.string().datetime().nullable(), + Start_Date: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)"), + End_Date: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)").nullable(), Target_Size: z.number().int().nullable(), Parent_Group: z.number().int().nullable(), Priority_ID: z.number().int().nullable(), @@ -26,8 +26,8 @@ export const GroupsSchema = z.object({ Required_Book: z.number().int().nullable(), Descended_From: z.number().int().nullable(), Reason_Ended: z.number().int().nullable(), - _Last_Attendance_Posted: z.string().datetime().nullable(), - _Last_Group_Member_Changed: z.string().datetime().nullable(), + _Last_Attendance_Posted: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)").nullable(), + _Last_Group_Member_Changed: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)").nullable(), "Secure_Check-in": z.boolean(), Suppress_Nametag: z.boolean(), Suppress_Care_Note: z.boolean(), @@ -35,7 +35,7 @@ export const GroupsSchema = z.object({ Promote_to_Group: z.number().int().nullable(), Age_in_Months_to_Promote: z.number().int().nullable(), Promote_Weekly: z.boolean(), - Promotion_Date: z.string().datetime().nullable(), + Promotion_Date: z.string().regex(/^\d{4}-\d{2}-\d{2}([ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?)?$/, "Expected YYYY-MM-DD (optionally with a time)").nullable(), Promote_Participants_Only: z.boolean(), Send_Attendance_Notification: z.boolean(), Send_Service_Notification: z.boolean(), @@ -43,7 +43,7 @@ export const GroupsSchema = z.object({ SMS_Number: z.number().int().nullable(), Default_Meeting_Room: z.number().int().nullable(), Create_Next_Meeting: z.boolean(), - Next_Scheduled_Meeting: z.string().datetime().nullable(), + Next_Scheduled_Meeting: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)").nullable(), Available_On_App: z.boolean().nullable(), }); diff --git a/src/lib/providers/ministry-platform/models/HouseholdCareLogSchema.ts b/src/lib/providers/ministry-platform/models/HouseholdCareLogSchema.ts index c2cc0cb..309eabe 100644 --- a/src/lib/providers/ministry-platform/models/HouseholdCareLogSchema.ts +++ b/src/lib/providers/ministry-platform/models/HouseholdCareLogSchema.ts @@ -5,7 +5,7 @@ export const HouseholdCareLogSchema = z.object({ Household_ID: z.number().int(), Care_Type_ID: z.number().int(), Care_Outcome_ID: z.number().int(), - Date_Provided: z.string().datetime().nullable(), + Date_Provided: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)").nullable(), Provided_By: z.number().int(), Notes: z.string().max(2000).nullable(), Care_Amount: z.number().nullable(), @@ -14,7 +14,7 @@ export const HouseholdCareLogSchema = z.object({ PRIVATE: z.boolean(), Contact_ID: z.number().int().nullable(), Completed: z.boolean(), - Action_Date: z.string().datetime().nullable(), + Action_Date: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)").nullable(), }); export type HouseholdCareLogInput = z.infer<typeof HouseholdCareLogSchema>; diff --git a/src/lib/providers/ministry-platform/models/HouseholdIdentifierLogSchema.ts b/src/lib/providers/ministry-platform/models/HouseholdIdentifierLogSchema.ts index b28aacd..b4e8fd4 100644 --- a/src/lib/providers/ministry-platform/models/HouseholdIdentifierLogSchema.ts +++ b/src/lib/providers/ministry-platform/models/HouseholdIdentifierLogSchema.ts @@ -6,7 +6,7 @@ export const HouseholdIdentifierLogSchema = z.object({ Source_System_Name: z.string().max(50), Source_Type_Name: z.string().max(128), Identifier_Value: z.string().max(4000), - _Date_Inserted: z.string().datetime(), + _Date_Inserted: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)"), }); export type HouseholdIdentifierLogInput = z.infer<typeof HouseholdIdentifierLogSchema>; diff --git a/src/lib/providers/ministry-platform/models/HouseholdsSchema.ts b/src/lib/providers/ministry-platform/models/HouseholdsSchema.ts index 6b15340..5bba643 100644 --- a/src/lib/providers/ministry-platform/models/HouseholdsSchema.ts +++ b/src/lib/providers/ministry-platform/models/HouseholdsSchema.ts @@ -12,12 +12,12 @@ export const HouseholdsSchema = z.object({ Home_Phone_Unlisted: z.boolean(), Home_Address_Unlisted: z.boolean(), Bulk_Mail_Opt_Out: z.boolean(), - _First_Donation: z.string().datetime().nullable(), - _Last_Donation: z.string().datetime().nullable(), - _Last_Activity: z.string().datetime().nullable(), + _First_Donation: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)").nullable(), + _Last_Donation: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)").nullable(), + _Last_Activity: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)").nullable(), Alternate_Mailing_Address: z.number().int().nullable(), - Season_Start: z.string().datetime().nullable(), - Season_End: z.string().datetime().nullable(), + Season_Start: z.string().regex(/^\d{4}-\d{2}-\d{2}([ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?)?$/, "Expected YYYY-MM-DD (optionally with a time)").nullable(), + Season_End: z.string().regex(/^\d{4}-\d{2}-\d{2}([ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?)?$/, "Expected YYYY-MM-DD (optionally with a time)").nullable(), Repeats_Annually: z.boolean(), Driving_Distance: z.number().nullable(), Driving_Time: z.number().nullable(), diff --git a/src/lib/providers/ministry-platform/models/Invoices.ts b/src/lib/providers/ministry-platform/models/Invoices.ts index c081b0c..0d88c13 100644 --- a/src/lib/providers/ministry-platform/models/Invoices.ts +++ b/src/lib/providers/ministry-platform/models/Invoices.ts @@ -37,6 +37,8 @@ export interface Invoices { * Max length: 255 characters */ Cancel_Reason?: string /* max 255 chars */ | null; + + Form_Response_ID?: number /* 32-bit integer */ | null; // Foreign Key -> Form_Responses.Form_Response_ID } export type InvoicesRecord = Invoices; diff --git a/src/lib/providers/ministry-platform/models/InvoicesSchema.ts b/src/lib/providers/ministry-platform/models/InvoicesSchema.ts index 6ab8f18..89c0d43 100644 --- a/src/lib/providers/ministry-platform/models/InvoicesSchema.ts +++ b/src/lib/providers/ministry-platform/models/InvoicesSchema.ts @@ -5,13 +5,14 @@ export const InvoicesSchema = z.object({ Purchaser_Contact_ID: z.number().int(), Invoice_Status_ID: z.number().int(), Invoice_Total: z.number(), - Invoice_Date: z.string().datetime(), + Invoice_Date: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)"), Notes: z.string().max(4000).nullable(), Currency: z.string().max(25).nullable(), Congregation_ID: z.number().int().nullable(), Invoice_GUID: z.string().uuid(), Invoice_Source: z.number().int().nullable(), Cancel_Reason: z.string().max(255).nullable(), + Form_Response_ID: z.number().int().nullable(), }); export type InvoicesInput = z.infer<typeof InvoicesSchema>; diff --git a/src/lib/providers/ministry-platform/models/JourneysSchema.ts b/src/lib/providers/ministry-platform/models/JourneysSchema.ts index db3ebf3..042f99f 100644 --- a/src/lib/providers/ministry-platform/models/JourneysSchema.ts +++ b/src/lib/providers/ministry-platform/models/JourneysSchema.ts @@ -8,8 +8,8 @@ export const JourneysSchema = z.object({ Gamify: z.boolean().nullable(), Active: z.boolean().nullable(), Badge: z.string().max(400).nullable(), - Start_Date: z.string().datetime().nullable(), - End_Date: z.string().datetime().nullable(), + Start_Date: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)").nullable(), + End_Date: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)").nullable(), Finish_Message: z.string().max(150).nullable(), }); diff --git a/src/lib/providers/ministry-platform/models/LocationsSchema.ts b/src/lib/providers/ministry-platform/models/LocationsSchema.ts index 349a468..2812235 100644 --- a/src/lib/providers/ministry-platform/models/LocationsSchema.ts +++ b/src/lib/providers/ministry-platform/models/LocationsSchema.ts @@ -7,8 +7,8 @@ export const LocationsSchema = z.object({ Description: z.string().max(255).nullable(), Location_Type_ID: z.number().int(), Address_ID: z.number().int(), - Move_In_Date: z.string().datetime().nullable(), - Move_Out_Date: z.string().datetime().nullable(), + Move_In_Date: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)").nullable(), + Move_Out_Date: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)").nullable(), Phone: z.string().nullable(), Location_Group_ID: z.number().int().nullable(), Mailing_Address_ID: z.number().int().nullable(), diff --git a/src/lib/providers/ministry-platform/models/MaintenanceRequestsSchema.ts b/src/lib/providers/ministry-platform/models/MaintenanceRequestsSchema.ts index baea4af..96989df 100644 --- a/src/lib/providers/ministry-platform/models/MaintenanceRequestsSchema.ts +++ b/src/lib/providers/ministry-platform/models/MaintenanceRequestsSchema.ts @@ -3,7 +3,7 @@ import { z } from 'zod'; export const MaintenanceRequestsSchema = z.object({ Maintenance_Request_ID: z.number().int(), Submitted_For: z.number().int(), - Request_Date: z.string().datetime(), + Request_Date: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)"), Request_Title: z.string().max(50), Description: z.string().max(4000).nullable(), Notes: z.string().max(4000).nullable(), diff --git a/src/lib/providers/ministry-platform/models/MinistriesSchema.ts b/src/lib/providers/ministry-platform/models/MinistriesSchema.ts index 9238797..9a5dfa9 100644 --- a/src/lib/providers/ministry-platform/models/MinistriesSchema.ts +++ b/src/lib/providers/ministry-platform/models/MinistriesSchema.ts @@ -5,8 +5,8 @@ export const MinistriesSchema = z.object({ Ministry_Name: z.string().max(50), Nickname: z.string().max(50).nullable(), Description: z.string().max(255).nullable(), - Start_Date: z.string().datetime(), - End_Date: z.string().datetime().nullable(), + Start_Date: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)"), + End_Date: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)").nullable(), Primary_Contact: z.number().int(), Parent_Ministry: z.number().int().nullable(), Available_Online: z.boolean(), diff --git a/src/lib/providers/ministry-platform/models/MpVwLastKnownActivitySchema.ts b/src/lib/providers/ministry-platform/models/MpVwLastKnownActivitySchema.ts index c55133f..b65d47e 100644 --- a/src/lib/providers/ministry-platform/models/MpVwLastKnownActivitySchema.ts +++ b/src/lib/providers/ministry-platform/models/MpVwLastKnownActivitySchema.ts @@ -2,7 +2,7 @@ import { z } from 'zod'; export const MpVwLastKnownActivitySchema = z.object({ Participant_ID: z.number().int(), - Last_Known_Activity: z.string().datetime().nullable(), + Last_Known_Activity: z.string().regex(/^\d{4}-\d{2}-\d{2}([ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?)?$/, "Expected YYYY-MM-DD (optionally with a time)").nullable(), }); export type MpVwLastKnownActivityInput = z.infer<typeof MpVwLastKnownActivitySchema>; diff --git a/src/lib/providers/ministry-platform/models/NeedsSchema.ts b/src/lib/providers/ministry-platform/models/NeedsSchema.ts index 81f98d0..7406c81 100644 --- a/src/lib/providers/ministry-platform/models/NeedsSchema.ts +++ b/src/lib/providers/ministry-platform/models/NeedsSchema.ts @@ -7,10 +7,10 @@ export const NeedsSchema = z.object({ Need_Campaign_ID: z.number().int(), Need_Type_ID: z.number().int().nullable(), Other_Need: z.string().max(255).nullable(), - Target_Date: z.string().datetime().nullable(), + Target_Date: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)").nullable(), Complete: z.boolean(), Need_Provider_ID: z.number().int().nullable(), - Date_Assigned: z.string().datetime().nullable(), + Date_Assigned: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)").nullable(), Notes: z.string().max(500).nullable(), Need_Guid: z.string().uuid(), Care_Case_ID: z.number().int().nullable(), diff --git a/src/lib/providers/ministry-platform/models/OpportunitiesSchema.ts b/src/lib/providers/ministry-platform/models/OpportunitiesSchema.ts index 4756855..47d8284 100644 --- a/src/lib/providers/ministry-platform/models/OpportunitiesSchema.ts +++ b/src/lib/providers/ministry-platform/models/OpportunitiesSchema.ts @@ -8,8 +8,8 @@ export const OpportunitiesSchema = z.object({ Program_ID: z.number().int(), Visibility_Level_ID: z.number().int(), Contact_Person: z.number().int(), - Publish_Date: z.string().datetime(), - Opportunity_Date: z.string().datetime().nullable(), + Publish_Date: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)"), + Opportunity_Date: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)").nullable(), Duration_in_Hours: z.number().int().nullable(), Add_to_Group: z.number().int().nullable(), Add_to_Event: z.number().int().nullable(), @@ -25,7 +25,7 @@ export const OpportunitiesSchema = z.object({ Custom_Form: z.number().int().nullable(), Response_Message: z.number().int().nullable(), Close_Responses: z.boolean(), - Date_To_Remind: z.string().datetime().nullable(), + Date_To_Remind: z.string().regex(/^\d{4}-\d{2}-\d{2}([ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?)?$/, "Expected YYYY-MM-DD (optionally with a time)").nullable(), Optional_Reminder_Message: z.number().int().nullable(), Send_To_Heads: z.boolean().nullable(), _Maximum_Needed_Met: z.boolean(), diff --git a/src/lib/providers/ministry-platform/models/OpportunityAttributesSchema.ts b/src/lib/providers/ministry-platform/models/OpportunityAttributesSchema.ts index b68918e..1655165 100644 --- a/src/lib/providers/ministry-platform/models/OpportunityAttributesSchema.ts +++ b/src/lib/providers/ministry-platform/models/OpportunityAttributesSchema.ts @@ -4,8 +4,8 @@ export const OpportunityAttributesSchema = z.object({ Opportunity_Attribute_ID: z.number().int(), Attribute_ID: z.number().int(), Opportunity_ID: z.number().int(), - Start_Date: z.string().datetime(), - End_Date: z.string().datetime().nullable(), + Start_Date: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)"), + End_Date: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)").nullable(), Notes: z.string().max(255).nullable(), }); diff --git a/src/lib/providers/ministry-platform/models/ParticipantCertificationsSchema.ts b/src/lib/providers/ministry-platform/models/ParticipantCertificationsSchema.ts index 46e40b2..b4d1de7 100644 --- a/src/lib/providers/ministry-platform/models/ParticipantCertificationsSchema.ts +++ b/src/lib/providers/ministry-platform/models/ParticipantCertificationsSchema.ts @@ -4,10 +4,10 @@ export const ParticipantCertificationsSchema = z.object({ Participant_Certification_ID: z.number().int(), Participant_ID: z.number().int(), Certification_Type_ID: z.number().int(), - Certification_Submitted: z.string().datetime(), - Certification_Completed: z.string().datetime().nullable(), + Certification_Submitted: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)"), + Certification_Completed: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)").nullable(), Passed: z.boolean().nullable(), - Certification_Expires: z.string().datetime().nullable(), + Certification_Expires: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)").nullable(), Certification_GUID: z.string().uuid(), Notes: z.string().max(500).nullable(), }); diff --git a/src/lib/providers/ministry-platform/models/ParticipantMilestonesSchema.ts b/src/lib/providers/ministry-platform/models/ParticipantMilestonesSchema.ts index 3887034..f000892 100644 --- a/src/lib/providers/ministry-platform/models/ParticipantMilestonesSchema.ts +++ b/src/lib/providers/ministry-platform/models/ParticipantMilestonesSchema.ts @@ -5,7 +5,7 @@ export const ParticipantMilestonesSchema = z.object({ Participant_ID: z.number().int(), Milestone_ID: z.number().int(), Program_ID: z.number().int(), - Date_Accomplished: z.string().datetime().nullable(), + Date_Accomplished: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)").nullable(), Event_ID: z.number().int().nullable(), Witness: z.number().int().nullable(), At_Prior_Church: z.boolean(), diff --git a/src/lib/providers/ministry-platform/models/ParticipantsSchema.ts b/src/lib/providers/ministry-platform/models/ParticipantsSchema.ts index eadccc2..23b3010 100644 --- a/src/lib/providers/ministry-platform/models/ParticipantsSchema.ts +++ b/src/lib/providers/ministry-platform/models/ParticipantsSchema.ts @@ -7,16 +7,16 @@ export const ParticipantsSchema = z.object({ Participant_Type_ID: z.number().int(), Member_Status_ID: z.number().int().nullable(), Participant_Engagement_ID: z.number().int().nullable(), - Participant_Start_Date: z.string().datetime(), - Participant_End_Date: z.string().datetime().nullable(), + Participant_Start_Date: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)"), + Participant_End_Date: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)").nullable(), Notes: z.string().max(1000).nullable(), - _First_Attendance_Ever: z.string().datetime().nullable(), - _Second_Attendance_Ever: z.string().datetime().nullable(), - _Third_Attendance_Ever: z.string().datetime().nullable(), - _Last_Attendance_Ever: z.string().datetime().nullable(), + _First_Attendance_Ever: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)").nullable(), + _Second_Attendance_Ever: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)").nullable(), + _Third_Attendance_Ever: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)").nullable(), + _Last_Attendance_Ever: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)").nullable(), _Background_Check_Type: z.string().max(128).nullable(), _Background_Check_Status: z.string().max(50).nullable(), - _Background_Check_Date: z.string().datetime().nullable(), + _Background_Check_Date: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)").nullable(), Church_of_Record: z.number().int().nullable(), Baptism_Parish_Name: z.string().max(75).nullable(), Baptism_Parish_Address: z.string().max(254).nullable(), diff --git a/src/lib/providers/ministry-platform/models/PaymentsSchema.ts b/src/lib/providers/ministry-platform/models/PaymentsSchema.ts index f6b5542..67daa39 100644 --- a/src/lib/providers/ministry-platform/models/PaymentsSchema.ts +++ b/src/lib/providers/ministry-platform/models/PaymentsSchema.ts @@ -4,7 +4,7 @@ export const PaymentsSchema = z.object({ Payment_ID: z.number().int(), Payment_Total: z.number(), Contact_ID: z.number().int(), - Payment_Date: z.string().datetime(), + Payment_Date: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)"), Gateway_Response: z.string().max(500).nullable(), Transaction_Code: z.string().max(50).nullable(), Notes: z.string().max(4000).nullable(), diff --git a/src/lib/providers/ministry-platform/models/PersonnelAssignmentsSchema.ts b/src/lib/providers/ministry-platform/models/PersonnelAssignmentsSchema.ts index 6fca363..df39168 100644 --- a/src/lib/providers/ministry-platform/models/PersonnelAssignmentsSchema.ts +++ b/src/lib/providers/ministry-platform/models/PersonnelAssignmentsSchema.ts @@ -6,8 +6,8 @@ export const PersonnelAssignmentsSchema = z.object({ Personnel_Assignment_Type_ID: z.number().int(), Location_ID: z.number().int().nullable(), Assignment_Role_ID: z.number().int().nullable(), - Assignment_Start: z.string().datetime().nullable(), - Assignment_End: z.string().datetime().nullable(), + Assignment_Start: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)").nullable(), + Assignment_End: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)").nullable(), Primary_Assignment: z.boolean(), Assignment_Comments: z.string().max(2000).nullable(), Mail_To_Location: z.boolean().nullable(), diff --git a/src/lib/providers/ministry-platform/models/PersonnelBenefitsSchema.ts b/src/lib/providers/ministry-platform/models/PersonnelBenefitsSchema.ts index dde7661..1531a0e 100644 --- a/src/lib/providers/ministry-platform/models/PersonnelBenefitsSchema.ts +++ b/src/lib/providers/ministry-platform/models/PersonnelBenefitsSchema.ts @@ -4,8 +4,8 @@ export const PersonnelBenefitsSchema = z.object({ Personnel_Benefit_ID: z.number().int(), Personnel_ID: z.number().int(), Benefit_Type_ID: z.number().int(), - Start_Date: z.string().datetime().nullable(), - Expiration_Date: z.string().datetime().nullable(), + Start_Date: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)").nullable(), + Expiration_Date: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)").nullable(), Benefit_Notes: z.string().max(2000).nullable(), }); diff --git a/src/lib/providers/ministry-platform/models/PersonnelCommentsSchema.ts b/src/lib/providers/ministry-platform/models/PersonnelCommentsSchema.ts index 119458d..6bfdb4d 100644 --- a/src/lib/providers/ministry-platform/models/PersonnelCommentsSchema.ts +++ b/src/lib/providers/ministry-platform/models/PersonnelCommentsSchema.ts @@ -3,7 +3,7 @@ import { z } from 'zod'; export const PersonnelCommentsSchema = z.object({ Personnel_Comment_ID: z.number().int(), Personnel_ID: z.number().int(), - Comment_Date: z.string().datetime(), + Comment_Date: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)"), Personnel_Comment_Type_ID: z.number().int(), Comment: z.string().max(2000), }); diff --git a/src/lib/providers/ministry-platform/models/PersonnelEstatePlansSchema.ts b/src/lib/providers/ministry-platform/models/PersonnelEstatePlansSchema.ts index bea80b6..afe4428 100644 --- a/src/lib/providers/ministry-platform/models/PersonnelEstatePlansSchema.ts +++ b/src/lib/providers/ministry-platform/models/PersonnelEstatePlansSchema.ts @@ -3,7 +3,7 @@ import { z } from 'zod'; export const PersonnelEstatePlansSchema = z.object({ Personnel_Estate_Plan_ID: z.number().int(), Personnel_ID: z.number().int(), - Living_Will_Last_Updated: z.string().datetime().nullable(), + Living_Will_Last_Updated: z.string().regex(/^\d{4}-\d{2}-\d{2}([ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?)?$/, "Expected YYYY-MM-DD (optionally with a time)").nullable(), Living_Will_Filed_At: z.string().max(100).nullable(), Living_Will_Surrogate_Name: z.string().max(150).nullable(), Living_Will_Relationship: z.string().max(150).nullable(), @@ -11,10 +11,10 @@ export const PersonnelEstatePlansSchema = z.object({ Living_Will_Phones: z.string().max(100).nullable(), Living_Will_Advance_Health_Directive: z.boolean().nullable(), Power_of_Attorney_Filed_At: z.string().max(100).nullable(), - Power_of_Attorney_Date_Completed: z.string().datetime().nullable(), + Power_of_Attorney_Date_Completed: z.string().regex(/^\d{4}-\d{2}-\d{2}([ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?)?$/, "Expected YYYY-MM-DD (optionally with a time)").nullable(), Power_of_Attorney_Copy_to_PCP: z.boolean().nullable(), Power_of_Attorney_Agent_Has_Copy: z.boolean().nullable(), - Will_Last_Updated_Date: z.string().datetime().nullable(), + Will_Last_Updated_Date: z.string().regex(/^\d{4}-\d{2}-\d{2}([ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?)?$/, "Expected YYYY-MM-DD (optionally with a time)").nullable(), Will_Filed_At: z.string().max(100).nullable(), Will_Executor_Name: z.string().max(150).nullable(), Will_Address: z.string().max(500).nullable(), diff --git a/src/lib/providers/ministry-platform/models/PersonnelOrdinationSchema.ts b/src/lib/providers/ministry-platform/models/PersonnelOrdinationSchema.ts index 5a666ba..7f759ed 100644 --- a/src/lib/providers/ministry-platform/models/PersonnelOrdinationSchema.ts +++ b/src/lib/providers/ministry-platform/models/PersonnelOrdinationSchema.ts @@ -6,27 +6,27 @@ export const PersonnelOrdinationSchema = z.object({ Deacon_Ordained_Here: z.number().int().nullable(), Deacon_Ordained_By: z.string().max(100).nullable(), First_Mass: z.string().max(100).nullable(), - Candidacy: z.string().datetime().nullable(), - Rite_Of_Lector: z.string().datetime().nullable(), - Rite_Of_Acolyte: z.string().datetime().nullable(), - Date_To_Priesthood: z.string().datetime().nullable(), + Candidacy: z.string().regex(/^\d{4}-\d{2}-\d{2}([ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?)?$/, "Expected YYYY-MM-DD (optionally with a time)").nullable(), + Rite_Of_Lector: z.string().regex(/^\d{4}-\d{2}-\d{2}([ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?)?$/, "Expected YYYY-MM-DD (optionally with a time)").nullable(), + Rite_Of_Acolyte: z.string().regex(/^\d{4}-\d{2}-\d{2}([ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?)?$/, "Expected YYYY-MM-DD (optionally with a time)").nullable(), + Date_To_Priesthood: z.string().regex(/^\d{4}-\d{2}-\d{2}([ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?)?$/, "Expected YYYY-MM-DD (optionally with a time)").nullable(), Religious_Order_ID: z.number().int().nullable(), Religious_Order_Status_ID: z.number().int().nullable(), - Incardination_Date: z.string().datetime().nullable(), + Incardination_Date: z.string().regex(/^\d{4}-\d{2}-\d{2}([ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?)?$/, "Expected YYYY-MM-DD (optionally with a time)").nullable(), Place_of_Incardination: z.string().max(100).nullable(), Profession_Of_Faith: z.boolean().nullable(), Letter_Of_Suitability: z.boolean().nullable(), - Excardination_Date: z.string().datetime().nullable(), + Excardination_Date: z.string().regex(/^\d{4}-\d{2}-\d{2}([ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?)?$/, "Expected YYYY-MM-DD (optionally with a time)").nullable(), Place_of_Excardination: z.string().max(100).nullable(), - Date_of_Arrival: z.string().datetime().nullable(), - Date_of_Departure: z.string().datetime().nullable(), + Date_of_Arrival: z.string().regex(/^\d{4}-\d{2}-\d{2}([ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?)?$/, "Expected YYYY-MM-DD (optionally with a time)").nullable(), + Date_of_Departure: z.string().regex(/^\d{4}-\d{2}-\d{2}([ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?)?$/, "Expected YYYY-MM-DD (optionally with a time)").nullable(), Comments: z.string().max(2147483647).nullable(), - Date_of_Deacon_Ordination: z.string().datetime().nullable(), - Priesthood_Date_of_Ordination: z.string().datetime().nullable(), + Date_of_Deacon_Ordination: z.string().regex(/^\d{4}-\d{2}-\d{2}([ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?)?$/, "Expected YYYY-MM-DD (optionally with a time)").nullable(), + Priesthood_Date_of_Ordination: z.string().regex(/^\d{4}-\d{2}-\d{2}([ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?)?$/, "Expected YYYY-MM-DD (optionally with a time)").nullable(), Priesthood_Ordained_Here: z.number().int().nullable(), Priesthood_Ordained_By: z.string().max(100).nullable(), - Date_of_First_Profession: z.string().datetime().nullable(), - Date_of_Profession: z.string().datetime().nullable(), + Date_of_First_Profession: z.string().regex(/^\d{4}-\d{2}-\d{2}([ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?)?$/, "Expected YYYY-MM-DD (optionally with a time)").nullable(), + Date_of_Profession: z.string().regex(/^\d{4}-\d{2}-\d{2}([ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?)?$/, "Expected YYYY-MM-DD (optionally with a time)").nullable(), }); export type PersonnelOrdinationInput = z.infer<typeof PersonnelOrdinationSchema>; diff --git a/src/lib/providers/ministry-platform/models/PersonnelResumeItemsSchema.ts b/src/lib/providers/ministry-platform/models/PersonnelResumeItemsSchema.ts index be3b761..1ff73c5 100644 --- a/src/lib/providers/ministry-platform/models/PersonnelResumeItemsSchema.ts +++ b/src/lib/providers/ministry-platform/models/PersonnelResumeItemsSchema.ts @@ -6,9 +6,9 @@ export const PersonnelResumeItemsSchema = z.object({ Resume_Item: z.string().max(249), Resume_Item_Type_ID: z.number().int(), Resume_Item_Notes: z.string().max(2000).nullable(), - Date_Started: z.string().datetime().nullable(), - Date_Achieved: z.string().datetime().nullable(), - Date_Expires: z.string().datetime().nullable(), + Date_Started: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)").nullable(), + Date_Achieved: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)").nullable(), + Date_Expires: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)").nullable(), Location_ID: z.number().int().nullable(), Place: z.string().max(500).nullable(), }); diff --git a/src/lib/providers/ministry-platform/models/PersonnelSchema.ts b/src/lib/providers/ministry-platform/models/PersonnelSchema.ts index 131e79b..8898cbf 100644 --- a/src/lib/providers/ministry-platform/models/PersonnelSchema.ts +++ b/src/lib/providers/ministry-platform/models/PersonnelSchema.ts @@ -4,25 +4,25 @@ export const PersonnelSchema = z.object({ Personnel_ID: z.number().int(), Contact_ID: z.number().int(), Personnel_Type_ID: z.number().int().nullable(), - Start_Date: z.string().datetime().nullable(), - End_Date: z.string().datetime().nullable(), + Start_Date: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)").nullable(), + End_Date: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)").nullable(), Personnel_Record_Status_ID: z.number().int().nullable(), Congregation_ID: z.number().int().nullable(), Personnel_Notes: z.string().max(2000).nullable(), - Hire_Date: z.string().datetime().nullable(), + Hire_Date: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)").nullable(), ID_Number: z.string().max(50).nullable(), Citizenship_Type_ID: z.number().int().nullable(), Citizen_Of: z.string().max(249).nullable(), - Document_Expiration: z.string().datetime().nullable(), + Document_Expiration: z.string().regex(/^\d{4}-\d{2}-\d{2}([ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?)?$/, "Expected YYYY-MM-DD (optionally with a time)").nullable(), Passport_On_File: z.boolean().nullable(), - Passport_Expiration: z.string().datetime().nullable(), + Passport_Expiration: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)").nullable(), Form_I9: z.boolean().nullable(), Eligible_For_Benefits: z.boolean().nullable(), Deferred_or_Vested: z.boolean().nullable(), Physician_On_File: z.boolean().nullable(), - Termination_Date: z.string().datetime().nullable(), + Termination_Date: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)").nullable(), Termination_Reason: z.string().max(249).nullable(), - Date_Retired: z.string().datetime().nullable(), + Date_Retired: z.string().regex(/^\d{4}-\d{2}-\d{2}([ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?)?$/, "Expected YYYY-MM-DD (optionally with a time)").nullable(), Personnel_Category_ID: z.number().int(), }); diff --git a/src/lib/providers/ministry-platform/models/PledgeAdjustmentsSchema.ts b/src/lib/providers/ministry-platform/models/PledgeAdjustmentsSchema.ts index fc38443..45b1b03 100644 --- a/src/lib/providers/ministry-platform/models/PledgeAdjustmentsSchema.ts +++ b/src/lib/providers/ministry-platform/models/PledgeAdjustmentsSchema.ts @@ -4,7 +4,7 @@ export const PledgeAdjustmentsSchema = z.object({ Pledge_Adjustment_ID: z.number().int(), Pledge_ID: z.number().int(), Pledge_Adjustment_Type_ID: z.number().int(), - Adjustment_Date: z.string().datetime(), + Adjustment_Date: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)"), Adjustment_Amount: z.number(), Prior_Amount: z.number(), Notes: z.string().max(2000).nullable(), diff --git a/src/lib/providers/ministry-platform/models/PledgeCampaignsSchema.ts b/src/lib/providers/ministry-platform/models/PledgeCampaignsSchema.ts index 790c0a1..9864413 100644 --- a/src/lib/providers/ministry-platform/models/PledgeCampaignsSchema.ts +++ b/src/lib/providers/ministry-platform/models/PledgeCampaignsSchema.ts @@ -7,12 +7,12 @@ export const PledgeCampaignsSchema = z.object({ Pledge_Campaign_Type_ID: z.number().int(), Description: z.string().max(500).nullable(), Campaign_Goal: z.number(), - Start_Date: z.string().datetime(), - End_Date: z.string().datetime().nullable(), + Start_Date: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)"), + End_Date: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)").nullable(), Event_ID: z.number().int().nullable(), Program_ID: z.number().int().nullable(), - Registration_Start: z.string().datetime().nullable(), - Registration_End: z.string().datetime().nullable(), + Registration_Start: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)").nullable(), + Registration_End: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)").nullable(), Maximum_Registrants: z.number().int().nullable(), Registration_Deposit: z.number().nullable(), Fundraising_Goal: z.number().nullable(), diff --git a/src/lib/providers/ministry-platform/models/Pledges.ts b/src/lib/providers/ministry-platform/models/Pledges.ts index 1461ddf..320a36c 100644 --- a/src/lib/providers/ministry-platform/models/Pledges.ts +++ b/src/lib/providers/ministry-platform/models/Pledges.ts @@ -57,6 +57,11 @@ export interface Pledges { _Last_Installment_Date?: string /* ISO datetime */ | null; // Read Only, Computed Batch_ID?: number /* 32-bit integer */ | null; // Foreign Key -> Batches.Batch_ID + + /** + * Max length: 16 characters + */ + _Vendor_Pledge_Code?: string /* max 16 chars */ | null; // Read Only } export type PledgesRecord = Pledges; diff --git a/src/lib/providers/ministry-platform/models/PledgesSchema.ts b/src/lib/providers/ministry-platform/models/PledgesSchema.ts index 6b2560a..08253e3 100644 --- a/src/lib/providers/ministry-platform/models/PledgesSchema.ts +++ b/src/lib/providers/ministry-platform/models/PledgesSchema.ts @@ -9,7 +9,7 @@ export const PledgesSchema = z.object({ Installments_Planned: z.number().int(), Installments_Per_Year: z.number().int(), _Installment_Amount: z.number().nullable(), - First_Installment_Date: z.string().datetime(), + First_Installment_Date: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)"), Notes: z.string().max(500).nullable(), Beneficiary: z.string().max(50).nullable(), Trip_Leader: z.boolean(), @@ -20,8 +20,9 @@ export const PledgesSchema = z.object({ Donation_Source_ID: z.number().int().nullable(), Send_Pledge_Statement: z.boolean(), _Original_Amount: z.number().nullable(), - _Last_Installment_Date: z.string().datetime().nullable(), + _Last_Installment_Date: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)").nullable(), Batch_ID: z.number().int().nullable(), + _Vendor_Pledge_Code: z.string().max(16).nullable(), }); export type PledgesInput = z.infer<typeof PledgesSchema>; diff --git a/src/lib/providers/ministry-platform/models/PocketPlatformDevicesSchema.ts b/src/lib/providers/ministry-platform/models/PocketPlatformDevicesSchema.ts index 3fd3edf..7ae31b2 100644 --- a/src/lib/providers/ministry-platform/models/PocketPlatformDevicesSchema.ts +++ b/src/lib/providers/ministry-platform/models/PocketPlatformDevicesSchema.ts @@ -4,8 +4,8 @@ export const PocketPlatformDevicesSchema = z.object({ Device_ID: z.number().int(), Hardware_ID: z.string().max(50), User_ID: z.number().int().nullable(), - First_Seen: z.string().datetime(), - Last_Seen: z.string().datetime(), + First_Seen: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)"), + Last_Seen: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)"), Platform: z.string().max(50).nullable(), App_ID: z.number().int().nullable(), }); diff --git a/src/lib/providers/ministry-platform/models/PocketPlatformSocialMediaProviders.ts b/src/lib/providers/ministry-platform/models/PocketPlatformSocialMediaProviders.ts new file mode 100644 index 0000000..008b49e --- /dev/null +++ b/src/lib/providers/ministry-platform/models/PocketPlatformSocialMediaProviders.ts @@ -0,0 +1,18 @@ +/** + * Interface for Pocket_Platform_Social_Media_Providers +* Table: Pocket_Platform_Social_Media_Providers + * Access Level: Read + * Special Permissions: None + * Generated from column metadata + */ +export interface PocketPlatformSocialMediaProviders { + + Social_Media_Provider_ID: number /* 32-bit integer */; // Primary Key + + /** + * Max length: 100 characters + */ + Social_Media_Provider: string /* max 100 chars */; +} + +export type PocketPlatformSocialMediaProvidersRecord = PocketPlatformSocialMediaProviders; diff --git a/src/lib/providers/ministry-platform/models/PocketPlatformSocialMediaProvidersSchema.ts b/src/lib/providers/ministry-platform/models/PocketPlatformSocialMediaProvidersSchema.ts new file mode 100644 index 0000000..c4277f6 --- /dev/null +++ b/src/lib/providers/ministry-platform/models/PocketPlatformSocialMediaProvidersSchema.ts @@ -0,0 +1,8 @@ +import { z } from 'zod'; + +export const PocketPlatformSocialMediaProvidersSchema = z.object({ + Social_Media_Provider_ID: z.number().int(), + Social_Media_Provider: z.string().max(100), +}); + +export type PocketPlatformSocialMediaProvidersInput = z.infer<typeof PocketPlatformSocialMediaProvidersSchema>; diff --git a/src/lib/providers/ministry-platform/models/ProceduresSchema.ts b/src/lib/providers/ministry-platform/models/ProceduresSchema.ts index c5483c8..57e5620 100644 --- a/src/lib/providers/ministry-platform/models/ProceduresSchema.ts +++ b/src/lib/providers/ministry-platform/models/ProceduresSchema.ts @@ -3,7 +3,7 @@ import { z } from 'zod'; export const ProceduresSchema = z.object({ Procedure_ID: z.number().int(), Procedure_Title: z.string().max(50), - Creation_Date: z.string().datetime().nullable(), + Creation_Date: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)").nullable(), Active: z.boolean(), User_ID: z.number().int(), Ministry_ID: z.number().int().nullable(), diff --git a/src/lib/providers/ministry-platform/models/ProgramGroupsSchema.ts b/src/lib/providers/ministry-platform/models/ProgramGroupsSchema.ts index 69bdd88..029d25a 100644 --- a/src/lib/providers/ministry-platform/models/ProgramGroupsSchema.ts +++ b/src/lib/providers/ministry-platform/models/ProgramGroupsSchema.ts @@ -5,8 +5,8 @@ export const ProgramGroupsSchema = z.object({ Program_ID: z.number().int(), Group_ID: z.number().int(), Room_ID: z.number().int().nullable(), - Start_Date: z.string().datetime(), - End_Date: z.string().datetime().nullable(), + Start_Date: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)"), + End_Date: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)").nullable(), }); export type ProgramGroupsInput = z.infer<typeof ProgramGroupsSchema>; diff --git a/src/lib/providers/ministry-platform/models/ProgramsSchema.ts b/src/lib/providers/ministry-platform/models/ProgramsSchema.ts index 93f7ad7..6aa221c 100644 --- a/src/lib/providers/ministry-platform/models/ProgramsSchema.ts +++ b/src/lib/providers/ministry-platform/models/ProgramsSchema.ts @@ -5,8 +5,8 @@ export const ProgramsSchema = z.object({ Program_Name: z.string().max(130), Congregation_ID: z.number().int(), Ministry_ID: z.number().int(), - Start_Date: z.string().datetime(), - End_Date: z.string().datetime().nullable(), + Start_Date: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)"), + End_Date: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)").nullable(), Program_Type_ID: z.number().int().nullable(), Leadership_Team: z.number().int().nullable(), Primary_Contact: z.number().int(), diff --git a/src/lib/providers/ministry-platform/models/ResponseFollowUpsSchema.ts b/src/lib/providers/ministry-platform/models/ResponseFollowUpsSchema.ts index 47d1dd2..1849581 100644 --- a/src/lib/providers/ministry-platform/models/ResponseFollowUpsSchema.ts +++ b/src/lib/providers/ministry-platform/models/ResponseFollowUpsSchema.ts @@ -3,7 +3,7 @@ import { z } from 'zod'; export const ResponseFollowUpsSchema = z.object({ Response_Follow_Up_ID: z.number().int(), Response_ID: z.number().int(), - Follow_Up_Date: z.string().datetime(), + Follow_Up_Date: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)"), Action_Type_ID: z.number().int(), Notes: z.string().max(1000), Made_By: z.number().int().nullable(), diff --git a/src/lib/providers/ministry-platform/models/ResponsesSchema.ts b/src/lib/providers/ministry-platform/models/ResponsesSchema.ts index 215bafb..eca52de 100644 --- a/src/lib/providers/ministry-platform/models/ResponsesSchema.ts +++ b/src/lib/providers/ministry-platform/models/ResponsesSchema.ts @@ -2,7 +2,7 @@ import { z } from 'zod'; export const ResponsesSchema = z.object({ Response_ID: z.number().int(), - Response_Date: z.string().datetime(), + Response_Date: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)"), Opportunity_ID: z.number().int(), Participant_ID: z.number().int(), Comments: z.string().max(2000).nullable(), diff --git a/src/lib/providers/ministry-platform/models/RoomsSchema.ts b/src/lib/providers/ministry-platform/models/RoomsSchema.ts index c326698..8dd8440 100644 --- a/src/lib/providers/ministry-platform/models/RoomsSchema.ts +++ b/src/lib/providers/ministry-platform/models/RoomsSchema.ts @@ -10,7 +10,7 @@ export const RoomsSchema = z.object({ Default_Room_Layout: z.number().int().nullable(), Room_Usage_Type_ID: z.number().int().nullable(), Bookable: z.boolean(), - Last_Remodel_Date: z.string().datetime().nullable(), + Last_Remodel_Date: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)").nullable(), Parent_Room_ID: z.number().int().nullable(), Auto_Approve: z.boolean(), Print_Server_ID: z.number().int().nullable(), diff --git a/src/lib/providers/ministry-platform/models/SacramentsSchema.ts b/src/lib/providers/ministry-platform/models/SacramentsSchema.ts index 6631027..d02201b 100644 --- a/src/lib/providers/ministry-platform/models/SacramentsSchema.ts +++ b/src/lib/providers/ministry-platform/models/SacramentsSchema.ts @@ -3,7 +3,7 @@ import { z } from 'zod'; export const SacramentsSchema = z.object({ Sacrament_ID: z.number().int(), Sacrament_Type_ID: z.number().int(), - Date_Received: z.string().datetime().nullable(), + Date_Received: z.string().regex(/^\d{4}-\d{2}-\d{2}([ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?)?$/, "Expected YYYY-MM-DD (optionally with a time)").nullable(), Date_Received_Accuracy_ID: z.number().int().nullable(), Participant_ID: z.number().int(), Participant_Name: z.string().max(100).nullable(), @@ -20,11 +20,11 @@ export const SacramentsSchema = z.object({ Mother_ID: z.number().int().nullable(), Name_of_Mother: z.string().max(100).nullable(), Profession_of_Faith: z.boolean().nullable(), - Profession_of_Faith_Date: z.string().datetime().nullable(), + Profession_of_Faith_Date: z.string().regex(/^\d{4}-\d{2}-\d{2}([ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?)?$/, "Expected YYYY-MM-DD (optionally with a time)").nullable(), Verified: z.boolean().nullable(), Notification_Received: z.boolean().nullable(), Place_of_Burial: z.string().max(128).nullable(), - Date_of_Burial: z.string().datetime().nullable(), + Date_of_Burial: z.string().regex(/^\d{4}-\d{2}-\d{2}([ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?)?$/, "Expected YYYY-MM-DD (optionally with a time)").nullable(), Spouse_ID: z.number().int().nullable(), Spouse_Name: z.string().max(100).nullable(), Interfaith: z.boolean().nullable(), diff --git a/src/lib/providers/ministry-platform/models/ScheduledDonationsSchema.ts b/src/lib/providers/ministry-platform/models/ScheduledDonationsSchema.ts index 22e0fa3..ae967b5 100644 --- a/src/lib/providers/ministry-platform/models/ScheduledDonationsSchema.ts +++ b/src/lib/providers/ministry-platform/models/ScheduledDonationsSchema.ts @@ -6,8 +6,8 @@ export const ScheduledDonationsSchema = z.object({ Donor_Account_ID: z.number().int().nullable(), Day_of_Month: z.unknown(), Amount: z.number(), - Start_Date: z.string().datetime(), - End_Date: z.string().datetime().nullable(), + Start_Date: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)"), + End_Date: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)").nullable(), Target_Event: z.number().int().nullable(), Notes: z.string().max(1000).nullable(), Payment_Type_ID: z.number().int(), diff --git a/src/lib/providers/ministry-platform/models/StaffSchema.ts b/src/lib/providers/ministry-platform/models/StaffSchema.ts index 79fbbb4..3311bec 100644 --- a/src/lib/providers/ministry-platform/models/StaffSchema.ts +++ b/src/lib/providers/ministry-platform/models/StaffSchema.ts @@ -4,8 +4,8 @@ export const StaffSchema = z.object({ Staff_ID: z.number().int(), Contact_ID: z.number().int(), Title: z.string().max(50), - Start_Date: z.string().datetime(), - End_Date: z.string().datetime().nullable(), + Start_Date: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)"), + End_Date: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)").nullable(), Show_Online: z.boolean(), Online_Order: z.unknown(), Facebook_URL: z.string().url().nullable(), diff --git a/src/lib/providers/ministry-platform/models/StatisticsLogs.ts b/src/lib/providers/ministry-platform/models/StatisticsLogs.ts new file mode 100644 index 0000000..8a03449 --- /dev/null +++ b/src/lib/providers/ministry-platform/models/StatisticsLogs.ts @@ -0,0 +1,24 @@ +/** + * Interface for Statistics_Logs +* Table: Statistics_Logs + * Access Level: Read + * Special Permissions: None + * Generated from column metadata + */ +export interface StatisticsLogs { + + Log_ID: number /* 32-bit integer */; // Primary Key + + Log_Date: string /* ISO datetime */; + + /** + * Max length: 75 characters + */ + Table_Name: string /* max 75 chars */; + + Duration?: number /* 32-bit integer */ | null; + + Sample_Percent?: unknown | null; +} + +export type StatisticsLogsRecord = StatisticsLogs; diff --git a/src/lib/providers/ministry-platform/models/StatisticsLogsSchema.ts b/src/lib/providers/ministry-platform/models/StatisticsLogsSchema.ts new file mode 100644 index 0000000..1b6d3cf --- /dev/null +++ b/src/lib/providers/ministry-platform/models/StatisticsLogsSchema.ts @@ -0,0 +1,11 @@ +import { z } from 'zod'; + +export const StatisticsLogsSchema = z.object({ + Log_ID: z.number().int(), + Log_Date: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)"), + Table_Name: z.string().max(75), + Duration: z.number().int().nullable(), + Sample_Percent: z.unknown().nullable(), +}); + +export type StatisticsLogsInput = z.infer<typeof StatisticsLogsSchema>; diff --git a/src/lib/providers/ministry-platform/models/VolunteerUnavailableDatesSchema.ts b/src/lib/providers/ministry-platform/models/VolunteerUnavailableDatesSchema.ts index 91df0a3..e35f09f 100644 --- a/src/lib/providers/ministry-platform/models/VolunteerUnavailableDatesSchema.ts +++ b/src/lib/providers/ministry-platform/models/VolunteerUnavailableDatesSchema.ts @@ -3,8 +3,8 @@ import { z } from 'zod'; export const VolunteerUnavailableDatesSchema = z.object({ Volunteer_Unavailable_Date_ID: z.number().int(), Contact_ID: z.number().int(), - Start_Date: z.string().datetime(), - End_Date: z.string().datetime(), + Start_Date: z.string().regex(/^\d{4}-\d{2}-\d{2}([ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?)?$/, "Expected YYYY-MM-DD (optionally with a time)"), + End_Date: z.string().regex(/^\d{4}-\d{2}-\d{2}([ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?)?$/, "Expected YYYY-MM-DD (optionally with a time)"), }); export type VolunteerUnavailableDatesInput = z.infer<typeof VolunteerUnavailableDatesSchema>; diff --git a/src/lib/providers/ministry-platform/models/VwMpGivingUnitSummarySchema.ts b/src/lib/providers/ministry-platform/models/VwMpGivingUnitSummarySchema.ts deleted file mode 100644 index 6713dcb..0000000 --- a/src/lib/providers/ministry-platform/models/VwMpGivingUnitSummarySchema.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { z } from 'zod'; - -export const VwMpGivingUnitSummarySchema = z.object({ - Household_ID: z.number().int().nullable(), - Household_Name: z.string().max(75), - Primary_Individuals: z.string().max(103).nullable(), - Giving_Unit_Type: z.string().max(9), - Giving_Notes: z.string().max(97), - Giving_YTD: z.number(), - Giving_YTD_Last_Year: z.number(), - Giving_Variance_Last_YTD: z.number().nullable(), - Giving_Last_Year: z.number(), - Online_Giving_Notes: z.string().max(47), - Online_Giving_YTD: z.number(), - Online_Giving_YTD_Last_Year: z.number(), - Online_Giving_Variance_Last_YTD: z.number().nullable(), - Online_Giving_Last_Year: z.number(), - Gifts_YTD: z.number().int(), - Gifts_YTD_Last_Year: z.number().int(), - Gifts_Variance_Last_YTD: z.number().int().nullable(), - Gifts_Last_Year: z.number().int(), - Online_Gifts_YTD: z.number().int(), - Online_Gifts_YTD_Last_Year: z.number().int(), - Online_Gifts_Variance_Last_YTD: z.number().int().nullable(), - Online_Gifts_Last_Year: z.number().int(), - Giver_Notes: z.string().max(102), - Givers_YTD: z.number().int(), - Givers_Last_Year: z.number().int(), - Comparison_Notes: z.string().max(171), - Date_Range_Before: z.string().max(4000).nullable(), - Date_Range_After: z.string().max(4000).nullable(), - Giving_Before: z.number(), - Giving_After: z.number(), - Giving_After_Variance: z.number().nullable(), - Gifts_Before: z.number().int(), - Gifts_After: z.number().int(), - Gifts_After_Variance: z.number().int().nullable(), - Online_Giving_Before: z.number(), - Online_Giving_After: z.number(), - Online_Giving_After_Variance: z.number().nullable(), - Online_Gifts_Before: z.number().int(), - Online_Gifts_After: z.number().int(), - Online_Gifts_After_Variance: z.number().int().nullable(), - HH_Member_Notes: z.string().max(93), - Last_Name: z.string().max(50).nullable(), - First_Name: z.string().max(50).nullable(), - Nickname: z.string().max(50).nullable(), - Email_Address: z.string().email().max(254).nullable(), - Mobile_Phone: z.string().max(25).nullable(), - Household_Position: z.string().max(25).nullable(), - Gender: z.string().max(25).nullable(), - Contact_Status: z.string().max(50), - Member_Status: z.string().max(50).nullable(), - Participant_Type: z.string().max(50).nullable(), - Age: z.number().int().nullable(), - Contact_ID: z.number().int(), - Home_Phone: z.string().max(25).nullable(), - Congregation_ID: z.number().int().nullable(), - Congregation_Name: z.string().max(50).nullable(), - First_HH_Donation: z.string().datetime().nullable(), - Last_HH_Donation: z.string().datetime().nullable(), - Months_Since_Last_HH_Donation: z.number().int().nullable(), - Spouse_Last_Name: z.string().max(50).nullable(), - Spouse_First_Name: z.string().max(50).nullable(), - Spouse_Nickname: z.string().max(50).nullable(), - Spouse_Email: z.string().email().max(254).nullable(), - Spouse_Mobile: z.string().max(25).nullable(), - Spouse_HH_Position: z.string().max(25).nullable(), - Spouse_Gender: z.string().max(25).nullable(), - Spouse_Contact_Status: z.string().max(50).nullable(), - Spouse_Member_Status: z.string().max(50).nullable(), - Spouse_Participant_Type: z.string().max(50).nullable(), - Spouse_Age: z.number().int().nullable(), -}); - -export type VwMpGivingUnitSummaryInput = z.infer<typeof VwMpGivingUnitSummarySchema>; diff --git a/src/lib/providers/ministry-platform/models/VwMpGivingUnitSummary.ts b/src/lib/providers/ministry-platform/models/VwMpGivingUnitSummaryStandard.ts similarity index 61% rename from src/lib/providers/ministry-platform/models/VwMpGivingUnitSummary.ts rename to src/lib/providers/ministry-platform/models/VwMpGivingUnitSummaryStandard.ts index a6308aa..04e60d9 100644 --- a/src/lib/providers/ministry-platform/models/VwMpGivingUnitSummary.ts +++ b/src/lib/providers/ministry-platform/models/VwMpGivingUnitSummaryStandard.ts @@ -1,13 +1,13 @@ /** - * Interface for vw_mp_giving_unit_summary -* Table: vw_mp_giving_unit_summary + * Interface for vw_mp_giving_unit_summary_standard +* Table: vw_mp_giving_unit_summary_standard * Access Level: Read * Special Permissions: DataExport * Generated from column metadata */ -export interface VwMpGivingUnitSummary { +export interface VwMpGivingUnitSummaryStandard { - Household_ID?: number /* 32-bit integer */ | null; // Primary Key, Foreign Key -> Households.Household_ID + Household_ID: number /* 32-bit integer */; // Primary Key, Foreign Key -> Households.Household_ID /** * Max length: 75 characters @@ -15,38 +15,40 @@ export interface VwMpGivingUnitSummary { Household_Name: string /* max 75 chars */; /** - * Max length: 103 characters + * Max length: 105 characters */ - Primary_Individuals?: string /* max 103 chars */ | null; + Primary_Individuals?: string /* max 105 chars */ | null; /** - * Max length: 9 characters + * Max length: 10 characters */ - Giving_Unit_Type: string /* max 9 chars */; + Giving_Unit_Type: string /* max 10 chars */; + + Last_Refresh: string /* ISO datetime */; /** - * Max length: 97 characters + * Max length: 100 characters */ - Giving_Notes: string /* max 97 chars */; + Giving_Notes: string /* max 100 chars */; Giving_YTD: number /* currency amount */; Giving_YTD_Last_Year: number /* currency amount */; - Giving_Variance_Last_YTD?: number /* currency amount */ | null; + Giving_Variance_Last_YTD: number /* currency amount */; Giving_Last_Year: number /* currency amount */; /** - * Max length: 47 characters + * Max length: 50 characters */ - Online_Giving_Notes: string /* max 47 chars */; + Online_Giving_Notes: string /* max 50 chars */; Online_Giving_YTD: number /* currency amount */; Online_Giving_YTD_Last_Year: number /* currency amount */; - Online_Giving_Variance_Last_YTD?: number /* currency amount */ | null; + Online_Giving_Variance_Last_YTD: number /* currency amount */; Online_Giving_Last_Year: number /* currency amount */; @@ -54,7 +56,7 @@ export interface VwMpGivingUnitSummary { Gifts_YTD_Last_Year: number /* 32-bit integer */; - Gifts_Variance_Last_YTD?: number /* 32-bit integer */ | null; + Gifts_Variance_Last_YTD: number /* 32-bit integer */; Gifts_Last_Year: number /* 32-bit integer */; @@ -62,62 +64,62 @@ export interface VwMpGivingUnitSummary { Online_Gifts_YTD_Last_Year: number /* 32-bit integer */; - Online_Gifts_Variance_Last_YTD?: number /* 32-bit integer */ | null; + Online_Gifts_Variance_Last_YTD: number /* 32-bit integer */; Online_Gifts_Last_Year: number /* 32-bit integer */; /** - * Max length: 102 characters + * Max length: 120 characters */ - Giver_Notes: string /* max 102 chars */; + Giver_Notes: string /* max 120 chars */; Givers_YTD: number /* 32-bit integer */; Givers_Last_Year: number /* 32-bit integer */; /** - * Max length: 171 characters + * Max length: 200 characters */ - Comparison_Notes: string /* max 171 chars */; + Comparison_Notes: string /* max 200 chars */; /** - * Max length: 4000 characters + * Max length: 50 characters */ - Date_Range_Before?: string /* max 4000 chars */ | null; + Date_Range_Before: string /* max 50 chars */; /** - * Max length: 4000 characters + * Max length: 50 characters */ - Date_Range_After?: string /* max 4000 chars */ | null; + Date_Range_After: string /* max 50 chars */; Giving_Before: number /* currency amount */; Giving_After: number /* currency amount */; - Giving_After_Variance?: number /* currency amount */ | null; + Giving_After_Variance: number /* currency amount */; Gifts_Before: number /* 32-bit integer */; Gifts_After: number /* 32-bit integer */; - Gifts_After_Variance?: number /* 32-bit integer */ | null; + Gifts_After_Variance: number /* 32-bit integer */; Online_Giving_Before: number /* currency amount */; Online_Giving_After: number /* currency amount */; - Online_Giving_After_Variance?: number /* currency amount */ | null; + Online_Giving_After_Variance: number /* currency amount */; Online_Gifts_Before: number /* 32-bit integer */; Online_Gifts_After: number /* 32-bit integer */; - Online_Gifts_After_Variance?: number /* 32-bit integer */ | null; + Online_Gifts_After_Variance: number /* 32-bit integer */; /** - * Max length: 93 characters + * Max length: 120 characters */ - HH_Member_Notes: string /* max 93 chars */; + HH_Member_Notes: string /* max 120 chars */; /** * Max length: 50 characters @@ -137,12 +139,9 @@ export interface VwMpGivingUnitSummary { /** * Max length: 254 characters */ - Email_Address?: string /* email, max 254 chars */ | null; + Email_Address?: string /* max 254 chars */ | null; - /** - * Max length: 25 characters - */ - Mobile_Phone?: string /* max 25 chars */ | null; + Mobile_Phone?: string /* phone number */ | null; /** * Max length: 25 characters @@ -169,14 +168,11 @@ export interface VwMpGivingUnitSummary { */ Participant_Type?: string /* max 50 chars */ | null; - Age?: number /* 32-bit integer */ | null; + Age?: unknown | null; Contact_ID: number /* 32-bit integer */; // Foreign Key -> Contacts.Contact_ID - /** - * Max length: 25 characters - */ - Home_Phone?: string /* max 25 chars */ | null; + Home_Phone?: string /* phone number */ | null; Congregation_ID?: number /* 32-bit integer */ | null; @@ -189,7 +185,7 @@ export interface VwMpGivingUnitSummary { Last_HH_Donation?: string /* ISO datetime */ | null; - Months_Since_Last_HH_Donation?: number /* 32-bit integer */ | null; + Months_Since_Last_HH_Donation: number /* 32-bit integer */; /** * Max length: 50 characters @@ -209,12 +205,9 @@ export interface VwMpGivingUnitSummary { /** * Max length: 254 characters */ - Spouse_Email?: string /* email, max 254 chars */ | null; + Spouse_Email?: string /* max 254 chars */ | null; - /** - * Max length: 25 characters - */ - Spouse_Mobile?: string /* max 25 chars */ | null; + Spouse_Mobile?: string /* phone number */ | null; /** * Max length: 25 characters @@ -241,7 +234,7 @@ export interface VwMpGivingUnitSummary { */ Spouse_Participant_Type?: string /* max 50 chars */ | null; - Spouse_Age?: number /* 32-bit integer */ | null; + Spouse_Age?: unknown | null; } -export type VwMpGivingUnitSummaryRecord = VwMpGivingUnitSummary; +export type VwMpGivingUnitSummaryStandardRecord = VwMpGivingUnitSummaryStandard; diff --git a/src/lib/providers/ministry-platform/models/VwMpGivingUnitSummaryStandardSchema.ts b/src/lib/providers/ministry-platform/models/VwMpGivingUnitSummaryStandardSchema.ts new file mode 100644 index 0000000..7028df5 --- /dev/null +++ b/src/lib/providers/ministry-platform/models/VwMpGivingUnitSummaryStandardSchema.ts @@ -0,0 +1,77 @@ +import { z } from 'zod'; + +export const VwMpGivingUnitSummaryStandardSchema = z.object({ + Household_ID: z.number().int(), + Household_Name: z.string().max(75), + Primary_Individuals: z.string().max(105).nullable(), + Giving_Unit_Type: z.string().max(10), + Last_Refresh: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)"), + Giving_Notes: z.string().max(100), + Giving_YTD: z.number(), + Giving_YTD_Last_Year: z.number(), + Giving_Variance_Last_YTD: z.number(), + Giving_Last_Year: z.number(), + Online_Giving_Notes: z.string().max(50), + Online_Giving_YTD: z.number(), + Online_Giving_YTD_Last_Year: z.number(), + Online_Giving_Variance_Last_YTD: z.number(), + Online_Giving_Last_Year: z.number(), + Gifts_YTD: z.number().int(), + Gifts_YTD_Last_Year: z.number().int(), + Gifts_Variance_Last_YTD: z.number().int(), + Gifts_Last_Year: z.number().int(), + Online_Gifts_YTD: z.number().int(), + Online_Gifts_YTD_Last_Year: z.number().int(), + Online_Gifts_Variance_Last_YTD: z.number().int(), + Online_Gifts_Last_Year: z.number().int(), + Giver_Notes: z.string().max(120), + Givers_YTD: z.number().int(), + Givers_Last_Year: z.number().int(), + Comparison_Notes: z.string().max(200), + Date_Range_Before: z.string().max(50), + Date_Range_After: z.string().max(50), + Giving_Before: z.number(), + Giving_After: z.number(), + Giving_After_Variance: z.number(), + Gifts_Before: z.number().int(), + Gifts_After: z.number().int(), + Gifts_After_Variance: z.number().int(), + Online_Giving_Before: z.number(), + Online_Giving_After: z.number(), + Online_Giving_After_Variance: z.number(), + Online_Gifts_Before: z.number().int(), + Online_Gifts_After: z.number().int(), + Online_Gifts_After_Variance: z.number().int(), + HH_Member_Notes: z.string().max(120), + Last_Name: z.string().max(50).nullable(), + First_Name: z.string().max(50).nullable(), + Nickname: z.string().max(50).nullable(), + Email_Address: z.string().max(254).nullable(), + Mobile_Phone: z.string().nullable(), + Household_Position: z.string().max(25).nullable(), + Gender: z.string().max(25).nullable(), + Contact_Status: z.string().max(50), + Member_Status: z.string().max(50).nullable(), + Participant_Type: z.string().max(50).nullable(), + Age: z.unknown().nullable(), + Contact_ID: z.number().int(), + Home_Phone: z.string().nullable(), + Congregation_ID: z.number().int().nullable(), + Congregation_Name: z.string().max(50).nullable(), + First_HH_Donation: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)").nullable(), + Last_HH_Donation: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)").nullable(), + Months_Since_Last_HH_Donation: z.number().int(), + Spouse_Last_Name: z.string().max(50).nullable(), + Spouse_First_Name: z.string().max(50).nullable(), + Spouse_Nickname: z.string().max(50).nullable(), + Spouse_Email: z.string().max(254).nullable(), + Spouse_Mobile: z.string().nullable(), + Spouse_HH_Position: z.string().max(25).nullable(), + Spouse_Gender: z.string().max(25).nullable(), + Spouse_Contact_Status: z.string().max(50).nullable(), + Spouse_Member_Status: z.string().max(50).nullable(), + Spouse_Participant_Type: z.string().max(50).nullable(), + Spouse_Age: z.unknown().nullable(), +}); + +export type VwMpGivingUnitSummaryStandardInput = z.infer<typeof VwMpGivingUnitSummaryStandardSchema>; diff --git a/src/lib/providers/ministry-platform/models/VwMpParticipationComplianceDetailsSchema.ts b/src/lib/providers/ministry-platform/models/VwMpParticipationComplianceDetailsSchema.ts index 627975d..f3f0614 100644 --- a/src/lib/providers/ministry-platform/models/VwMpParticipationComplianceDetailsSchema.ts +++ b/src/lib/providers/ministry-platform/models/VwMpParticipationComplianceDetailsSchema.ts @@ -7,8 +7,8 @@ export const VwMpParticipationComplianceDetailsSchema = z.object({ Participant_ID: z.number().int(), Group_Participant_ID: z.number().int(), Group_Role_ID: z.number().int(), - Start_Date: z.string().datetime(), - End_Date: z.string().datetime().nullable(), + Start_Date: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)"), + End_Date: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)").nullable(), Background_Check_Type_ID: z.number().int().nullable(), Background_Check: z.string().max(13), Certification_Type_ID: z.number().int().nullable(), diff --git a/src/lib/providers/ministry-platform/models/VwMpParticipationComplianceSchema.ts b/src/lib/providers/ministry-platform/models/VwMpParticipationComplianceSchema.ts index 9de68e1..d395080 100644 --- a/src/lib/providers/ministry-platform/models/VwMpParticipationComplianceSchema.ts +++ b/src/lib/providers/ministry-platform/models/VwMpParticipationComplianceSchema.ts @@ -5,8 +5,8 @@ export const VwMpParticipationComplianceSchema = z.object({ Group_ID: z.number().int(), Participant_ID: z.number().int(), Group_Role_ID: z.number().int(), - Start_Date: z.string().datetime(), - End_Date: z.string().datetime().nullable(), + Start_Date: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)"), + End_Date: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)").nullable(), Attribute_Alignment: z.string().max(4000).nullable(), Background_Check: z.string().max(13).nullable(), Certification: z.string().max(13).nullable(), diff --git a/src/lib/providers/ministry-platform/models/VwMpPersonnelAuditOverviewSchema.ts b/src/lib/providers/ministry-platform/models/VwMpPersonnelAuditOverviewSchema.ts index 60f61fa..383caf3 100644 --- a/src/lib/providers/ministry-platform/models/VwMpPersonnelAuditOverviewSchema.ts +++ b/src/lib/providers/ministry-platform/models/VwMpPersonnelAuditOverviewSchema.ts @@ -7,7 +7,7 @@ export const VwMpPersonnelAuditOverviewSchema = z.object({ Personnel_ID: z.number().int(), Description: z.string().max(50), User: z.string().max(254), - Date: z.string().datetime(), + Date: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)"), On_Behalf_Of: z.string().max(256), Impersonated_By: z.string().max(256), Field: z.string().max(50).nullable(), diff --git a/src/lib/providers/ministry-platform/models/VwMpProjectedScheduledDonationsSchema.ts b/src/lib/providers/ministry-platform/models/VwMpProjectedScheduledDonationsSchema.ts index 645e8c8..2a43ec7 100644 --- a/src/lib/providers/ministry-platform/models/VwMpProjectedScheduledDonationsSchema.ts +++ b/src/lib/providers/ministry-platform/models/VwMpProjectedScheduledDonationsSchema.ts @@ -9,8 +9,8 @@ export const VwMpProjectedScheduledDonationsSchema = z.object({ Amount: z.number(), Day_of_Month_1: z.unknown(), Day_of_Month_2: z.unknown().nullable(), - Start_Date: z.string().datetime(), - End_Date: z.string().datetime().nullable(), + Start_Date: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)"), + End_Date: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)").nullable(), Target_Event: z.number().int().nullable(), Notes: z.string().max(1000).nullable(), Payment_Type_ID: z.number().int(), @@ -18,7 +18,7 @@ export const VwMpProjectedScheduledDonationsSchema = z.object({ Gift_Frequency: z.string().max(128), Number_Of_Installments: z.number().int().nullable(), Third_Party_Note: z.string().max(1000).nullable(), - Projected_Donation_Date: z.string().datetime().nullable(), + Projected_Donation_Date: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)").nullable(), }); export type VwMpProjectedScheduledDonationsInput = z.infer<typeof VwMpProjectedScheduledDonationsSchema>; diff --git a/src/lib/providers/ministry-platform/models/WaitStatsBaseline.ts b/src/lib/providers/ministry-platform/models/WaitStatsBaseline.ts new file mode 100644 index 0000000..f7cde39 --- /dev/null +++ b/src/lib/providers/ministry-platform/models/WaitStatsBaseline.ts @@ -0,0 +1,26 @@ +/** + * Interface for Wait_Stats_Baseline +* Table: Wait_Stats_Baseline + * Access Level: Read + * Special Permissions: None + * Generated from column metadata + */ +export interface WaitStatsBaseline { + + /** + * Max length: 60 characters + */ + WaitType: string /* max 60 chars */; // Primary Key + + WaitingTasksCount: number /* 64-bit integer */; + + WaitTimeMS: number /* 64-bit integer */; + + MaxWaitTimeMS: number /* 64-bit integer */; + + SignalWaitTimeMS: number /* 64-bit integer */; + + SnapshotDT: string /* ISO datetime */; +} + +export type WaitStatsBaselineRecord = WaitStatsBaseline; diff --git a/src/lib/providers/ministry-platform/models/WaitStatsBaselineSchema.ts b/src/lib/providers/ministry-platform/models/WaitStatsBaselineSchema.ts new file mode 100644 index 0000000..32bad73 --- /dev/null +++ b/src/lib/providers/ministry-platform/models/WaitStatsBaselineSchema.ts @@ -0,0 +1,12 @@ +import { z } from 'zod'; + +export const WaitStatsBaselineSchema = z.object({ + WaitType: z.string().max(60), + WaitingTasksCount: z.number().int(), + WaitTimeMS: z.number().int(), + MaxWaitTimeMS: z.number().int(), + SignalWaitTimeMS: z.number().int(), + SnapshotDT: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)"), +}); + +export type WaitStatsBaselineInput = z.infer<typeof WaitStatsBaselineSchema>; diff --git a/src/lib/providers/ministry-platform/models/WaitStatsHistory.ts b/src/lib/providers/ministry-platform/models/WaitStatsHistory.ts new file mode 100644 index 0000000..4f7dada --- /dev/null +++ b/src/lib/providers/ministry-platform/models/WaitStatsHistory.ts @@ -0,0 +1,42 @@ +/** + * Interface for Wait_Stats_History +* Table: Wait_Stats_History + * Access Level: Read + * Special Permissions: None + * Generated from column metadata + */ +export interface WaitStatsHistory { + + WaitStatsID: number /* 32-bit integer */; // Primary Key + + SnapshotDT: string /* ISO datetime */; // Has Default + + /** + * Max length: 60 characters + */ + WaitType: string /* max 60 chars */; + + WaitingTasksCount: number /* 64-bit integer */; + + WaitTimeMS: number /* 64-bit integer */; + + MaxWaitTimeMS: number /* 64-bit integer */; + + SignalWaitTimeMS: number /* 64-bit integer */; + + ResourceWaitTimeMS?: number /* 64-bit integer */ | null; // Read Only, Computed + + SnapshotHour?: number /* 32-bit integer */ | null; // Read Only, Computed + + SnapshotDay?: string /* ISO date (YYYY-MM-DD) */ | null; // Read Only, Computed + + SnapshotISOYear?: number /* 16-bit integer */ | null; // Read Only, Computed + + SnapshotWeek?: number /* 32-bit integer */ | null; // Read Only, Computed + + SnapshotMonth?: number /* 32-bit integer */ | null; // Read Only, Computed + + SnapshotYear?: number /* 32-bit integer */ | null; // Read Only, Computed +} + +export type WaitStatsHistoryRecord = WaitStatsHistory; diff --git a/src/lib/providers/ministry-platform/models/WaitStatsHistorySchema.ts b/src/lib/providers/ministry-platform/models/WaitStatsHistorySchema.ts new file mode 100644 index 0000000..e562547 --- /dev/null +++ b/src/lib/providers/ministry-platform/models/WaitStatsHistorySchema.ts @@ -0,0 +1,20 @@ +import { z } from 'zod'; + +export const WaitStatsHistorySchema = z.object({ + WaitStatsID: z.number().int(), + SnapshotDT: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)"), + WaitType: z.string().max(60), + WaitingTasksCount: z.number().int(), + WaitTimeMS: z.number().int(), + MaxWaitTimeMS: z.number().int(), + SignalWaitTimeMS: z.number().int(), + ResourceWaitTimeMS: z.number().int().nullable(), + SnapshotHour: z.number().int().nullable(), + SnapshotDay: z.string().regex(/^\d{4}-\d{2}-\d{2}([ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?)?$/, "Expected YYYY-MM-DD (optionally with a time)").nullable(), + SnapshotISOYear: z.number().int().nullable(), + SnapshotWeek: z.number().int().nullable(), + SnapshotMonth: z.number().int().nullable(), + SnapshotYear: z.number().int().nullable(), +}); + +export type WaitStatsHistoryInput = z.infer<typeof WaitStatsHistorySchema>; diff --git a/src/lib/providers/ministry-platform/models/WaitTypes.ts b/src/lib/providers/ministry-platform/models/WaitTypes.ts new file mode 100644 index 0000000..26b0af6 --- /dev/null +++ b/src/lib/providers/ministry-platform/models/WaitTypes.ts @@ -0,0 +1,23 @@ +/** + * Interface for Wait_Types +* Table: Wait_Types + * Access Level: Read + * Special Permissions: None + * Generated from column metadata + */ +export interface WaitTypes { + + Wait_Type_ID: number /* 32-bit integer */; // Primary Key + + /** + * Max length: 60 characters + */ + Wait_Type: string /* max 60 chars */; + + /** + * Max length: 60 characters + */ + Category: string /* max 60 chars */; +} + +export type WaitTypesRecord = WaitTypes; diff --git a/src/lib/providers/ministry-platform/models/WaitTypesSchema.ts b/src/lib/providers/ministry-platform/models/WaitTypesSchema.ts new file mode 100644 index 0000000..1abb633 --- /dev/null +++ b/src/lib/providers/ministry-platform/models/WaitTypesSchema.ts @@ -0,0 +1,9 @@ +import { z } from 'zod'; + +export const WaitTypesSchema = z.object({ + Wait_Type_ID: z.number().int(), + Wait_Type: z.string().max(60), + Category: z.string().max(60), +}); + +export type WaitTypesInput = z.infer<typeof WaitTypesSchema>; diff --git a/src/lib/providers/ministry-platform/models/WeeklySnapshotsSchema.ts b/src/lib/providers/ministry-platform/models/WeeklySnapshotsSchema.ts index f57a460..801845b 100644 --- a/src/lib/providers/ministry-platform/models/WeeklySnapshotsSchema.ts +++ b/src/lib/providers/ministry-platform/models/WeeklySnapshotsSchema.ts @@ -2,7 +2,7 @@ import { z } from 'zod'; export const WeeklySnapshotsSchema = z.object({ Weekly_Snapshot_ID: z.number().int(), - Start_Date: z.string().datetime(), + Start_Date: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)"), Congregation_ID: z.number().int(), Active_People_7Days: z.number().int(), Active_People_1Month: z.number().int(), diff --git a/src/lib/providers/ministry-platform/models/WifiDeviceSessionsSchema.ts b/src/lib/providers/ministry-platform/models/WifiDeviceSessionsSchema.ts index 217eedb..41d41a5 100644 --- a/src/lib/providers/ministry-platform/models/WifiDeviceSessionsSchema.ts +++ b/src/lib/providers/ministry-platform/models/WifiDeviceSessionsSchema.ts @@ -4,8 +4,8 @@ export const WifiDeviceSessionsSchema = z.object({ Wifi_Device_Session_ID: z.number().int(), Wifi_Device_ID: z.number().int(), Session_GUID: z.string().max(128), - Session_Start: z.string().datetime(), - Session_End: z.string().datetime().nullable(), + Session_Start: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)"), + Session_End: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)").nullable(), Wifi_Space: z.string().max(50), Duration_in_Minutes: z.number().int(), Contact_ID_Value: z.string().max(50).nullable(), diff --git a/src/lib/providers/ministry-platform/models/WifiDevicesSchema.ts b/src/lib/providers/ministry-platform/models/WifiDevicesSchema.ts index a58707b..dea9dca 100644 --- a/src/lib/providers/ministry-platform/models/WifiDevicesSchema.ts +++ b/src/lib/providers/ministry-platform/models/WifiDevicesSchema.ts @@ -8,7 +8,7 @@ export const WifiDevicesSchema = z.object({ Last_Name: z.string().max(50).nullable(), Email_Address: z.string().email().max(254).nullable(), Mobile_Phone: z.string().nullable(), - Start_Date: z.string().datetime(), + Start_Date: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)"), }); export type WifiDevicesInput = z.infer<typeof WifiDevicesSchema>; diff --git a/src/lib/providers/ministry-platform/models/ZEventNotifications.ts b/src/lib/providers/ministry-platform/models/ZEventNotifications.ts deleted file mode 100644 index 7a1b9fd..0000000 --- a/src/lib/providers/ministry-platform/models/ZEventNotifications.ts +++ /dev/null @@ -1,25 +0,0 @@ -/** - * Interface for Z_Event_Notifications -* Table: Z_Event_Notifications - * Access Level: Read - * Special Permissions: None - * Generated from column metadata - */ -export interface ZEventNotifications { - - Event_ID: number /* 32-bit integer */; - - Registrant_Message?: number /* 32-bit integer */ | null; - - RM_Template?: number /* 32-bit integer */ | null; - - Optional_Reminder_Message?: number /* 32-bit integer */ | null; - - OR_Template?: number /* 32-bit integer */ | null; - - Attendee_Message?: number /* 32-bit integer */ | null; - - AM_Template?: number /* 32-bit integer */ | null; -} - -export type ZEventNotificationsRecord = ZEventNotifications; diff --git a/src/lib/providers/ministry-platform/models/ZEventNotificationsSchema.ts b/src/lib/providers/ministry-platform/models/ZEventNotificationsSchema.ts deleted file mode 100644 index 9c01b44..0000000 --- a/src/lib/providers/ministry-platform/models/ZEventNotificationsSchema.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { z } from 'zod'; - -export const ZEventNotificationsSchema = z.object({ - Event_ID: z.number().int(), - Registrant_Message: z.number().int().nullable(), - RM_Template: z.number().int().nullable(), - Optional_Reminder_Message: z.number().int().nullable(), - OR_Template: z.number().int().nullable(), - Attendee_Message: z.number().int().nullable(), - AM_Template: z.number().int().nullable(), -}); - -export type ZEventNotificationsInput = z.infer<typeof ZEventNotificationsSchema>; diff --git a/src/lib/providers/ministry-platform/models/ZFormNotifications.ts b/src/lib/providers/ministry-platform/models/ZFormNotifications.ts deleted file mode 100644 index 5708bfa..0000000 --- a/src/lib/providers/ministry-platform/models/ZFormNotifications.ts +++ /dev/null @@ -1,17 +0,0 @@ -/** - * Interface for Z_Form_Notifications -* Table: Z_Form_Notifications - * Access Level: Read - * Special Permissions: None - * Generated from column metadata - */ -export interface ZFormNotifications { - - Form_ID: number /* 32-bit integer */; - - Response_Message?: number /* 32-bit integer */ | null; - - RM_Template?: number /* 32-bit integer */ | null; -} - -export type ZFormNotificationsRecord = ZFormNotifications; diff --git a/src/lib/providers/ministry-platform/models/ZFormNotificationsSchema.ts b/src/lib/providers/ministry-platform/models/ZFormNotificationsSchema.ts deleted file mode 100644 index d832b4e..0000000 --- a/src/lib/providers/ministry-platform/models/ZFormNotificationsSchema.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { z } from 'zod'; - -export const ZFormNotificationsSchema = z.object({ - Form_ID: z.number().int(), - Response_Message: z.number().int().nullable(), - RM_Template: z.number().int().nullable(), -}); - -export type ZFormNotificationsInput = z.infer<typeof ZFormNotificationsSchema>; diff --git a/src/lib/providers/ministry-platform/models/ZOppNotifications.ts b/src/lib/providers/ministry-platform/models/ZOppNotifications.ts deleted file mode 100644 index 9f8b80f..0000000 --- a/src/lib/providers/ministry-platform/models/ZOppNotifications.ts +++ /dev/null @@ -1,21 +0,0 @@ -/** - * Interface for Z_Opp_Notifications -* Table: Z_Opp_Notifications - * Access Level: Read - * Special Permissions: None - * Generated from column metadata - */ -export interface ZOppNotifications { - - Opportunity_ID: number /* 32-bit integer */; - - Response_Message?: number /* 32-bit integer */ | null; - - RM_Template?: number /* 32-bit integer */ | null; - - Optional_Reminder_Message?: number /* 32-bit integer */ | null; - - OR_Template?: number /* 32-bit integer */ | null; -} - -export type ZOppNotificationsRecord = ZOppNotifications; diff --git a/src/lib/providers/ministry-platform/models/ZOppNotificationsSchema.ts b/src/lib/providers/ministry-platform/models/ZOppNotificationsSchema.ts deleted file mode 100644 index b97098a..0000000 --- a/src/lib/providers/ministry-platform/models/ZOppNotificationsSchema.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { z } from 'zod'; - -export const ZOppNotificationsSchema = z.object({ - Opportunity_ID: z.number().int(), - Response_Message: z.number().int().nullable(), - RM_Template: z.number().int().nullable(), - Optional_Reminder_Message: z.number().int().nullable(), - OR_Template: z.number().int().nullable(), -}); - -export type ZOppNotificationsInput = z.infer<typeof ZOppNotificationsSchema>; diff --git a/src/lib/providers/ministry-platform/models/index.ts b/src/lib/providers/ministry-platform/models/index.ts index 3abc4d1..ef2e1c4 100644 --- a/src/lib/providers/ministry-platform/models/index.ts +++ b/src/lib/providers/ministry-platform/models/index.ts @@ -1,40 +1,39 @@ // Auto-generated index file -// Generated on: 2026-04-16T02:07:32.635Z -// Successfully generated 301 of 301 tables +// Generated on: 2026-10-11T11:13:43.166Z +// Exports all 307 table type files in this directory -export * from "./Deployments"; -export * from "./AccountTypes"; export * from "./AccountingCompanies"; +export * from "./AccountTypes"; export * from "./ActivityLog"; export * from "./ActivityLogStaging"; export * from "./Addresses"; -export * from "./AlternateEmailTypes"; export * from "./AlternateEmails"; +export * from "./AlternateEmailTypes"; export * from "./AssignmentRoles"; -export * from "./AttributeTypes"; export * from "./Attributes"; +export * from "./AttributeTypes"; export * from "./AudienceAudienceFilters"; export * from "./AudienceFilters"; export * from "./AudienceMembers"; export * from "./AudienceMembersStaging"; export * from "./AudienceOperators"; export * from "./Audiences"; +export * from "./BackgroundChecks"; export * from "./BackgroundCheckStatuses"; export * from "./BackgroundCheckTypes"; -export * from "./BackgroundChecks"; export * from "./Banks"; export * from "./BatchEntryTypes"; +export * from "./Batches"; export * from "./BatchTypes"; export * from "./BatchUsageTypes"; -export * from "./Batches"; export * from "./BeneficiaryRelationships"; export * from "./BenefitTypes"; export * from "./BookCheckouts"; export * from "./Books"; export * from "./Buildings"; export * from "./CampaignGoals"; -export * from "./CareCaseTypes"; export * from "./CareCases"; +export * from "./CareCaseTypes"; export * from "./CareOutcomes"; export * from "./CareTypes"; export * from "./CertificationTypes"; @@ -51,9 +50,9 @@ export * from "./ContactLog"; export * from "./ContactLogTypes"; export * from "./ContactPrivateNotes"; export * from "./ContactRelationships"; +export * from "./Contacts"; export * from "./ContactStaging"; export * from "./ContactStatuses"; -export * from "./Contacts"; export * from "./Continents"; export * from "./ContributionStatementDonors"; export * from "./ContributionStatements"; @@ -61,12 +60,14 @@ export * from "./Countries"; export * from "./Currencies"; export * from "./CustomWidgetDs"; export * from "./DateAccuracies"; +export * from "./DeploymentLogs"; +export * from "./Deployments"; export * from "./Deposits"; export * from "./DonationDistributions"; export * from "./DonationFrequencies"; export * from "./DonationLevels"; -export * from "./DonationSources"; export * from "./Donations"; +export * from "./DonationSources"; export * from "./DonorAccounts"; export * from "./Donors"; export * from "./DpAccountInformation"; @@ -74,14 +75,17 @@ export * from "./DpApiClients"; export * from "./DpApplicationLabels"; export * from "./DpAuditRetentionMessages"; export * from "./DpAuditRetentionPolicies"; +export * from "./DpAuditRetentionRuns"; export * from "./DpAuthenticationLog"; export * from "./DpBookmarks"; +export * from "./DpCharts"; export * from "./DpCommunicationMessages"; +export * from "./DpCommunicationMessagesArchived"; export * from "./DpCommunicationPublications"; +export * from "./DpCommunications"; export * from "./DpCommunicationSnippets"; export * from "./DpCommunicationTemplates"; export * from "./DpCommunicationTypes"; -export * from "./DpCommunications"; export * from "./DpConfigurationLists"; export * from "./DpConfigurationSettings"; export * from "./DpContactPublications"; @@ -93,13 +97,13 @@ export * from "./DpInboundSms"; export * from "./DpIntegrationDefinitionTypes"; export * from "./DpNotificationPageRecords"; export * from "./DpNotificationPageViews"; +export * from "./DpNotifications"; export * from "./DpNotificationSelections"; export * from "./DpNotificationSubPageViews"; -export * from "./DpNotifications"; export * from "./DpPageViews"; +export * from "./DpProcesses"; export * from "./DpProcessSteps"; export * from "./DpProcessSubmissions"; -export * from "./DpProcesses"; export * from "./DpPublications"; export * from "./DpRecordInsights"; export * from "./DpRecordSecurity"; @@ -114,8 +118,8 @@ export * from "./DpUserGlobalFilters"; export * from "./DpUserGroups"; export * from "./DpUserPreferences"; export * from "./DpUserRoles"; -export * from "./DpUserUserGroups"; export * from "./DpUsers"; +export * from "./DpUserUserGroups"; export * from "./DpViewKeys"; export * from "./DpWebhookInvocations"; export * from "./DpWebhooks"; @@ -126,15 +130,15 @@ export * from "./EventGroups"; export * from "./EventMetrics"; export * from "./EventParticipants"; export * from "./EventRooms"; +export * from "./Events"; export * from "./EventServices"; export * from "./EventTypes"; -export * from "./Events"; export * from "./FaithBackgrounds"; export * from "./FeedbackEntries"; export * from "./FeedbackTypes"; export * from "./FollowUpActionTypes"; -export * from "./FormFieldTypes"; export * from "./FormFields"; +export * from "./FormFieldTypes"; export * from "./FormResponseAnswers"; export * from "./FormResponses"; export * from "./Forms"; @@ -149,34 +153,34 @@ export * from "./GroupInquiries"; export * from "./GroupParticipants"; export * from "./GroupRoleDirections"; export * from "./GroupRoleIntensities"; -export * from "./GroupRoleTypes"; export * from "./GroupRoles"; +export * from "./GroupRoleTypes"; export * from "./GroupRooms"; -export * from "./GroupTypes"; export * from "./Groups"; +export * from "./GroupTypes"; export * from "./HouseholdCareLog"; export * from "./HouseholdIdentifierLog"; export * from "./HouseholdPositions"; +export * from "./Households"; export * from "./HouseholdSources"; export * from "./HouseholdTypes"; -export * from "./Households"; export * from "./ImportDestinations"; export * from "./ImportTemplates"; export * from "./Industries"; export * from "./InvoiceDetail"; +export * from "./Invoices"; export * from "./InvoiceSources"; export * from "./InvoiceStatuses"; -export * from "./Invoices"; export * from "./ItemPriorities"; export * from "./ItemStatuses"; export * from "./Journeys"; export * from "./Letters"; export * from "./LifeStages"; export * from "./LocationCategories"; -export * from "./LocationGroupTypes"; export * from "./LocationGroups"; -export * from "./LocationTypes"; +export * from "./LocationGroupTypes"; export * from "./Locations"; +export * from "./LocationTypes"; export * from "./MaintenanceRequests"; export * from "./MappingValues"; export * from "./MaritalStatuses"; @@ -195,9 +199,9 @@ export * from "./MpVwPossibleLeaders"; export * from "./MpVwPrimaryHh"; export * from "./NeedCampaigns"; export * from "./NeedProviders"; +export * from "./Needs"; export * from "./NeedTypeProviders"; export * from "./NeedTypes"; -export * from "./Needs"; export * from "./Occupations"; export * from "./Opportunities"; export * from "./OpportunityAttributes"; @@ -205,39 +209,40 @@ export * from "./OrdinationTypes"; export * from "./ParticipantCertifications"; export * from "./ParticipantEngagement"; export * from "./ParticipantMilestones"; -export * from "./ParticipantTypes"; export * from "./Participants"; +export * from "./ParticipantTypes"; export * from "./ParticipationItems"; export * from "./ParticipationRequirements"; export * from "./ParticipationStatuses"; export * from "./PaymentDetail"; -export * from "./PaymentTypes"; export * from "./Payments"; +export * from "./PaymentTypes"; export * from "./Personnel"; -export * from "./PersonnelAssignmentTypes"; export * from "./PersonnelAssignments"; +export * from "./PersonnelAssignmentTypes"; export * from "./PersonnelBeneficiaries"; export * from "./PersonnelBenefits"; export * from "./PersonnelCategories"; -export * from "./PersonnelCommentTypes"; export * from "./PersonnelComments"; +export * from "./PersonnelCommentTypes"; export * from "./PersonnelEstatePlans"; export * from "./PersonnelOrdination"; export * from "./PersonnelRecordStatuses"; -export * from "./PersonnelResumeItemTypes"; export * from "./PersonnelResumeItems"; +export * from "./PersonnelResumeItemTypes"; export * from "./PersonnelTypes"; export * from "./Perspectives"; export * from "./PlannedContacts"; -export * from "./PledgeAdjustmentTypes"; export * from "./PledgeAdjustments"; -export * from "./PledgeCampaignTypes"; +export * from "./PledgeAdjustmentTypes"; export * from "./PledgeCampaigns"; +export * from "./PledgeCampaignTypes"; export * from "./PledgeFrequencies"; -export * from "./PledgeStatuses"; export * from "./Pledges"; +export * from "./PledgeStatuses"; export * from "./PocketPlatformDevices"; export * from "./PocketPlatformMigrations"; +export * from "./PocketPlatformSocialMediaProviders"; export * from "./Prefixes"; export * from "./PrimaryLanguages"; export * from "./PrintServers"; @@ -247,29 +252,29 @@ export * from "./ProductOptionGroups"; export * from "./ProductOptionPrices"; export * from "./Products"; export * from "./ProgramGroups"; -export * from "./ProgramTypes"; export * from "./Programs"; +export * from "./ProgramTypes"; export * from "./Relationships"; -export * from "./ReligiousOrderStatuses"; export * from "./ReligiousOrders"; +export * from "./ReligiousOrderStatuses"; export * from "./RequestStatuses"; export * from "./ResponseFollowUps"; export * from "./ResponseResults"; export * from "./Responses"; export * from "./RoomLayouts"; -export * from "./RoomUsageTypes"; export * from "./Rooms"; +export * from "./RoomUsageTypes"; export * from "./RsvpStatuses"; export * from "./SacramentPlaces"; +export * from "./Sacraments"; export * from "./SacramentSponsors"; export * from "./SacramentTypes"; -export * from "./Sacraments"; -export * from "./ScheduleRoles"; -export * from "./ScheduleStatuses"; export * from "./ScheduledDonationDistributions"; export * from "./ScheduledDonations"; export * from "./ScheduledParticipants"; +export * from "./ScheduleRoles"; export * from "./Schedules"; +export * from "./ScheduleStatuses"; export * from "./ServiceTypes"; export * from "./Servicing"; export * from "./SponsorTypes"; @@ -279,6 +284,7 @@ export * from "./StatementFrequencies"; export * from "./StatementHeaders"; export * from "./StatementMethods"; export * from "./StatementTypes"; +export * from "./StatisticsLogs"; export * from "./Suffixes"; export * from "./SuggestionTypes"; export * from "./TextingComplianceLevels"; @@ -289,7 +295,7 @@ export * from "./VolunteerUnavailableDates"; export * from "./VwMpCampaignGoals"; export * from "./VwMpContactDetails"; export * from "./VwMpContactMailName"; -export * from "./VwMpGivingUnitSummary"; +export * from "./VwMpGivingUnitSummaryStandard"; export * from "./VwMpParticipationCompliance"; export * from "./VwMpParticipationComplianceDetails"; export * from "./VwMpPersonnelAuditOverview"; @@ -297,9 +303,9 @@ export * from "./VwMpProjectedScheduledDonations"; export * from "./VwMpResponseQualificationDetails"; export * from "./VwMpResponseQualifications"; export * from "./VwMpUserRights"; +export * from "./WaitStatsBaseline"; +export * from "./WaitStatsHistory"; +export * from "./WaitTypes"; export * from "./WeeklySnapshots"; -export * from "./WifiDeviceSessions"; export * from "./WifiDevices"; -export * from "./ZEventNotifications"; -export * from "./ZFormNotifications"; -export * from "./ZOppNotifications"; +export * from "./WifiDeviceSessions"; diff --git a/src/lib/providers/ministry-platform/scripts/README.md b/src/lib/providers/ministry-platform/scripts/README.md index 11719cb..042ef7e 100644 --- a/src/lib/providers/ministry-platform/scripts/README.md +++ b/src/lib/providers/ministry-platform/scripts/README.md @@ -15,52 +15,61 @@ A CLI utility that generates TypeScript interfaces and Zod schemas from your Min - ✅ **Zod Schema Generation**: Optional runtime validation schemas with length and type constraints - ✅ **Flexible Output**: Choose your output directory - ✅ **Search Filtering**: Generate types for specific tables only -- ✅ **Auto-generated Index**: Creates barrel exports for easy importing +- ✅ **Auto-generated Index**: Creates an `index.ts` barrel re-exporting every generated **interface** file (the `*Schema.ts` files are not re-exported — import them by path) ## Prerequisites Ensure your environment contains the required Ministry Platform configuration: ```env -MINISTRY_PLATFORM_BASE_URL=https://your-domain.ministryplatformapi.com +MINISTRY_PLATFORM_BASE_URL=https://your-domain.ministryplatform.com/ministryplatformapi MINISTRY_PLATFORM_CLIENT_ID=your_client_id MINISTRY_PLATFORM_CLIENT_SECRET=your_client_secret ``` Supports `.env.local`, `.env.development`, and `.env` files (loaded in that order). +### Always run with `--conditions=react-server` + +The generators import `MPHelper`, and the MP client modules start with +`import 'server-only'`. That package throws unless it is resolved under the +`react-server` export condition, which Next applies to server code but plain +Node does not. Every direct `tsx` command below passes the flag. The +`npm run mp:generate*` scripts include it too, so `npm run mp:generate -- <options>` +is the shorter form. + ## Usage ### Basic Usage ```bash # Generate types for all tables -npx tsx src/lib/providers/ministry-platform/scripts/generate-types.ts +npx tsx --conditions=react-server src/lib/providers/ministry-platform/scripts/generate-types.ts # Generate with Zod schemas -npx tsx src/lib/providers/ministry-platform/scripts/generate-types.ts --zod +npx tsx --conditions=react-server src/lib/providers/ministry-platform/scripts/generate-types.ts --zod # Generate to models directory -npx tsx src/lib/providers/ministry-platform/scripts/generate-types.ts -o src/lib/providers/ministry-platform/models +npx tsx --conditions=react-server src/lib/providers/ministry-platform/scripts/generate-types.ts -o src/lib/providers/ministry-platform/models ``` ### Advanced Options ```bash # Generate detailed types by sampling records -npx tsx src/lib/providers/ministry-platform/scripts/generate-types.ts --detailed +npx tsx --conditions=react-server src/lib/providers/ministry-platform/scripts/generate-types.ts --detailed # Generate types for specific tables -npx tsx src/lib/providers/ministry-platform/scripts/generate-types.ts --search "Contact" +npx tsx --conditions=react-server src/lib/providers/ministry-platform/scripts/generate-types.ts --search "Contact" # Custom output directory with Zod schemas -npx tsx src/lib/providers/ministry-platform/scripts/generate-types.ts --output ./src/types/mp --zod +npx tsx --conditions=react-server src/lib/providers/ministry-platform/scripts/generate-types.ts --output ./src/types/mp --zod # Detailed mode with custom sample size -npx tsx src/lib/providers/ministry-platform/scripts/generate-types.ts --detailed --sample-size 10 +npx tsx --conditions=react-server src/lib/providers/ministry-platform/scripts/generate-types.ts --detailed --sample-size 10 # Combine options -npx tsx src/lib/providers/ministry-platform/scripts/generate-types.ts \ +npx tsx --conditions=react-server src/lib/providers/ministry-platform/scripts/generate-types.ts \ --detailed \ --search "Contact" \ --output ./types \ @@ -74,43 +83,48 @@ npx tsx src/lib/providers/ministry-platform/scripts/generate-types.ts \ |--------|-------|-------------|---------| | `--output` | `-o` | Output directory for generated types | `./generated-types` | | `--search` | `-s` | Filter tables by search term | (none) | -| `--detailed` | `-d` | Generate detailed types by sampling records | `false` | +| `--detailed` | `-d` | Sample records to infer types — only for tables that have no column metadata | `false` | | `--sample-size` | | Number of records to sample in detailed mode | `5` | | `--zod` | `-z` | Generate Zod schemas for runtime validation | `false` | +| `--clean` | `-c` | Delete every `.ts` file in an existing output directory before generating | `false` | | `--help` | `-h` | Show help message | | ## Output Examples ### Standard Interface +Excerpt of the committed `models/Contacts.ts`: + ```typescript /** * Interface for Contacts - * Table: Contacts +* Table: Contacts * Access Level: ReadWriteAssignDelete * Special Permissions: FileAttach, DataExport, SecureRecord * Generated from column metadata */ -export interface ContactsRecord { - Contact_ID: number; // Primary Key - +export interface Contacts { + + Contact_ID: number /* 32-bit integer */; // Primary Key + /** * Max length: 125 characters */ - Display_Name: string; - + Display_Name: string /* max 125 chars */; + /** * Max length: 254 characters */ - Email_Address?: string /* email */ | null; - + Email_Address?: string /* email, max 254 chars */ | null; + Mobile_Phone?: string /* phone number */ | null; - Contact_GUID: string /* GUID/UUID */; - + + Contact_GUID: string /* GUID/UUID */; // Has Default + // ... additional fields } -export type Contacts = ContactsRecord; +export type ContactsRecord = Contacts; ``` ### Zod Schema (with --zod flag) @@ -124,6 +138,9 @@ export const ContactsSchema = z.object({ Email_Address: z.string().email().max(254).nullable(), Mobile_Phone: z.string().nullable(), Contact_GUID: z.string().uuid(), + // Date columns: YYYY-MM-DD with an optional time part. DateTime columns use the + // same shape with the time required — MP wall-clock ("2026-05-17 14:30:00") or ISO. + Date_of_Birth: z.string().regex(/^\d{4}-\d{2}-\d{2}([ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?)?$/, "...").nullable(), // ... additional fields }); @@ -134,7 +151,8 @@ export type ContactsInput = z.infer<typeof ContactsSchema>; ```typescript import { MPHelper } from '@/lib/providers/ministry-platform'; -import { Contacts, ContactsSchema } from './generated-types'; +import { Contacts } from './generated-types'; // barrel: interfaces only +import { ContactsSchema } from './generated-types/ContactsSchema'; // schemas: import by path const mp = new MPHelper(); @@ -151,36 +169,39 @@ await mp.createTableRecords('Contacts', [validatedContact]); ## Recommended Workflow -1. Generate types to the models directory: +1. Regenerate the committed models (all tables, cleaned first): ```bash - npx tsx src/lib/providers/ministry-platform/scripts/generate-types.ts \ - -o src/lib/providers/ministry-platform/models \ - --zod \ - --search "Contact" + npm run mp:generate:models ``` + A `--search` run into the same directory is safe: `index.ts` is rebuilt from every type file in the output directory, so other tables stay exported, and the `mp-schema/tables.md` catalog is left untouched (it is only rewritten on a full run). 2. Import and use in your app: ```typescript - import { ContactLog, ContactLogSchema } from '@/lib/providers/ministry-platform/models'; + import { ContactLog } from '@/lib/providers/ministry-platform/models'; + import { ContactLogSchema } from '@/lib/providers/ministry-platform/models/ContactLogSchema'; ``` 3. Re-run when schema changes to keep types in sync ## Package.json Scripts -Add to your `package.json` for easier access: +Already defined in `package.json`: ```json { "scripts": { - "mp:generate": "tsx src/lib/providers/ministry-platform/scripts/generate-types.ts", - "mp:generate:models": "tsx src/lib/providers/ministry-platform/scripts/generate-types.ts -o src/lib/providers/ministry-platform/models --zod" + "mp:generate": "tsx --conditions=react-server src/lib/providers/ministry-platform/scripts/generate-types.ts", + "mp:generate:models": "tsx --conditions=react-server src/lib/providers/ministry-platform/scripts/generate-types.ts -o src/lib/providers/ministry-platform/models --zod --clean", + "mp:generate:storedprocs": "tsx --conditions=react-server src/lib/providers/ministry-platform/scripts/generate-storedprocs.ts", + "mp:build:install": "tsx src/lib/providers/ministry-platform/scripts/build-sql-install.ts" } } ``` -Then run with: -```bash -npm run mp:generate -npm run mp:generate:models -``` +## Other scripts in this folder + +- `generate-storedprocs.ts` — writes the stored-procedure reference to `.claude/references/mp-schema/stored-procs.md` (with frontmatter; `-o`, `-s`, `-h` — a `-s` run requires `-o` so it cannot overwrite the full catalog). +- `build-sql-install.ts` — combines `../db/*.sql` into `_INSTALL/ministryplatform-install.sql`; skips when the content hash is unchanged. Imports nothing from the provider, so it needs no `--conditions` flag. +- `codegen-escape.ts` — escaping helpers (`propertyKey`, `commentText`, `positiveInt`) for MP metadata interpolated into generated source. + +The type generator also writes the table catalog `.claude/references/mp-schema/tables.md` (with frontmatter) on full runs; a `--search` run skips it, and rebuilds `models/index.ts` from every type file in the output directory so other tables stay exported. diff --git a/src/lib/providers/ministry-platform/scripts/codegen-escape.test.ts b/src/lib/providers/ministry-platform/scripts/codegen-escape.test.ts new file mode 100644 index 0000000..d4c783e --- /dev/null +++ b/src/lib/providers/ministry-platform/scripts/codegen-escape.test.ts @@ -0,0 +1,48 @@ +import { describe, it, expect } from 'vitest'; +import { commentText, propertyKey, positiveInt } from './codegen-escape'; + +describe('commentText', () => { + it('passes ordinary text through', () => { + expect(commentText('Contacts.Contact_ID')).toBe('Contacts.Contact_ID'); + }); + + it('cannot close a block comment', () => { + const out = commentText('Evil */ export const x = 1; /*'); + expect(out).not.toContain('*/'); + }); + + it.each(['\n', '\r\n', '\u2028', '\u2029'])('cannot end a line comment with %j', (nl) => { + const out = commentText(`Table${nl}export const pwned = true;`); + expect(out).not.toMatch(/[\r\n\u2028\u2029]/); + }); + + it('stringifies null/undefined to empty', () => { + expect(commentText(undefined)).toBe(''); + expect(commentText(null)).toBe(''); + }); +}); + +describe('propertyKey', () => { + it('leaves valid identifiers bare', () => { + expect(propertyKey('Contact_ID')).toBe('Contact_ID'); + }); + + it('quotes names with special characters', () => { + expect(propertyKey('Allow_Check-in')).toBe('"Allow_Check-in"'); + }); + + it.each(['a"b', 'a\\b', 'a\nb', '"; export const x = 1; "'])('produces a single valid string literal for %j', (name) => { + const key = propertyKey(name); + expect(JSON.parse(key)).toBe(name); + }); +}); + +describe('positiveInt', () => { + it.each([[50, 50], ['50', 50]])('accepts %j', (input, expected) => { + expect(positiveInt(input)).toBe(expected); + }); + + it.each([0, -1, 1.5, NaN, Infinity, '1); process.exit(1', '', null, undefined, {}])('rejects %j', (input) => { + expect(positiveInt(input)).toBeUndefined(); + }); +}); diff --git a/src/lib/providers/ministry-platform/scripts/codegen-escape.ts b/src/lib/providers/ministry-platform/scripts/codegen-escape.ts new file mode 100644 index 0000000..05a6329 --- /dev/null +++ b/src/lib/providers/ministry-platform/scripts/codegen-escape.ts @@ -0,0 +1,37 @@ +/** + * Escaping for MP metadata interpolated into generated TypeScript source + * (`generate-types.ts`). + * + * Table names, column names, FK targets, permission strings and sizes all come + * from the MP API at generation time, and the output is committed and compiled + * into the app. A value carrying a newline, a `*\/`, a quote or a non-numeric + * "size" could otherwise close a comment or string early and turn metadata + * into code. Each helper below matches one interpolation context. + */ + +const IDENTIFIER = /^[a-zA-Z_$][a-zA-Z0-9_$]*$/; + +/** + * For text inside a `//` line comment or a `/* *\/` block comment: no line + * terminators (they end a `//` comment) and no `*\/` (it ends a block comment). + */ +export function commentText(value: unknown): string { + return String(value ?? '') + .replace(/[\r\n\u2028\u2029]+/g, ' ') + .replace(/\*\//g, '*\\/'); +} + +/** An object-literal / interface property key: bare if a valid identifier, else a JSON string literal. */ +export function propertyKey(name: string): string { + return IDENTIFIER.test(name) ? name : JSON.stringify(name); +} + +/** + * A size interpolated as a number literal (`.max(${n})`, `max ${n} chars`). + * Returns `undefined` for anything that is not a positive safe integer, so a + * caller can simply omit the constraint. + */ +export function positiveInt(value: unknown): number | undefined { + const n = typeof value === 'number' ? value : typeof value === 'string' && /^\d+$/.test(value) ? Number(value) : NaN; + return Number.isSafeInteger(n) && n > 0 ? n : undefined; +} diff --git a/src/lib/providers/ministry-platform/scripts/generate-storedprocs.test.ts b/src/lib/providers/ministry-platform/scripts/generate-storedprocs.test.ts new file mode 100644 index 0000000..1a68fc2 --- /dev/null +++ b/src/lib/providers/ministry-platform/scripts/generate-storedprocs.test.ts @@ -0,0 +1,55 @@ +import { describe, it, expect, vi } from 'vitest'; + +// The script loads .env files and imports the MP client at module scope; keep +// both out of the test process. +vi.mock('dotenv', () => ({ config: vi.fn() })); +vi.mock('../helper', () => ({ MPHelper: class {} })); + +import { STORED_PROCS_DOC_PATH, generateDocument, formatParamSignature } from './generate-storedprocs'; +import type { ProcedureInfo } from '../types'; + +const procs = [ + { + Name: 'api_Foo_Get', + Parameters: [ + { Name: '@Id', DataType: 'Integer32', Size: 4, Direction: 'Input' }, + { Name: '@Name', DataType: 'String', Size: 50, Direction: 'Input' }, + { Name: '@RETURN_VALUE', DataType: 'Integer32', Size: 4, Direction: 'ReturnValue' }, + ], + }, + { Name: 'cleanup', Parameters: [] }, +] as unknown as ProcedureInfo[]; + +describe('generate-storedprocs', () => { + const now = new Date('2026-10-11T08:00:00.000Z'); + + it('defaults to the committed mp-schema catalog', () => { + expect(STORED_PROCS_DOC_PATH).toBe('.claude/references/mp-schema/stored-procs.md'); + }); + + it('emits the catalog frontmatter with last_verified', () => { + const md = generateDocument(procs, undefined, now); + expect( + md.startsWith( + '---\ntitle: Ministry Platform Stored Procedures\ndomain: mp-schema\ntype: generated-reference\n' + + 'last_verified: 2026-10-11\nsource: npm run mp:generate:storedprocs\n---\n', + ), + ).toBe(true); + expect(md).toContain('## How to call'); + expect(md).toContain('**Generated (source):** 2026-10-11T08:00:00.000Z'); + expect(md).toContain('**Procedures:** 2'); + expect(md).not.toContain('**Filter:**'); + }); + + it('groups by prefix with Other last and records a filter', () => { + const md = generateDocument(procs, 'Foo', now); + expect(md).toContain('**Filter:** "Foo"'); + expect(md.indexOf('### api_* (1)')).toBeLessThan(md.indexOf('### Other (1)')); + expect(md).toContain('- `api_Foo_Get(@Id: Integer32, @Name: String(50))`'); + expect(md).toContain('- `cleanup(no parameters)`'); + }); + + it('omits the return value from signatures', () => { + expect(formatParamSignature(procs[0].Parameters)).not.toContain('RETURN_VALUE'); + }); +}); diff --git a/src/lib/providers/ministry-platform/scripts/generate-storedprocs.ts b/src/lib/providers/ministry-platform/scripts/generate-storedprocs.ts index 4dd7772..6a86d84 100644 --- a/src/lib/providers/ministry-platform/scripts/generate-storedprocs.ts +++ b/src/lib/providers/ministry-platform/scripts/generate-storedprocs.ts @@ -22,8 +22,13 @@ envFiles.forEach(file => { import { MPHelper } from "../helper"; import type { ProcedureInfo, ParameterInfo } from "../types"; +/** The committed catalog this script refreshes (with its frontmatter). */ +export const STORED_PROCS_DOC_PATH = '.claude/references/mp-schema/stored-procs.md'; + interface CLIOptions { output: string; + /** True when -o was given; a --search run only writes when it was. */ + outputExplicit?: boolean; search?: string; help?: boolean; } @@ -31,7 +36,7 @@ interface CLIOptions { function parseArguments(): CLIOptions { const args = process.argv.slice(2); const options: CLIOptions = { - output: path.resolve(process.cwd(), '.claude/references/ministryplatform.storedprocs.md'), + output: path.resolve(process.cwd(), STORED_PROCS_DOC_PATH), }; for (let i = 0; i < args.length; i++) { @@ -41,6 +46,7 @@ function parseArguments(): CLIOptions { case "-o": case "--output": options.output = args[++i]; + options.outputExplicit = true; break; case "-s": case "--search": @@ -64,7 +70,12 @@ function parseArguments(): CLIOptions { function showHelp() { console.log(` -Usage: tsx generate-storedprocs.ts [options] +Usage: tsx --conditions=react-server src/lib/providers/ministry-platform/scripts/generate-storedprocs.ts [options] + +Run through npm (the scripts already pass the flag below), e.g. + npm run mp:generate:storedprocs -- -s "Contact" +or directly with tsx, which MUST be given --conditions=react-server: the MP +client modules import 'server-only', which throws without that condition. Prerequisites: Ensure your environment configuration contains the required Ministry Platform settings: @@ -75,14 +86,15 @@ Prerequisites: Supports .env.local, .env.development, and .env files (loaded in that order) Options: - -o, --output <path> Output file path (default: .claude/references/ministryplatform.storedprocs.md) + -o, --output <path> Output file path (default: .claude/references/mp-schema/stored-procs.md; + a -s run needs -o, so it never overwrites the full catalog) -s, --search <term> Optional search term to filter procedures -h, --help Show this help message Examples: - tsx generate-storedprocs.ts - tsx generate-storedprocs.ts -s "Contact" - tsx generate-storedprocs.ts -o ./my-procs-reference.md + npm run mp:generate:storedprocs + npm run mp:generate:storedprocs -- -s "Contact" -o ./contact-procs.md + npm run mp:generate:storedprocs -- -o ./my-procs-reference.md `); } @@ -103,7 +115,7 @@ function validateEnvironment() { } } -function groupByPrefix(procedures: ProcedureInfo[]): Map<string, ProcedureInfo[]> { +export function groupByPrefix(procedures: ProcedureInfo[]): Map<string, ProcedureInfo[]> { const groups = new Map<string, ProcedureInfo[]>(); for (const proc of procedures) { @@ -135,7 +147,7 @@ function groupByPrefix(procedures: ProcedureInfo[]): Map<string, ProcedureInfo[] return sorted; } -function formatParamSignature(params: ParameterInfo[]): string { +export function formatParamSignature(params: ParameterInfo[]): string { const inputParams = params.filter(p => p.Direction !== "ReturnValue"); if (inputParams.length === 0) return "(no parameters)"; @@ -148,12 +160,25 @@ function formatParamSignature(params: ParameterInfo[]): string { }).join(", ") + ")"; } -function generateDocument(procedures: ProcedureInfo[], search?: string): string { +export function generateDocument(procedures: ProcedureInfo[], search?: string, now: Date = new Date()): string { const groups = groupByPrefix(procedures); - let md = "# Ministry Platform Stored Procedures Reference\n\n"; - md += "This document provides a summary of stored procedures available via the Ministry Platform API for LLM assistants working on the MPNext project.\n\n"; - md += `**Generated:** ${new Date().toISOString()}\n`; + let md = "---\n"; + md += "title: Ministry Platform Stored Procedures\n"; + md += "domain: mp-schema\n"; + md += "type: generated-reference\n"; + md += `last_verified: ${now.toISOString().slice(0, 10)}\n`; + md += "source: npm run mp:generate:storedprocs\n"; + md += "---\n\n"; + md += "## Purpose\n"; + md += "Auto-generated reference of every Ministry Platform stored procedure exposed via the MP API, with its input parameters and bounded string sizes. Regenerate with `npm run mp:generate:storedprocs` (runs `src/lib/providers/ministry-platform/scripts/generate-storedprocs.ts`). Do not edit by hand — the generator overwrites this file.\n\n"; + md += "## How to call\n"; + md += "- HTTP: `POST /procs/{procName}` with JSON body `{ \"@ParamName\": value, ... }` (or GET for simple query params).\n"; + md += "- Code: use `MPHelper.executeProcedure()` / `executeProcedureWithBody()` (`src/lib/providers/ministry-platform/helper.ts`), which delegate to `ProcedureService`.\n"; + md += "- Return shape: `unknown[][]` — an array of result sets, each an array of row objects.\n"; + md += "- `@DomainID` is auto-injected by the MP API — do not pass it.\n\n"; + md += "## Generated metadata\n\n"; + md += `**Generated (source):** ${now.toISOString()}\n`; md += `**Procedures:** ${procedures.length}\n`; if (search) { md += `**Filter:** "${search}"\n`; @@ -183,6 +208,11 @@ async function main() { return; } + if (options.search && !options.outputExplicit) { + console.error(`A --search run lists only matching procedures; pass -o <path> so it does not overwrite ${STORED_PROCS_DOC_PATH}.`); + process.exit(1); + } + validateEnvironment(); console.log("Generating stored procedure reference from Ministry Platform...\n"); @@ -235,6 +265,7 @@ async function main() { } } -if (require.main === module) { +// Guarded so tests can import the pure generators without running main(). +if (typeof require !== 'undefined' && typeof module !== 'undefined' && require.main === module) { main().catch(console.error); } diff --git a/src/lib/providers/ministry-platform/scripts/generate-types.test.ts b/src/lib/providers/ministry-platform/scripts/generate-types.test.ts new file mode 100644 index 0000000..78e5c69 --- /dev/null +++ b/src/lib/providers/ministry-platform/scripts/generate-types.test.ts @@ -0,0 +1,205 @@ +import { describe, it, expect, vi } from 'vitest'; +import { z } from 'zod'; + +// The script loads .env files and imports the MP client at module scope; keep +// both out of the test process. +vi.mock('dotenv', () => ({ config: vi.fn() })); +vi.mock('../helper', () => ({ MPHelper: class {} })); + +import { + MP_DATE_PATTERN, + MP_DATETIME_PATTERN, + SCHEMA_DOC_PATH, + generateIndexFile, + generateSchemaDocument, + generateZodSchema, + mapDataTypeToZod, +} from './generate-types'; +import type { TableMetadata } from '../types'; + +type Col = Parameters<typeof mapDataTypeToZod>[0]; +const col = (DataType: Col['DataType'], IsRequired = true): Col => + ({ Name: 'X', DataType, IsRequired, Size: 0 }) as Col; + +/** Evaluate an emitted zod expression the way the generated file would. */ +function evalZod(expr: string): z.ZodType { + return new Function('z', `return ${expr};`)(z) as z.ZodType; +} + +describe('mapDataTypeToZod — DateTime/Timestamp', () => { + const accepts = [ + '2026-05-17 14:30:00', // DomainTimezoneService.toMpSqlDatetime output + '2026-05-17 14:30', + '2026-05-17T14:30:00', // what MP returns on reads + '2026-05-17T14:30:00.123', + '2026-05-17T14:30:00.1234567', + '2026-05-17T03:33:00.000Z', // ISO instant + '2026-05-17T03:33:00-04:00', + '2026-05-17 03:33:00+0530', + ]; + const rejects = [ + '2026-05-17', + '14:30:00', + '2026-05-17 14', + '2026/05/17 14:30:00', + 'not a date', + '', + '2026-05-17 14:30:00 junk', + ]; + + it.each(['DateTime', 'Timestamp'] as const)('%s emits a regex, not z.string().datetime()', (t) => { + const out = mapDataTypeToZod(col(t)); + expect(out).not.toContain('.datetime('); + expect(out).toContain('.regex('); + }); + + it.each(accepts)('accepts %j', (v) => { + expect(evalZod(mapDataTypeToZod(col('DateTime'))).safeParse(v).success).toBe(true); + }); + + it.each(rejects)('rejects %j', (v) => { + expect(evalZod(mapDataTypeToZod(col('DateTime'))).safeParse(v).success).toBe(false); + }); + + it('is nullable when not required', () => { + const schema = evalZod(mapDataTypeToZod(col('DateTime', false))); + expect(schema.safeParse(null).success).toBe(true); + expect(schema.safeParse('2026-05-17 14:30:00').success).toBe(true); + }); + + it('emits the exported pattern', () => { + expect(mapDataTypeToZod(col('DateTime'))).toContain(`/${MP_DATETIME_PATTERN}/`); + }); +}); + +describe('mapDataTypeToZod — Date', () => { + it.each(['2026-05-17', '2026-05-17 00:00:00', '2026-05-17T00:00:00', '2026-05-17T00:00:00Z'])( + 'accepts %j', + (v) => { + expect(evalZod(mapDataTypeToZod(col('Date'))).safeParse(v).success).toBe(true); + }, + ); + + it.each(['05/17/2026', '2026-5-17', '2026-05-17 junk'])('rejects %j', (v) => { + expect(evalZod(mapDataTypeToZod(col('Date'))).safeParse(v).success).toBe(false); + }); + + it('emits the exported pattern', () => { + expect(mapDataTypeToZod(col('Date'))).toContain(`/${MP_DATE_PATTERN}/`); + }); +}); + +describe('generateZodSchema', () => { + it('produces a standalone module whose datetime field accepts MP wall-clock', () => { + const table = { + Name: 'Contact_Log', + Columns: [ + { Name: 'Contact_Log_ID', DataType: 'Integer32', IsRequired: true, Size: 4 }, + { Name: 'Contact_Date', DataType: 'DateTime', IsRequired: true, Size: 8 }, + { Name: 'Notes', DataType: 'String', IsRequired: false, Size: 2000 }, + ], + } as unknown as TableMetadata; + const src = generateZodSchema(table, 'Contact_Log'); + expect(src).toContain("import { z } from 'zod';"); + expect(src).toContain('export const ContactLogSchema = z.object({'); + + const body = src.slice(src.indexOf('z.object('), src.indexOf('});') + 2); + const schema = evalZod(body); + expect( + schema.safeParse({ Contact_Log_ID: 1, Contact_Date: '2026-05-17 14:30:00', Notes: null }).success, + ).toBe(true); + expect( + schema.safeParse({ Contact_Log_ID: 1, Contact_Date: '05/17/2026', Notes: null }).success, + ).toBe(false); + }); +}); + +describe('generateIndexFile', () => { + const now = new Date('2026-10-11T12:00:00Z'); + + it('exports every type file in the directory, not only this run', () => { + // A --search run only regenerated Contacts; the others are already there. + const files = [ + 'Contacts.ts', + 'ContactsSchema.ts', + 'Households.ts', + 'HouseholdsSchema.ts', + 'Events.ts', + 'index.ts', + ]; + const out = generateIndexFile(files, now); + expect(out).toContain('export * from "./Contacts";'); + expect(out).toContain('export * from "./Households";'); + expect(out).toContain('export * from "./Events";'); + expect(out).toContain('Exports all 3 table type files'); + }); + + it('skips schema companions, the barrel, tests and non-ts files', () => { + const out = generateIndexFile(['B.ts', 'BSchema.ts', 'index.ts', 'B.test.ts', 'x.d.ts', 'README.md'], now); + expect(out.match(/export \* from/g)).toHaveLength(1); + expect(out).not.toContain('BSchema'); + }); + + it('keeps a table type whose own name ends in Schema', () => { + // Table "Foo_Schema" -> FooSchema.ts + FooSchemaSchema.ts, with no Foo.ts. + const out = generateIndexFile(['FooSchema.ts', 'FooSchemaSchema.ts'], now); + expect(out).toContain('export * from "./FooSchema";'); + expect(out).not.toContain('"./FooSchemaSchema"'); + }); + + it('is sorted and deterministic', () => { + const out = generateIndexFile(['Zeta.ts', 'Alpha.ts', 'Mid.ts'], now); + expect(out.indexOf('Alpha')).toBeLessThan(out.indexOf('Mid')); + expect(out.indexOf('Mid')).toBeLessThan(out.indexOf('Zeta')); + }); +}); + +describe('generateSchemaDocument', () => { + const now = new Date('2026-10-11T12:34:56.000Z'); + const tables = [ + { + Name: 'Contacts', + AccessLevel: 'ReadWriteAssignDelete', + Columns: [ + { Name: 'Contact_ID', DataType: 'Integer32', IsRequired: true, Size: 4, IsPrimaryKey: true }, + { + Name: 'Household_ID', + DataType: 'Integer32', + IsRequired: false, + Size: 4, + IsForeignKey: true, + ReferencedTable: 'Households', + }, + ], + }, + { Name: 'Account_Types', AccessLevel: 'Read', Columns: [] }, + ] as unknown as TableMetadata[]; + + it('targets the committed mp-schema catalog', () => { + expect(SCHEMA_DOC_PATH).toBe('.claude/references/mp-schema/tables.md'); + }); + + it('emits the catalog frontmatter with last_verified', () => { + const md = generateSchemaDocument(tables, './src/lib/providers/ministry-platform/models', now); + expect( + md.startsWith( + '---\ntitle: Ministry Platform Schema — Tables\ndomain: mp-schema\ntype: generated-reference\n' + + 'last_verified: 2026-10-11\nsource: npm run mp:generate:models\n---\n', + ), + ).toBe(true); + expect(md).toContain('## Purpose'); + expect(md).toContain('**Generated (source):** 2026-10-11T12:34:56.000Z | **Tables:** 2'); + expect(md).toContain('`src/lib/providers/ministry-platform/models/{PascalCaseTableName}.ts`'); + }); + + it('lists tables sorted with PK/FK and access abbreviations', () => { + const md = generateSchemaDocument(tables, 'models', now); + expect(md.indexOf('### Account_Types [R]')).toBeLessThan(md.indexOf('### Contacts [RWAD]')); + expect(md).toContain('PK: `Contact_ID` | FK: Household_ID→Households'); + }); + + it('normalizes Windows separators in the type-file path', () => { + const md = generateSchemaDocument(tables, 'src\\lib\\models\\', now); + expect(md).toContain('`src/lib/models/{PascalCaseTableName}.ts`'); + }); +}); diff --git a/src/lib/providers/ministry-platform/scripts/generate-types.ts b/src/lib/providers/ministry-platform/scripts/generate-types.ts index 76e9b64..74c33a5 100644 --- a/src/lib/providers/ministry-platform/scripts/generate-types.ts +++ b/src/lib/providers/ministry-platform/scripts/generate-types.ts @@ -21,6 +21,7 @@ envFiles.forEach(file => { import { MPHelper } from "../helper"; import type { TableMetadata, ParameterDataType } from "../types"; +import { commentText, positiveInt, propertyKey } from "./codegen-escape"; interface ColumnMetadata { Name: string; @@ -36,13 +37,6 @@ interface ColumnMetadata { HasDefault?: boolean; } -// API returns 'Name' but TableMetadata type defines 'Table_Name' -// Use Omit to avoid type conflict and support both -interface TableMetadataWithName extends Omit<TableMetadata, 'Table_Name'> { - Name?: string; - Table_Name?: string; -} - interface CLIOptions { outputDir: string; search?: string; @@ -105,7 +99,12 @@ function parseArguments(): CLIOptions { function showHelp() { console.log(` -Usage: tsx CLI/generate-types.ts [options] +Usage: tsx --conditions=react-server src/lib/providers/ministry-platform/scripts/generate-types.ts [options] + +Run through npm (the scripts already pass the flag below), e.g. + npm run mp:generate -- -s "Contact" +or directly with tsx, which MUST be given --conditions=react-server: the MP +client modules import 'server-only', which throws without that condition. Prerequisites: Ensure your environment configuration contains the required Ministry Platform settings: @@ -125,18 +124,13 @@ Options: -h, --help Show this help message Examples: - tsx CLI/generate-types.ts -o ./src/types/generated -s "Contact" - tsx CLI/generate-types.ts -d --sample-size 10 -o ./types - tsx CLI/generate-types.ts --zod -o ./types # Include Zod schemas + npm run mp:generate -- -o ./src/types/generated -s "Contact" + npm run mp:generate -- -d --sample-size 10 -o ./types + npm run mp:generate -- --zod -o ./types # Include Zod schemas + npm run mp:generate:models # Canonical: regenerate src/.../models `); } -function getTableName(table: TableMetadata): string | undefined { - // API returns 'Name' but types define 'Table_Name' - support both - const extendedTable = table as TableMetadataWithName; - return extendedTable.Name ?? extendedTable.Table_Name; -} - function sanitizeTypeName(name: string): string { // Convert table name to PascalCase and remove special characters let result = name @@ -153,31 +147,27 @@ function sanitizeTypeName(name: string): string { return result; } -function isValidIdentifier(name: string): boolean { - // Check if the name is a valid JavaScript identifier - // Must start with letter, underscore, or dollar sign - // Can contain letters, digits, underscores, or dollar signs - return /^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(name); -} - +// Every MP metadata value interpolated into generated source goes through +// ./codegen-escape: propertyKey() for keys, commentText() for comment text, +// positiveInt() for sizes. See that file for why. function formatFieldName(name: string): string { - // Quote field names that aren't valid JavaScript identifiers - return isValidIdentifier(name) ? name : `"${name}"`; + return propertyKey(name); } -function mapDataTypeToTypeScript(dataType: ParameterDataType, isRequired: boolean, size?: number): string { +function mapDataTypeToTypeScript(dataType: ParameterDataType, isRequired: boolean, rawSize?: unknown): string { + const size = positiveInt(rawSize); const baseType = (() => { switch (dataType) { case "String": case "Text": case "LargeString": - return size && size > 0 ? `string /* max ${size} chars */` : "string"; + return size ? `string /* max ${size} chars */` : "string"; case "Email": - return size && size > 0 ? `string /* email, max ${size} chars */` : "string /* email */"; + return size ? `string /* email, max ${size} chars */` : "string /* email */"; case "Phone": return "string /* phone number */"; case "Url": - return size && size > 0 ? `string /* URL, max ${size} chars */` : "string /* URL */"; + return size ? `string /* URL, max ${size} chars */` : "string /* URL */"; case "Integer16": return "number /* 16-bit integer */"; case "Integer32": @@ -221,22 +211,39 @@ function mapDataTypeToTypeScript(dataType: ParameterDataType, isRequired: boolea return isRequired ? baseType : `${baseType} | null`; } -function mapDataTypeToZod(col: ColumnMetadata): string { +// MP stores datetimes as wall-clock values in the domain's time zone and returns +// them without a zone marker; DomainTimezoneService.toMpSqlDatetime() emits +// `YYYY-MM-DD HH:MM:SS`. Zod's z.string().datetime() / .date() accept only ISO +// forms (and .datetime() demands T + Z/offset), so they would reject exactly the +// values CLAUDE.md rule 17 requires. These patterns accept both: space or `T` +// separator, optional seconds/fraction, optional `Z` or `±HH:MM` offset. +// Emitted inline (as regex literal source) so each generated *Schema.ts stays a +// standalone file that only imports zod. +export const MP_DATETIME_PATTERN = + String.raw`^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$`; +// Date columns: a bare date, or a date with a time part (MP returns Date columns +// as `YYYY-MM-DDT00:00:00`, and toMpSqlDatetime('YYYY-MM-DD') yields `... 00:00:00`). +export const MP_DATE_PATTERN = + String.raw`^\d{4}-\d{2}-\d{2}([ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?)?$`; + +export function mapDataTypeToZod(col: ColumnMetadata): string { let zodType = ""; - + // `.max(${size})` is executable code in the generated schema, not a comment. + const size = positiveInt(col.Size); + switch (col.DataType) { case "String": case "Text": case "LargeString": zodType = "z.string()"; - if (col.Size > 0) { - zodType += `.max(${col.Size})`; + if (size) { + zodType += `.max(${size})`; } break; case "Email": zodType = "z.string().email()"; - if (col.Size > 0) { - zodType += `.max(${col.Size})`; + if (size) { + zodType += `.max(${size})`; } break; case "Phone": @@ -244,8 +251,8 @@ function mapDataTypeToZod(col: ColumnMetadata): string { break; case "Url": zodType = "z.string().url()"; - if (col.Size > 0) { - zodType += `.max(${col.Size})`; + if (size) { + zodType += `.max(${size})`; } break; case "Integer16": @@ -263,14 +270,14 @@ function mapDataTypeToZod(col: ColumnMetadata): string { zodType = "z.boolean()"; break; case "Date": - zodType = "z.string().date()"; + zodType = `z.string().regex(/${MP_DATE_PATTERN}/, "Expected YYYY-MM-DD (optionally with a time)")`; break; case "Time": zodType = "z.string()"; // Could add time format validation break; case "DateTime": case "Timestamp": - zodType = "z.string().datetime()"; + zodType = `z.string().regex(/${MP_DATETIME_PATTERN}/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)")`; break; case "Guid": zodType = "z.string().uuid()"; @@ -286,7 +293,7 @@ function mapDataTypeToZod(col: ColumnMetadata): string { return zodType; } -function generateZodSchema(table: TableMetadata, tableName: string): string { +export function generateZodSchema(table: TableMetadata, tableName: string): string { const typeName = sanitizeTypeName(tableName); const schemaName = `${typeName}Schema`; @@ -340,7 +347,7 @@ function generateDetailedTypeDefinition( sampleRecords?: Record<string, unknown>[], tableName?: string ): string { - const name = tableName || getTableName(table) || 'Unknown'; + const name = tableName || table.Name || 'Unknown'; const interfaceName = sanitizeTypeName(name); const typeName = `${interfaceName}Record`; @@ -352,13 +359,13 @@ function generateDetailedTypeDefinition( const fields = table.Columns .filter(col => col.DataType !== "Separator") // Skip separator fields .map(col => { - const fieldType = mapDataTypeToTypeScript(col.DataType, col.IsRequired, col.Size > 0 ? col.Size : undefined); + const fieldType = mapDataTypeToTypeScript(col.DataType, col.IsRequired, col.Size); const optionalMarker = col.IsRequired ? "" : "?"; const commentParts = []; if (col.IsPrimaryKey) commentParts.push("Primary Key"); if (col.IsForeignKey && col.ReferencedTable) { - commentParts.push(`Foreign Key -> ${col.ReferencedTable}.${col.ReferencedColumn}`); + commentParts.push(`Foreign Key -> ${commentText(col.ReferencedTable)}.${commentText(col.ReferencedColumn)}`); } if (col.IsReadOnly) commentParts.push("Read Only"); if (col.IsComputed) commentParts.push("Computed"); @@ -366,8 +373,9 @@ function generateDetailedTypeDefinition( // Add JSDoc comment for additional constraints let jsDocComment = ""; - if (col.Size > 0 && ["String", "Text", "Email", "Url"].includes(col.DataType)) { - jsDocComment = `\n /**\n * Max length: ${col.Size} characters\n */`; + const maxLength = positiveInt(col.Size); + if (maxLength && ["String", "Text", "Email", "Url"].includes(col.DataType)) { + jsDocComment = `\n /**\n * Max length: ${maxLength} characters\n */`; } const inlineComment = commentParts.length > 0 ? ` // ${commentParts.join(", ")}` : ""; @@ -404,16 +412,17 @@ function generateDetailedTypeDefinition( fieldsDefinition = fields.join("\n"); } else { // Basic fallback - fieldsDefinition = ` ${name}_ID?: number; // Primary key (assuming standard naming convention) + fieldsDefinition = ` ${formatFieldName(`${name}_ID`)}?: number; // Primary key (assuming standard naming convention) [key: string]: unknown; // Allow for additional fields`; } - const permissions = table.SpecialPermissions ? `Special Permissions: ${table.SpecialPermissions}` : ""; - const accessLevel = `Access Level: ${table.AccessLevel}`; - + const permissions = table.SpecialPermissions ? `Special Permissions: ${commentText(table.SpecialPermissions)}` : ""; + const accessLevel = `Access Level: ${commentText(table.AccessLevel)}`; + const nameInComment = commentText(name); + return `/** - * Interface for ${name} -* Table: ${name} + * Interface for ${nameInComment} +* Table: ${nameInComment} * ${accessLevel} * ${permissions} * ${sampleRecords ? `Generated from ${sampleRecords.length} sample records` : "Generated from column metadata"} @@ -458,52 +467,76 @@ function generateTableDocumentation(table: TableMetadata, tableName: string): st return md; } -function generateSchemaDocument(tables: TableMetadata[], outputDir: string): string { +export const SCHEMA_DOC_PATH = '.claude/references/mp-schema/tables.md'; + +/** Forward-slash, no leading `./` — the form the committed doc uses. */ +function displayPath(p: string): string { + return p.replace(/\\/g, '/').replace(/^\.\//, '').replace(/\/$/, ''); +} + +export function generateSchemaDocument( + tables: TableMetadata[], + outputDir: string, + now: Date = new Date(), +): string { const validTables = tables .filter(table => { - const name = getTableName(table); + const name = table.Name; return name && typeof name === 'string'; }) .sort((a, b) => { - const nameA = getTableName(a) || ''; - const nameB = getTableName(b) || ''; + const nameA = a.Name || ''; + const nameB = b.Name || ''; return nameA.localeCompare(nameB); }); - let md = `# Ministry Platform Schema Reference\n\n`; - md += `**Generated:** ${new Date().toISOString()} | **Tables:** ${validTables.length}\n\n`; - md += `**Type files:** \`${outputDir}/{PascalCaseTableName}.ts\` and \`{PascalCaseTableName}Schema.ts\`\n`; + const dir = displayPath(outputDir); + + let md = `---\n`; + md += `title: Ministry Platform Schema — Tables\n`; + md += `domain: mp-schema\n`; + md += `type: generated-reference\n`; + md += `last_verified: ${now.toISOString().slice(0, 10)}\n`; + md += `source: npm run mp:generate:models\n`; + md += `---\n\n`; + md += `## Purpose\n`; + md += `Auto-generated catalog of Ministry Platform tables, primary keys, and foreign key relationships. Regenerate with \`npm run mp:generate:models\` (runs \`src/lib/providers/ministry-platform/scripts/generate-types.ts\` with \`--clean --zod\`, emitting to \`src/lib/providers/ministry-platform/models/\`). Do not edit by hand — the generator overwrites this file.\n\n`; + md += `## Generated metadata\n\n`; + md += `**Generated (source):** ${now.toISOString()} | **Tables:** ${validTables.length}\n\n`; + md += `**Type files:** \`${dir}/{PascalCaseTableName}.ts\` and \`{PascalCaseTableName}Schema.ts\`\n`; md += `**Access:** R=Read, RW=ReadWrite, RWAD=ReadWriteAssignDelete\n\n`; md += `---\n\n`; validTables.forEach(table => { - const tableName = getTableName(table)!; + const tableName = table.Name; md += generateTableDocumentation(table, tableName); }); return md; } -function generateIndexFile(tables: TableMetadata[], generatedFiles: string[]): string { - // Filter tables to only include those that were successfully generated - const validTables = tables.filter(table => { - const name = getTableName(table); - return name && - typeof name === 'string' && - generatedFiles.includes(`${sanitizeTypeName(name)}.ts`); - }); - - const exports = validTables - .map(table => { - const name = getTableName(table)!; - const typeName = sanitizeTypeName(name); - return `export * from "./${typeName}";`; - }) +/** + * Builds the models barrel from every type file present in the output + * directory — not just the tables fetched this run — so a `--search` run adds + * or refreshes its tables without dropping the rest from `index.ts`. + * Zod schema files (`FooSchema.ts` next to `Foo.ts`) are not re-exported; + * import them by path. + */ +export function generateIndexFile(fileNames: string[], now: Date = new Date()): string { + const tsFiles = new Set( + fileNames.filter(f => f.endsWith('.ts') && !f.endsWith('.d.ts') && !f.endsWith('.test.ts') && f !== 'index.ts'), + ); + const typeFiles = [...tsFiles] + .filter(f => !(f.endsWith('Schema.ts') && tsFiles.has(f.replace(/Schema\.ts$/, '.ts')))) + .sort((a, b) => a.localeCompare(b)); + + const exports = typeFiles + .map(f => `export * from "./${f.replace(/\.ts$/, '')}";`) .join("\n"); return `// Auto-generated index file -// Generated on: ${new Date().toISOString()} -// Successfully generated ${validTables.length} of ${tables.length} tables +// Generated on: ${now.toISOString()} +// Exports all ${typeFiles.length} table type files in this directory ${exports} `; @@ -556,7 +589,7 @@ async function main() { // Debug: Check for tables with invalid names const invalidTables = tables.filter(table => { - const name = getTableName(table); + const name = table.Name; return !name || typeof name !== 'string'; }); if (invalidTables.length > 0) { @@ -592,7 +625,7 @@ async function main() { for (const table of tables) { // Skip tables with invalid names - const rawName = getTableName(table); + const rawName = table.Name; if (!rawName || typeof rawName !== 'string') { console.log(` ⚠ Skipping table with invalid name:`, table); continue; @@ -648,24 +681,29 @@ async function main() { // Generate index file const indexPath = path.join(options.outputDir, "index.ts"); - const indexContent = generateIndexFile(tables, generatedFiles); + const indexContent = generateIndexFile(fs.readdirSync(options.outputDir)); fs.writeFileSync(indexPath, indexContent); console.log(` ✓ index.ts (barrel export)`); - // Generate schema documentation - console.log("\n📝 Generating schema documentation..."); - const schemaDocPath = path.resolve(process.cwd(), '.claude/references/ministryplatform.schema.md'); - const schemaDocContent = generateSchemaDocument(tables, options.outputDir); + // Generate schema documentation — only on a full run: a --search run sees + // a subset of tables and would overwrite the committed catalog with it. + if (options.search) { + console.log(`\n📝 Skipping ${SCHEMA_DOC_PATH} (partial --search run; regenerate without -s to refresh it)`); + } else { + console.log("\n📝 Generating schema documentation..."); + const schemaDocPath = path.resolve(process.cwd(), SCHEMA_DOC_PATH); + const schemaDocContent = generateSchemaDocument(tables, options.outputDir); + + const schemaDocDir = path.dirname(schemaDocPath); + if (!fs.existsSync(schemaDocDir)) { + fs.mkdirSync(schemaDocDir, { recursive: true }); + } - const schemaDocDir = path.dirname(schemaDocPath); - if (!fs.existsSync(schemaDocDir)) { - fs.mkdirSync(schemaDocDir, { recursive: true }); + fs.writeFileSync(schemaDocPath, schemaDocContent); + console.log(` ✓ ${SCHEMA_DOC_PATH}`); } - fs.writeFileSync(schemaDocPath, schemaDocContent); - console.log(` ✓ ministryplatform.schema.md`); - const zodSchemaCount = options.zodSchemas ? successfulTables : 0; console.log(`\n🎉 Successfully generated ${successfulTables} table types${zodSchemaCount > 0 ? ` + ${zodSchemaCount} Zod schemas` : ''} (${generatedFiles.length} total files) in ${options.outputDir}`); @@ -709,6 +747,7 @@ async function main() { } } -if (require.main === module) { +// Guarded so tests can import the pure generators without running main(). +if (typeof require !== 'undefined' && typeof module !== 'undefined' && require.main === module) { main().catch(console.error); } 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<TableQueryParams, '$userId'>; @@ -35,7 +35,7 @@ export class CommunicationService { return await this.client.getHttpClient().post<Communication>('/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<Communication>('/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<GlobalFilterItem[]> { + 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<FileDescription[]> { + 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<FileDescription[]>( - `/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<FileDescription[]> { + 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<FileDescription[]>( - `/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<FileDescription> { + 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<FileDescription>( - `/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<void> { + 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<void>(`/files/${fileId}`, queryParams); + await this.client.getHttpClient().delete<void>(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<Blob> { + const endpoint = `/files/${guidSegment(uniqueFileId)}`; try { const queryParams: Record<string, string> = {}; 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<FileDescription> { + const endpoint = `/files/${idSegment(fileId, 'file ID')}/metadata`; try { await this.client.ensureValidToken(); - return await this.client.getHttpClient().get<FileDescription>(`/files/${fileId}/metadata`); + return await this.client.getHttpClient().get<FileDescription>(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<FileDescription> { + const endpoint = `/files/${guidSegment(uniqueFileId)}/metadata`; try { await this.client.ensureValidToken(); - return await this.client.getHttpClient().get<FileDescription>(`/files/${uniqueFileId}/metadata`); + return await this.client.getHttpClient().get<FileDescription>(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<void>('/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<TableMetadata[]>('/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<ProcedureInfo[]>('/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<unknown[][]> { + // 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<unknown[][]>(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<string, unknown>, queryParams?: QueryParams ): Promise<unknown[][]> { + // 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<unknown[][]>(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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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: <class name> } — 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<T>(table: string, params?: TableQueryParams): Promise<T[]> { - try { - await this.client.ensureValidToken(); - - - const endpoint = `/tables/${encodeURIComponent(table)}`; - const data = await this.client.getHttpClient().get<T[]>(endpoint, params as QueryParams); - - return data; - } catch (error) { - logger.error(`Error fetching records from table ${table}:`, error); - throw error; - } + public async getTableRecords<T>(table: string, params?: TableQueryParams): Promise<T[]> { + const endpoint = tableEndpoint(table); + try { + await this.client.ensureValidToken(); + return await this.client.getHttpClient().get<T[]>(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<T extends TableRecord = TableRecord>( - table: string, - records: T[], + table: string, + records: T[], params?: Pick<TableQueryParams, '$select' | '$userId'> ): Promise<T[]> { + const endpoint = tableEndpoint(table); try { await this.client.ensureValidToken(); - - const endpoint = `/tables/${encodeURIComponent(table)}`; - const result = await this.client.getHttpClient().post<T[]>(endpoint, records as unknown as Record<string, unknown>, params); - return result; + return await this.client.getHttpClient().post<T[]>(endpoint, records as unknown as Record<string, unknown>, 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<T extends TableRecord = TableRecord>( - table: string, - records: T[], + table: string, + records: T[], params?: Pick<TableQueryParams, '$select' | '$userId' | '$allowCreate'> ): Promise<T[]> { + const endpoint = tableEndpoint(table); try { await this.client.ensureValidToken(); - - const endpoint = `/tables/${encodeURIComponent(table)}`; - const result = await this.client.getHttpClient().put<T[]>(endpoint, records as unknown as Record<string, unknown>, params); - return result; + return await this.client.getHttpClient().put<T[]>(endpoint, records as unknown as Record<string, unknown>, 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<TableQueryParams, '$select' | '$userId'> ): Promise<T[]> { + 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<T[]>( + return await this.client.getHttpClient().post<T[]>( endpoint, pattern as unknown as Record<string, unknown>, 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<TableQueryParams, '$select' | '$userId'> ): Promise<T[]> { + 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<T[]>( + return await this.client.getHttpClient().post<T[]>( endpoint, copyParams as unknown as Record<string, unknown>, 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<T extends TableRecord = TableRecord>( - table: string, - ids: number[], + table: string, + ids: number[], params?: Pick<TableQueryParams, '$select' | '$userId'> ): Promise<T[]> { + 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<T[]>(endpoint, queryParams); - return result; + const queryParams = { ...params, id: safeIds }; + return await this.client.getHttpClient().delete<T[]>(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..c0f210a 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; } @@ -262,11 +261,16 @@ export interface RequestBody { [key: string]: unknown; } +/** + * One entry from `GET /tables`. Verified against a live response (2026-10-11): + * each entry carries exactly `Name`, `AccessLevel`, `SpecialPermissions` and + * `Columns` — there is no `Table_ID`, `Table_Name` or `Display_Name`. + * `AccessLevel`/`SpecialPermissions` are left to the index signature until a + * caller needs them typed. + */ export interface TableMetadata { - Table_ID: number; - Table_Name: string; - Display_Name: string; - Description?: string; + /** The table's name, e.g. `Contacts` (the API's key is `Name`, not `Table_Name`). */ + Name: string; Columns?: ColumnMetadata[]; [key: string]: unknown; } diff --git a/src/lib/providers/ministry-platform/types/user-profile.types.ts b/src/lib/providers/ministry-platform/types/user-profile.types.ts index d1f834a..2b59f2a 100644 --- a/src/lib/providers/ministry-platform/types/user-profile.types.ts +++ b/src/lib/providers/ministry-platform/types/user-profile.types.ts @@ -1,13 +1,15 @@ +/** + * The signed-in user's profile as sent to the CLIENT (via + * `getCurrentUserProfile`). Deliberately minimal: only what the UI renders. + * + * It used to carry `User_ID`, `User_GUID`, `Contact_ID`, `Mobile_Phone`, + * `Image_GUID`, and the user's security roles and user groups. None of that + * was displayed, and roles/groups describe the domain's security setup. + * Add a field back only together with the UI that needs it. + */ export interface MPUserProfile { - User_ID: number; - User_GUID: string; - Contact_ID: number; First_Name: string; Nickname: string; Last_Name: string; Email_Address: string | null; - Mobile_Phone: string | null; - Image_GUID: string | null; - roles: string[]; - userGroups: string[]; } 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<string, string>) { + 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<typeof vi.fn>; + let errorSpy: ReturnType<typeof vi.fn>; + + 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<typeof vi.fn>; + + 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<typeof vi.fn>; + 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<T>(response: Response, label: string): Promise<T> { + 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<void>; + +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<never> { - 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<T = unknown>(endpoint: string, queryParams?: QueryParams): Promise<T> { - 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<T>('GET', endpoint, { queryParams }); } async post<T = unknown>(endpoint: string, body?: RequestBody, queryParams?: QueryParams): Promise<T> { - 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<T>('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<T = unknown>(endpoint: string, formData: FormData, queryParams?: QueryParams): Promise<T> { - 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<T>('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<T = unknown>(endpoint: string, body: RequestBody, queryParams?: QueryParams): Promise<T> { - 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<T>('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<T = unknown>(endpoint: string, formData: FormData, queryParams?: QueryParams): Promise<T> { - 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<T>('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<T = unknown>(endpoint: string, queryParams?: QueryParams): Promise<T> { - 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<T>('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<T>(method: string, endpoint: string, options: RequestOptions): Promise<T> { + 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<T>(response, `${method} ${safeEndpoint} failed`); + } + + private async send( + method: string, + url: string, + safeEndpoint: string, + token: string, + options: RequestOptions + ): Promise<Response> { + const headers: Record<string, string> = { + '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; +} diff --git a/src/lib/security-headers.test.ts b/src/lib/security-headers.test.ts index fa74d37..abc7a0c 100644 --- a/src/lib/security-headers.test.ts +++ b/src/lib/security-headers.test.ts @@ -1,5 +1,11 @@ import { describe, it, expect } from 'vitest'; -import { buildCsp, cspHeaderName, generateNonce } from './security-headers'; +import { + buildCsp, + cspHeaderName, + cspReportUri, + generateNonce, + reportingEndpointsHeader, +} from './security-headers'; /** * Security-header tests. @@ -71,7 +77,7 @@ describe('buildCsp', () => { expect(csp).toContain("frame-ancestors 'none'"); expect(csp).toContain("object-src 'none'"); expect(csp).toContain("frame-src 'none'"); - expect(csp).toContain("base-uri 'self'"); + expect(csp).toContain("base-uri 'none'"); }); it('omits upgrade-insecure-requests when report-only', () => { @@ -104,6 +110,73 @@ describe('buildCsp', () => { expect(csp).not.toContain('undefined'); expect(csp).not.toContain('null'); }); + + it.each([ + ['plain http to a real host', 'http://mp.example.org/ministryplatformapi'], + ['a data: URL (whose origin is the string "null")', 'data:,x'], + ['a javascript: URL', 'javascript:alert(1)'], + ['a credentialed URL', 'https://user:pass@mp.example.org/api'], + ])('drops %s from the policy instead of trusting it', (_label, url) => { + const csp = buildCsp({ nonce: 'n', mpBaseUrl: url, mpFileUrl: url }); + expect(csp).toContain("form-action 'self';"); + expect(csp).toContain("img-src 'self' data: blob:;"); + }); + + it('accepts http://localhost only in development', () => { + const local = 'http://localhost:4000/api'; + expect(buildCsp({ nonce: 'n', isDev: true, mpBaseUrl: local })).toContain( + "form-action 'self' http://localhost:4000", + ); + expect(buildCsp({ nonce: 'n', mpBaseUrl: local })).toContain("form-action 'self';"); + }); + + it('emits no reporting directives by default', () => { + expect(buildCsp(base)).not.toContain('report-'); + }); + + it('emits report-uri and report-to when a collector is configured', () => { + const csp = buildCsp({ ...base, reportUri: 'https://r.example.org/csp' }); + expect(csp).toContain('report-uri https://r.example.org/csp'); + expect(csp).toContain('report-to csp-endpoint'); + }); +}); + +describe('cspReportUri', () => { + it('is off when unset or blank', () => { + expect(cspReportUri(undefined)).toBeNull(); + expect(cspReportUri('')).toBeNull(); + }); + + it('accepts an https collector, keeping its path', () => { + expect(cspReportUri(' https://o1.ingest.sentry.io/api/1/security/?sentry_key=k ')).toBe( + 'https://o1.ingest.sentry.io/api/1/security/?sentry_key=k', + ); + }); + + it('refuses non-https and malformed values', () => { + expect(cspReportUri('http://reports.example.org/csp')).toBeNull(); + expect(cspReportUri('not a url')).toBeNull(); + expect(cspReportUri('http://localhost:3000/csp')).toBeNull(); + expect(cspReportUri('http://localhost:3000/csp', true)).toBe('http://localhost:3000/csp'); + }); + + it('refuses characters that would break out of the directive or header', () => { + // `new URL` percent-encodes spaces and quotes but leaves `;` and `,` alone. + expect(cspReportUri('https://r.example.org/a;script-src *')).toBeNull(); + expect(cspReportUri('https://r.example.org/a,b')).toBeNull(); + }); +}); + +describe('reportingEndpointsHeader', () => { + it('is null when reporting is off', () => { + expect(reportingEndpointsHeader(null)).toBeNull(); + }); + + it('names the same group the CSP reports to', () => { + expect(reportingEndpointsHeader('https://r.example.org/csp')).toBe( + 'csp-endpoint="https://r.example.org/csp"', + ); + }); }); describe('generateNonce', () => { diff --git a/src/lib/security-headers.ts b/src/lib/security-headers.ts index bb738a3..6749bfc 100644 --- a/src/lib/security-headers.ts +++ b/src/lib/security-headers.ts @@ -31,14 +31,65 @@ export function cspHeaderName( : "Content-Security-Policy-Report-Only"; } -/** Reduces a configured URL to a bare scheme+host origin, or null. */ -function originOf(value: string | undefined): string | null { +/** Loopback hosts allowed over plain http, and only in development. */ +const LOOPBACK_HOSTNAMES = new Set(["localhost", "127.0.0.1", "[::1]"]); + +/** + * Parses a configured URL, accepting only `https:` (or `http:` to a loopback + * host in development). Anything else — `http:` to a real host, `javascript:`, + * `data:`, a credentialed URL, garbage — is null, so a bad env value drops the + * source from the policy instead of widening it. `new URL()` alone would + * happily turn `data:,x` into the origin `"null"`, and an `http:` origin would + * let a network attacker serve the "trusted" images. + */ +function safeUrl(value: string | undefined, isDev: boolean): URL | null { if (!value) return null; + let url: URL; try { - return new URL(value).origin; + url = new URL(value.trim()); } catch { return null; } + const loopbackHttp = + isDev && url.protocol === "http:" && LOOPBACK_HOSTNAMES.has(url.hostname); + if (url.protocol !== "https:" && !loopbackHttp) return null; + if (url.username || url.password) return null; + return url; +} + +/** Reduces a configured URL to a bare scheme+host origin, or null. */ +function originOf(value: string | undefined, isDev: boolean): string | null { + return safeUrl(value, isDev)?.origin ?? null; +} + +/** The `report-to` group name, shared by the CSP and `Reporting-Endpoints`. */ +const REPORT_GROUP = "csp-endpoint"; + +/** + * The validated CSP report collector (`CSP_REPORT_URI`), or null when unset or + * not a safe URL. Reporting is OPT-IN: this repo ships no collector, so each + * deployment points it at one it owns (Sentry, report-uri.com, ...). + * + * Characters that would break out of a header value or a directive (`;`, `,`, + * `"`, whitespace) are refused rather than escaped — a legitimate collector URL + * never needs them. + */ +export function cspReportUri( + value: string | undefined = process.env.CSP_REPORT_URI, + isDev: boolean = false, +): string | null { + const url = safeUrl(value, isDev); + if (!url) return null; + const href = url.href; + return /[\s;,"]/.test(href) ? null : href; +} + +/** + * The `Reporting-Endpoints` header value that `report-to` resolves against, or + * null when reporting is off. Sent alongside the CSP by the proxy. + */ +export function reportingEndpointsHeader(reportUri: string | null): string | null { + return reportUri ? `${REPORT_GROUP}="${reportUri}"` : null; } export interface CspOptions { @@ -48,6 +99,8 @@ export interface CspOptions { reportOnly?: boolean; mpBaseUrl?: string; mpFileUrl?: string; + /** Validated collector URL (see `cspReportUri`); null/omitted = no reporting. */ + reportUri?: string | null; } /** @@ -88,9 +141,10 @@ export function buildCsp({ reportOnly = false, mpBaseUrl = process.env.MINISTRY_PLATFORM_BASE_URL, mpFileUrl = process.env.NEXT_PUBLIC_MINISTRY_PLATFORM_FILE_URL, + reportUri = null, }: CspOptions): string { - const mpOrigin = originOf(mpBaseUrl); - const fileOrigin = originOf(mpFileUrl); + const mpOrigin = originOf(mpBaseUrl, isDev); + const fileOrigin = originOf(mpFileUrl, isDev); const imgSources = ["'self'", "data:", "blob:", mpOrigin, fileOrigin].filter( (v, i, a): v is string => Boolean(v) && a.indexOf(v) === i, @@ -111,7 +165,10 @@ export function buildCsp({ `connect-src 'self'${isDev ? " ws: wss:" : ""}`, `object-src 'none'`, `frame-src 'none'`, - `base-uri 'self'`, + // 'none', not 'self': nothing here emits a <base> tag, and 'self' would + // still let an injected <base href="/x/"> silently re-root every relative + // URL on the page. + `base-uri 'none'`, `form-action ${formActions.join(" ")}`, `frame-ancestors 'none'`, ]; @@ -120,6 +177,13 @@ export function buildCsp({ directives.push("upgrade-insecure-requests"); } + // Both spellings: `report-to` (with the `Reporting-Endpoints` header) is the + // current standard; `report-uri` is what Firefox still honours, and browsers + // that support `report-to` ignore `report-uri` when both are present. + if (reportUri) { + directives.push(`report-uri ${reportUri}`, `report-to ${REPORT_GROUP}`); + } + return directives.join("; "); } diff --git a/src/lib/tool-params.server.ts b/src/lib/tool-params.server.ts index eade546..0a9a4d2 100644 --- a/src/lib/tool-params.server.ts +++ b/src/lib/tool-params.server.ts @@ -1,3 +1,4 @@ +import "server-only"; import type { PageData, ToolParams } from "./tool-params"; /** @@ -25,6 +26,20 @@ function parseIntOrUndefined(value: string | undefined): number | undefined { return Number.isFinite(parsed) ? parsed : undefined; } +/** + * `decodeURIComponent` throws `URIError` on a malformed escape (a stray `%`, + * e.g. "50% off"), which used to crash the whole tool page. Fall back to the + * raw value: it is display text, and the raw string is the best rendering of + * whatever MP sent. + */ +function safeDecode(value: string): string { + try { + return decodeURIComponent(value); + } catch { + return value; + } +} + export async function parseToolParams(searchParams: URLSearchParams | { [key: string]: string | string[] | undefined }): Promise<ToolParams> { const getValue = (key: string): string | undefined => { if (searchParams instanceof URLSearchParams) { @@ -54,9 +69,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; } @@ -70,7 +85,7 @@ export async function parseToolParams(searchParams: URLSearchParams | { [key: st q: q || undefined, v: parseIntOrUndefined(v), recordID: parseIntOrUndefined(recordID), - recordDescription: recordDescription ? decodeURIComponent(recordDescription) : undefined, + recordDescription: recordDescription ? safeDecode(recordDescription) : undefined, addl: addl || undefined, pageData: pageData, }; diff --git a/src/lib/tool-params.test.ts b/src/lib/tool-params.test.ts index d5b080c..bc117d7 100644 --- a/src/lib/tool-params.test.ts +++ b/src/lib/tool-params.test.ts @@ -145,6 +145,14 @@ describe('tool-params', () => { expect(params.recordDescription).toBe('Plain Text'); }); + it.each(['50% off', 'Smith %E0%A4%A', '%'])( + 'keeps a malformed escape (%s) raw instead of throwing', + async (raw) => { + const params = await parseToolParams({ recordDescription: raw }); + expect(params.recordDescription).toBe(raw); + }, + ); + it('returns undefined when recordDescription absent', async () => { const params = await parseToolParams({}); expect(params.recordDescription).toBeUndefined(); 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/lib/zip-guard.test.ts b/src/lib/zip-guard.test.ts new file mode 100644 index 0000000..8459680 --- /dev/null +++ b/src/lib/zip-guard.test.ts @@ -0,0 +1,130 @@ +import { describe, it, expect } from 'vitest'; +import PizZip from 'pizzip'; +import { assertZipWithinLimits, ZipLimitError } from './zip-guard'; + +const LIMITS = { maxEntries: 10, maxTotalBytes: 1024 * 1024 }; + +function makeZip(files: Record<string, string | Buffer>, compression: 'DEFLATE' | 'STORE' = 'DEFLATE'): Buffer { + const zip = new PizZip(); + for (const [name, content] of Object.entries(files)) zip.file(name, content); + return zip.generate({ type: 'nodebuffer', compression }); +} + +/** Offset of the first central-directory record (signature PK\x01\x02). */ +function centralOffset(buf: Buffer): number { + return buf.indexOf(Buffer.from([0x50, 0x4b, 0x01, 0x02])); +} + +function expectRejected(buf: Buffer, message: string, limits = LIMITS) { + let caught: unknown; + try { + assertZipWithinLimits(buf, limits); + } catch (error) { + caught = error; + } + expect(caught).toBeInstanceOf(ZipLimitError); + expect((caught as Error).message).toBe(message); +} + +describe('assertZipWithinLimits', () => { + it('accepts a small, honest deflated archive', () => { + const buf = makeZip({ 'word/document.xml': '<w:document>' + 'a'.repeat(5000) + '</w:document>', '[Content_Types].xml': '<Types/>' }); + expect(() => assertZipWithinLimits(buf, LIMITS)).not.toThrow(); + }); + + it('accepts a stored (uncompressed) archive', () => { + const buf = makeZip({ 'a.xml': 'hello' }, 'STORE'); + expect(() => assertZipWithinLimits(buf, LIMITS)).not.toThrow(); + }); + + it('accepts an archive with an empty entry', () => { + const buf = makeZip({ 'empty.xml': '', 'a.xml': 'x' }); + expect(() => assertZipWithinLimits(buf, LIMITS)).not.toThrow(); + }); + + it('rejects an archive whose declared total exceeds the cap (classic bomb)', () => { + // 2 MB of zeros deflates to a couple of KB. + const buf = makeZip({ 'bomb.xml': Buffer.alloc(2 * 1024 * 1024) }); + expect(buf.length).toBeLessThan(20_000); + expectRejected(buf, 'Template is too large when uncompressed'); + }); + + it('rejects an entry that lies about its uncompressed size', () => { + const buf = makeZip({ 'liar.xml': Buffer.alloc(512 * 1024) }); + // Patch the central directory's declared uncompressed size down to 10 bytes. + buf.writeUInt32LE(10, centralOffset(buf) + 24); + expectRejected(buf, 'Template is too large when uncompressed'); + }); + + it('rejects an entry that declares more than it actually contains', () => { + const buf = makeZip({ 'short.xml': 'a'.repeat(1000) }); + buf.writeUInt32LE(5000, centralOffset(buf) + 24); + expectRejected(buf, 'Not a valid .docx file'); + }); + + it('rejects too many entries', () => { + const files = Object.fromEntries(Array.from({ length: 11 }, (_, i) => [`f${i}.xml`, 'x'])); + expectRejected(makeZip(files), 'Template contains too many files'); + }); + + it('rejects a buffer that is not a ZIP at all', () => { + expectRejected(Buffer.from('this is not a zip file, just some text padding it out'), 'Not a valid .docx file'); + }); + + it('rejects a buffer shorter than an end-of-central-directory record', () => { + expectRejected(Buffer.from('PK'), 'Not a valid .docx file'); + }); + + it('rejects ZIP64 markers', () => { + const buf = makeZip({ 'a.xml': 'x' }); + const eocd = buf.lastIndexOf(Buffer.from([0x50, 0x4b, 0x05, 0x06])); + buf.writeUInt16LE(0xffff, eocd + 10); + expectRejected(buf, 'ZIP64 templates are not supported'); + }); + + it('rejects encrypted entries', () => { + const buf = makeZip({ 'a.xml': 'x' }); + const cd = centralOffset(buf); + buf.writeUInt16LE(buf.readUInt16LE(cd + 8) | 0x1, cd + 8); + expectRejected(buf, 'Encrypted templates are not supported'); + }); + + it('rejects unsupported compression methods', () => { + const buf = makeZip({ 'a.xml': 'x' }); + buf.writeUInt16LE(12, centralOffset(buf) + 10); // bzip2 + expectRejected(buf, 'Template uses an unsupported compression method'); + }); + + it('rejects a corrupt deflate stream', () => { + const buf = makeZip({ 'a.xml': 'a'.repeat(2000) }); + const local = 0; + const dataStart = local + 30 + buf.readUInt16LE(local + 26) + buf.readUInt16LE(local + 28); + buf.fill(0xff, dataStart, dataStart + 8); + expectRejected(buf, 'Not a valid .docx file'); + }); + + it('rejects a stored entry whose sizes disagree', () => { + const buf = makeZip({ 'a.xml': 'hello' }, 'STORE'); + buf.writeUInt32LE(4, centralOffset(buf) + 20); // compressed size + expectRejected(buf, 'Not a valid .docx file'); + }); + + it('rejects a central directory that points past the end of the buffer', () => { + const buf = makeZip({ 'a.xml': 'x' }); + const eocd = buf.lastIndexOf(Buffer.from([0x50, 0x4b, 0x05, 0x06])); + buf.writeUInt32LE(buf.length + 100, eocd + 16); + expectRejected(buf, 'Not a valid .docx file'); + }); + + it('rejects a local header offset that does not point at a local header', () => { + const buf = makeZip({ 'a.xml': 'x' }); + buf.writeUInt32LE(5, centralOffset(buf) + 42); + expectRejected(buf, 'Not a valid .docx file'); + }); + + it('rejects entry data that runs past the end of the buffer', () => { + const buf = makeZip({ 'a.xml': 'a'.repeat(100) }); + buf.writeUInt32LE(buf.length * 2, centralOffset(buf) + 20); + expectRejected(buf, 'Not a valid .docx file'); + }); +}); diff --git a/src/lib/zip-guard.ts b/src/lib/zip-guard.ts new file mode 100644 index 0000000..5efa51b --- /dev/null +++ b/src/lib/zip-guard.ts @@ -0,0 +1,132 @@ +import { inflateRawSync } from 'zlib'; + +/** + * Pre-flight check for an uploaded ZIP container (a `.docx` template) BEFORE + * it is handed to PizZip. + * + * PizZip trusts the sizes declared in the archive's own headers and inflates + * each entry fully into memory before it compares the result against them, so + * a few-hundred-KB "zip bomb" can expand to gigabytes inside the server + * process. This walks the central directory independently and: + * + * 1. caps the entry count and the SUM of declared uncompressed sizes, then + * 2. actually inflates every entry with `maxOutputLength` set to its declared + * size, so an entry that lies about its size fails at that size instead of + * expanding without bound. + * + * After this passes, PizZip's own decompression is bounded by `maxTotalBytes`. + * ZIP64 archives, encryption and compression methods other than STORE/DEFLATE + * are rejected — a Word template never needs them, and PizZip cannot read the + * last two anyway. + * + * Errors name the failure mode only, never archive content. + */ +export interface ZipLimits { + maxEntries: number; + maxTotalBytes: number; +} + +export class ZipLimitError extends Error { + constructor(message: string) { + super(message); + this.name = 'ZipLimitError'; + } +} + +const EOCD_SIGNATURE = 0x06054b50; +const CENTRAL_SIGNATURE = 0x02014b50; +const LOCAL_SIGNATURE = 0x04034b50; +const EOCD_MIN_LENGTH = 22; +const MAX_COMMENT_LENGTH = 0xffff; +const METHOD_STORE = 0; +const METHOD_DEFLATE = 8; +const FLAG_ENCRYPTED = 0x1; + +function findEndOfCentralDirectory(buf: Buffer): number { + const stop = Math.max(0, buf.length - EOCD_MIN_LENGTH - MAX_COMMENT_LENGTH); + for (let i = buf.length - EOCD_MIN_LENGTH; i >= stop; i--) { + if (buf.readUInt32LE(i) === EOCD_SIGNATURE) return i; + } + throw new ZipLimitError('Not a valid .docx file'); +} + +export function assertZipWithinLimits(buf: Buffer, limits: ZipLimits): void { + if (buf.length < EOCD_MIN_LENGTH) throw new ZipLimitError('Not a valid .docx file'); + + const eocd = findEndOfCentralDirectory(buf); + const entryCount = buf.readUInt16LE(eocd + 10); + const directoryOffset = buf.readUInt32LE(eocd + 16); + + if (entryCount === 0xffff || directoryOffset === 0xffffffff) { + throw new ZipLimitError('ZIP64 templates are not supported'); + } + if (entryCount > limits.maxEntries) { + throw new ZipLimitError('Template contains too many files'); + } + + // Pass 1: declared sizes only — cheap, and bounds pass 2. + const entries: Array<{ method: number; compressed: number; uncompressed: number; localOffset: number }> = []; + let declaredTotal = 0; + let cursor = directoryOffset; + for (let i = 0; i < entryCount; i++) { + if (cursor + 46 > buf.length || buf.readUInt32LE(cursor) !== CENTRAL_SIGNATURE) { + throw new ZipLimitError('Not a valid .docx file'); + } + const flags = buf.readUInt16LE(cursor + 8); + const method = buf.readUInt16LE(cursor + 10); + const compressed = buf.readUInt32LE(cursor + 20); + const uncompressed = buf.readUInt32LE(cursor + 24); + const nameLength = buf.readUInt16LE(cursor + 28); + const extraLength = buf.readUInt16LE(cursor + 30); + const commentLength = buf.readUInt16LE(cursor + 32); + const localOffset = buf.readUInt32LE(cursor + 42); + + if (flags & FLAG_ENCRYPTED) throw new ZipLimitError('Encrypted templates are not supported'); + if (method !== METHOD_STORE && method !== METHOD_DEFLATE) { + throw new ZipLimitError('Template uses an unsupported compression method'); + } + + declaredTotal += uncompressed; + if (declaredTotal > limits.maxTotalBytes) { + throw new ZipLimitError('Template is too large when uncompressed'); + } + + entries.push({ method, compressed, uncompressed, localOffset }); + cursor += 46 + nameLength + extraLength + commentLength; + } + + // Pass 2: prove each entry's real size matches its declared size. + for (const entry of entries) { + const local = entry.localOffset; + if (local + 30 > buf.length || buf.readUInt32LE(local) !== LOCAL_SIGNATURE) { + throw new ZipLimitError('Not a valid .docx file'); + } + const dataStart = local + 30 + buf.readUInt16LE(local + 26) + buf.readUInt16LE(local + 28); + const dataEnd = dataStart + entry.compressed; + if (dataEnd > buf.length) throw new ZipLimitError('Not a valid .docx file'); + + if (entry.method === METHOD_STORE) { + if (entry.compressed !== entry.uncompressed) throw new ZipLimitError('Not a valid .docx file'); + continue; + } + + let actual: number; + try { + // +1 so "exactly the declared size" succeeds and anything larger throws. + actual = inflateRawSync(buf.subarray(dataStart, dataEnd), { + maxOutputLength: entry.uncompressed + 1, + }).length; + } catch (error) { + // zlib throws a RangeError when output would pass maxOutputLength; + // anything else is a corrupt stream. + throw new ZipLimitError( + error instanceof RangeError ? 'Template is too large when uncompressed' : 'Not a valid .docx file', + ); + } + if (actual !== entry.uncompressed) { + throw new ZipLimitError( + actual > entry.uncompressed ? 'Template is too large when uncompressed' : 'Not a valid .docx file', + ); + } + } +} diff --git a/src/proxy.test.ts b/src/proxy.test.ts index c11efdc..1badd16 100644 --- a/src/proxy.test.ts +++ b/src/proxy.test.ts @@ -107,6 +107,24 @@ describe('proxy', () => { expect(mockNext).not.toHaveBeenCalled(); }); + it.each(['/api/authz', '/api/auth-admin', '/api/authorize'])( + 'does NOT treat %s as public just because it shares the /api/auth prefix', + async (path) => { + mockGetSessionCookie.mockReturnValueOnce(null); + + await proxy(createMockRequest(path)); + + expect(mockGetSessionCookie).toHaveBeenCalled(); + expect(mockRedirect).toHaveBeenCalled(); + }, + ); + + it('treats the bare /api/auth path as public', async () => { + await proxy(createMockRequest('/api/auth')); + + expect(mockGetSessionCookie).not.toHaveBeenCalled(); + }); + it('should allow /signin path without session check', async () => { const request = createMockRequest('/signin'); @@ -230,9 +248,35 @@ describe('proxy', () => { const pattern = config.matcher[0]; expect(pattern).toContain('_next/static'); expect(pattern).toContain('_next/image'); - expect(pattern).toContain('favicon.ico'); + expect(pattern).toContain('favicon\\.ico'); expect(pattern).toContain('assets/'); }); + + // Next compiles the matcher with path-to-regexp, which anchors it at both + // ends; a plain anchored RegExp is a faithful stand-in for the custom group. + const matches = (path: string) => + new RegExp(`^${config.matcher[0]}$`).test(path); + + it.each([ + '/_next/static/chunks/main.js', + '/_next/image', + '/favicon.ico', + '/assets/logo.png', + ])('skips the exempt path %s', (path) => { + expect(matches(path)).toBe(false); + }); + + it.each([ + '/', + '/tools/addresslabels', + '/favicon.ico-admin', + '/faviconXico', + '/_next/imagery', + '/_next/staticky', + '/assets-admin', + ])('still runs on %s, which only shares a prefix with an exempt path', (path) => { + expect(matches(path)).toBe(true); + }); }); }); @@ -292,6 +336,30 @@ describe('proxy - Content Security Policy', () => { expect(nonceOf(a)).not.toBe(nonceOf(b)); }); + it('sends no Reporting-Endpoints header when CSP_REPORT_URI is unset', async () => { + vi.stubEnv('CSP_REPORT_URI', ''); + const result = await proxy(createMockRequest('/a')); + const headers = (result as unknown as { headers: Headers }).headers; + + expect(headers.get('Reporting-Endpoints')).toBeNull(); + expect(headers.get('Content-Security-Policy')).not.toContain('report-'); + vi.unstubAllEnvs(); + }); + + it('wires CSP_REPORT_URI into the policy and the Reporting-Endpoints header', async () => { + vi.stubEnv('CSP_REPORT_URI', 'https://reports.example.org/csp'); + const result = await proxy(createMockRequest('/a')); + const headers = (result as unknown as { headers: Headers }).headers; + + expect(headers.get('Reporting-Endpoints')).toBe( + 'csp-endpoint="https://reports.example.org/csp"', + ); + expect(headers.get('Content-Security-Policy')).toContain( + 'report-uri https://reports.example.org/csp; report-to csp-endpoint', + ); + vi.unstubAllEnvs(); + }); + it('sets the CSP on redirects as well', async () => { mockGetSessionCookie.mockReturnValue(null); diff --git a/src/proxy.ts b/src/proxy.ts index cd16a5d..cc2611d 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -1,6 +1,12 @@ import { NextResponse, NextRequest } from 'next/server'; import { getSessionCookie } from 'better-auth/cookies'; -import { buildCsp, cspHeaderName, generateNonce } from '@/lib/security-headers'; +import { + buildCsp, + cspHeaderName, + cspReportUri, + generateNonce, + reportingEndpointsHeader, +} from '@/lib/security-headers'; /** * Paths reachable without a session. @@ -19,7 +25,10 @@ import { buildCsp, cspHeaderName, generateNonce } from '@/lib/security-headers'; */ function isPublicPath(pathname: string): boolean { return ( - pathname.startsWith('/api/auth') || + // Exact segment match: a bare `startsWith('/api/auth')` would also make a + // future `/api/authz` or `/api/auth-admin` route public by accident. + pathname === '/api/auth' || + pathname.startsWith('/api/auth/') || pathname === '/signin' || pathname === '/auth-error' ); @@ -32,10 +41,13 @@ export async function proxy(request: NextRequest) { const isDev = process.env.NODE_ENV !== 'production'; const headerName = cspHeaderName(); const nonce = generateNonce(); + const reportUri = cspReportUri(process.env.CSP_REPORT_URI, isDev); + const reportingEndpoints = reportingEndpointsHeader(reportUri); const csp = buildCsp({ nonce, isDev, reportOnly: headerName === 'Content-Security-Policy-Report-Only', + reportUri, }); // Forward the original URL (pathname + search) to downstream server @@ -56,6 +68,9 @@ export async function proxy(request: NextRequest) { const withCsp = (response: NextResponse) => { response.headers.set(headerName, csp); + if (reportingEndpoints) { + response.headers.set('Reporting-Endpoints', reportingEndpoints); + } return response; }; @@ -90,8 +105,16 @@ export async function proxy(request: NextRequest) { } } +/** + * Every path except Next's static chunks, the image endpoint, the favicon and + * `public/assets/`. Each exclusion is ANCHORED — to a trailing `/` for the + * directories and to end-of-path (`$`) for the two single files — so a page + * named `/favicon.ico-admin` or `/_next/imagery` cannot slip past the session + * check and the CSP by sharing a prefix with an exempt path. The `.` in + * `favicon.ico` is escaped for the same reason. + */ export const config = { matcher: [ - '/((?!_next/static|_next/image|favicon.ico|assets/).*)', + '/((?!_next/static/|_next/image$|favicon\\.ico$|assets/).*)', ], }; diff --git a/src/services/addressLabelService.ts b/src/services/addressLabelService.ts index d4d5031..b77a6dc 100644 --- a/src/services/addressLabelService.ts +++ b/src/services/addressLabelService.ts @@ -1,3 +1,4 @@ +import 'server-only'; import { MPHelper } from '@/lib/providers/ministry-platform'; import { AuthorizationService } from '@/services/authorizationService'; import { validatePositiveInt } from '@/lib/validation'; @@ -79,7 +80,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<ContactAddressRow>({ 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: <T,>(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..2664e03 100644 --- a/src/services/authorizationService.ts +++ b/src/services/authorizationService.ts @@ -1,3 +1,4 @@ +import "server-only"; import { cache } from "react"; import { headers } from "next/headers"; import { auth } from "@/lib/auth"; @@ -63,22 +64,53 @@ const loadSecurityRoles = cache(async (userId: number): Promise<string[]> => { .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 +126,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 +166,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..d652628 100644 --- a/src/services/domainTimezoneService.test.ts +++ b/src/services/domainTimezoneService.test.ts @@ -158,11 +158,14 @@ describe('DomainTimezoneService', () => { * * These are the fail-fast paths that keep a bad time zone or an unparseable MP * datetime from silently drifting to the server's local zone — the exact class - * of bug CLAUDE.md rule 16 exists to prevent, so they are pinned explicitly. + * of bug CLAUDE.md rule 17 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..7cfa6ba 100644 --- a/src/services/domainTimezoneService.ts +++ b/src/services/domainTimezoneService.ts @@ -1,3 +1,4 @@ +import 'server-only'; import { MPHelper } from '@/lib/providers/ministry-platform'; /** @@ -291,7 +292,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 +310,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..f228f2d 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 () => { @@ -814,8 +877,6 @@ describe("FamilyService", () => { * `upsertDonor` uses `donorId` to target which `Donors` row gets written — a * future caller that skips the action, or a schema change that loosens a * field, must not silently reopen that. - * - * See `.claude/TODO/2026-09-13-unvalidated-envelope-donor-ids-in-filter.md`. */ describe("FamilyService envelope/donor filter hardening", () => { beforeEach(() => { diff --git a/src/services/familyService.ts b/src/services/familyService.ts index ba24c8c..6d87663 100644 --- a/src/services/familyService.ts +++ b/src/services/familyService.ts @@ -1,6 +1,12 @@ +import "server-only"; 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 +143,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<number | null> { + 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/fieldManagementService.test.ts b/src/services/fieldManagementService.test.ts index f9886ff..b581dee 100644 --- a/src/services/fieldManagementService.test.ts +++ b/src/services/fieldManagementService.test.ts @@ -178,26 +178,47 @@ describe('FieldManagementService', () => { expect(result).toBeNull(); }); - it('should return exact match by Table_Name when multiple tables returned', async () => { - const contactsTable = { Table_ID: 1, Table_Name: 'Contacts', Display_Name: 'Contacts' }; - const otherTable = { Table_ID: 2, Table_Name: 'Contact_Log', Display_Name: 'Contact Log' }; - mockGetTables.mockResolvedValueOnce([otherTable, contactsTable]); + it('returns the exact Name match when it is not the first search result', async () => { + const contactLog = { Name: 'Contact_Log', Columns: [] }; + const contacts = { Name: 'Contacts', Columns: [] }; + const contactAttributes = { Name: 'Contact_Attributes', Columns: [] }; + mockGetTables.mockResolvedValueOnce([contactLog, contacts, contactAttributes]); const service = await FieldManagementService.getInstance(); const result = await service.getTableMetadata('Contacts'); - expect(result).toBe(contactsTable); + expect(mockGetTables).toHaveBeenCalledWith('Contacts'); + expect(result).toBe(contacts); }); - it('should return first result if no exact match found', async () => { - const firstTable = { Table_ID: 1, Table_Name: 'Contact_Log', Display_Name: 'Contact Log' }; - const secondTable = { Table_ID: 2, Table_Name: 'Contact_Attributes', Display_Name: 'Contact Attributes' }; - mockGetTables.mockResolvedValueOnce([firstTable, secondTable]); + it('returns null (no fallback to the first result) when nothing matches exactly', async () => { + mockGetTables.mockResolvedValueOnce([ + { Name: 'Contact_Log', Columns: [] }, + { Name: 'Contact_Attributes', Columns: [] }, + ]); const service = await FieldManagementService.getInstance(); const result = await service.getTableMetadata('Contacts'); - expect(result).toBe(firstTable); + expect(result).toBeNull(); + }); + + it('does not match on a legacy Table_Name key (the API returns Name)', async () => { + mockGetTables.mockResolvedValueOnce([{ Name: 'Contacts_Backup', Table_Name: 'Contacts' }]); + + const service = await FieldManagementService.getInstance(); + const result = await service.getTableMetadata('Contacts'); + + expect(result).toBeNull(); + }); + + it('matches case-sensitively', async () => { + mockGetTables.mockResolvedValueOnce([{ Name: 'Contacts', Columns: [] }]); + + const service = await FieldManagementService.getInstance(); + const result = await service.getTableMetadata('contacts'); + + expect(result).toBeNull(); }); }); diff --git a/src/services/fieldManagementService.ts b/src/services/fieldManagementService.ts index 7631212..2704036 100644 --- a/src/services/fieldManagementService.ts +++ b/src/services/fieldManagementService.ts @@ -1,3 +1,4 @@ +import "server-only"; import { MPHelper } from "@/lib/providers/ministry-platform"; import { AuthorizationService } from "@/services/authorizationService"; import type { TableMetadata } from "@/lib/providers/ministry-platform/types/provider.types"; @@ -78,10 +79,12 @@ export class FieldManagementService { }); const tables = await this.mp!.getTables(tableName); - if (tables.length === 0) return null; - - // getTables with search may return multiple matches — find the exact one - return tables.find((t) => t.Table_Name === tableName) ?? tables[0]; + // `$search` can return several tables. Take only the exact `Name` match + // (the API's key — there is no `Table_Name` on a /tables entry). No + // fallback to `tables[0]`: a near-miss would merge another table's columns + // into this page's layout. `null` is the "no metadata" case the caller + // already handles — it merges nothing and tags no separators. + return tables.find((t) => t.Name === tableName) ?? null; } private static readonly CONCURRENCY = 5; diff --git a/src/services/googlePlacesService.test.ts b/src/services/googlePlacesService.test.ts index 4fa9355..8270fc1 100644 --- a/src/services/googlePlacesService.test.ts +++ b/src/services/googlePlacesService.test.ts @@ -4,6 +4,19 @@ const { mockGetTableRecords } = vi.hoisted(() => ({ mockGetTableRecords: vi.fn(), })); +const { mockRequireSecurityRole } = vi.hoisted(() => ({ + mockRequireSecurityRole: vi.fn(async () => 42), +})); + +vi.mock("@/services/authorizationService", () => ({ + AuthorizationService: { + getInstance: () => ({ + requireSecurityRole: mockRequireSecurityRole, + }), + }, + UnauthorizedError: class UnauthorizedError extends Error {}, +})); + vi.mock("@/lib/providers/ministry-platform", () => ({ MPHelper: class { getTableRecords = mockGetTableRecords; @@ -29,6 +42,7 @@ vi.mock("@/lib/providers/google-places", () => ({ })); import { GooglePlacesService } from "./googlePlacesService"; +import { UnauthorizedError } from "@/services/authorizationService"; describe("GooglePlacesService", () => { beforeEach(() => { @@ -143,4 +157,73 @@ describe("GooglePlacesService", () => { expect(mockGetTableRecords).toHaveBeenCalledTimes(1); }); }); + + describe("authorization gate", () => { + const configGate = { table: "dp_Configuration_Settings", operation: "read" }; + const details = { + placeId: "p1", + formattedAddress: "", + addressLine1: "", + city: "", + state: "", + postalCode: "", + countryCode: "", + }; + + it("gates every public method on dp_Configuration_Settings read", async () => { + mockGetTableRecords.mockResolvedValueOnce([{ Value: "mp-key-123" }]); + mockAutocomplete.mockResolvedValue([]); + mockGetPlaceDetails.mockResolvedValue(details); + const service = await GooglePlacesService.getInstance(); + + await service.isEnabled(); + await service.autocomplete("123 Main", "t"); + await service.getPlaceDetails("p1", "t"); + + expect(mockRequireSecurityRole).toHaveBeenCalledTimes(3); + for (const call of mockRequireSecurityRole.mock.calls) { + expect(call).toEqual([configGate]); + } + }); + + it.each([ + ["isEnabled", (s: GooglePlacesService) => s.isEnabled()], + ["autocomplete", (s: GooglePlacesService) => s.autocomplete("123 Main", "t")], + ["getPlaceDetails", (s: GooglePlacesService) => s.getPlaceDetails("p1", "t")], + ])("%s: a denied gate throws and makes no MP or Google call", async (_name, call) => { + vi.stubEnv("GOOGLE_PLACES_API_KEY", "env-key-456"); + mockRequireSecurityRole.mockRejectedValueOnce(new UnauthorizedError("denied")); + const service = await GooglePlacesService.getInstance(); + + await expect(call(service)).rejects.toBeInstanceOf(UnauthorizedError); + expect(mockGetTableRecords).not.toHaveBeenCalled(); + expect(mockAutocomplete).not.toHaveBeenCalled(); + expect(mockGetPlaceDetails).not.toHaveBeenCalled(); + }); + + it.each([ + ["isEnabled", (s: GooglePlacesService) => s.isEnabled()], + ["autocomplete", (s: GooglePlacesService) => s.autocomplete("123 Main", "t")], + ["getPlaceDetails", (s: GooglePlacesService) => s.getPlaceDetails("p1", "t")], + ])( + "%s: a denied gate throws even when an authorized caller already cached the key", + async (_name, call) => { + mockGetTableRecords.mockResolvedValueOnce([{ Value: "mp-key-123" }]); + mockAutocomplete.mockResolvedValue([]); + mockGetPlaceDetails.mockResolvedValue(details); + const service = await GooglePlacesService.getInstance(); + // Authorized caller warms both the key and the provider cache. + await service.autocomplete("warm", "t0"); + mockGetTableRecords.mockClear(); + mockAutocomplete.mockClear(); + mockGetPlaceDetails.mockClear(); + + mockRequireSecurityRole.mockRejectedValueOnce(new UnauthorizedError("denied")); + await expect(call(service)).rejects.toBeInstanceOf(UnauthorizedError); + expect(mockGetTableRecords).not.toHaveBeenCalled(); + expect(mockAutocomplete).not.toHaveBeenCalled(); + expect(mockGetPlaceDetails).not.toHaveBeenCalled(); + }, + ); + }); }); diff --git a/src/services/googlePlacesService.ts b/src/services/googlePlacesService.ts index 1c3f8f1..b81f377 100644 --- a/src/services/googlePlacesService.ts +++ b/src/services/googlePlacesService.ts @@ -1,4 +1,6 @@ +import "server-only"; import { MPHelper } from "@/lib/providers/ministry-platform"; +import { AuthorizationService } from "@/services/authorizationService"; import { GooglePlacesProvider } from "@/lib/providers/google-places"; import type { PlacePrediction, PlaceDetails } from "@/lib/providers/google-places"; @@ -26,6 +28,8 @@ export class GooglePlacesService { * 3. null (feature disabled) * * Cached on the singleton so MP is queried at most once per process lifetime. + * Never call this without `requireAccess()` first: the cache is process-wide, + * so the gate must run on every call, not only on a cache miss. */ private async resolveApiKey(): Promise<string | null> { if (this.resolvedKey !== undefined) return this.resolvedKey; @@ -51,7 +55,26 @@ export class GooglePlacesService { return this.resolvedKey; } + /** + * Authorization gate for every public method (CLAUDE.md rule 12). + * + * The key comes from an MP read of `dp_Configuration_Settings`, and once + * resolved it is cached on this process-wide singleton. Gating only the + * cache-miss path would let an unauthorized caller use a key an authorized + * caller had already loaded, so each public method gates before touching + * either the MP read or the cached key/provider. It runs outside + * `resolveApiKey`'s try/catch so a refusal is never swallowed into the + * env-var fallback. + */ + private async requireAccess(): Promise<void> { + await AuthorizationService.getInstance().requireSecurityRole({ + table: "dp_Configuration_Settings", + operation: "read", + }); + } + public async isEnabled(): Promise<boolean> { + await this.requireAccess(); return (await this.resolveApiKey()) !== null; } @@ -68,11 +91,13 @@ export class GooglePlacesService { } async autocomplete(input: string, sessionToken: string): Promise<PlacePrediction[]> { + await this.requireAccess(); const provider = await this.getProvider(); return provider.autocomplete(input, sessionToken); } async getPlaceDetails(placeId: string, sessionToken: string): Promise<PlaceDetails> { + await this.requireAccess(); const provider = await this.getProvider(); return provider.getPlaceDetails(placeId, sessionToken); } 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<string, unknown> = 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..553650a 100644 --- a/src/services/groupService.ts +++ b/src/services/groupService.ts @@ -1,3 +1,4 @@ +import 'server-only'; import { MPHelper } from '@/lib/providers/ministry-platform'; import { AuthorizationService } from '@/services/authorizationService'; import { escapeFilterString, validatePositiveInt } from '@/lib/validation'; @@ -21,19 +22,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<GroupWizardFormData>, ): Promise<Record<string, unknown>> { + const source = data as Record<string, unknown>; + const picked: Record<string, unknown> = {}; + 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 +386,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.test.ts b/src/services/toolService.test.ts index 69a093a..54a3967 100644 --- a/src/services/toolService.test.ts +++ b/src/services/toolService.test.ts @@ -45,7 +45,6 @@ vi.mock('@/services/authorizationService', () => ({ describe('ToolService', () => { beforeEach(() => { vi.clearAllMocks(); - (ToolService as any).instance = undefined; }); @@ -179,12 +178,32 @@ describe('ToolService', () => { }); describe('resolveContactIds', () => { + const page = (overrides: Record<string, unknown> = {}) => [[{ + Page_ID: 7, + Display_Name: 'Page', + Singular_Name: 'Page', + Table_Name: 'Group_Participants', + Primary_Key: 'Group_Participant_ID', + Contact_ID_Field: 'Participant_ID_Table.Contact_ID', + ...overrides, + }]]; + + it('looks up the page metadata server-side from the page ID', async () => { + mockExecuteProcedureWithBody.mockResolvedValueOnce(page()); + mockGetTableRecords.mockResolvedValueOnce([]); + + const service = await ToolService.getInstance(); + await service.resolveContactIds(7, [1]); + + expect(mockExecuteProcedureWithBody).toHaveBeenCalledWith('api_Tools_GetPageData', { '@PageID': 7 }); + expect(mockRequireSecurityRole).toHaveBeenCalledWith({ table: 'Contacts', operation: 'read' }); + }); + it('should return empty records when recordIds is empty', async () => { + mockExecuteProcedureWithBody.mockResolvedValueOnce(page()); + const service = await ToolService.getInstance(); - const result = await service.resolveContactIds( - 'Group_Participants', 'Group_Participant_ID', - 'Participant_ID_Table.Contact_ID', [] - ); + const result = await service.resolveContactIds(7, []); expect(mockGetTableRecords).not.toHaveBeenCalled(); expect(result).toEqual({ @@ -195,12 +214,35 @@ describe('ToolService', () => { }); }); - it('should short-circuit when contactIdField equals primaryKey', async () => { + it.each([ + ['the page is unknown', [[]]], + ['the page has no Contact_ID_Field', page({ Contact_ID_Field: undefined })], + ['the Contact_ID_Field is blank', page({ Contact_ID_Field: ' ' })], + ['the page has no Table_Name', page({ Table_Name: '' })], + ['the page has no Primary_Key', page({ Primary_Key: '' })], + ])('returns null when %s', async (_label, pageResult) => { + mockExecuteProcedureWithBody.mockResolvedValueOnce(pageResult); + const service = await ToolService.getInstance(); - const result = await service.resolveContactIds( - 'Contacts', 'Contact_ID', 'Contact_ID', [10, 20, 30] + expect(await service.resolveContactIds(7, [1])).toBeNull(); + expect(mockGetTableRecords).not.toHaveBeenCalled(); + }); + + it.each([0, -1, 1.5, NaN])('rejects invalid page ID %s before any I/O', async (bad) => { + const service = await ToolService.getInstance(); + await expect(service.resolveContactIds(bad, [1])).rejects.toThrow('Expected positive integer for pageId'); + expect(mockRequireSecurityRole).not.toHaveBeenCalled(); + expect(mockExecuteProcedureWithBody).not.toHaveBeenCalled(); + }); + + it('should short-circuit when contactIdField equals primaryKey', async () => { + mockExecuteProcedureWithBody.mockResolvedValueOnce( + page({ Table_Name: 'Contacts', Primary_Key: 'Contact_ID', Contact_ID_Field: 'Contact_ID' }) ); + const service = await ToolService.getInstance(); + const result = await service.resolveContactIds(7, [10, 20, 30]); + expect(mockGetTableRecords).not.toHaveBeenCalled(); expect(result).toEqual({ tableName: 'Contacts', @@ -214,52 +256,77 @@ describe('ToolService', () => { }); }); + it('rejects a non-positive record ID on the short-circuit path', async () => { + mockExecuteProcedureWithBody.mockResolvedValueOnce( + page({ Table_Name: 'Contacts', Primary_Key: 'Contact_ID', Contact_ID_Field: 'Contact_ID' }) + ); + + const service = await ToolService.getInstance(); + await expect(service.resolveContactIds(7, [1, 0])).rejects.toThrow('Expected positive integer'); + }); + it('should resolve contact IDs with a direct column', async () => { + mockExecuteProcedureWithBody.mockResolvedValueOnce( + page({ Table_Name: 'Donations', Primary_Key: 'Donation_ID', Contact_ID_Field: 'Contact_ID' }) + ); mockGetTableRecords.mockResolvedValueOnce([ { Donation_ID: 1, Contact_ID: 100 }, { Donation_ID: 2, Contact_ID: 200 }, ]); const service = await ToolService.getInstance(); - const result = await service.resolveContactIds( - 'Donations', 'Donation_ID', 'Contact_ID', [1, 2] - ); + const result = await service.resolveContactIds(7, [1, 2]); expect(mockGetTableRecords).toHaveBeenCalledWith({ table: 'Donations', select: 'Donation_ID, Contact_ID', filter: 'Donation_ID IN (1,2)', }); - expect(result.records).toEqual([ + expect(result!.records).toEqual([ { recordId: 1, contactId: 100 }, { recordId: 2, contactId: 200 }, ]); }); it('should resolve contact IDs with FK traversal path', async () => { + mockExecuteProcedureWithBody.mockResolvedValueOnce(page()); mockGetTableRecords.mockResolvedValueOnce([ { Group_Participant_ID: 10, Contact_ID: 500 }, { Group_Participant_ID: 11, Contact_ID: 501 }, ]); const service = await ToolService.getInstance(); - const result = await service.resolveContactIds( - 'Group_Participants', 'Group_Participant_ID', - 'Participant_ID_Table.Contact_ID', [10, 11] - ); + const result = await service.resolveContactIds(7, [10, 11]); expect(mockGetTableRecords).toHaveBeenCalledWith({ table: 'Group_Participants', select: 'Group_Participant_ID, Participant_ID_Table.Contact_ID', filter: 'Group_Participant_ID IN (10,11)', }); - expect(result.records).toEqual([ + expect(result!.records).toEqual([ { recordId: 10, contactId: 500 }, { recordId: 11, contactId: 501 }, ]); }); + it.each([ + ['Table_Name', { Table_Name: 'Contacts; DROP TABLE Contacts' }, 'Invalid column name'], + ['Primary_Key', { Primary_Key: 'Contact_ID) OR (1=1' }, 'Invalid column name'], + ['Contact_ID_Field (extra column)', { Contact_ID_Field: 'Contact_ID, Social_Security_Number' }, 'Invalid Contact_ID_Field'], + ['Contact_ID_Field (alias)', { Contact_ID_Field: 'Donor_ID AS Contact_ID' }, 'Invalid Contact_ID_Field'], + ['Contact_ID_Field (non-FK segment)', { Contact_ID_Field: 'dp_Users.Password' }, 'Invalid Contact_ID_Field'], + ])('rejects a hostile %s from page metadata without querying', async (_label, overrides, message) => { + mockExecuteProcedureWithBody.mockResolvedValueOnce(page(overrides)); + + const service = await ToolService.getInstance(); + await expect(service.resolveContactIds(7, [1])).rejects.toThrow(message); + expect(mockGetTableRecords).not.toHaveBeenCalled(); + }); + it('should batch record IDs in groups of 100', async () => { + mockExecuteProcedureWithBody.mockResolvedValueOnce( + page({ Table_Name: 'Events', Primary_Key: 'Event_ID', Contact_ID_Field: 'Contact_ID' }) + ); const ids = Array.from({ length: 150 }, (_, i) => i + 1); mockGetTableRecords.mockResolvedValueOnce( ids.slice(0, 100).map(id => ({ Event_ID: id, Contact_ID: id + 1000 })) @@ -269,23 +336,28 @@ describe('ToolService', () => { ); const service = await ToolService.getInstance(); - const result = await service.resolveContactIds( - 'Events', 'Event_ID', 'Contact_ID', ids - ); + const result = await service.resolveContactIds(7, ids); expect(mockGetTableRecords).toHaveBeenCalledTimes(2); - expect(result.records).toHaveLength(150); - expect(result.records[0]).toEqual({ recordId: 1, contactId: 1001 }); - expect(result.records[149]).toEqual({ recordId: 150, contactId: 1150 }); + expect(result!.records).toHaveLength(150); + expect(result!.records[0]).toEqual({ recordId: 1, contactId: 1001 }); + expect(result!.records[149]).toEqual({ recordId: 150, contactId: 1150 }); }); it('should propagate errors', async () => { + mockExecuteProcedureWithBody.mockResolvedValueOnce(page()); mockGetTableRecords.mockRejectedValueOnce(new Error('Table not found')); const service = await ToolService.getInstance(); - await expect( - service.resolveContactIds('Bad_Table', 'ID', 'Contact_ID', [1]) - ).rejects.toThrow('Table not found'); + await expect(service.resolveContactIds(7, [1])).rejects.toThrow('Table not found'); + }); + + it('propagates an authorization refusal before reading page metadata', async () => { + mockRequireSecurityRole.mockRejectedValueOnce(new Error('Access denied')); + + const service = await ToolService.getInstance(); + await expect(service.resolveContactIds(7, [1])).rejects.toThrow('Access denied'); + expect(mockExecuteProcedureWithBody).not.toHaveBeenCalled(); }); }); diff --git a/src/services/toolService.ts b/src/services/toolService.ts index 6f41f86..9e1d960 100644 --- a/src/services/toolService.ts +++ b/src/services/toolService.ts @@ -1,7 +1,8 @@ +import "server-only"; 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, validateFkPath, escapeFilterString } from "@/lib/validation"; import { MP_FETCH_BATCH_SIZE } from "@/lib/constants"; export interface ContactRecord { @@ -250,7 +251,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<RoleLookup>({ @@ -309,44 +310,50 @@ export class ToolService { } /** - * Resolves record IDs to their associated Contact IDs by querying the - * specified table. Supports direct columns and FK traversal paths + * Resolves record IDs on an MP page to their associated Contact IDs. + * Supports direct columns and FK traversal paths * (e.g. `Participant_ID_Table.Contact_ID`). * + * 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 + * (`getPageData`) and validated before they are interpolated, so no caller + * can choose which table or column the service account reads. Same + * treatment as `FamilyService.resolveContactIdFromPage`. + * * Short-circuits when the contact ID field IS the primary key (e.g. Contacts table). * Batches queries in groups of 100 to avoid oversized IN clauses. * - * @param tableName - The Ministry Platform table to query - * @param primaryKey - The primary key column of the table - * @param contactIdField - Column or FK path that resolves to a Contact_ID + * @param pageId - The Ministry Platform Page ID the records belong to * @param recordIds - Array of primary key values to look up - * @returns Promise<ContactRecordResult> - Mapped record/contact ID pairs + * @returns Promise<ContactRecordResult | null> - Mapped record/contact ID + * pairs, or null when the page is unknown or has no Contact_ID_Field */ public async resolveContactIds( - tableName: string, - primaryKey: string, - contactIdField: string, + pageId: number, recordIds: number[] - ): Promise<ContactRecordResult> { + ): Promise<ContactRecordResult | null> { + validatePositiveInt(pageId, 'pageId'); await AuthorizationService.getInstance().requireSecurityRole({ table: 'Contacts', operation: 'read', }); - const envelope = { tableName, primaryKey, contactIdField }; - validateColumnName(primaryKey); - validateColumnName(tableName); - if (contactIdField.includes('.')) { - contactIdField.split('.').forEach(validateColumnName); - } else { - validateColumnName(contactIdField); + const page = await this.getPageData(pageId); + if (!page?.Table_Name || !page.Primary_Key || !page.Contact_ID_Field?.trim()) { + return null; } + const tableName = validateColumnName(page.Table_Name); + const primaryKey = validateColumnName(page.Primary_Key); + const contactIdField = validateFkPath(page.Contact_ID_Field.trim(), 'Contact_ID_Field'); + const envelope = { tableName, primaryKey, contactIdField }; + if (recordIds.length === 0) { return { ...envelope, records: [] }; } if (contactIdField === primaryKey) { + recordIds.forEach((id) => validatePositiveInt(id)); return { ...envelope, records: recordIds.map(id => ({ recordId: id, contactId: id })), @@ -361,7 +368,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<Record<string, number>>({ table: tableName, select: `${primaryKey}, ${contactIdField}`, diff --git a/src/services/userService.test.ts b/src/services/userService.test.ts index 205b1e6..4848471 100644 --- a/src/services/userService.test.ts +++ b/src/services/userService.test.ts @@ -28,48 +28,48 @@ describe('UserService', () => { }); describe('getUserProfile', () => { - it('should fetch user profile with roles and groups', async () => { - const mockProfile = { - User_ID: 1, - User_GUID: '550e8400-e29b-41d4-a716-446655440000', - Contact_ID: 100, - First_Name: 'John', - Nickname: 'Johnny', - Last_Name: 'Doe', - Email_Address: 'john@example.com', - Mobile_Phone: '555-1234', - Image_GUID: 'img-guid-456', - }; - mockGetTableRecords - .mockResolvedValueOnce([mockProfile]) - .mockResolvedValueOnce([{ Role_Name: 'Admin' }, { Role_Name: 'Editor' }]) - .mockResolvedValueOnce([{ User_Group_Name: 'Staff' }]); + const row = { + First_Name: 'John', + Nickname: 'Johnny', + Last_Name: 'Doe', + Email_Address: 'john@example.com', + }; + + it('fetches only the display fields, in a single query', async () => { + mockGetTableRecords.mockResolvedValueOnce([row]); const service = await UserService.getInstance(); const result = await service.getUserProfile('550e8400-e29b-41d4-a716-446655440000'); - expect(mockGetTableRecords).toHaveBeenCalledTimes(3); + expect(mockGetTableRecords).toHaveBeenCalledTimes(1); expect(mockGetTableRecords).toHaveBeenCalledWith({ table: 'dp_Users', filter: "User_GUID = '550e8400-e29b-41d4-a716-446655440000'", - select: expect.stringContaining('User_ID'), + select: 'Contact_ID_TABLE.First_Name,Contact_ID_TABLE.Nickname,Contact_ID_TABLE.Last_Name,Contact_ID_TABLE.Email_Address', top: 1, }); - expect(mockGetTableRecords).toHaveBeenCalledWith({ - table: 'dp_User_Roles', - filter: 'User_ID = 1', - select: 'Role_ID_TABLE.Role_Name', - }); - expect(mockGetTableRecords).toHaveBeenCalledWith({ - table: 'dp_User_User_Groups', - filter: 'User_ID = 1', - select: 'User_Group_ID_TABLE.User_Group_Name', - }); - expect(result).toEqual({ - ...mockProfile, - roles: ['Admin', 'Editor'], - userGroups: ['Staff'], - }); + expect(result).toEqual(row); + }); + + it('never passes through fields beyond the profile DTO', async () => { + mockGetTableRecords.mockResolvedValueOnce([ + { ...row, User_ID: 1, Mobile_Phone: '555-1234', roles: ['Administrators'] }, + ]); + + const service = await UserService.getInstance(); + const result = await service.getUserProfile('550e8400-e29b-41d4-a716-446655440000'); + + expect(Object.keys(result!).sort()).toEqual(['Email_Address', 'First_Name', 'Last_Name', 'Nickname']); + }); + + it('normalizes a missing email to null', async () => { + const { Email_Address: _omit, ...noEmail } = row; + mockGetTableRecords.mockResolvedValueOnce([noEmail]); + + const service = await UserService.getInstance(); + const result = await service.getUserProfile('550e8400-e29b-41d4-a716-446655440000'); + + expect(result!.Email_Address).toBeNull(); }); it('should return undefined when user not found', async () => { @@ -82,31 +82,10 @@ describe('UserService', () => { expect(mockGetTableRecords).toHaveBeenCalledTimes(1); }); - it('should return empty arrays when user has no roles or groups', async () => { - const mockProfile = { - User_ID: 2, - User_GUID: '660e8400-e29b-41d4-a716-446655440001', - Contact_ID: 200, - First_Name: 'Jane', - Nickname: 'Jane', - Last_Name: 'Smith', - Email_Address: 'jane@example.com', - Mobile_Phone: null, - Image_GUID: null, - }; - mockGetTableRecords - .mockResolvedValueOnce([mockProfile]) - .mockResolvedValueOnce([]) - .mockResolvedValueOnce([]); - + it('rejects a malformed GUID before querying', async () => { const service = await UserService.getInstance(); - const result = await service.getUserProfile('660e8400-e29b-41d4-a716-446655440001'); - - expect(result).toEqual({ - ...mockProfile, - roles: [], - userGroups: [], - }); + await expect(service.getUserProfile("x' OR 1=1 --")).rejects.toThrow(); + expect(mockGetTableRecords).not.toHaveBeenCalled(); }); it('should propagate errors from MPHelper', async () => { diff --git a/src/services/userService.ts b/src/services/userService.ts index 3c3e974..525045e 100644 --- a/src/services/userService.ts +++ b/src/services/userService.ts @@ -1,3 +1,4 @@ +import "server-only"; import { MPUserProfile } from "@/lib/providers/ministry-platform/types"; import { MPHelper } from "@/lib/providers/ministry-platform"; import { validateGuid } from "@/lib/validation"; @@ -45,47 +46,32 @@ export class UserService { } /** - * Retrieves a user profile by User GUID from Ministry Platform - * - * Fetches user information including: - * - User GUID - * - Contact details (First Name, Nickname, Last Name) - * - Email Address - * - Mobile Phone - * - Profile Image GUID - * + * Retrieves the profile shown to the signed-in user, by User GUID. + * + * Returns only the fields the UI renders (see `MPUserProfile`): names and + * email. The row is mapped explicitly so nothing else the query returns can + * reach the client. + * * @param id - The User GUID to search for - * @returns Promise<MPUserProfile> - The user profile data from Ministry Platform + * @returns Promise<MPUserProfile | undefined> - The profile, or undefined if no such user * @throws Will throw an error if the Ministry Platform query fails */ public async getUserProfile(id: string): Promise<MPUserProfile | undefined> { const records = await this.mp!.getTableRecords<MPUserProfile>({ table: "dp_Users", filter: `User_GUID = '${validateGuid(id)}'`, - select: "User_ID, User_GUID, Contact_ID_TABLE.First_Name,Contact_ID_TABLE.Nickname,Contact_ID_TABLE.Last_Name,Contact_ID_TABLE.Email_Address,Contact_ID_TABLE.Mobile_Phone,Contact_ID_TABLE.dp_fileUniqueId AS Image_GUID", + select: "Contact_ID_TABLE.First_Name,Contact_ID_TABLE.Nickname,Contact_ID_TABLE.Last_Name,Contact_ID_TABLE.Email_Address", top: 1 }); - const profile = records[0]; - if (!profile) return undefined; - - const [roleRecords, groupRecords] = await Promise.all([ - this.mp!.getTableRecords<{ Role_Name: string }>({ - table: "dp_User_Roles", - filter: `User_ID = ${profile.User_ID}`, - select: "Role_ID_TABLE.Role_Name", - }), - this.mp!.getTableRecords<{ User_Group_Name: string }>({ - table: "dp_User_User_Groups", - filter: `User_ID = ${profile.User_ID}`, - select: "User_Group_ID_TABLE.User_Group_Name", - }), - ]); + const row = records[0]; + if (!row) return undefined; return { - ...profile, - roles: roleRecords.map((r) => r.Role_Name), - userGroups: groupRecords.map((g) => g.User_Group_Name), + First_Name: row.First_Name, + Nickname: row.Nickname, + Last_Name: row.Last_Name, + Email_Address: row.Email_Address ?? null, }; } -} \ No newline at end of file +} diff --git a/src/styles/grapesjs-overrides.css b/src/styles/grapesjs-overrides.css deleted file mode 100644 index bd39c2c..0000000 --- a/src/styles/grapesjs-overrides.css +++ /dev/null @@ -1,293 +0,0 @@ -/* ========================================================== - GrapesJS Overrides — MPNext Tools Template Editor - ========================================================== */ - -/* ---- Layout & Container ---- */ - -.gjs-editor { - position: relative !important; -} - -/* Hide the built-in device switcher panel (we use our own toolbar) */ -.gjs-pn-devices-c { - display: none !important; -} - -/* Right sidebar panel — blocks, layers, styles */ -.gjs-pn-views-container { - background-color: #1e293b; - width: 240px !important; - right: 0 !important; - left: auto !important; - top: 0 !important; - padding-top: 0 !important; - border-left: 1px solid #334155; - box-shadow: none !important; - overflow-y: auto; - overflow-x: hidden; -} - -/* Canvas: fill remaining space minus the right panel. - Width is toggled via inline style from editor-toolbar.tsx */ -.gjs-cv-canvas.gjs-cui { - left: 0 !important; - transition: width 0.15s ease; -} - -/* Canvas background */ -.gjs-cv-canvas { - background-color: #e2e8f0; -} - -/* Frame wrapper — center the email canvas with padding */ -.gjs-frame-wrapper { - height: 100% !important; - padding: 16px; - box-sizing: border-box; -} - -/* The actual iframe */ -.gjs-frame { - box-shadow: 0 4px 24px rgba(0, 0, 0, 0.12); - border-radius: 4px; - background: #ffffff; -} - -/* ---- Block Manager ---- */ - -.gjs-blocks-cs { - background-color: transparent; - padding: 8px; -} - -/* Block category headers */ -.gjs-block-categories { - padding: 0; -} - -.gjs-block-category { - border-bottom: 1px solid #334155; -} - -.gjs-block-category .gjs-title { - background-color: transparent !important; - color: #94a3b8 !important; - font-size: 11px; - font-weight: 600; - text-transform: uppercase; - letter-spacing: 0.05em; - padding: 10px 8px 6px; - border: none; -} - -.gjs-block-category .gjs-caret-icon { - color: #64748b; -} - -/* Individual block tiles */ -.gjs-block { - background-color: #334155; - border: 1px solid #475569; - border-radius: 6px; - padding: 8px 4px; - margin: 3px; - min-height: auto; - width: calc(50% - 6px) !important; - color: #cbd5e1; - font-size: 11px; - transition: all 0.15s ease; - justify-content: center; -} - -.gjs-block:hover { - background-color: #475569; - border-color: #0891b2; - color: #ffffff; - box-shadow: 0 0 0 1px #0891b2; -} - -.gjs-block svg { - fill: #94a3b8; -} - -.gjs-block:hover svg { - fill: #ffffff; -} - -.gjs-block__media { - margin-bottom: 4px; -} - -/* ---- Layer Manager ---- */ - -.gjs-layers { - background-color: transparent; - padding: 8px; -} - -.gjs-layer { - background-color: #334155; - border-radius: 4px; - margin-bottom: 2px; -} - -.gjs-layer-name { - color: #cbd5e1; - font-size: 12px; -} - -.gjs-layer.gjs-selected .gjs-layer-name { - color: #ffffff; -} - -/* ---- Style Manager ---- */ - -.gjs-sm-sector { - background-color: transparent; - border-bottom: 1px solid #334155; -} - -.gjs-sm-sector-title { - background-color: transparent !important; - color: #94a3b8 !important; - font-size: 11px; - font-weight: 600; - text-transform: uppercase; - letter-spacing: 0.05em; - border: none !important; - padding: 10px 8px 6px; -} - -.gjs-sm-properties { - padding: 4px 8px; -} - -.gjs-sm-label { - color: #94a3b8; - font-size: 11px; -} - -.gjs-field { - background-color: #334155; - border: 1px solid #475569; - border-radius: 4px; - color: #e2e8f0; -} - -.gjs-field:focus-within { - border-color: #0891b2; -} - -/* ---- Traits Manager ---- */ - -.gjs-trt-traits { - background-color: transparent; - padding: 8px; -} - -.gjs-trt-trait { - color: #cbd5e1; -} - -.gjs-trt-trait .gjs-label-wrp { - color: #94a3b8; - font-size: 11px; -} - -/* ---- Canvas Interactions ---- */ - -/* Selected component highlight */ -.gjs-selected { - outline: 2px solid #0891b2 !important; - outline-offset: -2px; -} - -/* Hovered component highlight */ -.gjs-hovered { - outline: 1px dashed #06b6d4 !important; -} - -/* Badge (component name tag) */ -.gjs-badge { - background-color: #0891b2 !important; - font-size: 10px; - padding: 2px 6px; - border-radius: 3px; - font-weight: 500; -} - -/* Toolbar on selected component */ -.gjs-toolbar { - background-color: #1e293b; - border-radius: 4px; - box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3); -} - -.gjs-toolbar-item { - padding: 3px 5px; - color: #cbd5e1; - transition: color 0.15s ease; -} - -.gjs-toolbar-item:hover { - color: #ffffff; -} - -/* Component drag indicator */ -.gjs-placeholder { - border-color: #0891b2 !important; - border-width: 2px !important; -} - -/* ---- Rich Text Editor ---- */ - -.gjs-rte-toolbar { - z-index: 30 !important; - background-color: #1e293b; - border-radius: 6px; - padding: 4px; - box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); -} - -.gjs-rte-actionbar .gjs-rte-action { - color: #cbd5e1; - border-color: #475569; -} - -.gjs-rte-actionbar .gjs-rte-action:hover { - color: #ffffff; -} - -.gjs-rte-actionbar .gjs-rte-active { - color: #0891b2; -} - -/* ---- Modal/Dialog z-index ---- */ - -.gjs-mdl-dialog { - z-index: 40 !important; -} - -/* Ensure Radix portals render above GrapesJS */ -[data-radix-popper-content-wrapper] { - z-index: 50 !important; -} - -/* ---- Scrollbar styling for right panel ---- */ - -.gjs-pn-views-container::-webkit-scrollbar { - width: 6px; -} - -.gjs-pn-views-container::-webkit-scrollbar-track { - background: transparent; -} - -.gjs-pn-views-container::-webkit-scrollbar-thumb { - background: #475569; - border-radius: 3px; -} - -.gjs-pn-views-container::-webkit-scrollbar-thumb:hover { - background: #64748b; -} diff --git a/src/test-setup.ts b/src/test-setup.ts index 06753d4..59d03bd 100644 --- a/src/test-setup.ts +++ b/src/test-setup.ts @@ -15,10 +15,14 @@ if (typeof globalThis.ResizeObserver === 'undefined') { // Mock environment variables for tests vi.stubEnv('MINISTRY_PLATFORM_BASE_URL', 'https://test-mp.example.com'); +// One MP client serves both the service account and user sign-in (see +// getMpClient in src/lib/env.ts). `test-client-id` is the id_token audience +// the mock OIDC providers sign for (MOCK_CLIENT_ID in src/test-utils/mock-oidc.ts). 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..55be648 --- /dev/null +++ b/src/test-utils/mock-oidc.ts @@ -0,0 +1,323 @@ +/** + * 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 + * (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. 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()); + * + * `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, type KeyObject } 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 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<string, unknown>; + /** Sign the next id_tokens with a key the JWKS does NOT publish. */ + foreignKey: boolean; + /** Fields merged over the userinfo response. */ + userinfo: Record<string, unknown>; +} + +export interface RecordedCall { + url: string; + method: string; + body: string; +} + +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, unknown>): string; + 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' }, + }); + +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<MockEndpointMode, 'ok'>, 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<Response>((_, reject) => { + signal.addEventListener('abort', () => reject(signal.reason), { once: true }); + }); +} + +export function installMockOidc(): MockOidc { + 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(overrides: Record<string, unknown> = {}): string { + const now = Math.floor(Date.now() / 1000); + // 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<string, unknown>; + 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() }); + + 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<string, unknown> = { + issuer: MOCK_ISSUER, + authorization_endpoint: `${MOCK_ISSUER}/connect/authorize`, + token_endpoint: `${MOCK_ISSUER}/connect/token`, + userinfo_endpoint: MOCK_USERINFO_URL, + end_session_endpoint: `${MOCK_ISSUER}/connect/endsession`, + 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_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') { + 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_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}`); + }) as typeof fetch; + + return mock; +} + +/** A minimal browser cookie jar: applies Set-Cookie (including deletions). */ +export class CookieJar { + readonly cookies = new Map<string, string>(); + + 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<Response>; +} + +/** `POST /sign-in/social` as the sign-in page sends it. */ +export function postSignInSocial( + instance: AuthLike, + origin: string, + body: Record<string, unknown>, +): Promise<Response> { + return instance.handler( + new Request(`${origin}/api/auth/sign-in/social`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Origin: origin }, + 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<string, string>, + jar: CookieJar, +): Promise<Response> { + 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 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')) }; +} + +/** `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<string, unknown>; session: Record<string, unknown> } | 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<string, unknown>; session: Record<string, unknown> } | 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..8a180c1 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 @@ -66,6 +67,11 @@ export default defineConfig({ // `import.meta.dirname`, not `__dirname`: this file is ESM (.mts), and // Vite's native config loader has no CommonJS globals. '@': path.resolve(import.meta.dirname, './src'), + // `server-only` throws unless resolved under the `react-server` export + // condition, which Next applies to server code and Vitest does not. Map + // it to the package's own no-op entry so modules marked server-only can + // still be unit tested. The guard itself is exercised by `next build`. + 'server-only': path.resolve(import.meta.dirname, './node_modules/server-only/empty.js'), }, }, });