diff --git a/.claude/references/GLOSSARY.md b/.claude/references/GLOSSARY.md index 093a7ba..1529cc6 100644 --- a/.claude/references/GLOSSARY.md +++ b/.claude/references/GLOSSARY.md @@ -127,8 +127,8 @@ Hand-written TypeScript interface/type shaping data between layers (e.g. `LabelD 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` (`handleSignOut`) -**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) diff --git a/.claude/references/GOTCHAS.md b/.claude/references/GOTCHAS.md index ea7da8f..4b48070 100644 --- a/.claude/references/GOTCHAS.md +++ b/.claude/references/GOTCHAS.md @@ -112,7 +112,7 @@ Known pitfalls for agents working on MPNext-Tools. Each entry has a symptom-firs **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:** Fixed — `handleSignOut` reads the origin through `getAuthBaseUrl()`, which throws when unset; there is no localhost fallback. -**Enforced where:** `src/components/user-menu/actions.ts` (`getAuthBaseUrl()` / `getMpBaseUrl()`), `src/lib/env.ts`. +**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) @@ -120,7 +120,7 @@ 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 diff --git a/.claude/references/auth/README.md b/.claude/references/auth/README.md index 0a39fdf..58a44c5 100644 --- a/.claude/references/auth/README.md +++ b/.claude/references/auth/README.md @@ -25,7 +25,7 @@ Better Auth (`1.7.x`) wired to Ministry Platform OIDC via the `genericOAuth` plu | `src/app/signin/page.tsx` | Sign-in page — auto-redirects via `authClient.signIn.social({ provider: "ministryplatform" })` | | `src/proxy.ts` | Route protection (session-cookie presence check + `x-pathname` forwarding) | | `src/components/layout/auth-wrapper.tsx` | Server-component guard — redirects to `/signin` with `callbackUrl` | -| `src/components/user-menu/actions.ts` | `handleSignOut()` — clears the session, then `endsession` with `client_id` (+ `id_token_hint` when this process has it) | +| `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` | Config pins, `customSession`, `mapProfileToUser` | @@ -35,7 +35,7 @@ Better Auth (`1.7.x`) wired to Ministry Platform OIDC via the `genericOAuth` plu | `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 657d4a5..26b6bd8 100644 --- a/.claude/references/auth/oauth-flow.md +++ b/.claude/references/auth/oauth-flow.md @@ -2,7 +2,7 @@ title: OAuth Flow (genericOAuth, lazy id_token verification, OIDC endsession) domain: auth type: reference -applies_to: [src/lib/auth.ts, src/lib/env.ts, src/app/signin/page.tsx, src/app/api/auth/[...all]/route.ts, src/components/user-menu/actions.ts, src/components/user-menu/user-menu.tsx] +applies_to: [src/lib/auth.ts, src/lib/env.ts, src/app/signin/page.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 @@ -17,8 +17,8 @@ id_token verification, profile mapping, and RP-initiated logout via `endsession` - `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/user-menu/actions.ts` — `handleSignOut()` server action -- Tests: `src/auth.oidc-discovery.test.ts` (outages, lazy discovery), `src/auth.user-info.test.ts` (`getUserInfo` with signed tokens), `src/auth.id-token-sign-in.test.ts` (F12), `src/auth.shared-instance.test.ts`, `src/auth.test.ts` (config pins), `src/components/user-menu/actions.test.ts`; mock MP in `src/test-utils/mock-oidc.ts` +- `src/components/shared-actions/sign-out.ts` — `handleSignOut()` server action (the only caller is the `/session-error` form) +- 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 - **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. @@ -65,7 +65,7 @@ the outage lands on `/auth-error?error=unable_to_get_user_info` (retry copy), and the next sign-in recovers on the same process. `PROVIDER_NOT_FOUND` / `oauth_provider_not_found` now means misconfiguration only. -## Sign-out flow (`src/components/user-menu/actions.ts`) +## 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 @@ -76,9 +76,10 @@ and the next sign-in recovers on the same process. `PROVIDER_NOT_FOUND` / `${MP}/oauth/connect/endsession?post_logout_redirect_uri=&client_id=[&id_token_hint=…]` — `post_logout_redirect_uri` exactly as registered (better-auth would add a trailing `/` and drop `client_id` when it has a hint). -4. `redirect()` — a `NEXT_REDIRECT` throw. `user-menu.tsx` wraps the call in - try/catch with `unstable_rethrow(err)` **first**, then alerts on a genuine - failure (it used to fail silently). +4. `redirect()` — a `NEXT_REDIRECT` throw. Invoke it as a `
` (as + `/session-error` does) 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 diff --git a/.claude/references/auth/user-identity.md b/.claude/references/auth/user-identity.md index 32469d4..597a929 100644 --- a/.claude/references/auth/user-identity.md +++ b/.claude/references/auth/user-identity.md @@ -53,8 +53,8 @@ user: { > `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 +> gets a `userGuid`. Result: `session.user.userGuid` is `undefined` → every +> tool redirects to `/session-error`, and MP profile lookups break. 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. > Guarded by `src/auth.test.ts` (runs the real better-auth parser against the @@ -76,7 +76,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`. diff --git a/.claude/references/components/README.md b/.claude/references/components/README.md index 9a9b7d9..19483da 100644 --- a/.claude/references/components/README.md +++ b/.claude/references/components/README.md @@ -17,7 +17,6 @@ Reference docs for `src/components/` — the tool layout framework, layout wrapp | `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 | | `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 | @@ -25,12 +24,11 @@ Reference docs for `src/components/` — the tool layout framework, layout wrapp | `src/components/tool/` | `ToolContainer`, `ToolHeader`, `ToolFooter` — 4 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/shared-actions/` | Cross-feature server actions — `user.ts`, `domain.ts`, `sign-out.ts` + tests (+ 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/user-menu/` | User menu — 4 files | ## Conventions (domain-wide) - File names: `kebab-case.tsx` (e.g., `tool-container.tsx`) diff --git a/.claude/references/components/layout.md b/.claude/references/components/layout.md index 8618984..cd82a58 100644 --- a/.claude/references/components/layout.md +++ b/.claude/references/components/layout.md @@ -8,6 +8,7 @@ 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/README.md symbols: [AuthWrapper, getCurrentUserProfile] related: @@ -19,7 +20,7 @@ last_verified: 2026-09-13 --- ## 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 +28,7 @@ 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) - `src/components/shared-actions/README.md` — when-to-shard-vs-colocate guidelines ## Key concepts diff --git a/.claude/references/components/user-menu.md b/.claude/references/components/user-menu.md deleted file mode 100644 index 149234e..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 `{children}`. -- `userProfile` prop is typed `MPUserProfile` (`src/lib/providers/ministry-platform/types/user-profile.types.ts`) — display-only DTO (`First_Name`, `Nickname`, `Last_Name`, `Email_Address`), typically supplied by `UserProvider` / `useUser()`. Every field it carries is rendered here. -- 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; -``` - -- `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 ( - - - - ); -} -``` - -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: }` | -| `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/data-flow/call-graphs.md b/.claude/references/data-flow/call-graphs.md index 8a17b28..fd57a7a 100644 --- a/.claude/references/data-flow/call-graphs.md +++ b/.claude/references/data-flow/call-graphs.md @@ -188,32 +188,29 @@ last_verified: 2026-04-17 ## 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 the "Sign out" form on `/session-error` — the only sign-out control in the app (the dropdown `UserMenu` was removed 2026-10-10). +**Entry point:** `src/app/session-error/page.tsx` — ``. **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` — `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=&client_id=[&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=`. -- **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 diff --git a/.claude/references/data-flow/error-catalog.md b/.claude/references/data-flow/error-catalog.md index d42b817..9cc820a 100644 --- a/.claude/references/data-flow/error-catalog.md +++ b/.claude/references/data-flow/error-catalog.md @@ -18,7 +18,7 @@ Every error thrown in `src/**` categorized by origin layer. Useful for debugging | 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 | +| `Error('MINISTRY_PLATFORM_BASE_URL is not configured')` | `src/components/shared-actions/sign-out.ts` (now thrown by `src/lib/env.ts` readers) | 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")` | diff --git a/.claude/references/mp-provider/type-generation.md b/.claude/references/mp-provider/type-generation.md index 05cdccb..27a59e1 100644 --- a/.claude/references/mp-provider/type-generation.md +++ b/.claude/references/mp-provider/type-generation.md @@ -30,8 +30,9 @@ last_verified: 2026-04-17 ## 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 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 @@ -148,10 +149,10 @@ Non-required columns chain `.nullable()`. 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 --conditions=react-server src/lib/providers/ministry-platform/scripts/generate-types.ts --help @@ -161,7 +162,7 @@ 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 diff --git a/.claude/references/security/README.md b/.claude/references/security/README.md index 1ae25e1..5eebce0 100644 --- a/.claude/references/security/README.md +++ b/.claude/references/security/README.md @@ -23,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. **There is no sign-out control on +`/no-access`** — the only one in the app is the `/session-error` form (the +dropdown `UserMenu` was removed 2026-10-10). Such a session ends at the 12 h +hard lifetime or when the browser drops the cookie. ### Why authentication is not enough @@ -254,8 +256,9 @@ 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 `