Story-1351 pramaan-admin pramaan-docs ADR-123 (D2)

A firm admin can no longer be a PRAMAAN employee

This story is the admin front door for the pramaan-docs ADR-123 internal-domain guard (D2): firm-invite paths reject internal-domain emails — default pramaan.io — before any Clerk organization or invitation is created. The pramaan-functions Story-1347 webhook reversal stays the backstop; this is the loud, early check.

What changed

One shared guard plus the two firm-invite choke points. The opposite-intent staff path (src/lib/actions/invite-staff.ts) is deliberately left untouched — platform staff must keep using pramaan.io.

Shared guard

src/lib/clerk/internal-domain.ts resolves the internal-domain list from the INTERNAL_EMAIL_DOMAINS setting (default pramaan.io) and exposes isInternalEmailDomain(), matching a domain exactly or as a subdomain.

Provisioning front door

src/lib/actions/clerk-firm-provisioning.ts rejects an internal-domain first-admin email with reason internal_domain_rejected and a clear message, returning before createOrganization — so no org and no invitation are created.

Resend front door

src/lib/actions/resend-invite.ts refuses to re-issue a firm-admin invite to an internal-domain email (catching rows that predate the guard) without revoking or recreating; the UI surfaces a clear message.

Operational behavior

Creating a firm whose initial-admin email is on pramaan.io (or any configured internal domain, or a subdomain of one) now fails at the form with "A firm admin cannot use a PRAMAAN internal email domain. Use the firm's own email domain." No Clerk organization is provisioned and no org invitation is sent. The same check guards the resend-invite action. The list is configurable via INTERNAL_EMAIL_DOMAINS (comma-separated) so future internal domains/aliases are covered without a code change.

Staff invitations are unaffected: invite-staff.ts still requires a pramaan.io address, because platform staff are operators of the tool, never firm members (pramaan-docs ADR-123 D1).

Verification proof

Unit coverage: tests/unit/lib/internal-domain.test.ts (default, blank-fallback, configured list, case/whitespace, subdomain, malformed input), tests/unit/clerk-firm-provisioning-action.test.ts (internal email rejected before any Clerk call; configured allowlist honored; firm provisions on its own domain), and tests/unit/resend-invite-internal-domain.test.ts (internal-domain resend rejected without revoke/recreate; external domain re-issues normally).

Gates green: pnpm exec tsc --noEmit, pnpm run lint (0 errors), pnpm run test (531 passed incl. the new cases), and pnpm run build all pass.

No new dependency: the guard uses only standard string handling and process.env.