diff --git a/.cspell/custom-words.txt b/.cspell/custom-words.txt index 479e492de98..3ecb14bfe5c 100644 --- a/.cspell/custom-words.txt +++ b/.cspell/custom-words.txt @@ -12,4 +12,5 @@ merch prefs uncompromised myapp -Neue \ No newline at end of file +Neue +user_01KBT4BMFF7ASGRDD0WZ6W63FF \ No newline at end of file diff --git a/.cspell/third-party.txt b/.cspell/third-party.txt index 7a2477187bb..18145e92201 100644 --- a/.cspell/third-party.txt +++ b/.cspell/third-party.txt @@ -33,4 +33,5 @@ giget segoe conar nuxtzzle -Deel \ No newline at end of file +Deel +WorkOS \ No newline at end of file diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8ba880d4610..50fba64a1ac 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -163,7 +163,7 @@ jobs: env: TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }} TURBO_TEAM: ${{ vars.TURBO_TEAM || github.repository_owner }} - run: pnpm test + run: pnpm coverage - name: Stop Docker Containers run: docker compose down diff --git a/ORGANIZATION_PLUGIN_RBAC_RESEARCH.md b/ORGANIZATION_PLUGIN_RBAC_RESEARCH.md new file mode 100644 index 00000000000..4883b3bf9cb --- /dev/null +++ b/ORGANIZATION_PLUGIN_RBAC_RESEARCH.md @@ -0,0 +1,796 @@ +# Better Auth Organization Plugin - RBAC Research Document + +## Executive Summary + +The organization plugin implements a flexible Role-Based Access Control (RBAC) system. Currently: +- **Resources (statements)** are STATIC and defined at compile-time via `createAccessControl(statements)` +- **Roles** can be DYNAMIC when `dynamicAccessControl.enabled = true` +- **Permissions** (actions within resources) are STATIC and defined in the statements + +Your goal is to make **resources and permissions also customizable per organization**, similar to how roles are currently customizable. + +--- + +## 1. Core Access Control System + +### 1.1 Location +- Core implementation: `packages/better-auth/src/plugins/access/` + - `access.ts` - Main logic + - `types.ts` - Type definitions + - `index.ts` - Exports + +### 1.2 Key Concepts + +#### **Statements** (Resources + Permissions) +A statement defines what resources exist and what actions (permissions) are available for each resource: + +```typescript +const statements = { + organization: ["update", "delete"], // Resource: organization, Permissions: update, delete + member: ["create", "update", "delete"], // Resource: member, Permissions: create, update, delete + invitation: ["create", "cancel"], // Resource: invitation, Permissions: create, cancel + team: ["create", "update", "delete"], // Resource: team, Permissions: create, update, delete + ac: ["create", "read", "update", "delete"], // Resource: ac (access control), Permissions: CRUD +} as const; +``` + +**Important**: These statements are STATIC and defined once at the application level. + +#### **Access Control Instance** +Created via `createAccessControl(statements)`: +- Returns an object with `newRole()` method +- Stores the statements as `statements` property +- The statements define the "universe" of possible resources and permissions + +```typescript +export function createAccessControl(s: TStatements) { + return { + newRole(statements: Subset) { + return role>(statements); + }, + statements: s, + }; +} +``` + +#### **Roles** +A role is a subset of the available statements, specifying which permissions a role has: + +```typescript +const adminRole = ac.newRole({ + organization: ["update"], // Can only update, not delete + member: ["create", "update", "delete"], // Full CRUD on members + invitation: ["create", "cancel"], // Full access to invitations + team: ["create", "update", "delete"], // Full CRUD on teams + ac: ["create", "read", "update", "delete"], // Full CRUD on access control +}); +``` + +#### **Authorization** +The `authorize()` method checks if a role has specific permissions: + +```typescript +role.authorize({ + member: ["create"], // Check if role can create members + organization: ["update"] // AND can update organization +}); // Returns { success: true/false, error?: string } +``` + +**How it works:** +1. For each requested resource, check if the role has that resource +2. For each requested permission in that resource, check if it's in the role's allowed permissions +3. Uses "AND" logic by default (all permissions must be present) +4. Can use "OR" logic for flexible checks + +--- + +## 2. Organization Plugin Architecture + +### 2.1 File Structure +``` +packages/better-auth/src/plugins/organization/ +├── index.ts # Main export +├── organization.ts # Plugin definition, ~1255 lines +├── types.ts # OrganizationOptions interface +├── schema.ts # Database schemas, zod validators +├── has-permission.ts # Permission checking logic +├── permission.ts # Permission utilities +├── adapter.ts # Database adapter wrapper +├── call.ts # Middleware definitions +├── access/ +│ ├── index.ts # Re-exports +│ └── statement.ts # Default statements and roles +└── routes/ + ├── crud-access-control.ts # Dynamic role CRUD endpoints (~1227 lines) + ├── crud-org.ts # Organization CRUD + ├── crud-members.ts # Member CRUD + ├── crud-invites.ts # Invitation CRUD + └── crud-team.ts # Team CRUD +``` + +### 2.2 Default Configuration + +#### Default Statements (Resources + Permissions) +From `access/statement.ts`: + +```typescript +export const defaultStatements = { + organization: ["update", "delete"], + member: ["create", "update", "delete"], + invitation: ["create", "cancel"], + team: ["create", "update", "delete"], + ac: ["create", "read", "update", "delete"], // Access Control management +} as const; +``` + +#### Default Roles +```typescript +export const adminAc = defaultAc.newRole({ + organization: ["update"], + invitation: ["create", "cancel"], + member: ["create", "update", "delete"], + team: ["create", "update", "delete"], + ac: ["create", "read", "update", "delete"], +}); + +export const ownerAc = defaultAc.newRole({ + organization: ["update", "delete"], + member: ["create", "update", "delete"], + invitation: ["create", "cancel"], + team: ["create", "update", "delete"], + ac: ["create", "read", "update", "delete"], +}); + +export const memberAc = defaultAc.newRole({ + organization: [], + member: [], + invitation: [], + team: [], + ac: ["read"], // Can only read roles +}); +``` + +--- + +## 3. Current Dynamic Access Control Implementation + +### 3.1 What's Dynamic Now? + +When `dynamicAccessControl.enabled = true`: +- **Roles become customizable per organization** +- New database table: `organizationRole` +- CRUD endpoints are added: `createOrgRole`, `deleteOrgRole`, `updateOrgRole`, `listOrgRoles`, `getOrgRole` + +### 3.2 Database Schema - OrganizationRole Table + +```typescript +organizationRole: { + id: string; + organizationId: string; // Foreign key to organization + role: string; // Role name (e.g., "developer", "qa") + permission: string; // JSON stringified: Record + createdAt: Date; + updatedAt?: Date; +} +``` + +**Important**: The `permission` field is a JSON string storing the role's permissions: +```json +{ + "organization": ["update"], + "member": ["create", "update"], + "team": ["create"] +} +``` + +### 3.3 Configuration + +```typescript +interface OrganizationOptions { + // ... other options + + ac?: AccessControl | undefined; // Required for dynamic AC + + dynamicAccessControl?: { + enabled?: boolean; + maximumRolesPerOrganization?: number | ((orgId: string) => Promise | number); + }; + + roles?: { + [key: string]?: Role; // Pre-defined roles (static) + }; +} +``` + +**Key point**: You MUST provide an `ac` instance when enabling dynamic access control. This `ac` instance defines the **available resources and permissions** that dynamic roles can use. + +### 3.4 How Dynamic Roles Work + +#### Creating a Role +From `routes/crud-access-control.ts`: + +1. **Endpoint**: `POST /organization/create-role` +2. **Body**: + ```typescript + { + organizationId?: string; + role: string; + permission: Record; + additionalFields?: any; + } + ``` + +3. **Validation Process**: + - Check if `ac` instance exists (required) + - Get organization ID from body or active session + - Verify user is a member of the organization + - Check if user has `ac: ["create"]` permission + - Check if role name conflicts with pre-defined roles + - Check if organization has hit max roles limit + - **Validate resources**: Check if all resources in `permission` exist in `ac.statements` + - **Validate permissions**: Check if user has all the permissions they're trying to grant + - Check if role name already exists in DB + +4. **Key Validation Function** - `checkForInvalidResources`: + ```typescript + const validResources = Object.keys(ac.statements); // e.g., ["organization", "member", "invitation", "team", "ac"] + const providedResources = Object.keys(permission); // e.g., ["organization", "member"] + const hasInvalidResource = providedResources.some(r => !validResources.includes(r)); + if (hasInvalidResource) { + throw new APIError("BAD_REQUEST", { message: "INVALID_RESOURCE" }); + } + ``` + + **This is where resources are currently restricted!** + +5. **Permission Delegation Check** - `checkIfMemberHasPermission`: + ```typescript + // For each permission in the new role, check if the creating user has it + for (const [resource, permissions] of Object.entries(permission)) { + for (const perm of permissions) { + const hasIt = await hasPermission({ + options, + organizationId, + permissions: { [resource]: [perm] }, + role: member.role, + }, ctx); + + if (!hasIt) { + throw new APIError("FORBIDDEN", { + message: "Missing permissions to create role with those permissions", + missingPermissions: [...] + }); + } + } + } + ``` + + **This ensures users can only grant permissions they already have.** + +6. **Storage**: + ```typescript + await ctx.context.adapter.create({ + model: "organizationRole", + data: { + createdAt: new Date(), + organizationId, + permission: JSON.stringify(permission), // Store as JSON string + role: roleName, + ...additionalFields, + }, + }); + ``` + +#### Loading Dynamic Roles + +From `has-permission.ts`: + +```typescript +export const hasPermission = async (input, ctx) => { + let acRoles = { ...(input.options.roles || defaultRoles) }; // Start with static roles + + if (input.options.dynamicAccessControl?.enabled && input.options.ac) { + // Load dynamic roles from database + const roles = await ctx.context.adapter.findMany({ + model: "organizationRole", + where: [{ field: "organizationId", value: input.organizationId }], + }); + + for (const { role, permission: permissionsString } of roles) { + // Skip if it's a pre-defined role (don't override) + if (role in acRoles) continue; + + // Parse the JSON permissions + const parsedPermissions = JSON.parse(permissionsString); + + // Create a new role instance using the AC + acRoles[role] = input.options.ac.newRole(parsedPermissions); + } + } + + // Now check permissions using the combined static + dynamic roles + return hasPermissionFn(input, acRoles); +}; +``` + +**Flow**: +1. Start with static roles (owner, admin, member) +2. If dynamic AC is enabled, load all roles from `organizationRole` table for the organization +3. For each DB role, parse its permissions and create a Role instance using `ac.newRole()` +4. Merge dynamic roles with static roles (static roles take precedence) +5. Use the combined role set for permission checking + +#### Permission Checking + +From `permission.ts`: + +```typescript +export const hasPermissionFn = (input, acRoles) => { + const roles = input.role.split(","); // User can have multiple roles + + for (const role of roles) { + const _role = acRoles[role]; + const result = _role?.authorize(input.permissions); // Use Role.authorize() + if (result?.success) { + return true; // User has permission + } + } + return false; // User doesn't have permission +}; +``` + +--- + +## 4. Key Flows + +### 4.1 Permission Check Flow + +``` +User Action + ↓ +1. Get user's role from member.role (e.g., "admin" or "developer") + ↓ +2. Call hasPermission({ role, permissions, organizationId, options }, ctx) + ↓ +3. hasPermission loads: + - Static roles from options.roles + - Dynamic roles from organizationRole table (if enabled) + ↓ +4. For each role the user has: + - Get the Role instance from acRoles + - Call role.authorize(permissions) + - If successful, return true + ↓ +5. Return false if no role authorizes the action +``` + +### 4.2 Dynamic Role Creation Flow + +``` +POST /organization/create-role + ↓ +1. Validate user is in organization + ↓ +2. Check user has ac: ["create"] permission + ↓ +3. Validate role name not taken + ↓ +4. FOR EACH resource in permission: + - Check resource exists in ac.statements + - FOR EACH action in resource: + - Check user has that permission (delegation check) + ↓ +5. If all validations pass: + - Create Role instance: ac.newRole(permission) + - Store in DB: { role: name, permission: JSON.stringify(permission) } + ↓ +6. Return success +``` + +### 4.3 Organization Session Flow + +``` +User logs in + ↓ +Session created with: + - activeOrganizationId: string | null + - activeTeamId: string | null (if teams enabled) + ↓ +When user performs action: + - Fetch member record by userId + activeOrganizationId + - member.role contains the role name(s) + - Use hasPermission() with that role +``` + +--- + +## 5. Important Functions and Files + +### 5.1 Core Permission Functions + +#### `hasPermission` (`has-permission.ts`) +- **Purpose**: Check if a user's role has specific permissions +- **Loads**: Static + dynamic roles +- **Caching**: Uses in-memory cache (`cacheAllRoles`) to avoid repeated DB queries +- **Key Logic**: + - Loads dynamic roles from DB only if `dynamicAccessControl.enabled` + - Skips overriding pre-defined roles + - Uses `ac.newRole()` to create Role instances from DB data + +#### `hasPermissionFn` (`permission.ts`) +- **Purpose**: Pure permission checking logic +- **Input**: Role name(s), permissions to check, pre-loaded acRoles +- **Logic**: Iterates through roles, calls `role.authorize()`, returns true if any role succeeds + +#### `role.authorize` (`access/access.ts`) +- **Purpose**: Check if a specific role has requested permissions +- **Logic**: + - For each requested resource, check if role has it + - For each requested permission, check if it's in role's allowed list + - Supports AND/OR connectors + +### 5.2 Dynamic Role CRUD (`routes/crud-access-control.ts`) + +#### `createOrgRole` +- **Key Validations**: + - `checkForInvalidResources`: Validates all resources exist in `ac.statements` + - `checkIfMemberHasPermission`: Ensures user has permissions they're trying to delegate + - `checkIfRoleNameIsTakenByPreDefinedRole`: Prevents override of static roles + - `checkIfRoleNameIsTakenByRoleInDB`: Prevents duplicate roles + +#### `updateOrgRole` +- Similar validations as create +- Can update role name and/or permissions +- Partial updates supported + +#### `deleteOrgRole` +- Cannot delete pre-defined roles +- Removes from DB only + +#### `listOrgRoles` / `getOrgRole` +- Requires `ac: ["read"]` permission +- Returns all custom roles for the organization + +### 5.3 Schema and Types + +#### `OrganizationRole` (`schema.ts`) +```typescript +{ + id: string; + organizationId: string; + role: string; + permission: Record; // Type-level + // Stored as JSON string in DB + createdAt: Date; + updatedAt?: Date; +} +``` + +#### `OrganizationOptions` (`types.ts`) +- Contains all configuration for the plugin +- Key fields for RBAC: + - `ac?: AccessControl` - The access control instance defining resources + - `roles?: { [key: string]: Role }` - Pre-defined static roles + - `dynamicAccessControl?` - Configuration for dynamic roles + +--- + +## 6. Current Limitations (What You Want to Change) + +### 6.1 Resources Are Static +**Current State**: Resources (e.g., `organization`, `member`, `team`) are defined once at application level via: + +```typescript +const ac = createAccessControl({ + organization: ["update", "delete"], + member: ["create", "update", "delete"], + // ... +}); +``` + +**Limitation**: All organizations share the same set of resources. You cannot have: +- Organization A with resources: `project`, `document`, `workflow` +- Organization B with resources: `campaign`, `lead`, `contact` + +### 6.2 Permissions Within Resources Are Static +**Current State**: The available permissions (actions) for each resource are also defined at application level. + +**Limitation**: All organizations must use the same permissions. You cannot have: +- Organization A: `project: ["view", "edit", "approve", "archive"]` +- Organization B: `project: ["read", "write", "publish", "delete"]` + +### 6.3 What IS Dynamic +Currently, only **role assignments** are dynamic: +- Role name (e.g., "developer", "qa", "manager") +- Which permissions from the available statements each role gets + +--- + +## 7. What Needs to Change (Your Goals) + +### 7.1 Make Resources Customizable Per Organization + +**Goal**: Each organization should be able to define their own resources. + +**Example**: +```typescript +// Organization A (software company) +resources: { + project: ["create", "read", "update", "delete"], + task: ["create", "assign", "complete"], + sprint: ["create", "start", "close"] +} + +// Organization B (marketing agency) +resources: { + campaign: ["create", "launch", "pause"], + lead: ["create", "qualify", "convert"], + report: ["view", "export"] +} +``` + +### 7.2 Make Permissions Customizable Per Organization + +**Goal**: Each organization should be able to define their own actions for each resource. + +**Example**: +```typescript +// Organization A +project: ["view", "edit", "approve", "archive", "clone"] + +// Organization B +project: ["read", "write", "publish", "unpublish"] +``` + +### 7.3 Maintain Backward Compatibility + +**Goal**: Organizations that don't configure custom resources should continue to work with default resources. + +--- + +## 8. Suggested Implementation Approach + +### 8.1 Database Schema Changes + +#### Add New Table: `organizationResource` + +```typescript +organizationResource: { + id: string; + organizationId: string; + resource: string; // Resource name (e.g., "project") + permissions: string; // JSON array: ["create", "read", "update"] + createdAt: Date; + updatedAt?: Date; +} +``` + +**Alternative Approach**: Store all resources in a single JSON field per organization: + +```typescript +// Add to organization table +organization: { + // ... existing fields + customResources?: string; // JSON: { project: ["create", "read"], task: [...] } +} +``` + +### 8.2 Configuration Changes + +```typescript +interface OrganizationOptions { + // ... existing options + + dynamicAccessControl?: { + enabled?: boolean; + maximumRolesPerOrganization?: number | ((orgId: string) => Promise); + + // NEW: Allow custom resources + enableCustomResources?: boolean; + maximumResourcesPerOrganization?: number | ((orgId: string) => Promise); + + // NEW: Default resources for organizations without custom resources + defaultResources?: Record; + }; +} +``` + +### 8.3 New Endpoints + +Add CRUD endpoints for resources: +- `POST /organization/create-resource` +- `POST /organization/update-resource` +- `POST /organization/delete-resource` +- `GET /organization/list-resources` +- `GET /organization/get-resource` + +### 8.4 Modified `hasPermission` Function + +```typescript +export const hasPermission = async (input, ctx) => { + // 1. Get the statements for this organization + let statements = input.options.ac?.statements || defaultStatements; + + if (input.options.dynamicAccessControl?.enableCustomResources) { + // Load custom resources from DB + const customResources = await loadCustomResources(input.organizationId, ctx); + if (customResources) { + // Create new AC with custom resources + const customAc = createAccessControl(customResources); + statements = customAc.statements; + } + } + + // 2. Load roles (static + dynamic) + let acRoles = { ...(input.options.roles || defaultRoles) }; + + if (input.options.dynamicAccessControl?.enabled) { + const roles = await ctx.context.adapter.findMany({ + model: "organizationRole", + where: [{ field: "organizationId", value: input.organizationId }], + }); + + for (const { role, permission: permissionsString } of roles) { + if (role in acRoles) continue; + + const parsedPermissions = JSON.parse(permissionsString); + + // Use the organization-specific statements to create the role + const ac = createAccessControl(statements); + acRoles[role] = ac.newRole(parsedPermissions); + } + } + + // 3. Check permissions + return hasPermissionFn(input, acRoles); +}; +``` + +### 8.5 Validation Changes + +In `createOrgRole` and `updateOrgRole`: + +```typescript +// OLD validation: +const validResources = Object.keys(ac.statements); + +// NEW validation: +const statements = await getOrganizationStatements(organizationId, options, ctx); +const validResources = Object.keys(statements); +``` + +--- + +## 9. Migration Considerations + +### 9.1 Backward Compatibility +- Existing organizations continue using default resources +- Flag: `enableCustomResources` defaults to `false` +- When false, behavior is identical to current implementation + +### 9.2 Performance +- Cache custom resources per organization +- Load resources + roles in parallel +- Consider caching at application level with invalidation + +### 9.3 Validation Complexity +- When creating roles, must validate against organization's custom resources +- When updating resources, must validate existing roles don't break +- May need migration logic if resources are deleted/renamed + +--- + +## 10. Testing Considerations + +### 10.1 Unit Tests +- Test `createAccessControl` with custom resources +- Test role creation with custom resources +- Test permission checking with custom resources + +### 10.2 Integration Tests +- Test organization with default resources +- Test organization with custom resources +- Test migration from default to custom resources +- Test resource CRUD operations +- Test role validation against custom resources + +### 10.3 Edge Cases +- Organization deletes a resource that's used in existing roles +- Organization renames a resource +- User tries to create role with non-existent resource +- Multiple organizations with different resources + +--- + +## 11. Key Files to Modify + +### High Priority (Core Logic) +1. `has-permission.ts` - Load organization-specific resources +2. `routes/crud-access-control.ts` - Validate against org-specific resources +3. `schema.ts` - Add organizationResource schema +4. `types.ts` - Update OrganizationOptions + +### Medium Priority (New Features) +5. Create `routes/crud-resources.ts` - Resource CRUD endpoints +6. `adapter.ts` - Add resource-related adapter methods +7. `organization.ts` - Wire up new endpoints + +### Low Priority (Supporting) +8. Add tests for custom resources +9. Update documentation +10. Add migration utilities + +--- + +## 12. Questions to Consider + +1. **Resource Deletion**: What happens to roles that reference a deleted resource? + - Option A: Cascade delete (remove resource from all roles) + - Option B: Block deletion if resource is in use + - Option C: Orphan the permissions (allow but ignore) + +Answer: Option B (Block) + +2. **Resource Renaming**: How to handle resource renames? + - Option A: Cascade update (update all roles) + - Option B: Treat as delete + create + - Option C: Don't allow renames + +Answer: Option C (dont allow) + +3. **Default Resources**: Should there be built-in resources that can't be deleted? + - e.g., `organization`, `member`, `invitation` might be required + +Answer: Yes, those are the static resources i think. and they should pair nicely with the dynamic ones. + +4. **Permission Defaults**: When adding a new resource, what are default permissions? + - CRUD: `["create", "read", "update", "delete"]`? + - Or require explicit permission definition? + +Answer: Require explicit permission definition. I will be able to pass anything there. + +5. **Performance**: How to optimize loading of custom resources? + - Cache at application level? + - Cache per request? + - Cache in memory map? + +Answer: in memory map + +6. **Multi-Tenancy**: Should resources be tenant-specific or global per organization? + - Current: Organization-level (seems appropriate) + +Answer: correct, organization is the tenant! + +7. **Resource Validation**: Should there be naming restrictions? + - No special characters? + - Length limits? + - Reserved names? + +Answer: add reasonable naming restrictions. and let me specify on the plugin config reseved names. + +--- + +## 13. Summary + +The organization plugin currently supports: +- ✅ Static resources (defined at app level) +- ✅ Static permissions (defined at app level) +- ✅ Dynamic roles (per organization) + +To achieve your goal, you need to make: +- ❌ → ✅ Dynamic resources (per organization) +- ❌ → ✅ Dynamic permissions (per organization) + +The key is to modify the `hasPermission` flow to: +1. Load organization-specific resources/permissions from DB +2. Create a custom `AccessControl` instance with those resources +3. Use that AC instance to create/validate roles +4. Perform permission checks against the custom resources + +The architecture is well-designed for this extension - the `createAccessControl` and `ac.newRole` pattern already supports arbitrary resources and permissions. The main work is: +- Database schema for storing custom resources +- Loading logic for custom resources +- Validation logic updates +- CRUD endpoints for resource management +- Caching strategy for performance + diff --git a/biome.json b/biome.json index f24af00b327..f8554d378ed 100644 --- a/biome.json +++ b/biome.json @@ -81,6 +81,7 @@ "includes": [ "**", "!**/dist", + "!**/coverage", "!**/build", "!**/.next", "!**/.svelte-kit", diff --git a/context7.json b/context7.json new file mode 100644 index 00000000000..4bb2bdbcf29 --- /dev/null +++ b/context7.json @@ -0,0 +1,4 @@ +{ + "url": "https://context7.com/better-auth/better-auth", + "public_key": "pk_fT2x7RBZHB78HypnSkPVr" +} diff --git a/docs/app/enterprise/_components/enterprise-form.tsx b/docs/app/enterprise/_components/enterprise-form.tsx index 5a991e15201..1bb2797a7bc 100644 --- a/docs/app/enterprise/_components/enterprise-form.tsx +++ b/docs/app/enterprise/_components/enterprise-form.tsx @@ -52,9 +52,9 @@ export function EnterpriseForm() { }; return ( -
+
-
+

@@ -161,7 +161,7 @@ export function EnterpriseForm() { diff --git a/docs/app/enterprise/_components/enterprise-hero.tsx b/docs/app/enterprise/_components/enterprise-hero.tsx index 44f36619495..33a5553cb1b 100644 --- a/docs/app/enterprise/_components/enterprise-hero.tsx +++ b/docs/app/enterprise/_components/enterprise-hero.tsx @@ -11,7 +11,9 @@ export function EnterpriseHero() {

- BETTER AUTH{" "} + + BETTER AUTH. + {" "} ENTERPRISE @@ -23,8 +25,8 @@ export function EnterpriseHero() {

{/* Trusted By Section */} -
-

+

+

Trusted by teams at

@@ -92,16 +94,13 @@ export function EnterpriseHero() { className="inline-block cursor-pointer hover:opacity-100 transition-opacity" > - + @@ -149,7 +148,7 @@ export function EnterpriseHero() { diff --git a/docs/components/mobile-search-icon.tsx b/docs/components/mobile-search-icon.tsx index b097bd749c8..2b507866e28 100644 --- a/docs/components/mobile-search-icon.tsx +++ b/docs/components/mobile-search-icon.tsx @@ -23,7 +23,7 @@ export function MobileSearchIcon({ className }: MobileSearchIconProps) { aria-label="Search" onClick={handleSearchClick} className={cn( - "flex ring-0 shrink-0 navbar:hidden size-9 hover:bg-transparent", + "flex ring-0 shrink-0 navbar:hidden size-8 hover:bg-transparent", className, )} > diff --git a/docs/components/nav-bar.tsx b/docs/components/nav-bar.tsx index 907d0ab7f84..ac7fdf9893d 100644 --- a/docs/components/nav-bar.tsx +++ b/docs/components/nav-bar.tsx @@ -1,6 +1,6 @@ import Link from "next/link"; import { MobileSearchIcon } from "@/components/mobile-search-icon"; -import { ThemeToggle } from "@/components/theme-toggler"; +import { ThemeToggle } from "@/components/theme-toggle"; import DarkPng from "../public/branding/better-auth-logo-dark.png"; import WhitePng from "../public/branding/better-auth-logo-light.png"; import { Logo } from "./logo"; diff --git a/docs/components/nav-mobile.tsx b/docs/components/nav-mobile.tsx index acbe86bc056..ad1259131b0 100644 --- a/docs/components/nav-mobile.tsx +++ b/docs/components/nav-mobile.tsx @@ -56,16 +56,15 @@ export const NavbarMobileBtn: React.FC = () => { const { toggleNavbar } = useNavbarMobile(); return ( -
- -
+ ); }; diff --git a/docs/components/sidebar-content.tsx b/docs/components/sidebar-content.tsx index e1c08adcfae..8a3414de129 100644 --- a/docs/components/sidebar-content.tsx +++ b/docs/components/sidebar-content.tsx @@ -2128,6 +2128,83 @@ C0.7,239.6,62.1,0.5,62.2,0.4c0,0,54,13.8,119.9,30.8S302.1,62,302.2,62c0.2,0,0.2, ), list: [ + { + title: "Create Your First Plugin", + href: "/docs/guides/your-first-plugin", + icon: () => ( + + + + + + + + + ), + }, + { + title: "Create a Database Adapter", + href: "/docs/guides/create-a-db-adapter", + icon: () => , + }, + { + title: "Browser Extension Guide", + href: "/docs/guides/browser-extension-guide", + icon: () => ( + + + + ), + }, + { + title: "SAML SSO with Okta", + href: "/docs/guides/saml-sso-with-okta", + icon: () => ( + + + + ), + }, + { + title: "Optimize for Performance", + href: "/docs/guides/optimizing-for-performance", + icon: () => , + }, + { + title: "Migration", + group: true, + icon: () => null, + href: "", + }, { title: "Auth.js Migration Guide", href: "/docs/guides/next-auth-migration-guide", @@ -2170,18 +2247,21 @@ C0.7,239.6,62.1,0.5,62.2,0.4c0,0,54,13.8,119.9,30.8S302.1,62,302.2,62c0.2,0,0.2, ), }, { - title: "Supabase Migration Guide", - href: "/docs/guides/supabase-migration-guide", + title: "Auth0 Migration Guide", + href: "/docs/guides/auth0-migration-guide", icon: () => ( ), @@ -2207,96 +2287,43 @@ C0.7,239.6,62.1,0.5,62.2,0.4c0,0,54,13.8,119.9,30.8S302.1,62,302.2,62c0.2,0,0.2, ), }, { - title: "Auth0 Migration Guide", - href: "/docs/guides/auth0-migration-guide", - icon: () => ( - - - - ), - }, - { - title: "Create Your First Plugin", - href: "/docs/guides/your-first-plugin", + title: "Supabase Migration Guide", + href: "/docs/guides/supabase-migration-guide", icon: () => ( - - - - - - - - ), - }, - { - title: "Create a Database Adapter", - href: "/docs/guides/create-a-db-adapter", - icon: () => , - }, - { - title: "Browser Extension Guide", - href: "/docs/guides/browser-extension-guide", - icon: () => ( - - + + ), }, { - title: "SAML SSO with Okta", - href: "/docs/guides/saml-sso-with-okta", + title: "WorkOS Migration Guide", + href: "/docs/guides/workos-migration-guide", icon: () => ( - + + + ), }, - { - title: "Optimize for Performance", - href: "/docs/guides/optimizing-for-performance", - icon: () => , - }, ], }, { diff --git a/docs/components/theme-toggle.tsx b/docs/components/theme-toggle.tsx new file mode 100644 index 00000000000..4c7e69eab74 --- /dev/null +++ b/docs/components/theme-toggle.tsx @@ -0,0 +1,207 @@ +"use client"; + +import { AnimatePresence, motion } from "framer-motion"; +import { useTheme } from "next-themes"; +import type { ComponentProps } from "react"; +import { useEffect, useState } from "react"; +import { Button } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; + +const themeMap = { + light: "light", + dark: "dark", +} as const; + +function renderThemeIcon(theme: string | undefined) { + switch (theme) { + case themeMap.light: + return ; + case themeMap.dark: + return ; + default: + return null; + } +} + +export function ThemeToggle(props: ComponentProps) { + const { setTheme, resolvedTheme } = useTheme(); + const [mounted, setMounted] = useState(false); + + useEffect(() => { + setMounted(true); + }, []); + + return ( + + ); +} + +const LightThemeIcon = () => { + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +}; + +const DarkThemeIcon = () => { + return ( + + + + ); +}; diff --git a/docs/components/theme-toggler.tsx b/docs/components/theme-toggler.tsx deleted file mode 100644 index ff100a6ef4e..00000000000 --- a/docs/components/theme-toggler.tsx +++ /dev/null @@ -1,189 +0,0 @@ -"use client"; - -import { useTheme } from "next-themes"; -import type { ComponentProps } from "react"; -import { Button } from "@/components/ui/button"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from "@/components/ui/dropdown-menu"; -import { cn } from "@/lib/utils"; - -export function ThemeToggle(props: ComponentProps) { - const { setTheme, theme } = useTheme(); - - return ( - - - - - - setTheme("light")} - > - Light - - setTheme("dark")} - > - Dark - - setTheme("system")} - > - System - - - - ); -} diff --git a/docs/content/docs/guides/workos-migration-guide.mdx b/docs/content/docs/guides/workos-migration-guide.mdx new file mode 100644 index 00000000000..a061cff367d --- /dev/null +++ b/docs/content/docs/guides/workos-migration-guide.mdx @@ -0,0 +1,712 @@ +--- +title: Migrating from WorkOS to Better Auth +description: A step-by-step guide to transitioning from WorkOS to Better Auth. +--- + +In this guide, we’ll walk through how to migrate a project from WorkOS to Better Auth, covering how to move a basic WorkOS setup integrated with a Next.js app and the key considerations to keep in mind. + +## Before we begin + +Before getting started, let’s review which WorkOS authentication features are fully or partially supported in Better Auth. If a feature you use in WorkOS is available via a plugin, you’ll need to configure it in the next step. + + + +<> +| from WorkOS | to Better Auth | +|---------|-------------| +| Single Sign-On | Use the [SSO Plugin](/docs/plugins/sso). | +| Email + Password | Built-in support. | +| Passkeys | Use the [Passkey Plugin](/docs/plugins/passkey). | +| Social Login | Built-in support with even more providers. | +| Multi-Factor Auth | Use the [Two Factor Plugin](/docs/plugins/2fa). | +| Magic Auth | Use the [Magic Link Plugin](/docs/plugins/magic-link). | +| CLI Auth | Use the [Device Authorization Plugin](/docs/plugins/device-authorization). | +| API Keys | Use the [API Key Plugin](/docs/plugins/api-key). | +| Custom Emails | Fully customizable. | +| Directory Provisioning | Use the [SCIM Plugin](/docs/plugins/scim). | +| Domain Verification | Use the [SSO Plugin](/docs/plugins/sso). | +| Email Verification | Built-in support. | +| Identity Linking | Built-in support. | +| Impersonation | Use the [Admin Plugin](/docs/plugins/admin). | +| JWT Templates | Use the [JWT Plugin](/docs/plugins/jwt). | +| Metadata External IDs | Can be freely added by [extending the core schema](/docs/concepts/database#extending-core-schema). | +| Roles and Permissions | Use the [Organization Plugin](/docs/plugins/organization). | + + + + +<> +| from WorkOS | to Better Auth | +|---------|-------------| +| JIT Provisioning | Partially supported via the [SSO Plugin](/docs/plugins/sso). | +| Invitations | No ready-to-use dashboard is provided, but can be implemented using the [Admin Plugin](/docs/plugins/admin) + [Organization Plugin](/docs/plugins/organization). | +| Organization Policies | Partially supported, but can be fully implemented using [SSO Plugin](/docs/plugins/sso) + [Organization Plugin](/docs/plugins/organization) hooks. | + + + + + + +## Create Better Auth Instance + +First, set up Better Auth in your project. Follow the [installation guide](/docs/installation) to get started. + +### Database + +Better Auth supports various databases. Set up your preferred database. In this guide, we’ll use PostgreSQL with the default database adapter. + +```ts title="auth.ts" +import { betterAuth } from "better-auth"; +import { Pool } from "pg"; + +export const auth = betterAuth({ + database: new Pool({ // [!code highlight] + connectionString: process.env.DATABASE_URL, // [!code highlight] + }), // [!code highlight] +}); +``` + +### Email & Password + +Enable Email & Password authentication as shown below. Since WorkOS verifies each user’s email by default, this setup is similar to the default behavior. You can adjust it if needed. For more information, see [here](/docs/authentication/email-password). + +```ts +import { betterAuth } from "better-auth"; +import { Pool } from "pg"; + +export const auth = betterAuth({ + database: new Pool({ + connectionString: process.env.DATABASE_URL, + }), + emailAndPassword: { // [!code highlight] + enabled: true, // [!code highlight] + requireEmailVerification: true, // [!code highlight] + minPasswordLength: 10, // [!code highlight] + sendResetPassword: async ({ user, url, token }, request) => { // [!code highlight] + // Implement your email sending logic // [!code highlight] + }, // [!code highlight] + }, // [!code highlight] + emailVerification: { // [!code highlight] + sendVerificationEmail: async ({ user, url, token }, request) => { // [!code highlight] + // Implement your email sending logic // [!code highlight] + }, // [!code highlight] + }, // [!code highlight] +}); +``` + +### Social Providers (optional) + +Set up the social providers you used in WorkOS as follows. Better Auth supports a wider range of providers, so you can add more if needed. Since WorkOS ensures emails are unique, configure `account.accountLinking` in Better Auth to ensure the same behavior. + +```ts +import { betterAuth } from "better-auth"; +import { Pool } from "pg"; + +export const auth = betterAuth({ + // ... other options + + socialProviders: { // [!code highlight] + github: { // [!code highlight] + clientId: process.env.GITHUB_CLIENT_ID!, // [!code highlight] + clientSecret: process.env.GITHUB_CLIENT_SECRET!, // [!code highlight] + }, // [!code highlight] + // ... other providers // [!code highlight] + }, // [!code highlight] + account: { // [!code highlight] + accountLinking: { // [!code highlight] + enabled: true, // [!code highlight] + trustedProviders: ["email-password", "github"], // [!code highlight] + }, // [!code highlight] + }, // [!code highlight] +}); +``` + +### Additional Fields + +You probably used metadata in WorkOS. To preserve that metadata and the user id from WorkOS (e.g., user_01KBT4BMFF7ASGRDD0WZ6W63FF), extend the `user` schema as shown below. Better Auth provides a more flexible way to store user data. For more information, see [here](/docs/concepts/database#extending-core-schema). + +```ts title="auth.ts" +import { betterAuth } from "better-auth"; +import { Pool } from "pg"; + +export const auth = betterAuth({ + // ... other options + + user: { // [!code highlight] + additionalFields: { // [!code highlight] + metadata: { // [!code highlight] + type: "json", // [!code highlight] + required: false, // [!code highlight] + defaultValue: null, // [!code highlight] + }, // [!code highlight] + }, // [!code highlight] + }, // [!code highlight] +}); +``` + +### Plugins + +Refer to the [section](#before-we-begin) mapping WorkOS features to Better Auth. If a feature you used in WorkOS is available as a Better Auth plugin, add it to the plugin options. Better Auth provides a wider range of out-of-the-box features through plugins. For more information, see [here](/docs/concepts/plugins). + +```ts title="auth.ts" +import { betterAuth } from "better-auth"; +import { haveIBeenPwned } from "better-auth/plugins/haveibeenpwned"; +import { Pool } from "pg"; + +export const auth = betterAuth({ + // ... other options + + plugins: [ // [!code highlight] + haveIBeenPwned() // [!code highlight] + // ... other plugins // [!code highlight] + ], // [!code highlight] +}); +``` + + +If you rely on advanced WorkOS features beyond basic email+password and social login, refer to the feature mapping above to configure the appropriate plugins. + + + + +## Generate Schema + +Better Auth allows you to control your own database, and you can easily generate the appropriate schema for your auth instance using the CLI. For more information, see [here](/docs/concepts/cli). + +### Default database adapter + +Run the `migrate` command to create the schema for your Better Auth instance in the database. + +```package-install +npx @better-auth/cli migrate +``` + +### Other database adapters + +If you’re using a database adapter like Prisma or Drizzle, use the `generate` command to create the schema for your ORM. After that, run the migration with an external tool such as Drizzle Kit. + +```package-install +npx @better-auth/cli generate +``` + + + +## Migration Script + +### Create Migration Script + +Create a migration script to import your user data from WorkOS into your database. + +```ts title="scripts/migration.ts" +import { auth } from "@/lib/auth"; // Your auth instance path +import { WorkOS } from "@workos-inc/node"; + +//============================================================================== + +/* + Rate limiting configuration + + WorkOS Read APIs: 1,000 requests per 10 seconds + Default setting: Use 80% of limit to avoid edge cases + + Reference: https://workos.com/docs/reference/rate-limits +*/ +const TIME_WINDOW_MS = 10 * 1000; // Time window in ms (10 seconds) +const MAX_REQUESTS_PER_WINDOW = 800; // Maximum API calls per time window +const USERS_PER_REQUEST = 100; // How many users to fetch per API call + +//============================================================================== + +if (!process.env.WORKOS_API_KEY || !process.env.WORKOS_CLIENT_ID) { + throw new Error( + "Missing required environment variables WORKOS_API_KEY and/or WORKOS_CLIENT_ID", + ); +} +const workos = new WorkOS(process.env.WORKOS_API_KEY); + +/** + * Create a rate limiter to track and control request rate + */ +const createRateLimiter = (maxRequests: number, windowMs: number) => { + let requestTimestamps: number[] = []; + + const waitIfNeeded = async (): Promise => { + const now = Date.now(); + + // Remove timestamps outside the current window + requestTimestamps = requestTimestamps.filter( + (timestamp) => now - timestamp < windowMs, + ); + + // If we've hit the limit, calculate wait time + if (requestTimestamps.length >= maxRequests) { + const oldestTimestamp = requestTimestamps[0]!; + const waitTime = windowMs - (now - oldestTimestamp) + 1000; // 1 sec buffer + + console.log( + `⏳ Throttling (${requestTimestamps.length}/${maxRequests} calls used). Waiting ${Math.ceil(waitTime / 1000)}s...`, + ); + await new Promise((resolve) => setTimeout(resolve, waitTime)); + + // Clean up old timestamps after waiting + const newNow = Date.now(); + requestTimestamps = requestTimestamps.filter( + (timestamp) => newNow - timestamp < windowMs, + ); + } + + // Record this request + requestTimestamps.push(Date.now()); + }; + + const getStats = (): { + current: number; + max: number; + windowMinutes: number; + } => { + const now = Date.now(); + requestTimestamps = requestTimestamps.filter( + (timestamp) => now - timestamp < windowMs, + ); + + return { + current: requestTimestamps.length, + max: maxRequests, + windowMinutes: windowMs / (60 * 1000), + }; + }; + + return { waitIfNeeded, getStats }; +}; + +/** + * Safely converts various date formats to Date object. + * Returns current date if conversion fails (safe for createdAt/updatedAt). + */ +const safeDateConversion = (date?: string | number | Date | null): Date => { + if (date == null) return new Date(); + + if (date instanceof Date) return new Date(date.getTime()); + + if (typeof date === "number") { + if (!Number.isFinite(date)) return new Date(); + return new Date(date); + } + + if (typeof date === "string") { + const trimmed = date.trim(); + if (trimmed === "") return new Date(); + const parsed = new Date(trimmed); + if (isNaN(parsed.getTime())) return new Date(); + return parsed; + } + + return new Date(); +}; + +/** + * Safely converts firstName and lastName to a full name string. + * Returns "Username" if both names are empty. + */ +const safeNameConversion = ( + firstName?: string | null, + lastName?: string | null, +): string => { + const trimmedFirstName = firstName?.trim(); + const trimmedLastName = lastName?.trim(); + + if (trimmedFirstName && trimmedLastName) { + return `${trimmedFirstName} ${trimmedLastName}`; + } + + if (trimmedFirstName) return trimmedFirstName; + if (trimmedLastName) return trimmedLastName; + + return "Username"; +}; + +async function migrateFromWorkOS() { + const ctx = await auth.$context; + const rateLimiter = createRateLimiter( + MAX_REQUESTS_PER_WINDOW, + TIME_WINDOW_MS, + ); + + let totalUsers = 0; + let migratedUsers = 0; + let skippedUsers = 0; + let failedUsers = 0; + + let hasMoreUsers = true; + let after: string | undefined; + let batchCount = 0; + + console.log(""); + console.log("=".repeat(40)); + console.log("🚀 Starting migration"); + console.log(""); + console.log(`Settings:`); + console.log( + ` - Max API calls: ${MAX_REQUESTS_PER_WINDOW} per ${TIME_WINDOW_MS / 1000}s`, + ); + console.log(` - Users per call: ${USERS_PER_REQUEST}`); + console.log("=".repeat(40)); + console.log(""); + + while (hasMoreUsers) { + try { + await rateLimiter.waitIfNeeded(); + + const workosUserList = await workos.userManagement.listUsers({ + limit: USERS_PER_REQUEST, + after, + }); + + batchCount++; + console.log( + `📦 Batch ${batchCount}: Fetched ${workosUserList.data.length} users from WorkOS`, + ); + + after = workosUserList.listMetadata.after || undefined; + hasMoreUsers = !!after; + totalUsers += workosUserList.data.length; + + for (const workosUser of workosUserList.data) { + try { + console.log(`\nProcessing user: ${workosUser.email}`); + + // Check if user already exists by email + // WorkOS ensures all user emails are unique via an email verification process + const existingUser = await ctx.adapter.findOne< + typeof auth.$Infer.Session.user + >({ + model: "user", + where: [ + { + field: "email", + value: workosUser.email, + }, + ], + }); + + if (existingUser) { + console.log( + `🟡 User already exists, skipping: ${workosUser.email}`, + ); + skippedUsers++; + continue; + } + + // Create the user + await ctx.adapter.create({ + model: "user", + data: { + email: workosUser.email, + emailVerified: workosUser.emailVerified, + image: workosUser.profilePictureUrl, + name: safeNameConversion( + workosUser.firstName, + workosUser.lastName, + ), + createdAt: safeDateConversion(workosUser.createdAt), + updatedAt: safeDateConversion(workosUser.updatedAt), + metadata: { + workosId: workosUser.id, + ...(workosUser.metadata || {}), + }, + }, + }); + + console.log(`🟢 Migrated user ${workosUser.email}`); + migratedUsers++; + } catch (error) { + console.error( + `🔴 Failed to migrate user ${workosUser.email}\n`, + error, + ); + failedUsers++; + } + } + + console.log(""); + } catch (error) { + console.error("🚨 Error fetching batch:", error); + throw error; + } + } + + console.log(""); + console.log("=".repeat(40)); + console.log("📝 Migration Summary"); + console.log(`Total users processed: ${totalUsers}`); + console.log(""); + console.log(`🔴 Failed: ${failedUsers}`); + console.log(`🟡 Skipped: ${skippedUsers}`); + console.log(`🟢 Successfully migrated: ${migratedUsers}`); + console.log("=".repeat(40)); +} + +async function main() { + try { + await migrateFromWorkOS(); + process.exit(0); + } catch (error) { + console.error("\nMigration failed:", error); + process.exit(1); + } +} +main(); +``` + + +**Notes** + +- When retrieving user data from WorkOS, you need to use their API, which is subject to rate limits. The example script includes a basic configuration, so adjust it as needed for your environment. +- This migration script covers the common cases of managing users with email+password and social login. For features like SSO or CLI Auth, which are provided as plugins in Better Auth, be sure to update the script based on the examples. + + + +### Run Migration Script + +```bash title="Terminal" +bun scripts/migration.ts # or use node, ts-node, etc. +``` + +🎉 Now that you’ve migrated your user data into your database, let’s look at how to update your application logic. + + +## Create Client Instance + +This client instance includes a set of functions for interacting with the Better Auth server instance. For more information, see [here](/docs/concepts/client). + +```ts title="auth-client.ts" +import { createAuthClient } from "better-auth/react"; + +export const authClient = createAuthClient({ + plugins: [ + // Add plugins that require a client, if needed + ] +}); +``` + + + +## Create API Route + +In WorkOS, the auth API was provided as a managed service. With Better Auth, the auth API now lives directly within your application. + +```ts title="/app/api/auth/[...all]/route.ts" +import { auth } from "@/lib/auth"; +import { toNextJsHandler } from "better-auth/next-js"; + +export const { POST, GET } = toNextJsHandler(auth) +``` + + + +## Sign-in/Sign-up Page + +In WorkOS, you probably fetched and used the URL like this. + +```ts +const signInUrl = await getSignInUrl(); +const signUpUrl = await getSignUpUrl(); +``` + +In Better Auth, instead of fetching these values via an API, you can create the pages at your desired paths and use them directly. + + + +## Protecting Resources + +> Proxy (Middleware) is not intended for slow data fetching. While Proxy can be helpful for optimistic checks such as permission-based redirects, it should not be used as a full session management or authorization solution. - [Next.js docs](https://nextjs.org/docs/app/getting-started/proxy#use-cases) + +### Middleware auth + +WorkOS provides Proxy (Middleware) authentication. Better Auth doesn’t recommend protecting resources directly in middleware, so we don't provide dedicated helpers for that. + +```ts title="proxy.ts / middleware.ts" +import { authkitMiddleware } from '@workos-inc/authkit-nextjs'; + +export default authkitMiddleware({ + middlewareAuth: { + enabled: true, + unauthenticatedPaths: ['/'], + }, +}); + +export const config = { matcher: ['/', '/account/:page*'] }; +``` + +In Better Auth, for convenience rather than resource protection, the proxy (middleware) can be used as follows. This is supported in Next.js 15+ with the Node.js runtime. + +```ts title="proxy.ts" +import { NextRequest, NextResponse } from "next/server"; +import { headers } from "next/headers"; +import { auth } from "@/lib/auth"; + +export async function proxy(request: NextRequest) { + const session = await auth.api.getSession({ + headers: await headers() + }) + + // This is the recommended approach to optimistically redirect users + // We recommend handling auth checks in each page/route + if(!session) { + return NextResponse.redirect(new URL("/sign-in", request.url)); + } + + return NextResponse.next(); +} + +export const config = { + matcher: ["/dashboard"], // Specify the routes the middleware applies to +}; +``` + +### Page based auth + +In WorkOS, if resources were protected on each page, you can update the logic in Better Auth as follows. + +#### Server-side + + + +```ts title="app/dashboard/page.tsx" +import { withAuth } from "@workos-inc/authkit-nextjs"; + +export default async function DashboardPage() { + const { user } = await withAuth({ ensureSignedIn: true }); + + return ( +
+

Welcome {user.firstName && `, ${user.lastName}`}

+
+ ); +} +``` +
+ + +```ts title="app/dashboard/page.tsx" +import { auth } from "@/lib/auth"; +import { headers } from "next/headers"; +import { redirect } from "next/navigation"; + +const DashboardPage = async () => { + const session = await auth.api.getSession({ + headers: await headers(), + }); + + if (!session) { + redirect("/sign-in"); + } + + return ( +
+

Welcome {session.user.name}

+
+ ); +}; + +export default DashboardPage; +``` +
+
+ +#### Client-side + + + +```ts title="app/dashboard/page.tsx" +"use client"; + +import { useAuth } from "@workos-inc/authkit-nextjs/components"; + +export default function HomePage() { + const { user, loading } = useAuth({ ensureSignedIn: true }); + + if (loading) { + return
Loading...
; + } + + return ( +
+

Welcome {user.firstName && `, ${user.lastName}`}

+
+ ); +} + +``` +
+ + +```ts title="app/dashboard/page.tsx" +"use client"; + +import { authClient } from "@/lib/auth-client"; +import { redirect } from "next/navigation"; + +const DashboardPage = () => { + const { data, error, isPending } = authClient.useSession(); + + if (isPending) { + return
Pending...
; + } + if (!data || error) { + redirect("/sign-in"); + } + + return ( +
+

Welcome {data.user.name}

+
+ ); +}; + +export default DashboardPage; +``` +
+
+ + +If options like `ensureSignedIn` were convenient in WorkOS, you can create a reusable helper like `ensureSession()` in Better Auth. + + +
+ + +## Remove WorkOS Dependencies + +After verifying everything works, remove WorkOS dependencies: + +```package-install +npm uninstall @workos-inc/node @workos-inc/authkit-nextjs +``` + + +
+ +## Considerations + +Password hashes + +If you’ve been managing users with an email + password system, WorkOS does not provide an export of password hashes at this time. After migration, users will need to reset their passwords within your authentication system. Make sure to notify them of this change with sufficient lead time both before and after the migration. + +Data syncing + +WorkOS is a managed service and keeps your data in sync with your server through APIs or Webhooks. With Better Auth, you fully own your authentication system and can manage data freely through the API. However, if you previously relied on Webhooks for synchronization, additional adjustments will be needed. + +Downtime + +WorkOS exposes data through its API, but with limitations such as the inability to export password hashes. Because of these constraints, performing a migration with zero downtime is challenging. Plan the migration carefully, allow enough buffer time, and communicate the expected impact to your users. + +Active sessions + +Existing active sessions will not be migrated. After the migration, users will need to sign in again, so be sure to notify them in advance. + +## Wrapping Up + +Congratulations! You've successfully migrated from WorkOS to Better Auth. Better Auth offers greater flexibility and more features, so be sure to explore the [documentation](/docs) to unlock its full potential. + +If you need help with migration, join our [community](/community) or reach out for Enterprise support [here](/enterprise). \ No newline at end of file diff --git a/docs/content/docs/plugins/2fa.mdx b/docs/content/docs/plugins/2fa.mdx index 1338b5bf101..53f9fc797fd 100644 --- a/docs/content/docs/plugins/2fa.mdx +++ b/docs/content/docs/plugins/2fa.mdx @@ -356,7 +356,7 @@ When you generate backup codes, the old backup codes will be deleted and new one #### Using Backup Codes -You can now allow users to provider backup code as account recover method. +You can now allow users to provide a backup code as an account recovery method. @@ -384,7 +384,7 @@ Once a backup code is used, it will be removed from the database and can't be us #### Viewing Backup Codes -To display the backup codes to the user, you can call `viewBackupCodes` on the server. This will return the backup codes in the response. You should only this if the user has a fresh session - a session that was just created. +To display the backup codes to the user, you can call `viewBackupCodes` on the server. This will return the backup codes in the response. You should only do this if the user has a fresh session - a session that was just created. [erickweil](https://github.com/erickweil) | | [better-auth-opaque](https://github.com/TheUntraceable/better-auth-opaque) | Provides database-breach resistant authentication using the zero-knowledge OPAQUE protocol. | [TheUntraceable](https://github.com/TheUntraceable) | | [better-auth-firebase-auth](https://github.com/yultyyev/better-auth-firebase-auth) | Firebase Authentication plugin for Better Auth with built-in email service, Google Sign-In, and password reset functionality. | [yultyyev](https://github.com/yultyyev) | +| [better-auth-university](https://github.com/LuyxLLC/better-auth-university) | University plugin for allowing only specific email domains to be passed through. Includes a University model with name and domain. | [Fyrlex](https://github.com/Fyrlex) | diff --git a/docs/content/docs/plugins/creem.mdx b/docs/content/docs/plugins/creem.mdx index ad71654cbc0..b4d4f48ef39 100644 --- a/docs/content/docs/plugins/creem.mdx +++ b/docs/content/docs/plugins/creem.mdx @@ -194,7 +194,9 @@ If you're using database persistence (`persistSubscriptions: true`), generate an When `persistSubscriptions: true`, the plugin creates the following schema: -### Subscription Table +### Creem Subscription Table + +Table Name: `creem_subscription` | Field | Type | Description | | --------------------- | ------- | -------------------------------- | diff --git a/docs/content/docs/plugins/last-login-method.mdx b/docs/content/docs/plugins/last-login-method.mdx index 8a390ecf341..5da43483567 100644 --- a/docs/content/docs/plugins/last-login-method.mdx +++ b/docs/content/docs/plugins/last-login-method.mdx @@ -361,4 +361,31 @@ export const auth = betterAuth({ }) ``` +### Usage with Expo +When using Better Auth with Expo, make sure to import the client plugin from `@better-auth/expo/plugins` rather than from `better-auth/plugins/client`. This ensures the last login method is stored correctly using the configured storage. + +```ts +import { createAuthClient } from "better-auth/react" +import { expoClient } from "@better-auth/expo" +import { lastLoginMethodClient } from "@better-auth/expo/plugins" // [!code highlight] +import * as SecureStore from "expo-secure-store" + +export const authClient = createAuthClient({ + plugins: [ + expoClient({ + scheme: "myapp", + storagePrefix: "myapp", + storage: SecureStore, + }), + lastLoginMethodClient({ + storagePrefix: "myapp", + storage: SecureStorage, + }) + ] +}) +``` + + + In Expo only apps, where browser support isn’t needed, you can omit the server plugin and rely solely on the client plugin. + diff --git a/docs/content/docs/plugins/organization.mdx b/docs/content/docs/plugins/organization.mdx index 4367c4edfa4..c38d648ecc5 100644 --- a/docs/content/docs/plugins/organization.mdx +++ b/docs/content/docs/plugins/organization.mdx @@ -1219,7 +1219,7 @@ By default, there are three roles in the organization: `admin`: Users with the admin role have full control over the organization except for deleting the organization or changing the owner. -`member`: Users with the member role have limited control over the organization. They can create projects, invite users, and manage projects they have created. +`member`: Users with the member role have limited control over the organization. They can only read organization data and have no permissions to create, update, or delete resources. A user can have multiple roles. Multiple roles are stored as string separated @@ -1769,6 +1769,509 @@ export const authClient = createAuthClient({ }) ``` +### Custom Resources + +By default, the organization plugin provides a fixed set of resources (organization, member, invitation, team, ac) with predefined permissions. With custom resources enabled, organizations can define their own resources and permissions that are specific to their business domain. + +#### Enabling Custom Resources + +To enable custom resources, set `enableCustomResources` to `true` in the `dynamicAccessControl` configuration: + +```ts title="auth.ts" +import { betterAuth } from "better-auth"; +import { organization } from "better-auth/plugins"; +import { createAccessControl } from "better-auth/plugins/access"; + +const ac = createAccessControl({ + organization: ["update", "delete"], + member: ["create", "update", "delete"], + invitation: ["create", "cancel"], + team: ["create", "update", "delete"], + ac: ["create", "read", "update", "delete"], +}); + +export const auth = betterAuth({ + plugins: [ + organization({ + ac, + dynamicAccessControl: { + enabled: true, + enableCustomResources: true, // [!code highlight] + maximumResourcesPerOrganization: 50, // [!code highlight] + }, + }), + ], +}); +``` + +```ts title="auth-client.ts" +import { createAuthClient } from "better-auth/client"; +import { organizationClient } from "better-auth/client/plugins"; + +export const authClient = createAuthClient({ + plugins: [ + organizationClient({ + dynamicAccessControl: { + enabled: true, + enableCustomResources: true, // [!code highlight] + }, + }), + ], +}); +``` + + + This will require you to run migrations to add the new `organizationResource` table to the database. + + +#### How It Works + +Custom resources work alongside the default resources defined in your `ac` instance: + +1. **Default Resources**: Resources defined in your `ac` instance are always available to all organizations. These are considered "protected" resources and cannot be modified or deleted. + +2. **Custom Resources**: Each organization can create additional resources specific to their needs. These resources are stored in the database and are only available to that organization. + +3. **Merging**: When checking permissions or creating roles, both default and custom resources are merged together. If a custom resource has the same name as a default resource, the custom one takes precedence. + +#### Creating a Resource + +To create a custom resource for an organization: + + +```ts +type createOrgResource = { + /** + * The name of the resource to create. + * Must be alphanumeric with underscores only. + * Length between 1 and 50 characters. + */ + resource: string = "project" + /** + * The permissions (actions) available for this resource. + * Must be a non-empty array. + */ + permissions: string[] = ["create", "read", "update", "delete", "archive"] + /** + * The organization ID. Defaults to the active organization. + */ + organizationId?: string = "organization-id" +} +``` + + +**Example Usage:** + +```ts +await authClient.organization.createOrgResource({ + resource: "project", + permissions: ["create", "read", "update", "delete", "archive"], +}); +``` + +#### Listing Resources + +To list all resources (both default and custom) for an organization: + + +```ts +type listOrgResources = { + /** + * The organization ID to list resources for. Defaults to the active organization. + */ + organizationId?: string = "organization-id" +} +``` + + +The response includes metadata for each resource: + +```ts +{ + resources: [ + { + resource: "organization", + permissions: ["update", "delete"], + isCustom: false, + isProtected: true // Cannot be modified or deleted + }, + { + resource: "project", + permissions: ["create", "read", "update", "delete", "archive"], + isCustom: true, + isProtected: false // Can be modified or deleted + } + ] +} +``` + +#### Getting a Specific Resource + +To get details about a specific resource: + + +```ts +type getOrgResource = { + /** + * The name of the resource to retrieve. + */ + resource: string = "project" + /** + * The organization ID. Defaults to the active organization. + */ + organizationId?: string = "organization-id" +} +``` + + +#### Updating a Resource + +To update the permissions of a custom resource: + + +```ts +type updateOrgResource = { + /** + * The name of the resource to update. + * Note: Resource names cannot be changed. Only permissions can be updated. + */ + resource: string = "project" + /** + * The new permissions for this resource. + */ + permissions: string[] = ["read", "write", "delete"] + /** + * The organization ID. Defaults to the active organization. + */ + organizationId?: string = "organization-id" +} +``` + + + + Resource names cannot be changed after creation. If you need to rename a resource, you must delete it and create a new one. + + +#### Deleting a Resource + +To delete a custom resource: + + +```ts +type deleteOrgResource = { + /** + * The name of the resource to delete. + */ + resource: string = "project" + /** + * The organization ID. Defaults to the active organization. + */ + organizationId?: string = "organization-id" +} +``` + + + + Resources that are being used by existing roles cannot be deleted. You must first remove the resource from all roles before deleting it. + + +#### Using Custom Resources in Roles + +Once you've created custom resources, you can use them when creating or updating roles: + +```ts +// Create a custom resource +await authClient.organization.createOrgResource({ + resource: "project", + permissions: ["create", "read", "update", "delete", "archive"], +}); + +// Create a role that uses the custom resource +await authClient.organization.createOrgRole({ + role: "project_manager", + permission: { + project: ["create", "read", "update", "archive"], + member: ["read"], + }, +}); +``` + +#### Auto-Creating Resources with Roles + +When custom resources are enabled, you can create roles with resources that don't exist yet, and they will be automatically created: + +```ts +// Create a role with a new resource - the "task" resource will be auto-created +await authClient.organization.createOrgRole({ + role: "task_manager", + permission: { + task: ["create", "read", "update", "complete"], // "task" resource doesn't exist yet + project: ["read"], // "project" must already exist + }, +}); +``` + +This is particularly useful when: +- Rapidly prototyping permission models +- Defining roles and resources together +- Migrating existing data structures + + + Auto-creation only works when `enableCustomResources` is `true`. The resource names must still pass validation (alphanumeric with underscores, not reserved names). + + +#### Resource Name Validation + +Resource names must follow these rules: + +- Must be alphanumeric with underscores only (uppercase and lowercase allowed) +- Length between 1 and 50 characters +- Cannot be a reserved name (organization, member, invitation, team, ac by default) + +**Valid names:** +- `project` +- `Project` +- `task_123` +- `myCustomResource` +- `MY_RESOURCE` + +**Invalid names:** +- `my-resource` (contains dash) +- `my resource` (contains space) +- `organization` (reserved name) + +#### Configuration Options + +The custom resources feature supports the following configuration options: + +##### `enableCustomResources` + +Enable or disable custom resources per organization. Default: `false` + +```ts +organization({ + dynamicAccessControl: { + enableCustomResources: true // [!code highlight] + } +}) +``` + +##### `maximumResourcesPerOrganization` + +Limit the number of custom resources that can be created per organization. Default: `50` + +```ts +organization({ + dynamicAccessControl: { + maximumResourcesPerOrganization: 20 // [!code highlight] + } +}) +``` + +You can also pass a function that returns a number: + +```ts +organization({ + dynamicAccessControl: { + maximumResourcesPerOrganization: async (organizationId) => { // [!code highlight] + const org = await getOrganization(organizationId); // [!code highlight] + return org.plan === "enterprise" ? 100 : 20; // [!code highlight] + } // [!code highlight] + } +}) +``` + +##### `reservedResourceNames` + +Specify custom reserved resource names that cannot be used. Default: `["organization", "member", "invitation", "team", "ac"]` + +```ts +organization({ + dynamicAccessControl: { + reservedResourceNames: ["system", "admin", "root"] // [!code highlight] + } +}) +``` + +##### `resourceNameValidation` + +Provide a custom validation function for resource names: + +```ts +organization({ + dynamicAccessControl: { + resourceNameValidation: (name) => { // [!code highlight] + if (name.startsWith("_")) { // [!code highlight] + return { valid: false, error: "Resource names cannot start with underscore" }; // [!code highlight] + } // [!code highlight] + return true; // [!code highlight] + } // [!code highlight] + } +}) +``` + +#### Permissions Required + +To manage custom resources, users need the following permissions from the `ac` resource: + +- `ac:create` - Create new resources +- `ac:read` - List and view resources +- `ac:update` - Update resource permissions +- `ac:delete` - Delete resources + +By default, only organization owners and admins have these permissions. + +### Custom Role Creation Logic + +You can extend the default permission checking logic for role creation by providing a `canCreateRole` callback: + +```ts +organization({ + dynamicAccessControl: { + enabled: true, + canCreateRole: async ({ member, organizationId, permission, roleName }) => { + // Check subscription + const subscription = await getSubscription(organizationId); + if (!subscription.premium) { + return { + allow: false, + message: "Premium subscription required to create custom roles" + }; + } + + // Custom logic: Only admins can create roles with more than 5 resources + if (Object.keys(permission).length > 5 && member.role !== "admin") { + return { + allow: false, + message: "Only admins can create roles with more than 5 resources" + }; + } + + // Superadmin can bypass all permission checks + if (member.role === "superadmin") { + return { allow: true, bypass: true }; + } + + // Use default permission logic (check ac:create and delegation) + return { allow: true }; + } + } +}) +``` + +The callback returns a discriminated union: +- `{ allow: true }` - Allow and continue with default permission/delegation checks +- `{ allow: true, bypass: true }` - Allow and skip all checks (⚠️ use with caution) +- `{ allow: false, message: "..." }` - Deny with a custom error message + +#### Database Schema + +When custom resources are enabled, a new `organizationResource` table is added: + +Table Name: `organizationResource` + + + +#### Use Cases + +Custom resources are particularly useful for: + +1. **Multi-tenant SaaS Applications**: Each organization can define resources specific to their workflow + ```ts + // Marketing Agency + resources: { campaign: ["create", "launch", "pause"], lead: ["qualify", "convert"] } + + // Software Company + resources: { project: ["create", "deploy"], sprint: ["start", "close"] } + ``` + +2. **Domain-Specific Permissions**: Model your exact business domain + ```ts + resources: { + invoice: ["create", "send", "void", "refund"], + contract: ["draft", "review", "sign", "terminate"] + } + ``` + +3. **Flexible Access Control**: Allow organizations to create their own permission models + ```ts + // Organization A might need + resources: { document: ["read", "edit", "approve", "publish"] } + + // Organization B might need + resources: { document: ["view", "comment", "share"] } + ``` + +#### Best Practices + +1. **Start with defaults**: Define common resources in your `ac` instance as defaults +2. **Document permissions**: Clearly document what each permission means for custom resources +3. **Validate carefully**: Use `resourceNameValidation` to enforce naming conventions +4. **Plan for growth**: Set appropriate `maximumResourcesPerOrganization` limits +5. **Clean up unused resources**: Regularly audit and remove resources that are no longer needed +6. **Test permission changes**: Test roles thoroughly after modifying resource permissions + --- diff --git a/docs/content/docs/plugins/sso.mdx b/docs/content/docs/plugins/sso.mdx index 6c5ba388fac..0530f7cf0e8 100644 --- a/docs/content/docs/plugins/sso.mdx +++ b/docs/content/docs/plugins/sso.mdx @@ -707,7 +707,9 @@ mapping: { ## Domain verification Domain verification allows your application to automatically trust a new SSO provider -by automatically validating ownership via the associated domain: +by automatically validating ownership via the associated domain. + +When a provider's domain is verified, it is also trusted for **automatic account linking**. This means that if a user signs in with an SSO provider (OIDC or SAML) and an existing account with the same email exists, the accounts will be linked automatically — as long as the user's email domain matches the provider's verified domain. @@ -870,8 +872,6 @@ For a detailed guide on setting up SAML SSO with examples for Okta and testing w **disableImplicitSignUp**: Disable implicit sign up for new users. -**trustEmailVerified** — Trusts the `email_verified` flag from the provider. ⚠️ Use this with caution — it can lead to account takeover if misused. Only enable it if users **cannot freely register new providers**. You can prevent that by using `disabledPaths` or other safeguards to block provider registration from the client. - If you want to allow account linking for specific trusted providers, enable the `accountLinking` option in your auth config and specify those providers in the `trustedProviders` list. { + const ac = createAccessControl({ + ...defaultStatements, + }); + const owner = ac.newRole({ + ...ownerAc.statements, + }); + const admin = ac.newRole({ + ...adminAc.statements, + }); + const member = ac.newRole({ + ...memberAc.statements, + }); + + const { auth, customFetchImpl, signInWithTestUser } = await getTestInstance({ + plugins: [ + organization({ + ac, + roles: { + owner, + admin, + member, + }, + dynamicAccessControl: { + enabled: true, + enableCustomResources: true, + maximumResourcesPerOrganization: 10, + }, + }), + ], + }); + + const authClient = createAuthClient({ + baseURL: "http://localhost:3000", + plugins: [ + organizationClient({ + ac, + roles: { + owner, + admin, + member, + }, + dynamicAccessControl: { + enabled: true, + enableCustomResources: true, + }, + }), + ], + fetchOptions: { + customFetchImpl, + }, + }); + + const { headers, user } = await signInWithTestUser(); + + let organizationId: string; + + beforeAll(async () => { + // Create an organization for testing + const org = await authClient.organization.create( + { + name: "Test Organization", + slug: "test-org-resources", + }, + { + headers, + }, + ); + organizationId = org.data!.id; + }); + + it("should create a custom resource", async () => { + const result = await authClient.organization.createOrgResource( + { + organizationId, + resource: "project", + permissions: ["create", "read", "update", "delete", "archive"], + }, + { + headers, + }, + ); + + expect(result.data?.success).toBe(true); + expect(result.data?.resource.resource).toBe("project"); + expect(result.data?.resource.permissions).toEqual([ + "create", + "read", + "update", + "delete", + "archive", + ]); + expect(result.data?.resource.organizationId).toBe(organizationId); + }); + + it("should list all resources including default and custom", async () => { + const result = await authClient.organization.listOrgResources( + { + organizationId, + }, + { + headers, + }, + ); + + expect(result.data?.resources).toBeDefined(); + const resources = result.data!.resources; + + // Should have default resources + const orgResource = resources.find((r) => r.resource === "organization"); + expect(orgResource).toBeDefined(); + expect(orgResource?.isProtected).toBe(true); + expect(orgResource?.isCustom).toBe(false); + + // Should have custom resource + const projectResource = resources.find((r) => r.resource === "project"); + expect(projectResource).toBeDefined(); + expect(projectResource?.isProtected).toBe(false); + expect(projectResource?.isCustom).toBe(true); + }); + + it("should get a specific custom resource", async () => { + const result = await authClient.organization.getOrgResource( + { + organizationId, + resource: "project", + }, + { + headers, + }, + ); + + expect(result.data?.resource.resource).toBe("project"); + expect(result.data?.resource.permissions).toEqual([ + "create", + "read", + "update", + "delete", + "archive", + ]); + expect(result.data?.resource.isCustom).toBe(true); + }); + + it("should update a custom resource permissions", async () => { + const result = await authClient.organization.updateOrgResource( + { + organizationId, + resource: "project", + permissions: ["read", "write", "delete"], + }, + { + headers, + }, + ); + + expect(result.data?.success).toBe(true); + expect(result.data?.resource.permissions).toEqual([ + "read", + "write", + "delete", + ]); + }); + + it("should create a role using custom resource", async () => { + const result = await authClient.organization.createOrgRole( + { + organizationId, + role: "project_manager", + permission: { + project: ["read", "write"], + member: ["read"], + }, + }, + { + headers, + }, + ); + + expect(result.data?.success).toBe(true); + expect(result.data?.roleData.role).toBe("project_manager"); + }); + + it("should auto-create resources when creating a role with non-existent resources", async () => { + // Create a role with a resource that doesn't exist yet + const result = await authClient.organization.createOrgRole( + { + organizationId, + role: "task_manager", + permission: { + task: ["create", "read", "update", "complete"], // "task" doesn't exist yet + project: ["read"], // "project" exists from previous test + }, + }, + { + headers, + }, + ); + + expect(result.data?.success).toBe(true); + expect(result.data?.roleData.role).toBe("task_manager"); + + // Verify the "task" resource was auto-created + const resourceResult = await authClient.organization.getOrgResource( + { + organizationId, + resource: "task", + }, + { + headers, + }, + ); + + expect(resourceResult.data?.resource.resource).toBe("task"); + expect(resourceResult.data?.resource.permissions).toEqual([ + "create", + "read", + "update", + "complete", + ]); + expect(resourceResult.data?.resource.isCustom).toBe(true); + }); + + it("should prevent deleting resource that is in use by roles", async () => { + const result = await authClient.organization.deleteOrgResource( + { + organizationId, + resource: "project", + }, + { + headers, + onError: (ctx) => { + return ctx.response; + }, + }, + ); + + expect(result.error).toBeDefined(); + expect(result.error?.message).toContain("in use"); + }); + + it("should delete a role first, then delete the custom resource", async () => { + // Delete the role first + const deleteRoleResult = await authClient.organization.deleteOrgRole( + { + organizationId, + roleName: "project_manager", + }, + { + headers, + }, + ); + expect(deleteRoleResult.data?.success).toBe(true); + + // Now delete the resource + const deleteResourceResult = + await authClient.organization.deleteOrgResource( + { + organizationId, + resource: "project", + }, + { + headers, + }, + ); + + expect(deleteResourceResult.data?.success).toBe(true); + }); + + it("should reject invalid resource names", async () => { + const testCases = [ + { name: "Invalid-Name", reason: "contains dash" }, + { name: "Invalid Name", reason: "contains space" }, + { name: "", reason: "empty" }, + ]; + + for (const { name } of testCases) { + const result = await authClient.organization.createOrgResource( + { + organizationId, + resource: name, + permissions: ["read"], + }, + { + headers, + onError: (ctx) => { + return ctx.response; + }, + }, + ); + + expect(result.error).toBeDefined(); + } + }); + + it("should accept uppercase in resource names", async () => { + const result = await authClient.organization.createOrgResource( + { + organizationId, + resource: "MyCustomResource", + permissions: ["read", "write"], + }, + { + headers, + }, + ); + + expect(result.data?.success).toBe(true); + expect(result.data?.resource.resource).toBe("MyCustomResource"); + }); + + it("should reject reserved resource names", async () => { + const reservedNames = [ + "organization", + "member", + "invitation", + "team", + "ac", + ]; + + for (const name of reservedNames) { + const result = await authClient.organization.createOrgResource( + { + organizationId, + resource: name, + permissions: ["read"], + }, + { + headers, + onError: (ctx) => { + return ctx.response; + }, + }, + ); + + expect(result.error).toBeDefined(); + expect(result.error?.message).toContain("reserved"); + } + }); + + it("should enforce maximum resources limit", async () => { + // Create resources up to the limit (10 total, already have defaults) + const customResourceCount = 10; + for (let i = 0; i < customResourceCount; i++) { + await authClient.organization.createOrgResource( + { + organizationId, + resource: `resource_${i}`, + permissions: ["read"], + }, + { + headers, + onError: (ctx) => { + return ctx.response; + }, + }, + ); + } + + // Try to create one more, should fail + const result = await authClient.organization.createOrgResource( + { + organizationId, + resource: "over_limit", + permissions: ["read"], + }, + { + headers, + onError: (ctx) => { + return ctx.response; + }, + }, + ); + + expect(result.error).toBeDefined(); + expect(result.error?.message).toContain("too many"); + }); + + it("should prevent updating reserved resources", async () => { + const result = await authClient.organization.updateOrgResource( + { + organizationId, + resource: "organization", + permissions: ["custom_permission"], + }, + { + headers, + onError: (ctx) => { + return ctx.response; + }, + }, + ); + + expect(result.error).toBeDefined(); + expect(result.error?.message).toContain("reserved"); + }); + + it("should check permissions when creating resources", async () => { + // Create a member user + const { headers: memberHeaders } = await signInWithTestUser(); + + // Add member to organization + await auth.api.addMember({ + body: { + userId: ( + await authClient.getSession({ + headers: memberHeaders, + }) + ).data?.user.id!, + organizationId, + role: "member", + }, + }); + + // Set active organization for member + await authClient.organization.setActive( + { + organizationId, + }, + { + headers: memberHeaders, + }, + ); + + // Member should not be able to create resources (no ac:create permission) + const result = await authClient.organization.createOrgResource( + { + resource: "unauthorized_resource", + permissions: ["read"], + }, + { + headers: memberHeaders, + onError: (ctx) => { + return ctx.response; + }, + }, + ); + + expect(result.error).toBeDefined(); + expect(result.error?.message).toContain("not allowed"); + }); +}); diff --git a/package.json b/package.json index 6dd9b44868c..c397d7e3859 100644 --- a/package.json +++ b/package.json @@ -18,14 +18,22 @@ "release:canary": "turbo build --filter=./packages/* && bumpp && pnpm -r publish --access public --tag canary --no-git-checks", "bump": "bumpp", "test": "turbo test --continue --filter=./packages/* --filter=./test", + "coverage": "turbo coverage --filter=./packages/* --filter=./test -- --coverage.reporter=json --coverage.provider=istanbul", + "postcoverage": "pnpm coverage:collect && pnpm coverage:merge && pnpm coverage:report", + "coverage:merge": "nyc merge coverage/raw coverage/merged/merged-coverage.json", + "coverage:collect": "rm -rf coverage && mkdir -p coverage/raw && for f in $(find packages test -name coverage-final.json); do pkg=$(basename \"$(dirname \"$(dirname \"$f\")\")\"); cp \"$f\" \"./coverage/raw/$pkg-coverage-final.json\"; done", + "coverage:report": "nyc --temp-dir=./coverage/raw report --reporter=lcov --reporter text-summary", + "coverage:open": "open coverage/lcov-report/index.html", "e2e:smoke": "turbo e2e:smoke --filter=./e2e/*", "e2e:integration": "turbo e2e:integration --filter=./e2e/*", "typecheck": "tsc --build" }, "devDependencies": { + "nyc": "^17.1.0", "@biomejs/biome": "2.3.8", "@types/bun": "^1.3.3", "@types/node": "^24.10.1", + "@vitest/coverage-istanbul": "^4.0.14", "bumpp": "^10.3.2", "cspell": "^9.4.0", "knip": "^5.71.0", @@ -33,6 +41,6 @@ "tinyglobby": "^0.2.15", "turbo": "^2.6.3", "typescript": "catalog:", - "vitest": "catalog:" + "vitest": "catalog:vitest" } } diff --git a/packages/better-auth/package.json b/packages/better-auth/package.json index ce850a113e2..60df24a7a0d 100644 --- a/packages/better-auth/package.json +++ b/packages/better-auth/package.json @@ -1,6 +1,6 @@ { "name": "better-auth", - "version": "1.4.6-beta.3", + "version": "1.4.6-beta.4", "description": "The most comprehensive authentication framework for TypeScript.", "type": "module", "license": "MIT", @@ -26,6 +26,7 @@ "scripts": { "build": "tsdown", "test": "vitest", + "coverage": "vitest run --coverage", "lint:package": "publint run --strict", "test:adapters": "vitest run --config vitest.config.adapters.ts", "prepare": "prisma generate --schema ./src/adapters/prisma-adapter/test/base.prisma", diff --git a/packages/better-auth/src/adapters/drizzle-adapter/drizzle-adapter.ts b/packages/better-auth/src/adapters/drizzle-adapter/drizzle-adapter.ts index 5e1c3874541..7443f06e68b 100644 --- a/packages/better-auth/src/adapters/drizzle-adapter/drizzle-adapter.ts +++ b/packages/better-auth/src/adapters/drizzle-adapter/drizzle-adapter.ts @@ -579,7 +579,11 @@ export const drizzleAdapter = (db: DB, config: DrizzleAdapterConfig) => { usePlural: config.usePlural ?? false, debugLogs: config.debugLogs ?? false, supportsUUIDs: config.provider === "pg" ? true : false, - supportsJSON: config.provider === "pg" ? true : false, + supportsJSON: + config.provider === "pg" // even though mysql also supports it, mysql requires to pass stringified json anyway. + ? true + : false, + supportsArrays: config.provider === "pg" ? true : false, transaction: (config.transaction ?? false) ? (cb) => diff --git a/packages/better-auth/src/adapters/kysely-adapter/kysely-adapter.ts b/packages/better-auth/src/adapters/kysely-adapter/kysely-adapter.ts index 5b8707ba9ee..6c631851239 100644 --- a/packages/better-auth/src/adapters/kysely-adapter/kysely-adapter.ts +++ b/packages/better-auth/src/adapters/kysely-adapter/kysely-adapter.ts @@ -536,8 +536,10 @@ export const kyselyAdapter = ( if (or) { query = query.where((eb) => eb.or(or.map((expr) => expr(eb)))); } - const res = await query.execute(); - return res.length; + const res = (await query.executeTakeFirst()).numUpdatedRows; + return res > Number.MAX_SAFE_INTEGER + ? Number.MAX_SAFE_INTEGER + : Number(res); }, async count({ model, where }) { const { and, or } = convertWhereClause(model, where); @@ -581,7 +583,10 @@ export const kyselyAdapter = ( if (or) { query = query.where((eb) => eb.or(or.map((expr) => expr(eb)))); } - return (await query.execute()).length; + const res = (await query.executeTakeFirst()).numDeletedRows; + return res > Number.MAX_SAFE_INTEGER + ? Number.MAX_SAFE_INTEGER + : Number(res); }, options: config, }; @@ -605,7 +610,11 @@ export const kyselyAdapter = ( config?.type === "sqlite" || config?.type === "mssql" || !config?.type ? false : true, - supportsJSON: false, + supportsJSON: + config?.type === "postgres" + ? true // even if there is JSON support, only pg supports passing direct json, all others must stringify + : false, + supportsArrays: false, // Even if field supports JSON, we must pass stringified arrays to the database. supportsUUIDs: config?.type === "postgres" ? true : false, transaction: config?.transaction ? (cb) => diff --git a/packages/better-auth/src/adapters/prisma-adapter/prisma-adapter.ts b/packages/better-auth/src/adapters/prisma-adapter/prisma-adapter.ts index 51e82b3081d..9b8a94a26f1 100644 --- a/packages/better-auth/src/adapters/prisma-adapter/prisma-adapter.ts +++ b/packages/better-auth/src/adapters/prisma-adapter/prisma-adapter.ts @@ -272,13 +272,10 @@ export const prismaAdapter = (prisma: PrismaClient, config: PrismaConfig) => { const selects = convertSelect(select, model, join); - let result = ( - await db[model]!.findMany({ - where: whereClause, - select: selects, - take: 1, - }) - )[0]; + let result = await db[model]!.findFirst({ + where: whereClause, + select: selects, + }); // transform the resulting `include` items to use better-auth expected field names if (join && result) { @@ -414,6 +411,10 @@ export const prismaAdapter = (prisma: PrismaClient, config: PrismaConfig) => { usePlural: config.usePlural ?? false, debugLogs: config.debugLogs ?? false, supportsUUIDs: config.provider === "postgresql" ? true : false, + supportsArrays: + config.provider === "postgresql" || config.provider === "mongodb" + ? true + : false, transaction: (config.transaction ?? false) ? (cb) => diff --git a/packages/better-auth/src/adapters/tests/basic.ts b/packages/better-auth/src/adapters/tests/basic.ts index acb818f8441..baed588262b 100644 --- a/packages/better-auth/src/adapters/tests/basic.ts +++ b/packages/better-auth/src/adapters/tests/basic.ts @@ -2899,6 +2899,91 @@ export const getNormalTestSuiteTests = ( expect(Array.isArray(result4?.session)).toBe(true); expect(result4?.session).toHaveLength(0); }, + "create - should support arrays": { + migrateBetterAuth: { + plugins: [ + { + id: "string-arrays-test", + schema: { + testModel: { + fields: { + stringArray: { + type: "string[]", + required: true, + }, + numberArray: { + type: "number[]", + required: true, + }, + }, + }, + }, + } satisfies BetterAuthPlugin, + ], + }, + test: async () => { + const result = await adapter.create<{ + id: string; + stringArray: string[]; + numberArray: number[]; + }>({ + model: "testModel", + data: { stringArray: ["1", "2", "3"], numberArray: [1, 2, 3] }, + }); + expect(result.stringArray).toEqual(["1", "2", "3"]); + expect(result.numberArray).toEqual([1, 2, 3]); + + const findResult = await adapter.findOne<{ + stringArray: string[]; + numberArray: number[]; + }>({ + model: "testModel", + where: [{ field: "id", value: result.id }], + }); + expect(findResult).toEqual(result); + expect(findResult?.stringArray).toEqual(["1", "2", "3"]); + expect(findResult?.numberArray).toEqual([1, 2, 3]); + }, + }, + "create - should support json": { + migrateBetterAuth: { + plugins: [ + { + id: "json-test", + schema: { + testModel: { + fields: { + json: { + type: "json", + required: true, + }, + }, + }, + }, + } satisfies BetterAuthPlugin, + ], + }, + test: async () => { + const result = await adapter.create<{ + id: string; + json: Record; + }>({ + model: "testModel", + data: { json: { foo: "bar" } }, + }); + expect(result.json).toEqual({ foo: "bar" }); + + const findResult = await adapter.findOne<{ + json: Record; + }>({ + model: "testModel", + where: [{ field: "id", value: result.id }], + }); + expect(findResult).toEqual(result); + expect(findResult?.json).toEqual({ foo: "bar" }); + console.log(findResult); + }, + }, }; }; diff --git a/packages/better-auth/src/context/__snapshots__/create-context.test.ts.snap b/packages/better-auth/src/context/__snapshots__/create-context.test.ts.snap index 01ed2159f74..5a1e05155cb 100644 --- a/packages/better-auth/src/context/__snapshots__/create-context.test.ts.snap +++ b/packages/better-auth/src/context/__snapshots__/create-context.test.ts.snap @@ -20,6 +20,7 @@ exports[`base context creation > should match config 1`] = ` "disableTransformInput": false, "disableTransformJoin": false, "disableTransformOutput": false, + "supportsArrays": false, "supportsBooleans": true, "supportsDates": true, "supportsJSON": false, diff --git a/packages/better-auth/src/context/create-context.ts b/packages/better-auth/src/context/create-context.ts index 58865b1f6e2..eb602de589a 100644 --- a/packages/better-auth/src/context/create-context.ts +++ b/packages/better-auth/src/context/create-context.ts @@ -170,7 +170,9 @@ export async function createAuthContext( socialProviders: providers, options, oauthConfig: { - storeStateStrategy: options.account?.storeStateStrategy || "database", + storeStateStrategy: + options.account?.storeStateStrategy || + (options.database ? "database" : "cookie"), skipStateCookieCheck: !!options.account?.skipStateCookieCheck, }, tables, diff --git a/packages/better-auth/src/cookies/index.ts b/packages/better-auth/src/cookies/index.ts index c85aff20a14..27e05a492cd 100644 --- a/packages/better-auth/src/cookies/index.ts +++ b/packages/better-auth/src/cookies/index.ts @@ -21,7 +21,7 @@ import { parseUserOutput } from "../db/schema"; import type { Session, User } from "../types"; import { getDate } from "../utils/date"; import { getBaseURL } from "../utils/url"; -import { createSessionStore } from "./session-store"; +import { createAccountStore, createSessionStore } from "./session-store"; export function createCookieGetter(options: BetterAuthOptions) { const secure = @@ -307,6 +307,30 @@ export function deleteSessionCookie( maxAge: 0, }); + if (ctx.context.options.account?.storeAccountCookie) { + ctx.setCookie(ctx.context.authCookies.accountData.name, "", { + ...ctx.context.authCookies.accountData.options, + maxAge: 0, + }); + + //clean up the account data chunks + const accountStore = createAccountStore( + ctx.context.authCookies.accountData.name, + ctx.context.authCookies.accountData.options, + ctx, + ); + const cleanCookies = accountStore.clean(); + accountStore.setCookies(cleanCookies); + } + + if (ctx.context.oauthConfig.storeStateStrategy === "cookie") { + const stateCookie = ctx.context.createAuthCookie("oauth_state"); + ctx.setCookie(stateCookie.name, "", { + ...stateCookie.attributes, + maxAge: 0, + }); + } + // Use createSessionStore to clean up all session data chunks const sessionStore = createSessionStore( ctx.context.authCookies.sessionData.name, diff --git a/packages/better-auth/src/cookies/session-store.ts b/packages/better-auth/src/cookies/session-store.ts index 02e7efa82d7..f519f3ecc2a 100644 --- a/packages/better-auth/src/cookies/session-store.ts +++ b/packages/better-auth/src/cookies/session-store.ts @@ -229,7 +229,7 @@ const storeFactory = }; export const createSessionStore = storeFactory("Session"); -const createAccountStore = storeFactory("Account"); +export const createAccountStore = storeFactory("Account"); export function getChunkedCookie( ctx: GenericEndpointContext, diff --git a/packages/better-auth/src/db/get-migration.ts b/packages/better-auth/src/db/get-migration.ts index f6c471763bb..69cd1089c73 100644 --- a/packages/better-auth/src/db/get-migration.ts +++ b/packages/better-auth/src/db/get-migration.ts @@ -9,9 +9,11 @@ import { createLogger } from "@better-auth/core/env"; import type { AlterTableBuilder, AlterTableColumnAlteringBuilder, + ColumnDataType, CreateIndexBuilder, CreateTableBuilder, Kysely, + RawBuilder, } from "kysely"; import { sql } from "kysely"; import { createKyselyAdapter } from "../adapters/kysely-adapter/dialect"; @@ -278,7 +280,12 @@ export async function getMigrations(config: BetterAuthOptions) { function getType(field: DBFieldAttribute, fieldName: string) { const type = field.type; - const typeMap = { + const provider = dbType || "sqlite"; + type StringOnlyUnion = T extends string ? T : never; + const typeMap: Record< + StringOnlyUnion | "id" | "foreignKeyId", + Record> + > = { string: { sqlite: "text", postgres: "text", @@ -357,18 +364,24 @@ export async function getMigrations(config: BetterAuthOptions) { : "varchar(36)", sqlite: useNumberId ? "integer" : "text", }, + "string[]": { + sqlite: "text", + postgres: "jsonb", + mysql: "json", + mssql: "varchar(8000)", + }, + "number[]": { + sqlite: "text", + postgres: "jsonb", + mysql: "json", + mssql: "varchar(8000)", + }, } as const; if (fieldName === "id" || field.references?.field === "id") { if (fieldName === "id") { - return typeMap.id[dbType!]; + return typeMap.id[provider]; } - return typeMap.foreignKeyId[dbType!]; - } - if (dbType === "sqlite" && (type === "string[]" || type === "number[]")) { - return "text"; - } - if (type === "string[]" || type === "number[]") { - return "jsonb"; + return typeMap.foreignKeyId[provider]; } if (Array.isArray(type)) { return "text"; @@ -378,7 +391,7 @@ export async function getMigrations(config: BetterAuthOptions) { `Unsupported field type '${String(type)}' for field '${fieldName}'. Allowed types are: string, number, boolean, date, string[], number[]. If you need to store structured data, store it as a JSON string (type: "string") or split it into primitive fields. See https://better-auth.com/docs/advanced/schema#additional-fields`, ); } - return typeMap[type]![dbType || "sqlite"]; + return typeMap[type][provider]; } const getModelName = initGetModelName({ schema: getAuthTables(config), diff --git a/packages/better-auth/src/oauth2/link-account.test.ts b/packages/better-auth/src/oauth2/link-account.test.ts index 1302662fdfe..bb21d37cd20 100644 --- a/packages/better-auth/src/oauth2/link-account.test.ts +++ b/packages/better-auth/src/oauth2/link-account.test.ts @@ -231,6 +231,167 @@ describe("oauth2 - email verification on link", async () => { }); }); +describe("oauth2 - account linking without trustedProviders", async () => { + const { auth, client, cookieSetter } = await getTestInstance({ + socialProviders: { + google: { + clientId: "test", + clientSecret: "test", + enabled: true, + }, + }, + emailAndPassword: { + enabled: true, + }, + account: { + accountLinking: { + enabled: true, + trustedProviders: [], + }, + }, + }); + + const ctx = await auth.$context; + + it("should deny account linking when provider is not trusted and email is not verified", async () => { + const testEmail = "untrusted@example.com"; + + await ctx.adapter.create({ + model: "user", + data: { + id: "existing-user-id", + email: testEmail, + name: "Existing User", + emailVerified: true, + createdAt: new Date(), + updatedAt: new Date(), + }, + }); + + server.use( + http.post("https://oauth2.googleapis.com/token", async () => { + const profile = { + email: testEmail, + email_verified: false, + name: "Test User", + sub: "google_untrusted_123", + iat: 1234567890, + exp: 1234567890, + aud: "test", + iss: "test", + }; + const idToken = await signJWT(profile, DEFAULT_SECRET); + return HttpResponse.json({ + access_token: "test_access_token", + refresh_token: "test_refresh_token", + id_token: idToken, + }); + }), + ); + + const oAuthHeaders = new Headers(); + const signInRes = await client.signIn.social({ + provider: "google", + callbackURL: "/", + fetchOptions: { + onSuccess: cookieSetter(oAuthHeaders), + }, + }); + + const state = new URL(signInRes.data!.url!).searchParams.get("state") || ""; + let redirectLocation = ""; + await client.$fetch("/callback/google", { + query: { state, code: "test_code" }, + method: "GET", + headers: oAuthHeaders, + onError(context) { + redirectLocation = context.response.headers.get("location") || ""; + }, + }); + + expect(redirectLocation).toContain("error=account_not_linked"); + + const accounts = await ctx.adapter.findMany<{ providerId: string }>({ + model: "account", + where: [{ field: "userId", value: "existing-user-id" }], + }); + const googleAccount = accounts.find((a) => a.providerId === "google"); + expect(googleAccount).toBeUndefined(); + }); + + it("should allow account linking when email is verified by provider", async () => { + const testEmail = "verified-provider@example.com"; + + await ctx.adapter.create({ + model: "user", + data: { + id: "existing-user-verified", + email: testEmail, + name: "Existing User", + emailVerified: false, + createdAt: new Date(), + updatedAt: new Date(), + }, + }); + + server.use( + http.post("https://oauth2.googleapis.com/token", async () => { + const profile = { + email: testEmail, + email_verified: true, + name: "Test User", + sub: "google_verified_456", + iat: 1234567890, + exp: 1234567890, + aud: "test", + iss: "test", + }; + const idToken = await signJWT(profile, DEFAULT_SECRET); + return HttpResponse.json({ + access_token: "test_access_token", + refresh_token: "test_refresh_token", + id_token: idToken, + }); + }), + ); + + const oAuthHeaders = new Headers(); + const signInRes = await client.signIn.social({ + provider: "google", + callbackURL: "/dashboard", + fetchOptions: { + onSuccess: cookieSetter(oAuthHeaders), + }, + }); + + const state = new URL(signInRes.data!.url!).searchParams.get("state") || ""; + let redirectLocation = ""; + await client.$fetch("/callback/google", { + query: { state, code: "test_code" }, + method: "GET", + headers: oAuthHeaders, + onError(context) { + redirectLocation = context.response.headers.get("location") || ""; + }, + }); + + expect(redirectLocation).not.toContain("error=account_not_linked"); + + const user = await ctx.adapter.findOne<{ id: string }>({ + model: "user", + where: [{ field: "email", value: testEmail }], + }); + expect(user).toBeTruthy(); + + const accounts = await ctx.adapter.findMany<{ providerId: string }>({ + model: "account", + where: [{ field: "userId", value: user!.id }], + }); + const googleAccount = accounts.find((a) => a.providerId === "google"); + expect(googleAccount).toBeTruthy(); + }); +}); + describe("oauth2 - override user info on sign-in", async () => { const { auth, client, cookieSetter } = await getTestInstance({ socialProviders: { diff --git a/packages/better-auth/src/oauth2/link-account.ts b/packages/better-auth/src/oauth2/link-account.ts index 71734a47c40..072cd89240d 100644 --- a/packages/better-auth/src/oauth2/link-account.ts +++ b/packages/better-auth/src/oauth2/link-account.ts @@ -7,20 +7,17 @@ import { setTokenUtil } from "./utils"; export async function handleOAuthUserInfo( c: GenericEndpointContext, - { - userInfo, - account, - callbackURL, - disableSignUp, - overrideUserInfo, - }: { + opts: { userInfo: Omit; account: Omit; callbackURL?: string | undefined; disableSignUp?: boolean | undefined; overrideUserInfo?: boolean | undefined; + isTrustedProvider?: boolean | undefined; }, ) { + const { userInfo, account, callbackURL, disableSignUp, overrideUserInfo } = + opts; const dbUser = await c.context.internalAdapter .findOAuthUser( userInfo.email.toLowerCase(), @@ -48,9 +45,9 @@ export async function handleOAuthUserInfo( if (!hasBeenLinked) { const trustedProviders = c.context.options.account?.accountLinking?.trustedProviders; - const isTrustedProvider = trustedProviders?.includes( - account.providerId as "apple", - ); + const isTrustedProvider = + opts.isTrustedProvider || + trustedProviders?.includes(account.providerId as "apple"); if ( (!isTrustedProvider && !userInfo.emailVerified) || c.context.options.account?.accountLinking?.enabled === false diff --git a/packages/better-auth/src/oauth2/state.ts b/packages/better-auth/src/oauth2/state.ts index e54324120fc..973ae151de8 100644 --- a/packages/better-auth/src/oauth2/state.ts +++ b/packages/better-auth/src/oauth2/state.ts @@ -27,8 +27,7 @@ export async function generateState( const codeVerifier = generateRandomString(128); const state = generateRandomString(32); - const storeStateStrategy = - c.context.oauthConfig?.storeStateStrategy || "cookie"; + const storeStateStrategy = c.context.oauthConfig.storeStateStrategy; const stateData = { ...(additionalData ? additionalData : {}), @@ -99,8 +98,7 @@ export async function generateState( export async function parseState(c: GenericEndpointContext) { const state = c.query.state || c.body.state; - const storeStateStrategy = - c.context.oauthConfig.storeStateStrategy || "cookie"; + const storeStateStrategy = c.context.oauthConfig.storeStateStrategy; const stateDataSchema = z.looseObject({ callbackURL: z.string(), diff --git a/packages/better-auth/src/plugins/admin/admin.test.ts b/packages/better-auth/src/plugins/admin/admin.test.ts index 579f72e8b02..578660eaa89 100644 --- a/packages/better-auth/src/plugins/admin/admin.test.ts +++ b/packages/better-auth/src/plugins/admin/admin.test.ts @@ -654,6 +654,30 @@ describe("Admin plugin", async () => { expect(res.error?.status).toBe(403); }); + it("should not allow to impersonate admins", async () => { + const userToImpersonate = await client.signUp.email({ + email: "impersonate-admin@mail.com", + password: "password", + name: "Impersonate Admin User", + }); + const userId = userToImpersonate.data?.user.id || ""; + await client.admin.setRole({ + userId, + role: "admin", + fetchOptions: { + headers: adminHeaders, + }, + }); + const res = await client.admin.impersonateUser( + { + userId, + }, + { headers: adminHeaders }, + ); + + expect(res.error?.status).toBe(403); + }); + it("should filter impersonated sessions", async () => { const { headers } = await signInWithUser(data.email, data.password); const res = await client.listSessions({ diff --git a/packages/better-auth/src/plugins/admin/error-codes.ts b/packages/better-auth/src/plugins/admin/error-codes.ts index dd2dd595bbf..eabb9239f83 100644 --- a/packages/better-auth/src/plugins/admin/error-codes.ts +++ b/packages/better-auth/src/plugins/admin/error-codes.ts @@ -28,4 +28,5 @@ export const ADMIN_ERROR_CODES = defineErrorCodes({ YOU_CANNOT_REMOVE_YOURSELF: "You cannot remove yourself", YOU_ARE_NOT_ALLOWED_TO_SET_NON_EXISTENT_VALUE: "You are not allowed to set a non-existent role value", + YOU_CANNOT_IMPERSONATE_ADMINS: "You cannot impersonate admins", }); diff --git a/packages/better-auth/src/plugins/admin/routes.ts b/packages/better-auth/src/plugins/admin/routes.ts index 420766a4c32..12ec22f7a22 100644 --- a/packages/better-auth/src/plugins/admin/routes.ts +++ b/packages/better-auth/src/plugins/admin/routes.ts @@ -1005,9 +1005,9 @@ export const impersonateUser = (opts: AdminOptions) => }); } - const targetUser = await ctx.context.internalAdapter.findUserById( + const targetUser = (await ctx.context.internalAdapter.findUserById( ctx.body.userId, - ); + )) as UserWithRole | null; if (!targetUser) { throw new APIError("NOT_FOUND", { @@ -1015,6 +1015,26 @@ export const impersonateUser = (opts: AdminOptions) => }); } + const adminRoles = ( + Array.isArray(opts.adminRoles) + ? opts.adminRoles + : opts.adminRoles?.split(",") || [] + ).map((role) => role.trim()); + const targetUserRole = ( + targetUser.role || + opts.defaultRole || + "user" + ).split(","); + if ( + opts.allowImpersonatingAdmins !== true && + (targetUserRole.some((role) => adminRoles.includes(role)) || + opts.adminUserIds?.includes(targetUser.id)) + ) { + throw new APIError("FORBIDDEN", { + message: ADMIN_ERROR_CODES.YOU_CANNOT_IMPERSONATE_ADMINS, + }); + } + const session = await ctx.context.internalAdapter.createSession( targetUser.id, true, diff --git a/packages/better-auth/src/plugins/admin/types.ts b/packages/better-auth/src/plugins/admin/types.ts index b2eef1b2da5..70931e16ea2 100644 --- a/packages/better-auth/src/plugins/admin/types.ts +++ b/packages/better-auth/src/plugins/admin/types.ts @@ -76,6 +76,12 @@ export interface AdminOptions { * By default, the message is "You have been banned from this application" */ bannedUserMessage?: string | undefined; + /** + * Whether to allow impersonating other admins + * + * @default false + */ + allowImpersonatingAdmins?: boolean | undefined; } export type InferAdminRolesFromOption = diff --git a/packages/better-auth/src/plugins/magic-link/index.ts b/packages/better-auth/src/plugins/magic-link/index.ts index 084e5d03da6..fecb56528a7 100644 --- a/packages/better-auth/src/plugins/magic-link/index.ts +++ b/packages/better-auth/src/plugins/magic-link/index.ts @@ -315,31 +315,32 @@ export const magicLink = (options: MagicLinkOptions) => { ? decodeURIComponent(ctx.query.errorCallbackURL) : callbackURL, ctx.context.baseURL, - ).toString(); + ); + + function redirectWithError(error: string): never { + errorCallbackURL.searchParams.set("error", error); + throw ctx.redirect(errorCallbackURL.toString()); + } + const newUserCallbackURL = new URL( ctx.query.newUserCallbackURL ? decodeURIComponent(ctx.query.newUserCallbackURL) : callbackURL, ctx.context.baseURL, ).toString(); - const toRedirectTo = callbackURL?.startsWith("http") - ? callbackURL - : callbackURL - ? `${ctx.context.options.baseURL}${callbackURL}` - : ctx.context.options.baseURL; const storedToken = await storeToken(ctx, token); const tokenValue = await ctx.context.internalAdapter.findVerificationValue( storedToken, ); if (!tokenValue) { - throw ctx.redirect(`${errorCallbackURL}?error=INVALID_TOKEN`); + redirectWithError("INVALID_TOKEN"); } if (tokenValue.expiresAt < new Date()) { await ctx.context.internalAdapter.deleteVerificationValue( tokenValue.id, ); - throw ctx.redirect(`${errorCallbackURL}?error=EXPIRED_TOKEN`); + redirectWithError("EXPIRED_TOKEN"); } await ctx.context.internalAdapter.deleteVerificationValue( tokenValue.id, @@ -363,14 +364,10 @@ export const magicLink = (options: MagicLinkOptions) => { isNewUser = true; user = newUser; if (!user) { - throw ctx.redirect( - `${errorCallbackURL}?error=failed_to_create_user`, - ); + redirectWithError("failed_to_create_user"); } } else { - throw ctx.redirect( - `${errorCallbackURL}?error=new_user_signup_disabled`, - ); + redirectWithError("new_user_signup_disabled"); } } @@ -385,9 +382,7 @@ export const magicLink = (options: MagicLinkOptions) => { ); if (!session) { - throw ctx.redirect( - `${errorCallbackURL}?error=failed_to_create_session`, - ); + redirectWithError("failed_to_create_session"); } await setSessionCookie(ctx, { diff --git a/packages/better-auth/src/plugins/magic-link/magic-link.test.ts b/packages/better-auth/src/plugins/magic-link/magic-link.test.ts index 81f27526308..1b05cdd88d0 100644 --- a/packages/better-auth/src/plugins/magic-link/magic-link.test.ts +++ b/packages/better-auth/src/plugins/magic-link/magic-link.test.ts @@ -103,6 +103,36 @@ describe("magic link", async () => { ); }); + it("should redirect to errorCallbackURL in case of error", async () => { + const errorCallbackURL = new URL("http://localhost:3000/error-page"); + errorCallbackURL.searchParams.set("foo", "bar"); + errorCallbackURL.searchParams.set("baz", "qux"); + + await client.magicLink.verify( + { + query: { + token: "invalid-token", + errorCallbackURL: errorCallbackURL.toString(), + }, + }, + { + onError(context) { + expect(context.response.status).toBe(302); + + const location = context.response.headers.get("location"); + expect(location).toBeDefined(); + + const url = new URL(location!); + expect(url.origin).toBe(errorCallbackURL.origin); + expect(url.pathname).toBe(errorCallbackURL.pathname); + expect(url.searchParams.get("foo")).toBe("bar"); + expect(url.searchParams.get("baz")).toBe("qux"); + expect(url.searchParams.get("error")).toBe("INVALID_TOKEN"); + }, + }, + ); + }); + it("should sign up with magic link", async () => { const email = "new-email@email.com"; await client.signIn.magicLink({ diff --git a/packages/better-auth/src/plugins/organization/error-codes.ts b/packages/better-auth/src/plugins/organization/error-codes.ts index 3c1bafe61df..bca274b1ab9 100644 --- a/packages/better-auth/src/plugins/organization/error-codes.ts +++ b/packages/better-auth/src/plugins/organization/error-codes.ts @@ -88,4 +88,22 @@ export const ORGANIZATION_ERROR_CODES = defineErrorCodes({ INVALID_RESOURCE: "The provided permission includes an invalid resource", ROLE_NAME_IS_ALREADY_TAKEN: "That role name is already taken", CANNOT_DELETE_A_PRE_DEFINED_ROLE: "Cannot delete a pre-defined role", + YOU_ARE_NOT_ALLOWED_TO_CREATE_A_RESOURCE: + "You are not allowed to create a resource", + YOU_ARE_NOT_ALLOWED_TO_UPDATE_A_RESOURCE: + "You are not allowed to update a resource", + YOU_ARE_NOT_ALLOWED_TO_DELETE_A_RESOURCE: + "You are not allowed to delete a resource", + YOU_ARE_NOT_ALLOWED_TO_READ_A_RESOURCE: + "You are not allowed to read a resource", + YOU_ARE_NOT_ALLOWED_TO_LIST_RESOURCES: + "You are not allowed to list resources", + TOO_MANY_RESOURCES: "This organization has too many resources", + RESOURCE_NAME_IS_ALREADY_TAKEN: "That resource name is already taken", + RESOURCE_NAME_IS_RESERVED: "That resource name is reserved", + INVALID_RESOURCE_NAME: "Invalid resource name", + RESOURCE_NOT_FOUND: "Resource not found", + RESOURCE_IS_IN_USE: + "Cannot delete resource because it is being used by existing roles", + INVALID_PERMISSIONS_ARRAY: "Permissions must be a non-empty array", }); diff --git a/packages/better-auth/src/plugins/organization/has-permission.ts b/packages/better-auth/src/plugins/organization/has-permission.ts index 1aa62a9bf2e..da4cd96bd92 100644 --- a/packages/better-auth/src/plugins/organization/has-permission.ts +++ b/packages/better-auth/src/plugins/organization/has-permission.ts @@ -3,6 +3,7 @@ import * as z from "zod"; import { APIError } from "../../api"; import type { Role } from "../access"; import { defaultRoles } from "./access"; +import { getOrganizationAccessControl } from "./load-resources"; import type { HasPermissionBaseInput } from "./permission"; import { cacheAllRoles, hasPermissionFn } from "./permission"; import type { OrganizationRole } from "./schema"; @@ -32,6 +33,15 @@ export const hasPermission = async ( input.options.ac && !input.useMemoryCache ) { + // Get the organization-specific AC instance (with merged default + custom resources) + const orgAc = input.options.dynamicAccessControl?.enableCustomResources + ? await getOrganizationAccessControl( + input.organizationId, + input.options, + ctx, + ) + : input.options.ac; + // Load roles from database const roles = await ctx.context.adapter.findMany< OrganizationRole & { permission: string } @@ -65,7 +75,8 @@ export const hasPermission = async ( }); } - acRoles[role] = input.options.ac.newRole(result.data); + // Use the organization-specific AC to create the role + acRoles[role] = orgAc.newRole(result.data); } } diff --git a/packages/better-auth/src/plugins/organization/load-resources.test.ts b/packages/better-auth/src/plugins/organization/load-resources.test.ts new file mode 100644 index 00000000000..966786f7fc3 --- /dev/null +++ b/packages/better-auth/src/plugins/organization/load-resources.test.ts @@ -0,0 +1,216 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { + clearAllResourceCache, + getDefaultReservedResourceNames, + getReservedResourceNames, + invalidateResourceCache, + validateResourceName, +} from "./load-resources"; +import type { OrganizationOptions } from "./types"; + +describe("load-resources utility functions", () => { + beforeEach(() => { + clearAllResourceCache(); + }); + + describe("getDefaultReservedResourceNames", () => { + it("should return default reserved resource names", () => { + const reserved = getDefaultReservedResourceNames(); + expect(reserved).toEqual([ + "organization", + "member", + "invitation", + "team", + "ac", + ]); + }); + }); + + describe("getReservedResourceNames", () => { + it("should return default reserved names when none configured", () => { + const options: OrganizationOptions = {}; + const reserved = getReservedResourceNames(options); + expect(reserved).toEqual([ + "organization", + "member", + "invitation", + "team", + "ac", + ]); + }); + + it("should return custom reserved names when configured", () => { + const options: OrganizationOptions = { + dynamicAccessControl: { + reservedResourceNames: ["custom", "reserved"], + }, + }; + const reserved = getReservedResourceNames(options); + expect(reserved).toEqual(["custom", "reserved"]); + }); + }); + + describe("validateResourceName", () => { + it("should accept valid alphanumeric names", () => { + const options: OrganizationOptions = {}; + expect(validateResourceName("project", options)).toEqual({ valid: true }); + expect(validateResourceName("task123", options)).toEqual({ + valid: true, + }); + expect(validateResourceName("my_resource", options)).toEqual({ + valid: true, + }); + expect(validateResourceName("Project", options)).toEqual({ valid: true }); + expect(validateResourceName("MyResource", options)).toEqual({ + valid: true, + }); + expect(validateResourceName("UPPERCASE", options)).toEqual({ + valid: true, + }); + }); + + it("should reject names with special characters", () => { + const options: OrganizationOptions = {}; + const result = validateResourceName("project-name", options); + expect(result.valid).toBe(false); + expect(result.error).toContain("alphanumeric"); + }); + + it("should reject names with spaces", () => { + const options: OrganizationOptions = {}; + const result = validateResourceName("my project", options); + expect(result.valid).toBe(false); + expect(result.error).toContain("alphanumeric"); + }); + + it("should reject empty names", () => { + const options: OrganizationOptions = {}; + const result = validateResourceName("", options); + expect(result.valid).toBe(false); + expect(result.error).toContain("between 1 and 50"); + }); + + it("should reject names longer than 50 characters", () => { + const options: OrganizationOptions = {}; + const longName = "a".repeat(51); + const result = validateResourceName(longName, options); + expect(result.valid).toBe(false); + expect(result.error).toContain("between 1 and 50"); + }); + + it("should accept names up to 50 characters", () => { + const options: OrganizationOptions = {}; + const maxLengthName = "a".repeat(50); + const result = validateResourceName(maxLengthName, options); + expect(result.valid).toBe(true); + }); + + it("should reject default reserved names", () => { + const options: OrganizationOptions = {}; + const reservedNames = [ + "organization", + "member", + "invitation", + "team", + "ac", + ]; + + for (const name of reservedNames) { + const result = validateResourceName(name, options); + expect(result.valid).toBe(false); + expect(result.error).toContain("reserved"); + } + }); + + it("should reject custom reserved names", () => { + const options: OrganizationOptions = { + dynamicAccessControl: { + reservedResourceNames: ["custom_resource", "another_one"], + }, + }; + + expect(validateResourceName("custom_resource", options).valid).toBe( + false, + ); + expect(validateResourceName("another_one", options).valid).toBe(false); + expect(validateResourceName("allowed_name", options).valid).toBe(true); + }); + + it("should apply custom validation function", () => { + const options: OrganizationOptions = { + dynamicAccessControl: { + resourceNameValidation: (name) => { + if (name.startsWith("test_")) { + return { valid: false, error: "Cannot start with test_" }; + } + return true; + }, + }, + }; + + const result1 = validateResourceName("test_resource", options); + expect(result1.valid).toBe(false); + expect(result1.error).toBe("Cannot start with test_"); + + const result2 = validateResourceName("valid_resource", options); + expect(result2.valid).toBe(true); + }); + + it("should handle custom validation returning boolean", () => { + const options: OrganizationOptions = { + dynamicAccessControl: { + resourceNameValidation: (name) => name.length <= 20, + }, + }; + + const result1 = validateResourceName("short", options); + expect(result1.valid).toBe(true); + + const result2 = validateResourceName( + "verylongnamethatexceedstwentycharacters", + options, + ); + expect(result2.valid).toBe(false); + expect(result2.error).toContain("custom validation"); + }); + }); + + describe("cache management", () => { + it("should invalidate specific organization cache", () => { + // Cache operations are tested indirectly through the main functions + // but we can test that the functions exist and don't throw + expect(() => invalidateResourceCache("org-123")).not.toThrow(); + }); + + it("should clear all cache", () => { + expect(() => clearAllResourceCache()).not.toThrow(); + }); + }); + + describe("edge cases", () => { + it("should handle underscores in resource names", () => { + const options: OrganizationOptions = {}; + expect(validateResourceName("user_profile", options).valid).toBe(true); + expect(validateResourceName("_leading_underscore", options).valid).toBe( + true, + ); + expect(validateResourceName("trailing_underscore_", options).valid).toBe( + true, + ); + }); + + it("should handle numbers in resource names", () => { + const options: OrganizationOptions = {}; + expect(validateResourceName("resource123", options).valid).toBe(true); + expect(validateResourceName("123resource", options).valid).toBe(true); + expect(validateResourceName("123", options).valid).toBe(true); + }); + + it("should accept mixed case with valid characters", () => { + const options: OrganizationOptions = {}; + expect(validateResourceName("camelCase", options).valid).toBe(true); + expect(validateResourceName("PascalCase", options).valid).toBe(true); + expect(validateResourceName("UPPERCASE", options).valid).toBe(true); + }); + }); +}); diff --git a/packages/better-auth/src/plugins/organization/load-resources.ts b/packages/better-auth/src/plugins/organization/load-resources.ts new file mode 100644 index 00000000000..fb9ca6a0231 --- /dev/null +++ b/packages/better-auth/src/plugins/organization/load-resources.ts @@ -0,0 +1,201 @@ +import type { GenericEndpointContext } from "@better-auth/core"; +import { safeJSONParse } from "@better-auth/core/utils"; +import * as z from "zod"; +import { APIError } from "../../api"; +import type { AccessControl, Statements } from "../access"; +import { createAccessControl } from "../access"; +import { defaultStatements } from "./access/statement"; +import type { OrganizationResource } from "./schema"; +import type { OrganizationOptions } from "./types"; + +/** + * In-memory cache for custom resources per organization + * Map + */ +const customResourcesCache = new Map(); + +/** + * Load custom resources from the database for a specific organization + */ +export async function loadCustomResources( + organizationId: string, + ctx: GenericEndpointContext, +): Promise { + // Check cache first + const cached = customResourcesCache.get(organizationId); + if (cached) { + return cached; + } + + // Load from database + const resources = await ctx.context.adapter.findMany< + OrganizationResource & { permissions: string } + >({ + model: "organizationResource", + where: [ + { + field: "organizationId", + value: organizationId, + }, + ], + }); + + if (!resources || resources.length === 0) { + return null; + } + + // Build statements object from resources + const statements: Record = {}; + + for (const resource of resources) { + const permissions = safeJSONParse(resource.permissions); + const result = z.array(z.string()).safeParse(permissions); + + if (!result.success) { + ctx.context.logger.error( + "[loadCustomResources] Invalid permissions for resource " + + resource.resource, + { + permissions, + }, + ); + throw new APIError("INTERNAL_SERVER_ERROR", { + message: "Invalid permissions for resource " + resource.resource, + }); + } + + statements[resource.resource] = result.data as readonly string[]; + } + + // Cache the result + customResourcesCache.set(organizationId, statements as Statements); + + return statements as Statements; +} + +/** + * Get the merged statements (default + custom) for an organization + * Returns an AccessControl instance with the merged statements + */ +export async function getOrganizationStatements( + organizationId: string, + options: OrganizationOptions, + ctx: GenericEndpointContext, +): Promise { + // Start with default statements from AC instance or fallback to defaults + const baseStatements = options.ac?.statements || defaultStatements; + + // If custom resources are not enabled, return base statements + if (!options.dynamicAccessControl?.enableCustomResources) { + return baseStatements; + } + + // Load custom resources + const customResources = await loadCustomResources(organizationId, ctx); + + if (!customResources) { + // No custom resources for this organization, return base statements + return baseStatements; + } + + // Merge: custom resources override defaults if they have the same name + const merged = { + ...baseStatements, + ...customResources, + }; + + return merged as Statements; +} + +/** + * Create an AccessControl instance for a specific organization + * Uses merged statements (default + custom) + */ +export async function getOrganizationAccessControl( + organizationId: string, + options: OrganizationOptions, + ctx: GenericEndpointContext, +): Promise { + const statements = await getOrganizationStatements( + organizationId, + options, + ctx, + ); + return createAccessControl(statements); +} + +/** + * Invalidate the cache for a specific organization + */ +export function invalidateResourceCache(organizationId: string): void { + customResourcesCache.delete(organizationId); +} + +/** + * Clear all cached resources + */ +export function clearAllResourceCache(): void { + customResourcesCache.clear(); +} + +/** + * Get default reserved resource names + */ +export function getDefaultReservedResourceNames(): string[] { + return ["organization", "member", "invitation", "team", "ac"]; +} + +/** + * Get reserved resource names from config or defaults + */ +export function getReservedResourceNames( + options: OrganizationOptions, +): string[] { + return ( + options.dynamicAccessControl?.reservedResourceNames || + getDefaultReservedResourceNames() + ); +} + +/** + * Validate a resource name according to the rules: + * - If custom validation is provided, uses ONLY that (full override) + * - Otherwise uses default rules: + * - Must be alphanumeric with underscores + * - Length between 1 and 50 characters + * - Cannot be a reserved name + */ +export function validateResourceName( + name: string, + options: OrganizationOptions, +): { valid: boolean; error?: string } { + // Use custom validation if provided + if (options.dynamicAccessControl?.resourceNameValidation) { + return options.dynamicAccessControl.resourceNameValidation(name); + } + + if (name.length < 1 || name.length > 50) { + return { + valid: false, + error: "Resource name must be between 1 and 50 characters", + }; + } + + if (!/^[a-zA-Z0-9_]+$/.test(name)) { + return { + valid: false, + error: "Resource name must be alphanumeric with underscores only", + }; + } + + // Check reserved names + const reservedNames = getReservedResourceNames(options); + if (reservedNames.includes(name)) { + return { + valid: false, + error: `Resource name "${name}" is reserved and cannot be used`, + }; + } + + return { valid: true }; +} diff --git a/packages/better-auth/src/plugins/organization/organization.ts b/packages/better-auth/src/plugins/organization/organization.ts index 14d3cc0c50e..ee83e4ba080 100644 --- a/packages/better-auth/src/plugins/organization/organization.ts +++ b/packages/better-auth/src/plugins/organization/organization.ts @@ -46,6 +46,13 @@ import { setActiveOrganization, updateOrganization, } from "./routes/crud-org"; +import { + createOrgResource, + deleteOrgResource, + getOrgResource, + listOrgResources, + updateOrgResource, +} from "./routes/crud-resources"; import { addTeamMember, createTeam, @@ -80,6 +87,14 @@ export type DynamicAccessControlEndpoints = { updateOrgRole: ReturnType>; }; +export type DynamicResourceEndpoints = { + createOrgResource: ReturnType>; + deleteOrgResource: ReturnType>; + listOrgResources: ReturnType>; + getOrgResource: ReturnType>; + updateOrgResource: ReturnType>; +}; + export type TeamEndpoints = { createTeam: ReturnType>; listOrganizationTeams: ReturnType>; @@ -262,6 +277,9 @@ export type OrganizationPlugin = { (O extends { teams: { enabled: true } } ? TeamEndpoints : {}) & (O extends { dynamicAccessControl: { enabled: true } } ? DynamicAccessControlEndpoints + : {}) & + (O extends { dynamicAccessControl: { enableCustomResources: true } } + ? DynamicResourceEndpoints : {}); schema: OrganizationSchema; $Infer: { @@ -908,6 +926,20 @@ export function organization( ...dynamicAccessControlEndpoints, }; } + + const dynamicResourceEndpoints = { + createOrgResource: createOrgResource(options as O), + deleteOrgResource: deleteOrgResource(options as O), + listOrgResources: listOrgResources(options as O), + getOrgResource: getOrgResource(options as O), + updateOrgResource: updateOrgResource(options as O), + }; + if (options?.dynamicAccessControl?.enableCustomResources) { + endpoints = { + ...endpoints, + ...dynamicResourceEndpoints, + }; + } const roles = { ...defaultRoles, ...options?.roles, @@ -1026,6 +1058,56 @@ export function organization( } satisfies BetterAuthPluginDBSchema) : {}; + const organizationResourceSchema = options?.dynamicAccessControl + ?.enableCustomResources + ? ({ + organizationResource: { + fields: { + organizationId: { + type: "string", + required: true, + references: { + model: "organization", + field: "id", + }, + fieldName: + options?.schema?.organizationResource?.fields?.organizationId, + index: true, + }, + resource: { + type: "string", + required: true, + fieldName: + options?.schema?.organizationResource?.fields?.resource, + index: true, + }, + permissions: { + type: "string", + required: true, + fieldName: + options?.schema?.organizationResource?.fields?.permissions, + }, + createdAt: { + type: "date", + required: true, + defaultValue: () => new Date(), + fieldName: + options?.schema?.organizationResource?.fields?.createdAt, + }, + updatedAt: { + type: "date", + required: false, + fieldName: + options?.schema?.organizationResource?.fields?.updatedAt, + onUpdate: () => new Date(), + }, + ...(options?.schema?.organizationResource?.additionalFields || {}), + }, + modelName: options?.schema?.organizationResource?.modelName, + }, + } satisfies BetterAuthPluginDBSchema) + : {}; + const schema = { ...({ organization: { @@ -1043,6 +1125,7 @@ export function organization( unique: true, sortable: true, fieldName: options?.schema?.organization?.fields?.slug, + index: true, }, logo: { type: "string", @@ -1064,6 +1147,7 @@ export function organization( }, } satisfies BetterAuthPluginDBSchema), ...organizationRoleSchema, + ...organizationResourceSchema, ...teamSchema, ...({ member: { diff --git a/packages/better-auth/src/plugins/organization/routes/crud-access-control.ts b/packages/better-auth/src/plugins/organization/routes/crud-access-control.ts index ad34fa0aecb..090eb161a36 100644 --- a/packages/better-auth/src/plugins/organization/routes/crud-access-control.ts +++ b/packages/better-auth/src/plugins/organization/routes/crud-access-control.ts @@ -6,11 +6,16 @@ import { APIError } from "../../../api"; import type { InferAdditionalFieldsFromPluginOptions } from "../../../db"; import { toZodSchema } from "../../../db"; import type { User } from "../../../types"; -import type { AccessControl } from "../../access"; +import type { AccessControl, Statements } from "../../access"; import { orgSessionMiddleware } from "../call"; import { ORGANIZATION_ERROR_CODES } from "../error-codes"; import { hasPermission } from "../has-permission"; -import type { Member, OrganizationRole } from "../schema"; +import { + getOrganizationStatements, + invalidateResourceCache, + validateResourceName, +} from "../load-resources"; +import type { Member, OrganizationResource, OrganizationRole } from "../schema"; import type { OrganizationOptions } from "../types"; type IsExactlyEmptyObject = keyof T extends never // no keys @@ -21,6 +26,32 @@ type IsExactlyEmptyObject = keyof T extends never // no keys : false : false; +/** + * Describes what resource changes need to be applied to the database + */ +type ResourceChanges = { + /** + * Resources that need to be created + */ + toCreate: Array<{ + resourceName: string; + permissions: string[]; + }>; + /** + * Resources that need to have permissions expanded + */ + toExpand: Array<{ + resourceName: string; + existingPermissions: string[]; + newPermissions: string[]; + mergedPermissions: string[]; + }>; + /** + * All resource names that will be created or expanded (for skipping delegation check) + */ + resourcesToSkipDelegation: string[]; +}; + const normalizeRoleName = (role: string) => role.toLowerCase(); const DEFAULT_MAXIMUM_ROLES_PER_ORGANIZATION = Number.POSITIVE_INFINITY; @@ -184,6 +215,7 @@ export const createOrgRole = (options: O) => { }, ctx, ); + if (!canCreateRole) { ctx.context.logger.error( `[Dynamic Access Control] The user is not permitted to create a role. If this is unexpected, please make sure the role associated to that member has the "ac" resource with the "create" permission.`, @@ -232,9 +264,27 @@ export const createOrgRole = (options: O) => { }); } - await checkForInvalidResources({ ac, ctx, permission }); + // Check if role name is already taken in DB + // This must happen BEFORE resource creation to avoid orphaned resources + await checkIfRoleNameIsTakenByRoleInDB({ + ctx, + organizationId, + role: roleName, + }); - await checkIfMemberHasPermission({ + // Step 1: Calculate what resource changes are needed (read-only) + const resourceChanges = await calculateRequiredResourceChanges({ + ac, + ctx, + permission, + organizationId, + options, + member, + user, + }); + + // Step 2: Check permission delegation (read-only) + await checkPermissionDelegation({ ctx, member, options, @@ -242,12 +292,16 @@ export const createOrgRole = (options: O) => { permissionRequired: permission, user, action: "create", + resourcesToSkipDelegation: resourceChanges.resourcesToSkipDelegation, }); - await checkIfRoleNameIsTakenByRoleInDB({ - ctx, + // Step 3: Apply resource changes (mutations) + await applyResourceChanges({ + resourceChanges, organizationId, - role: roleName, + options, + ctx, + user, }); const newRole = ac.newRole(permission); @@ -974,12 +1028,51 @@ export const updateOrgRole = (options: O) => { ...additionalFields, }; + // ----- + // Step 1: Perform all validations first (no DB mutations) + // ----- + + // Validate role name change if requested + let newRoleName: string | undefined; + if (ctx.body.data.roleName) { + newRoleName = normalizeRoleName(ctx.body.data.roleName); + + await checkIfRoleNameIsTakenByPreDefinedRole({ + role: newRoleName, + organizationId, + options, + ctx, + }); + await checkIfRoleNameIsTakenByRoleInDB({ + role: newRoleName, + organizationId, + ctx, + }); + + updateData.role = newRoleName; + } + + // ----- + // Step 2: Plan and validate resource changes (read-only) + // Only after all cheap validations pass + // ----- + if (ctx.body.data.permission) { let newPermission = ctx.body.data.permission; - await checkForInvalidResources({ ac, ctx, permission: newPermission }); + // Step 2a: Calculate what resource changes are needed (read-only) + const resourceChanges = await calculateRequiredResourceChanges({ + ac, + ctx, + permission: newPermission, + organizationId, + options, + member, + user, + }); - await checkIfMemberHasPermission({ + // Step 2b: Check permission delegation (read-only) + await checkPermissionDelegation({ ctx, member, options, @@ -987,28 +1080,19 @@ export const updateOrgRole = (options: O) => { permissionRequired: newPermission, user, action: "update", + resourcesToSkipDelegation: resourceChanges.resourcesToSkipDelegation, }); - updateData.permission = newPermission; - } - if (ctx.body.data.roleName) { - let newRoleName = ctx.body.data.roleName; - - newRoleName = normalizeRoleName(newRoleName); - - await checkIfRoleNameIsTakenByPreDefinedRole({ - role: newRoleName, + // Step 2c: Apply resource changes (mutations) + await applyResourceChanges({ + resourceChanges, organizationId, options, ctx, - }); - await checkIfRoleNameIsTakenByRoleInDB({ - role: newRoleName, - organizationId, - ctx, + user, }); - updateData.role = newRoleName; + updateData.permission = newPermission; } // ----- @@ -1047,35 +1131,579 @@ export const updateOrgRole = (options: O) => { ); }; -async function checkForInvalidResources({ +/** + * Calculate what resource changes are needed (create new or expand existing). + * This is a read-only operation that validates and returns what needs to change. + * Does NOT mutate the database. + */ +async function calculateRequiredResourceChanges({ ac, ctx, permission, + organizationId, + options, + member, + user, }: { ac: AccessControl; ctx: GenericEndpointContext; permission: Record; -}) { - const validResources = Object.keys(ac.statements); + organizationId: string; + options: OrganizationOptions; + member: Member; + user: User; +}): Promise { + // Get organization-specific statements (merged default + custom) + const orgStatements = await getOrganizationStatements( + organizationId, + options, + ctx, + ); + const validResources = Object.keys(orgStatements); const providedResources = Object.keys(permission); - const hasInvalidResource = providedResources.some( + + // Find resources that don't exist yet + const missingResources = providedResources.filter( (r) => !validResources.includes(r), ); - if (hasInvalidResource) { + + // Guard: Handle missing resources when custom resources not enabled + if ( + missingResources.length > 0 && + !options.dynamicAccessControl?.enableCustomResources + ) { ctx.context.logger.error( - `[Dynamic Access Control] The provided permission includes an invalid resource.`, + `[Dynamic Access Control] The provided permission includes invalid resources.`, { providedResources, validResources, + missingResources, }, ); throw new APIError("BAD_REQUEST", { message: ORGANIZATION_ERROR_CODES.INVALID_RESOURCE, }); } + + // Calculate both creation and expansion needs in parallel + const [creationPlan, expansionPlan] = await Promise.all([ + missingResources.length > 0 + ? planResourceCreation({ + missingResources, + permission, + organizationId, + options, + ctx, + member, + user, + }) + : Promise.resolve({ + toCreate: [], + toExpand: [], + resourcesToSkipDelegation: [], + }), + planResourceExpansion({ + permission, + orgStatements, + ac, + organizationId, + options, + ctx, + member, + user, + }), + ]); + + // Merge both plans + return { + toCreate: creationPlan.toCreate, + toExpand: expansionPlan.toExpand, + resourcesToSkipDelegation: [ + ...creationPlan.resourcesToSkipDelegation, + ...expansionPlan.resourcesToSkipDelegation, + ], + }; +} + +/** + * Plan resource creation - validates and returns what needs to be created. + * Does NOT mutate the database. + */ +async function planResourceCreation({ + missingResources, + permission, + organizationId, + options, + ctx, + member, + user, +}: { + missingResources: string[]; + permission: Record; + organizationId: string; + options: OrganizationOptions; + ctx: GenericEndpointContext; + member: Member; + user: User; +}): Promise { + // Validate and check permissions for all resources first + for (const resourceName of missingResources) { + // Validate resource name using existing validateResourceName function + const validation = validateResourceName(resourceName, options); + if (!validation.valid) { + ctx.context.logger.error( + `[Dynamic Access Control] Cannot auto-create resource "${resourceName}": ${validation.error}`, + { + resourceName, + error: validation.error, + }, + ); + throw new APIError("BAD_REQUEST", { + message: validation.error || `Invalid resource name: ${resourceName}`, + }); + } + + // Check if user can create this resource + let canCreate: { allow: boolean; message?: string }; + if (options.dynamicAccessControl?.canCreateResource) { + // Use custom function if provided + canCreate = await options.dynamicAccessControl.canCreateResource({ + organizationId, + userId: user.id, + member, + resourceName, + permissions: permission[resourceName] || [], + }); + } else { + // Use default role-based check + const allowedRoles = options.dynamicAccessControl + ?.allowedRolesToCreateResources ?? ["owner"]; + if (!allowedRoles.includes(member.role)) { + canCreate = { + allow: false, + message: `Only ${allowedRoles.join(", ")} can create resources`, + }; + } else { + canCreate = { allow: true }; + } + } + + if (!canCreate.allow) { + ctx.context.logger.error( + `[Dynamic Access Control] User not allowed to create resource "${resourceName}"`, + { + userId: user.id, + organizationId, + memberRole: member.role, + reason: canCreate.message, + }, + ); + throw new APIError("FORBIDDEN", { + message: + canCreate.message || + `Not allowed to create resource: ${resourceName}`, + }); + } + } + + // Check for existing resources in parallel + const existingResourceChecks = await Promise.all( + missingResources.map((resourceName) => + ctx.context.adapter + .findOne({ + model: "organizationResource", + where: [ + { + field: "organizationId", + value: organizationId, + operator: "eq", + connector: "AND", + }, + { + field: "resource", + value: resourceName, + operator: "eq", + connector: "AND", + }, + ], + }) + .then((existing: OrganizationResource | null) => ({ + resourceName, + existing, + })), + ), + ); + + // Collect resources that need to be created (don't exist in DB yet) + const toCreate = existingResourceChecks + .filter( + (r: { resourceName: string; existing: OrganizationResource | null }) => + !r.existing, + ) + .map( + ({ + resourceName, + }: { + resourceName: string; + existing: OrganizationResource | null; + }) => ({ + resourceName, + permissions: permission[resourceName] || [], + }), + ); + + return { + toCreate, + toExpand: [], + resourcesToSkipDelegation: toCreate.map( + (r: { resourceName: string; permissions: string[] }) => r.resourceName, + ), + }; +} + +/** + * Plan resource expansion - validates and returns what permissions need to be expanded. + * Also identifies ALL custom resources to skip delegation checks for them. + * Does NOT mutate the database. + */ +async function planResourceExpansion({ + permission, + orgStatements, + ac, + organizationId, + options, + ctx, + member, + user, +}: { + permission: Record; + orgStatements: Statements; + ac: AccessControl; + organizationId: string; + options: OrganizationOptions; + ctx: GenericEndpointContext; + member: Member; + user: User; +}): Promise { + const defaultStatements = ac.statements; + const resourcesToExpand: Array<{ + resource: string; + existingPermissions: string[]; + invalidPermissions: string[]; + }> = []; + + // Identify all custom resources in the permission set (for delegation skip) + const customResourcesInPermissions: string[] = []; + + // Find resources that need permission expansion + for (const [resource, permissions] of Object.entries(permission)) { + const validPermissions = orgStatements[resource as keyof Statements]; + if (!validPermissions) continue; + + // Check if this is a custom resource (not a default one) + const isCustomResource = !defaultStatements[resource as keyof Statements]; + + // Track all custom resources (even if not expanding) + if (isCustomResource) { + customResourcesInPermissions.push(resource); + } + + const invalidPermissions = permissions.filter( + (p) => !validPermissions.includes(p), + ); + + if (invalidPermissions.length === 0) continue; + + if ( + !isCustomResource || + !options.dynamicAccessControl?.enableCustomResources + ) { + ctx.context.logger.error( + `[Dynamic Access Control] The provided permissions include invalid actions for resource "${resource}".`, + { + resource, + invalidPermissions, + validPermissions, + isCustomResource, + }, + ); + throw new APIError("BAD_REQUEST", { + message: `Invalid permissions for resource "${resource}": ${invalidPermissions.join(", ")}`, + }); + } + + resourcesToExpand.push({ + resource, + existingPermissions: [...validPermissions], + invalidPermissions, + }); + } + + // If no resources need expansion but there are custom resources, + // still return them for delegation skip + if (resourcesToExpand.length === 0) { + return { + toCreate: [], + toExpand: [], + resourcesToSkipDelegation: customResourcesInPermissions, + }; + } + + // Check if user can expand permissions for these resources + for (const { + resource, + existingPermissions, + invalidPermissions, + } of resourcesToExpand) { + // Check if user can expand this resource (same logic as creation) + let canExpand: { allow: boolean; message?: string }; + if (options.dynamicAccessControl?.canCreateResource) { + // Use custom function if provided + canExpand = await options.dynamicAccessControl.canCreateResource({ + organizationId, + userId: user.id, + member, + resourceName: resource, + permissions: invalidPermissions, + }); + } else { + // Use default role-based check + const allowedRoles = options.dynamicAccessControl + ?.allowedRolesToCreateResources ?? ["owner"]; + if (!allowedRoles.includes(member.role)) { + canExpand = { + allow: false, + message: `Only ${allowedRoles.join(", ")} can expand resource permissions`, + }; + } else { + canExpand = { allow: true }; + } + } + + if (!canExpand.allow) { + ctx.context.logger.error( + `[Dynamic Access Control] User not allowed to expand permissions for resource "${resource}"`, + { + userId: user.id, + organizationId, + memberRole: member.role, + reason: canExpand.message, + }, + ); + throw new APIError("FORBIDDEN", { + message: + canExpand.message || + `Not allowed to expand permissions for resource: ${resource}`, + }); + } + } + + // Fetch existing resources to get their current permissions + const existingResources = await Promise.all( + resourcesToExpand.map(({ resource }) => + ctx.context.adapter + .findOne({ + model: "organizationResource", + where: [ + { + field: "organizationId", + value: organizationId, + operator: "eq", + connector: "AND", + }, + { + field: "resource", + value: resource, + operator: "eq", + connector: "AND", + }, + ], + }) + .then((existing: OrganizationResource | null) => ({ + resource, + existing, + })), + ), + ); + + // Calculate what needs to be expanded + const toExpand = existingResources + .filter( + ({ + existing, + }: { + resource: string; + existing: OrganizationResource | null; + }) => existing !== null, + ) + .map( + ({ + resource, + existing, + }: { + resource: string; + existing: OrganizationResource | null; + }) => { + const resourceToExpand = resourcesToExpand.find( + (r) => r.resource === resource, + )!; + + const existingPermissions: string[] = JSON.parse( + existing!.permissions as never as string, + ); + + const mergedPermissions = Array.from( + new Set([ + ...existingPermissions, + ...resourceToExpand.invalidPermissions, + ]), + ); + + return { + resourceName: resource, + existingPermissions, + newPermissions: resourceToExpand.invalidPermissions, + mergedPermissions, + }; + }, + ); + + return { + toCreate: [], + toExpand, + // Skip delegation for ALL custom resources, not just those being expanded + // This allows users to remove permissions from custom resources without delegation errors + resourcesToSkipDelegation: customResourcesInPermissions, + }; +} + +/** + * Apply resource changes to the database - creates new resources and expands existing ones. + * This is the only function that mutates the database for resource management. + */ +async function applyResourceChanges({ + resourceChanges, + organizationId, + options, + ctx, + user, +}: { + resourceChanges: ResourceChanges; + organizationId: string; + options: OrganizationOptions; + ctx: GenericEndpointContext; + user: User; +}) { + // Create new resources + if (resourceChanges.toCreate.length > 0) { + await Promise.all( + resourceChanges.toCreate.map(async ({ resourceName, permissions }) => { + await ctx.context.adapter.create< + Omit & { permissions: string } + >({ + model: "organizationResource", + data: { + createdAt: new Date(), + organizationId, + permissions: JSON.stringify(permissions), + resource: resourceName, + }, + }); + + ctx.context.logger.info( + `[Dynamic Access Control] Auto-created resource "${resourceName}" for organization ${organizationId}`, + { + resourceName, + organizationId, + permissions, + }, + ); + + // Call hook after resource creation (if provided) + await options.dynamicAccessControl?.onResourceCreated?.({ + organizationId, + resourceName, + permissions, + createdBy: user.id, + }); + }), + ); + } + + // Expand existing resources + if (resourceChanges.toExpand.length > 0) { + await Promise.all( + resourceChanges.toExpand.map( + async ({ + resourceName, + existingPermissions, + newPermissions, + mergedPermissions, + }) => { + await ctx.context.adapter.update< + Omit & { + permissions: string; + } + >({ + model: "organizationResource", + where: [ + { + field: "organizationId", + value: organizationId, + operator: "eq", + connector: "AND", + }, + { + field: "resource", + value: resourceName, + operator: "eq", + connector: "AND", + }, + ], + update: { + permissions: JSON.stringify(mergedPermissions), + updatedAt: new Date(), + }, + }); + + ctx.context.logger.info( + `[Dynamic Access Control] Auto-expanded permissions for custom resource "${resourceName}"`, + { + resource: resourceName, + organizationId, + oldPermissions: existingPermissions, + newPermissions, + mergedPermissions, + }, + ); + + // Call hook after resource expansion (if provided) + await options.dynamicAccessControl?.onResourceExpanded?.({ + organizationId, + resourceName, + oldPermissions: existingPermissions, + newPermissions, + expandedBy: user.id, + }); + }, + ), + ); + } + + // Invalidate cache if we made any changes + if ( + resourceChanges.toCreate.length > 0 || + resourceChanges.toExpand.length > 0 + ) { + invalidateResourceCache(organizationId); + } } -async function checkIfMemberHasPermission({ +/** + * Check permission delegation - validates that the member has the permissions they're trying to grant. + * This is a read-only validation, does NOT mutate the database. + */ +async function checkPermissionDelegation({ ctx, permissionRequired: permission, options, @@ -1083,6 +1711,7 @@ async function checkIfMemberHasPermission({ member, user, action, + resourcesToSkipDelegation, }: { ctx: GenericEndpointContext; permissionRequired: Record; @@ -1091,6 +1720,7 @@ async function checkIfMemberHasPermission({ member: Member; user: User; action: "create" | "update" | "delete" | "read" | "list" | "get"; + resourcesToSkipDelegation: string[]; }) { const hasNecessaryPermissions: { resource: { [x: string]: string[] }; @@ -1098,6 +1728,19 @@ async function checkIfMemberHasPermission({ }[] = []; const permissionEntries = Object.entries(permission); for await (const [resource, permissions] of permissionEntries) { + // Skip delegation check for auto-created/expanded resources + // Users don't need to have permissions for resources that are being created or expanded + if (resourcesToSkipDelegation.includes(resource)) { + ctx.context.logger.info( + `[Dynamic Access Control] Skipping permission delegation check for auto-created resource "${resource}"`, + { + resource, + organizationId, + }, + ); + continue; + } + for await (const perm of permissions) { hasNecessaryPermissions.push({ resource: { [resource]: [perm] }, diff --git a/packages/better-auth/src/plugins/organization/routes/crud-resources.ts b/packages/better-auth/src/plugins/organization/routes/crud-resources.ts new file mode 100644 index 00000000000..e872e045151 --- /dev/null +++ b/packages/better-auth/src/plugins/organization/routes/crud-resources.ts @@ -0,0 +1,1049 @@ +import { createAuthEndpoint } from "@better-auth/core/api"; +import { safeJSONParse } from "@better-auth/core/utils"; +import * as z from "zod"; +import { APIError } from "../../../api"; +import type { InferAdditionalFieldsFromPluginOptions } from "../../../db"; +import { toZodSchema } from "../../../db"; +import { orgSessionMiddleware } from "../call"; +import { ORGANIZATION_ERROR_CODES } from "../error-codes"; +import { hasPermission } from "../has-permission"; +import { + getReservedResourceNames, + invalidateResourceCache, + validateResourceName, +} from "../load-resources"; +import type { Member, OrganizationResource, OrganizationRole } from "../schema"; +import type { OrganizationOptions } from "../types"; + +const DEFAULT_MAXIMUM_RESOURCES_PER_ORGANIZATION = 50; + +type IsExactlyEmptyObject = keyof T extends never + ? T extends {} + ? {} extends T + ? true + : false + : false + : false; + +const getAdditionalFields = < + O extends OrganizationOptions, + AllPartial extends boolean = false, +>( + options: O, + shouldBePartial: AllPartial = false as AllPartial, +) => { + let additionalFields = + options?.schema?.organizationResource?.additionalFields || {}; + if (shouldBePartial) { + // Clone the object to avoid mutating the original options + additionalFields = Object.fromEntries( + Object.entries(additionalFields).map(([key, field]) => [ + key, + { ...field, required: false }, + ]), + ); + } + const additionalFieldsSchema = toZodSchema({ + fields: additionalFields, + isClientSide: true, + }); + type AdditionalFields = AllPartial extends true + ? Partial> + : InferAdditionalFieldsFromPluginOptions<"organizationResource", O>; + type ReturnAdditionalFields = InferAdditionalFieldsFromPluginOptions< + "organizationResource", + O, + false + >; + + return { + additionalFieldsSchema, + $AdditionalFields: {} as AdditionalFields, + $ReturnAdditionalFields: {} as ReturnAdditionalFields, + }; +}; + +const baseCreateResourceSchema = z.object({ + organizationId: z.string().optional().meta({ + description: + "The id of the organization to create the resource in. If not provided, the user's active organization will be used.", + }), + resource: z.string().meta({ + description: "The name of the resource to create", + }), + permissions: z.array(z.string()).min(1).meta({ + description: "The permissions (actions) available for this resource", + }), +}); + +export const createOrgResource = ( + options: O, +) => { + const { additionalFieldsSchema, $AdditionalFields, $ReturnAdditionalFields } = + getAdditionalFields(options, false); + type AdditionalFields = typeof $AdditionalFields; + type ReturnAdditionalFields = typeof $ReturnAdditionalFields; + + return createAuthEndpoint( + "/organization/create-resource", + { + method: "POST", + body: baseCreateResourceSchema.safeExtend({ + additionalFields: z + .object({ ...additionalFieldsSchema.shape }) + .optional(), + }), + metadata: { + $Infer: { + body: {} as { + organizationId?: string | undefined; + resource: string; + permissions: string[]; + } & (IsExactlyEmptyObject extends true + ? { additionalFields?: {} | undefined } + : { additionalFields: AdditionalFields }), + }, + }, + requireHeaders: true, + use: [orgSessionMiddleware], + }, + async (ctx) => { + const { session, user } = ctx.context.session; + let resourceName = ctx.body.resource; + const permissions = ctx.body.permissions; + const additionalFields = ctx.body.additionalFields; + + // Get the organization id + const organizationId = + ctx.body.organizationId ?? session.activeOrganizationId; + + if (!organizationId) { + ctx.context.logger.error( + `[Dynamic Resources] The session is missing an active organization id to create a resource. Either set an active org id, or pass an organizationId in the request body.`, + ); + throw new APIError("BAD_REQUEST", { + message: ORGANIZATION_ERROR_CODES.NO_ACTIVE_ORGANIZATION, + }); + } + + // Check if the user is a member of the organization + const member = await ctx.context.adapter.findOne({ + model: "member", + where: [ + { + field: "organizationId", + value: organizationId, + operator: "eq", + connector: "AND", + }, + { + field: "userId", + value: user.id, + operator: "eq", + connector: "AND", + }, + ], + }); + if (!member) { + ctx.context.logger.error( + `[Dynamic Resources] The user is not a member of the organization to create a resource.`, + { + userId: user.id, + organizationId, + }, + ); + throw new APIError("FORBIDDEN", { + message: + ORGANIZATION_ERROR_CODES.YOU_ARE_NOT_A_MEMBER_OF_THIS_ORGANIZATION, + }); + } + + // Check if user has permission to create resources + const canCreateResource = await hasPermission( + { + options, + organizationId, + permissions: { + ac: ["create"], + }, + role: member.role, + }, + ctx, + ); + if (!canCreateResource) { + ctx.context.logger.error( + `[Dynamic Resources] The user is not permitted to create a resource.`, + { + userId: user.id, + organizationId, + role: member.role, + }, + ); + throw new APIError("FORBIDDEN", { + message: + ORGANIZATION_ERROR_CODES.YOU_ARE_NOT_ALLOWED_TO_CREATE_A_RESOURCE, + }); + } + + // Resource name is used as-is (no normalization) + + // Validate resource name + const validation = validateResourceName(resourceName, options); + if (!validation.valid) { + ctx.context.logger.error( + `[Dynamic Resources] Invalid resource name: ${resourceName}`, + { + resourceName, + error: validation.error, + }, + ); + throw new APIError("BAD_REQUEST", { + message: + validation.error || ORGANIZATION_ERROR_CODES.INVALID_RESOURCE_NAME, + }); + } + + // Validate permissions array + if (!permissions || permissions.length === 0) { + throw new APIError("BAD_REQUEST", { + message: ORGANIZATION_ERROR_CODES.INVALID_PERMISSIONS_ARRAY, + }); + } + + // Check max resources limit + const maximumResourcesPerOrganization = + typeof options.dynamicAccessControl?.maximumResourcesPerOrganization === + "function" + ? await options.dynamicAccessControl.maximumResourcesPerOrganization( + organizationId, + ) + : (options.dynamicAccessControl?.maximumResourcesPerOrganization ?? + DEFAULT_MAXIMUM_RESOURCES_PER_ORGANIZATION); + const resourcesInDB = await ctx.context.adapter.count({ + model: "organizationResource", + where: [ + { + field: "organizationId", + value: organizationId, + operator: "eq", + connector: "AND", + }, + ], + }); + if (resourcesInDB >= maximumResourcesPerOrganization) { + ctx.context.logger.error( + `[Dynamic Resources] Failed to create a new resource, the organization has too many resources. Maximum allowed resources is ${maximumResourcesPerOrganization}.`, + { + organizationId, + maximumResourcesPerOrganization, + resourcesInDB, + }, + ); + throw new APIError("BAD_REQUEST", { + message: ORGANIZATION_ERROR_CODES.TOO_MANY_RESOURCES, + }); + } + + // Check if resource name is already taken + const existingResource = + await ctx.context.adapter.findOne({ + model: "organizationResource", + where: [ + { + field: "organizationId", + value: organizationId, + operator: "eq", + connector: "AND", + }, + { + field: "resource", + value: resourceName, + operator: "eq", + connector: "AND", + }, + ], + }); + if (existingResource) { + ctx.context.logger.error( + `[Dynamic Resources] The resource name "${resourceName}" is already taken.`, + { + resourceName, + organizationId, + }, + ); + throw new APIError("BAD_REQUEST", { + message: ORGANIZATION_ERROR_CODES.RESOURCE_NAME_IS_ALREADY_TAKEN, + }); + } + + // Create the resource + const newResource = await ctx.context.adapter.create< + Omit & { permissions: string } + >({ + model: "organizationResource", + data: { + createdAt: new Date(), + organizationId, + permissions: JSON.stringify(permissions), + resource: resourceName, + ...additionalFields, + }, + }); + + // Invalidate cache + invalidateResourceCache(organizationId); + + const data = { + ...newResource, + permissions, + } as OrganizationResource & ReturnAdditionalFields; + + return ctx.json({ + success: true, + resource: data, + }); + }, + ); +}; + +const updateResourceBodySchema = z.object({ + organizationId: z.string().optional().meta({ + description: + "The id of the organization. If not provided, the user's active organization will be used.", + }), + resource: z.string().meta({ + description: "The name of the resource to update", + }), + permissions: z.array(z.string()).min(1).meta({ + description: "The new permissions for this resource", + }), +}); + +export const updateOrgResource = ( + options: O, +) => { + const { additionalFieldsSchema, $AdditionalFields, $ReturnAdditionalFields } = + getAdditionalFields(options, true); + type AdditionalFields = typeof $AdditionalFields; + type ReturnAdditionalFields = typeof $ReturnAdditionalFields; + + return createAuthEndpoint( + "/organization/update-resource", + { + method: "POST", + body: updateResourceBodySchema.safeExtend({ + additionalFields: z + .object({ ...additionalFieldsSchema.shape }) + .optional(), + }), + requireHeaders: true, + use: [orgSessionMiddleware], + metadata: { + $Infer: { + body: {} as { + resource: string; + permissions: string[]; + organizationId?: string | undefined; + } & (IsExactlyEmptyObject extends true + ? { additionalFields?: {} | undefined } + : { additionalFields?: AdditionalFields }), + }, + }, + }, + async (ctx) => { + const { session, user } = ctx.context.session; + + const organizationId = + ctx.body.organizationId ?? session.activeOrganizationId; + if (!organizationId) { + ctx.context.logger.error( + `[Dynamic Resources] The session is missing an active organization id to update a resource.`, + ); + throw new APIError("BAD_REQUEST", { + message: ORGANIZATION_ERROR_CODES.NO_ACTIVE_ORGANIZATION, + }); + } + + // Check if the user is a member of the organization + const member = await ctx.context.adapter.findOne({ + model: "member", + where: [ + { + field: "organizationId", + value: organizationId, + operator: "eq", + connector: "AND", + }, + { + field: "userId", + value: user.id, + operator: "eq", + connector: "AND", + }, + ], + }); + if (!member) { + ctx.context.logger.error( + `[Dynamic Resources] The user is not a member of the organization to update a resource.`, + { + userId: user.id, + organizationId, + }, + ); + throw new APIError("FORBIDDEN", { + message: + ORGANIZATION_ERROR_CODES.YOU_ARE_NOT_A_MEMBER_OF_THIS_ORGANIZATION, + }); + } + + // Check if user has permission to update resources + const canUpdateResource = await hasPermission( + { + options, + organizationId, + permissions: { + ac: ["update"], + }, + role: member.role, + }, + ctx, + ); + if (!canUpdateResource) { + ctx.context.logger.error( + `[Dynamic Resources] The user is not permitted to update a resource.`, + { + userId: user.id, + organizationId, + role: member.role, + }, + ); + throw new APIError("FORBIDDEN", { + message: + ORGANIZATION_ERROR_CODES.YOU_ARE_NOT_ALLOWED_TO_UPDATE_A_RESOURCE, + }); + } + + const resourceName = ctx.body.resource; + const permissions = ctx.body.permissions; + const additionalFields = ctx.body.additionalFields; + + // Check if resource is reserved (can't update reserved resources) + const reservedNames = getReservedResourceNames(options); + if (reservedNames.includes(resourceName)) { + ctx.context.logger.error( + `[Dynamic Resources] Cannot update reserved resource: ${resourceName}`, + { + resourceName, + }, + ); + throw new APIError("BAD_REQUEST", { + message: ORGANIZATION_ERROR_CODES.RESOURCE_NAME_IS_RESERVED, + }); + } + + // Check if resource exists + const existingResource = + await ctx.context.adapter.findOne({ + model: "organizationResource", + where: [ + { + field: "organizationId", + value: organizationId, + operator: "eq", + connector: "AND", + }, + { + field: "resource", + value: resourceName, + operator: "eq", + connector: "AND", + }, + ], + }); + if (!existingResource) { + ctx.context.logger.error( + `[Dynamic Resources] The resource "${resourceName}" does not exist.`, + { + resourceName, + organizationId, + }, + ); + throw new APIError("BAD_REQUEST", { + message: ORGANIZATION_ERROR_CODES.RESOURCE_NOT_FOUND, + }); + } + + // Validate permissions array + if (!permissions || permissions.length === 0) { + throw new APIError("BAD_REQUEST", { + message: ORGANIZATION_ERROR_CODES.INVALID_PERMISSIONS_ARRAY, + }); + } + + // Update the resource + const updateData: Record = { + permissions: JSON.stringify(permissions), + updatedAt: new Date(), + ...additionalFields, + }; + + await ctx.context.adapter.update({ + model: "organizationResource", + where: [ + { + field: "organizationId", + value: organizationId, + operator: "eq", + connector: "AND", + }, + { + field: "resource", + value: resourceName, + operator: "eq", + connector: "AND", + }, + ], + update: updateData, + }); + + // Invalidate cache + invalidateResourceCache(organizationId); + + return ctx.json({ + success: true, + resource: { + ...existingResource, + ...updateData, + permissions, + } as OrganizationResource & ReturnAdditionalFields, + }); + }, + ); +}; + +const deleteResourceBodySchema = z.object({ + organizationId: z.string().optional().meta({ + description: + "The id of the organization. If not provided, the user's active organization will be used.", + }), + resource: z.string().meta({ + description: "The name of the resource to delete", + }), +}); + +export const deleteOrgResource = ( + options: O, +) => { + return createAuthEndpoint( + "/organization/delete-resource", + { + method: "POST", + body: deleteResourceBodySchema, + requireHeaders: true, + use: [orgSessionMiddleware], + metadata: { + $Infer: { + body: {} as { + resource: string; + organizationId?: string | undefined; + }, + }, + }, + }, + async (ctx) => { + const { session, user } = ctx.context.session; + + const organizationId = + ctx.body.organizationId ?? session.activeOrganizationId; + if (!organizationId) { + ctx.context.logger.error( + `[Dynamic Resources] The session is missing an active organization id to delete a resource.`, + ); + throw new APIError("BAD_REQUEST", { + message: ORGANIZATION_ERROR_CODES.NO_ACTIVE_ORGANIZATION, + }); + } + + // Check if the user is a member of the organization + const member = await ctx.context.adapter.findOne({ + model: "member", + where: [ + { + field: "organizationId", + value: organizationId, + operator: "eq", + connector: "AND", + }, + { + field: "userId", + value: user.id, + operator: "eq", + connector: "AND", + }, + ], + }); + if (!member) { + ctx.context.logger.error( + `[Dynamic Resources] The user is not a member of the organization to delete a resource.`, + { + userId: user.id, + organizationId, + }, + ); + throw new APIError("FORBIDDEN", { + message: + ORGANIZATION_ERROR_CODES.YOU_ARE_NOT_A_MEMBER_OF_THIS_ORGANIZATION, + }); + } + + // Check if user has permission to delete resources + const canDeleteResource = await hasPermission( + { + options, + organizationId, + permissions: { + ac: ["delete"], + }, + role: member.role, + }, + ctx, + ); + if (!canDeleteResource) { + ctx.context.logger.error( + `[Dynamic Resources] The user is not permitted to delete a resource.`, + { + userId: user.id, + organizationId, + role: member.role, + }, + ); + throw new APIError("FORBIDDEN", { + message: + ORGANIZATION_ERROR_CODES.YOU_ARE_NOT_ALLOWED_TO_DELETE_A_RESOURCE, + }); + } + + const resourceName = ctx.body.resource; + + // Check if resource is reserved (can't delete reserved resources) + const reservedNames = getReservedResourceNames(options); + if (reservedNames.includes(resourceName)) { + ctx.context.logger.error( + `[Dynamic Resources] Cannot delete reserved resource: ${resourceName}`, + { + resourceName, + }, + ); + throw new APIError("BAD_REQUEST", { + message: ORGANIZATION_ERROR_CODES.RESOURCE_NAME_IS_RESERVED, + }); + } + + // Check if resource exists + const existingResource = + await ctx.context.adapter.findOne({ + model: "organizationResource", + where: [ + { + field: "organizationId", + value: organizationId, + operator: "eq", + connector: "AND", + }, + { + field: "resource", + value: resourceName, + operator: "eq", + connector: "AND", + }, + ], + }); + if (!existingResource) { + ctx.context.logger.error( + `[Dynamic Resources] The resource "${resourceName}" does not exist.`, + { + resourceName, + organizationId, + }, + ); + throw new APIError("BAD_REQUEST", { + message: ORGANIZATION_ERROR_CODES.RESOURCE_NOT_FOUND, + }); + } + + // Check if resource is being used by any roles + const rolesUsingResource = await ctx.context.adapter.findMany< + OrganizationRole & { permission: string } + >({ + model: "organizationRole", + where: [ + { + field: "organizationId", + value: organizationId, + }, + ], + }); + + const rolesWithResource = rolesUsingResource.filter((role) => { + const permissions = safeJSONParse(role.permission) as Record< + string, + string[] + >; + return permissions !== null && resourceName in permissions; + }); + + if (rolesWithResource.length > 0) { + ctx.context.logger.error( + `[Dynamic Resources] Cannot delete resource "${resourceName}" because it is being used by ${rolesWithResource.length} role(s).`, + { + resourceName, + organizationId, + rolesUsingResource: rolesWithResource.map((r) => r.role), + }, + ); + throw new APIError("BAD_REQUEST", { + message: ORGANIZATION_ERROR_CODES.RESOURCE_IS_IN_USE, + }); + } + + // Delete the resource + await ctx.context.adapter.delete({ + model: "organizationResource", + where: [ + { + field: "organizationId", + value: organizationId, + operator: "eq", + connector: "AND", + }, + { + field: "resource", + value: resourceName, + operator: "eq", + connector: "AND", + }, + ], + }); + + // Invalidate cache + invalidateResourceCache(organizationId); + + return ctx.json({ + success: true, + }); + }, + ); +}; + +const listResourcesQuerySchema = z + .object({ + organizationId: z.string().optional().meta({ + description: + "The id of the organization to list resources for. If not provided, the user's active organization will be used.", + }), + }) + .optional(); + +export const listOrgResources = (options: O) => { + const { $ReturnAdditionalFields } = getAdditionalFields(options, false); + type ReturnAdditionalFields = typeof $ReturnAdditionalFields; + + return createAuthEndpoint( + "/organization/list-resources", + { + method: "GET", + query: listResourcesQuerySchema, + requireHeaders: true, + use: [orgSessionMiddleware], + metadata: { + $Infer: { + query: {} as { + organizationId?: string | undefined; + }, + }, + }, + }, + async (ctx) => { + const { session, user } = ctx.context.session; + + const organizationId = + ctx.query?.organizationId ?? session.activeOrganizationId; + if (!organizationId) { + ctx.context.logger.error( + `[Dynamic Resources] The session is missing an active organization id to list resources.`, + ); + throw new APIError("BAD_REQUEST", { + message: ORGANIZATION_ERROR_CODES.NO_ACTIVE_ORGANIZATION, + }); + } + + // Check if the user is a member of the organization + const member = await ctx.context.adapter.findOne({ + model: "member", + where: [ + { + field: "organizationId", + value: organizationId, + operator: "eq", + connector: "AND", + }, + { + field: "userId", + value: user.id, + operator: "eq", + connector: "AND", + }, + ], + }); + if (!member) { + ctx.context.logger.error( + `[Dynamic Resources] The user is not a member of the organization to list resources.`, + { + userId: user.id, + organizationId, + }, + ); + throw new APIError("FORBIDDEN", { + message: + ORGANIZATION_ERROR_CODES.YOU_ARE_NOT_A_MEMBER_OF_THIS_ORGANIZATION, + }); + } + + // Check if user has permission to read resources + const canReadResources = await hasPermission( + { + options, + organizationId, + permissions: { + ac: ["read"], + }, + role: member.role, + }, + ctx, + ); + if (!canReadResources) { + ctx.context.logger.error( + `[Dynamic Resources] The user is not permitted to list resources.`, + { + userId: user.id, + organizationId, + role: member.role, + }, + ); + throw new APIError("FORBIDDEN", { + message: + ORGANIZATION_ERROR_CODES.YOU_ARE_NOT_ALLOWED_TO_LIST_RESOURCES, + }); + } + + // Get default resources from AC + const defaultResources = options.ac?.statements || {}; + const defaultResourceList = Object.entries(defaultResources).map( + ([resource, permissions]) => ({ + resource, + permissions: permissions as string[], + isCustom: false, + isProtected: true, + }), + ); + + // Get custom resources from database + const customResources = await ctx.context.adapter.findMany< + OrganizationResource & { permissions: string } + >({ + model: "organizationResource", + where: [ + { + field: "organizationId", + value: organizationId, + }, + ], + }); + + const customResourceList = customResources.map((r) => ({ + ...r, + permissions: (safeJSONParse(r.permissions) as string[]) ?? [], + isCustom: true, + isProtected: false, + })) as (OrganizationResource & + ReturnAdditionalFields & { + isCustom: boolean; + isProtected: boolean; + })[]; + + return ctx.json({ + resources: [...defaultResourceList, ...customResourceList], + }); + }, + ); +}; + +const getResourceQuerySchema = z.object({ + organizationId: z.string().optional().meta({ + description: + "The id of the organization. If not provided, the user's active organization will be used.", + }), + resource: z.string().meta({ + description: "The name of the resource to get", + }), +}); + +export const getOrgResource = (options: O) => { + const { $ReturnAdditionalFields } = getAdditionalFields(options, false); + type ReturnAdditionalFields = typeof $ReturnAdditionalFields; + + return createAuthEndpoint( + "/organization/get-resource", + { + method: "GET", + query: getResourceQuerySchema, + requireHeaders: true, + use: [orgSessionMiddleware], + metadata: { + $Infer: { + query: {} as { + resource: string; + organizationId?: string | undefined; + }, + }, + }, + }, + async (ctx) => { + const { session, user } = ctx.context.session; + + const organizationId = + ctx.query.organizationId ?? session.activeOrganizationId; + if (!organizationId) { + ctx.context.logger.error( + `[Dynamic Resources] The session is missing an active organization id to get a resource.`, + ); + throw new APIError("BAD_REQUEST", { + message: ORGANIZATION_ERROR_CODES.NO_ACTIVE_ORGANIZATION, + }); + } + + // Check if the user is a member of the organization + const member = await ctx.context.adapter.findOne({ + model: "member", + where: [ + { + field: "organizationId", + value: organizationId, + operator: "eq", + connector: "AND", + }, + { + field: "userId", + value: user.id, + operator: "eq", + connector: "AND", + }, + ], + }); + if (!member) { + ctx.context.logger.error( + `[Dynamic Resources] The user is not a member of the organization to get a resource.`, + { + userId: user.id, + organizationId, + }, + ); + throw new APIError("FORBIDDEN", { + message: + ORGANIZATION_ERROR_CODES.YOU_ARE_NOT_A_MEMBER_OF_THIS_ORGANIZATION, + }); + } + + // Check if user has permission to read resources + const canReadResource = await hasPermission( + { + options, + organizationId, + permissions: { + ac: ["read"], + }, + role: member.role, + }, + ctx, + ); + if (!canReadResource) { + ctx.context.logger.error( + `[Dynamic Resources] The user is not permitted to read a resource.`, + { + userId: user.id, + organizationId, + role: member.role, + }, + ); + throw new APIError("FORBIDDEN", { + message: + ORGANIZATION_ERROR_CODES.YOU_ARE_NOT_ALLOWED_TO_READ_A_RESOURCE, + }); + } + + const resourceName = ctx.query.resource; + + // Check if it's a default resource + const defaultResources = options.ac?.statements || {}; + if (resourceName in defaultResources) { + return ctx.json({ + resource: { + resource: resourceName, + permissions: defaultResources[ + resourceName as keyof typeof defaultResources + ] as string[], + isCustom: false, + isProtected: true, + }, + }); + } + + // Look for custom resource + const customResource = + await ctx.context.adapter.findOne({ + model: "organizationResource", + where: [ + { + field: "organizationId", + value: organizationId, + operator: "eq", + connector: "AND", + }, + { + field: "resource", + value: resourceName, + operator: "eq", + connector: "AND", + }, + ], + }); + + if (!customResource) { + ctx.context.logger.error( + `[Dynamic Resources] The resource "${resourceName}" does not exist.`, + { + resourceName, + organizationId, + }, + ); + throw new APIError("BAD_REQUEST", { + message: ORGANIZATION_ERROR_CODES.RESOURCE_NOT_FOUND, + }); + } + + return ctx.json({ + resource: { + ...customResource, + permissions: + (safeJSONParse(customResource.permissions) as string[]) ?? [], + isCustom: true, + isProtected: false, + } as OrganizationResource & + ReturnAdditionalFields & { + isCustom: boolean; + isProtected: boolean; + }, + }); + }, + ); +}; diff --git a/packages/better-auth/src/plugins/organization/schema.ts b/packages/better-auth/src/plugins/organization/schema.ts index 029d700d948..3482069c845 100644 --- a/packages/better-auth/src/plugins/organization/schema.ts +++ b/packages/better-auth/src/plugins/organization/schema.ts @@ -48,6 +48,34 @@ interface OrganizationRoleDefaultFields { }; } +interface OrganizationResourceDefaultFields { + organizationId: { + type: "string"; + required: true; + references: { + model: "organization"; + field: "id"; + }; + }; + resource: { + type: "string"; + required: true; + }; + permissions: { + type: "string"; + required: true; + }; + createdAt: { + type: "date"; + required: true; + defaultValue: Date; + }; + updatedAt: { + type: "date"; + required: false; + }; +} + interface TeamDefaultFields { name: { type: "string"; @@ -207,15 +235,23 @@ export type OrganizationSchema = "organizationRole", OrganizationRoleDefaultFields >; - } & { - session: { - fields: InferSchema< - O["schema"] extends BetterAuthPluginDBSchema ? O["schema"] : {}, - "session", - SessionDefaultFields - >["fields"]; - }; - } + } & (O["dynamicAccessControl"] extends { enableCustomResources: true } + ? { + organizationResource: InferSchema< + O["schema"] extends BetterAuthPluginDBSchema ? O["schema"] : {}, + "organizationResource", + OrganizationResourceDefaultFields + >; + } + : {}) & { + session: { + fields: InferSchema< + O["schema"] extends BetterAuthPluginDBSchema ? O["schema"] : {}, + "session", + SessionDefaultFields + >["fields"]; + }; + } : {} & (O["teams"] extends { enabled: true } ? { team: InferSchema< @@ -341,6 +377,15 @@ export const organizationRoleSchema = z.object({ updatedAt: z.date().optional(), }); +export const organizationResourceSchema = z.object({ + id: z.string().default(generateId), + organizationId: z.string(), + resource: z.string(), + permissions: z.array(z.string()), + createdAt: z.date().default(() => new Date()), + updatedAt: z.date().optional(), +}); + export type Organization = z.infer; export type Member = z.infer; export type TeamMember = z.infer; @@ -352,6 +397,7 @@ export type TeamMemberInput = z.input; export type OrganizationInput = z.input; export type TeamInput = z.infer; export type OrganizationRole = z.infer; +export type OrganizationResource = z.infer; const defaultRoles = ["admin", "member", "owner"] as const; export const defaultRolesSchema = z.union([ diff --git a/packages/better-auth/src/plugins/organization/types.ts b/packages/better-auth/src/plugins/organization/types.ts index 3f0332d1ae8..f21ec472d2b 100644 --- a/packages/better-auth/src/plugins/organization/types.ts +++ b/packages/better-auth/src/plugins/organization/types.ts @@ -6,6 +6,7 @@ import type { Invitation, Member, Organization, + OrganizationResource, OrganizationRole, Team, TeamMember, @@ -84,6 +85,131 @@ export interface OrganizationOptions { maximumRolesPerOrganization?: | number | ((organizationId: string) => Promise | number); + /** + * Whether to enable custom resources per organization. + * + * When enabled, organizations can define their own resources and permissions + * dynamically alongside the statically defined resources + * + * @default false + */ + enableCustomResources?: boolean; + /** + * The maximum number of custom resources that can be created for an organization. + * + * @default 50 + */ + maximumResourcesPerOrganization?: + | number + | ((organizationId: string) => Promise | number); + /** + * Reserved resource names that cannot be used for custom resources. + * + * @default ["organization", "member", "invitation", "team", "ac"] + */ + reservedResourceNames?: string[]; + /** + * Custom validation function for resource names. + * When provided, this COMPLETELY OVERRIDES the default validation rules. + * + * Default rules (only used if this is not provided): + * - Length between 1-50 characters + * - Alphanumeric with underscores only + * - Not a reserved name + * + * @param name - The resource name to validate + * @returns An object with valid flag and optional error message + * + * @example + * ```ts + * // This completely replaces all default validation + * resourceNameValidation: (name) => { + * if (name.length > 100) { + * return { valid: false, error: "Resource name too long" }; + * } + * if (!/^[a-z-]+$/.test(name)) { + * return { valid: false, error: "Only lowercase letters and hyphens allowed" }; + * } + * return { valid: true }; + * } + * ``` + */ + resourceNameValidation?: + | ((name: string) => { valid: boolean; error?: string }) + | undefined; + /** + * Roles allowed to create new custom resources. + * + * @default ['owner'] + * + * @example + * ```ts + * allowedRolesToCreateResources: ['owner', 'admin'] + * ``` + */ + allowedRolesToCreateResources?: string[]; + /** + * Custom logic to determine if a user can create a new resource. + * If provided, overrides the default allowedRolesToCreateResources check. + * + * @returns Authorization result + * - `{ allow: true }` - Allow resource creation + * - `{ allow: false, message: "..." }` - Deny with custom error message + * + * @example + * ```ts + * canCreateResource: async ({ member, organizationId, resourceName }) => { + * // Check subscription or quota + * const quota = await checkResourceQuota(organizationId); + * if (!quota.canCreate) { + * return { allow: false, message: "Resource quota exceeded" }; + * } + * return { allow: true }; + * } + * ``` + */ + canCreateResource?: (params: { + organizationId: string; + userId: string; + member: Member & Record; + resourceName: string; + permissions: string[]; + }) => + | Promise<{ allow: boolean; message?: string }> + | { allow: boolean; message?: string }; + /** + * Optional hook called after a resource is successfully created. + * + * @example + * ```ts + * onResourceCreated: async ({ organizationId, resourceName }) => { + * await logResourceCreation(organizationId, resourceName); + * } + * ``` + */ + onResourceCreated?: (params: { + organizationId: string; + resourceName: string; + permissions: string[]; + createdBy: string; + }) => Promise | void; + /** + * Optional hook called after resource permissions are successfully expanded. + * + * @example + * ```ts + * onResourceExpanded: async ({ organizationId, resourceName, newPermissions }) => { + * await logPermissionExpansion(organizationId, resourceName, newPermissions); + * } + * ``` + */ + onResourceExpanded?: (params: { + organizationId: string; + resourceName: string; + oldPermissions: string[]; + newPermissions: string[]; + expandedBy: string; + }) => Promise | void; } | undefined; /** @@ -316,6 +442,15 @@ export interface OrganizationOptions { [key in string]: DBFieldAttribute; }; }; + organizationResource?: { + modelName?: string; + fields?: { + [key in keyof Omit]?: string; + }; + additionalFields?: { + [key in string]: DBFieldAttribute; + }; + }; } | undefined; /** diff --git a/packages/better-auth/src/plugins/two-factor/two-factor.test.ts b/packages/better-auth/src/plugins/two-factor/two-factor.test.ts index 441ef2b07f9..858c91939ec 100644 --- a/packages/better-auth/src/plugins/two-factor/two-factor.test.ts +++ b/packages/better-auth/src/plugins/two-factor/two-factor.test.ts @@ -322,10 +322,30 @@ describe("two factor", async () => { expect(currentBackupCodes.backupCodes).toBeDefined(); expect(currentBackupCodes.backupCodes).not.toContain(backupCode); + // Start a new 2FA session to test invalid backup code + const headers2 = new Headers(); + await client.signIn.email({ + email: testUser.email, + password: testUser.password, + fetchOptions: { + onSuccess(context) { + const parsed = parseSetCookieHeader( + context.response.headers.get("Set-Cookie") || "", + ); + headers2.append( + "cookie", + `better-auth.two_factor=${ + parsed.get("better-auth.two_factor")?.value + }`, + ); + }, + }, + }); + const res = await client.twoFactor.verifyBackupCode({ code: "invalid-code", fetchOptions: { - headers, + headers: headers2, onSuccess(context) { const parsed = parseSetCookieHeader( context.response.headers.get("Set-Cookie") || "", diff --git a/packages/better-auth/src/plugins/two-factor/verify-two-factor.ts b/packages/better-auth/src/plugins/two-factor/verify-two-factor.ts index 2def816c636..29c24a71074 100644 --- a/packages/better-auth/src/plugins/two-factor/verify-two-factor.ts +++ b/packages/better-auth/src/plugins/two-factor/verify-two-factor.ts @@ -60,10 +60,18 @@ export async function verifyTwoFactor(ctx: GenericEndpointContext) { message: "failed to create session", }); } + // Delete the verification token from the database after successful verification + await ctx.context.internalAdapter.deleteVerificationValue( + verificationToken.id, + ); await setSessionCookie(ctx, { session, user, }); + // Always clear the two factor cookie after successful verification + ctx.setCookie(cookieName.name, "", { + maxAge: 0, + }); if (ctx.body.trustDevice) { const trustDeviceCookie = ctx.context.createAuthCookie( TRUST_DEVICE_COOKIE_NAME, @@ -89,10 +97,6 @@ export async function verifyTwoFactor(ctx: GenericEndpointContext) { ctx.setCookie(ctx.context.authCookies.dontRememberToken.name, "", { maxAge: 0, }); - // delete the two factor cookie - ctx.setCookie(cookieName.name, "", { - maxAge: 0, - }); } return ctx.json({ token: session.token, diff --git a/packages/better-auth/src/plugins/username/index.ts b/packages/better-auth/src/plugins/username/index.ts index 5733c5efe34..ecb21c2be9b 100644 --- a/packages/better-auth/src/plugins/username/index.ts +++ b/packages/better-auth/src/plugins/username/index.ts @@ -275,7 +275,8 @@ export const username = (options?: UsernameOptions | undefined) => { const validator = options?.usernameValidator || defaultUsernameValidator; - if (!validator(username)) { + const valid = await validator(username); + if (!valid) { throw new APIError("UNPROCESSABLE_ENTITY", { message: ERROR_CODES.INVALID_USERNAME, }); @@ -445,7 +446,8 @@ export const username = (options?: UsernameOptions | undefined) => { const validator = options?.usernameValidator || defaultUsernameValidator; - if (!(await validator(username))) { + const valid = await validator(username); + if (!valid) { throw new APIError("UNPROCESSABLE_ENTITY", { message: ERROR_CODES.INVALID_USERNAME, }); diff --git a/packages/better-auth/src/plugins/username/username.test.ts b/packages/better-auth/src/plugins/username/username.test.ts index 04c896bb957..5976cf4fbdb 100644 --- a/packages/better-auth/src/plugins/username/username.test.ts +++ b/packages/better-auth/src/plugins/username/username.test.ts @@ -468,7 +468,7 @@ describe("username with displayUsername validation", async (it) => { }); describe("isUsernameAvailable with custom validator", async (it) => { - const { client } = await getTestInstance( + const { client, cookieSetter } = await getTestInstance( { plugins: [ username({ @@ -499,6 +499,26 @@ describe("isUsernameAvailable with custom validator", async (it) => { expect(res.error?.status).toBe(422); expect(res.error?.code).toBe("USERNAME_IS_INVALID"); }); + + it("should reject username that doesn't match custom validator during sign-up/sign-in", async () => { + const signUpRes = await client.signUp.email({ + email: "test-user@test.com", + password: "password1234", + name: "Test user", + username: "invalid_user", + }); + + expect(signUpRes.error).toBeDefined(); + expect(signUpRes.error?.code).toBe("USERNAME_IS_INVALID"); + + const signInRes = await client.signIn.username({ + username: "invalid_user", + password: "password1234", + }); + + expect(signInRes.error).toBeDefined(); + expect(signInRes.error?.code).toBe("USERNAME_IS_INVALID"); + }); }); describe("post normalization flow", async (it) => { diff --git a/packages/cli/package.json b/packages/cli/package.json index 40335432c73..a505a84a90e 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@better-auth/cli", - "version": "1.4.6-beta.3", + "version": "1.4.6-beta.4", "type": "module", "description": "The CLI for Better Auth", "module": "dist/index.mjs", @@ -17,6 +17,7 @@ "lint:package": "publint run --strict", "dev": "tsx ./src/index.ts", "test": "vitest", + "coverage": "vitest run --coverage", "typecheck": "tsc --project tsconfig.json" }, "publishConfig": { diff --git a/packages/cli/src/generators/drizzle.ts b/packages/cli/src/generators/drizzle.ts index 6098c19231d..8fb07fd7f92 100644 --- a/packages/cli/src/generators/drizzle.ts +++ b/packages/cli/src/generators/drizzle.ts @@ -139,23 +139,21 @@ export const generateDrizzleSchema: SchemaGenerator = async ({ mysql: `timestamp('${name}', { fsp: 3 })`, }, "number[]": { - sqlite: `integer('${name}').array()`, + sqlite: `text('${name}', { mode: "json" })`, pg: field.bigint ? `bigint('${name}', { mode: 'number' }).array()` : `integer('${name}').array()`, - mysql: field.bigint - ? `bigint('${name}', { mode: 'number' }).array()` - : `int('${name}').array()`, + mysql: `text('${name}', { mode: 'json' })`, }, "string[]": { - sqlite: `text('${name}').array()`, + sqlite: `text('${name}', { mode: "json" })`, pg: `text('${name}').array()`, - mysql: `text('${name}').array()`, + mysql: `text('${name}', { mode: "json" })`, }, json: { - sqlite: `text('${name}')`, + sqlite: `text('${name}', { mode: "json" })`, pg: `jsonb('${name}')`, - mysql: `json('${name}')`, + mysql: `json('${name}', { mode: "json" })`, }, } as const; const dbTypeMap = ( diff --git a/packages/cli/src/generators/prisma.ts b/packages/cli/src/generators/prisma.ts index a57c60d47f5..e3a2ce30995 100644 --- a/packages/cli/src/generators/prisma.ts +++ b/packages/cli/src/generators/prisma.ts @@ -14,7 +14,8 @@ export const generatePrismaSchema: SchemaGenerator = async ({ options, file, }) => { - const provider = adapter.options?.provider || "postgresql"; + const provider: "sqlite" | "postgresql" | "mysql" | "mongodb" = + adapter.options?.provider || "postgresql"; const tables = getAuthTables(options); const filePath = file || "./prisma/schema.prisma"; const schemaPrismaExist = existsSync(path.join(process.cwd(), filePath)); @@ -133,13 +134,26 @@ export const generatePrismaSchema: SchemaGenerator = async ({ return isOptional ? "DateTime?" : "DateTime"; } if (type === "json") { + if (provider === "sqlite" || provider === "mysql") { + return isOptional ? "String?" : "String"; + } return isOptional ? "Json?" : "Json"; } if (type === "string[]") { - return isOptional ? "String[]" : "String[]"; + // SQLite and MySQL don't support array of strings, so we use string instead + // adapter should handle JSON.stringify and JSON.parse conversion for these fields + if (provider === "sqlite" || provider === "mysql") { + return isOptional ? "String?" : "String"; + } + return "String[]"; } if (type === "number[]") { - return isOptional ? "Int[]" : "Int[]"; + // SQLite and MySQL don't support array of numbers, so we use int instead + // adapter should handle JSON.stringify and JSON.parse conversion for these fields + if (provider === "sqlite" || provider === "mysql") { + return "String"; + } + return "Int[]"; } } diff --git a/packages/core/package.json b/packages/core/package.json index 32c501ca0af..a28bc77f279 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@better-auth/core", - "version": "1.4.6-beta.3", + "version": "1.4.6-beta.4", "description": "The most comprehensive authentication framework for TypeScript.", "type": "module", "repository": { @@ -110,7 +110,8 @@ "dev": "tsdown --watch", "lint:package": "publint run --strict", "typecheck": "tsc --project tsconfig.json", - "test": "vitest" + "test": "vitest", + "coverage": "vitest run --coverage" }, "devDependencies": { "@better-auth/utils": "0.3.0", diff --git a/packages/core/src/db/adapter/factory.ts b/packages/core/src/db/adapter/factory.ts index 4f1fa683c92..ac7da3628d3 100644 --- a/packages/core/src/db/adapter/factory.ts +++ b/packages/core/src/db/adapter/factory.ts @@ -64,11 +64,13 @@ export const createAdapterFactory = adapterName: cfg.adapterName ?? cfg.adapterId, supportsNumericIds: cfg.supportsNumericIds ?? true, supportsUUIDs: cfg.supportsUUIDs ?? false, + supportsArrays: cfg.supportsArrays ?? false, transaction: cfg.transaction ?? false, disableTransformInput: cfg.disableTransformInput ?? false, disableTransformOutput: cfg.disableTransformOutput ?? false, disableTransformJoin: cfg.disableTransformJoin ?? false, } satisfies AdapterFactoryConfig; + const useNumberId = options.advanced?.database?.useNumberId === true || options.advanced?.database?.generateId === "serial"; @@ -251,7 +253,7 @@ export const createAdapterFactory = ) { newValue = JSON.stringify(newValue); } else if ( - config.supportsJSON === false && + config.supportsArrays === false && Array.isArray(newValue) && (fieldAttributes!.type === "string[]" || fieldAttributes!.type === "number[]") @@ -346,7 +348,7 @@ export const createAdapterFactory = ) { newValue = safeJSONParse(newValue); } else if ( - config.supportsJSON === false && + config.supportsArrays === false && typeof newValue === "string" && (field.type === "string[]" || field.type === "number[]") ) { diff --git a/packages/core/src/db/adapter/index.ts b/packages/core/src/db/adapter/index.ts index c3c4df435e2..ee3f1b7a050 100644 --- a/packages/core/src/db/adapter/index.ts +++ b/packages/core/src/db/adapter/index.ts @@ -112,6 +112,14 @@ export interface DBAdapterFactoryConfig< * @default true */ supportsBooleans?: boolean | undefined; + /** + * If the database doesn't support arrays, set this to `false`. + * + * We will handle the translation between using `array`s, and saving `string`s to the database. + * + * @default false + */ + supportsArrays?: boolean | undefined; /** * Execute multiple operations in a transaction. * diff --git a/packages/core/src/social-providers/google.ts b/packages/core/src/social-providers/google.ts index 522865c40ec..58e04b0dd3d 100644 --- a/packages/core/src/social-providers/google.ts +++ b/packages/core/src/social-providers/google.ts @@ -1,5 +1,6 @@ import { betterFetch } from "@better-fetch/fetch"; -import { decodeJwt } from "jose"; +import { APIError } from "better-call"; +import { decodeJwt, decodeProtectedHeader, importJWK, jwtVerify } from "jose"; import { logger } from "../env"; import { BetterAuthError } from "../error"; import type { OAuthProvider, ProviderOptions } from "../oauth2"; @@ -126,24 +127,26 @@ export const google = (options: GoogleOptions) => { if (options.verifyIdToken) { return options.verifyIdToken(token, nonce); } - const googlePublicKeyUrl = `https://www.googleapis.com/oauth2/v3/tokeninfo?id_token=${token}`; - const { data: tokenInfo } = await betterFetch<{ - aud: string; - iss: string; - email: string; - email_verified: boolean; - name: string; - picture: string; - sub: string; - }>(googlePublicKeyUrl); - if (!tokenInfo) { + + // Verify JWT integrity + // See https://developers.google.com/identity/sign-in/web/backend-auth#verify-the-integrity-of-the-id-token + + const { kid, alg: jwtAlg } = decodeProtectedHeader(token); + if (!kid || !jwtAlg) return false; + + const publicKey = await getGooglePublicKey(kid); + const { payload: jwtClaims } = await jwtVerify(token, publicKey, { + algorithms: [jwtAlg], + issuer: ["https://accounts.google.com", "accounts.google.com"], + audience: options.clientId, + maxTokenAge: "1h", + }); + + if (nonce && jwtClaims.nonce !== nonce) { return false; } - const isValid = - tokenInfo.aud === options.clientId && - (tokenInfo.iss === "https://accounts.google.com" || - tokenInfo.iss === "accounts.google.com"); - return isValid; + + return true; }, async getUserInfo(token) { if (options.getUserInfo) { @@ -169,3 +172,29 @@ export const google = (options: GoogleOptions) => { options, } satisfies OAuthProvider; }; + +export const getGooglePublicKey = async (kid: string) => { + const { data } = await betterFetch<{ + keys: Array<{ + kid: string; + alg: string; + kty: string; + use: string; + n: string; + e: string; + }>; + }>("https://www.googleapis.com/oauth2/v3/certs"); + + if (!data?.keys) { + throw new APIError("BAD_REQUEST", { + message: "Keys not found", + }); + } + + const jwk = data.keys.find((key) => key.kid === kid); + if (!jwk) { + throw new Error(`JWK with kid ${kid} not found`); + } + + return await importJWK(jwk, jwk.alg); +}; diff --git a/packages/core/src/social-providers/kick.ts b/packages/core/src/social-providers/kick.ts index 29a26098ecc..6711601b2b2 100644 --- a/packages/core/src/social-providers/kick.ts +++ b/packages/core/src/social-providers/kick.ts @@ -1,6 +1,10 @@ import { betterFetch } from "@better-fetch/fetch"; import type { OAuthProvider, ProviderOptions } from "../oauth2"; -import { createAuthorizationURL, validateAuthorizationCode } from "../oauth2"; +import { + createAuthorizationURL, + refreshAccessToken, + validateAuthorizationCode, +} from "../oauth2"; export interface KickProfile { /** @@ -53,6 +57,18 @@ export const kick = (options: KickOptions) => { codeVerifier, }); }, + refreshAccessToken: options.refreshAccessToken + ? options.refreshAccessToken + : async (refreshToken) => { + return refreshAccessToken({ + refreshToken, + options: { + clientId: options.clientId, + clientSecret: options.clientSecret, + }, + tokenEndpoint: "https://id.kick.com/oauth/token", + }); + }, async getUserInfo(token) { if (options.getUserInfo) { return options.getUserInfo(token); diff --git a/packages/expo/package.json b/packages/expo/package.json index 69edb1aa79f..ef21d05b1fe 100644 --- a/packages/expo/package.json +++ b/packages/expo/package.json @@ -1,6 +1,6 @@ { "name": "@better-auth/expo", - "version": "1.4.6-beta.3", + "version": "1.4.6-beta.4", "type": "module", "description": "Better Auth integration for Expo and React Native applications.", "main": "dist/index.mjs", @@ -14,6 +14,7 @@ "homepage": "https://www.better-auth.com/docs/integrations/expo", "scripts": { "test": "vitest", + "coverage": "vitest run --coverage", "lint:package": "publint run --strict", "build": "tsdown", "dev": "tsdown --watch", @@ -29,6 +30,11 @@ "dev-source": "./src/client.ts", "types": "./dist/client.d.mts", "default": "./dist/client.mjs" + }, + "./plugins": { + "dev-source": "./src/plugins/index.ts", + "types": "./dist/plugins/index.d.mts", + "default": "./dist/plugins/index.mjs" } }, "typesVersions": { @@ -38,6 +44,9 @@ ], "client": [ "./dist/client.d.mts" + ], + "plugins": [ + "./dist/plugins/index.d.mts" ] } }, diff --git a/packages/expo/src/plugins/index.ts b/packages/expo/src/plugins/index.ts new file mode 100644 index 00000000000..e867dd762fa --- /dev/null +++ b/packages/expo/src/plugins/index.ts @@ -0,0 +1 @@ +export * from "./last-login-method"; diff --git a/packages/expo/src/plugins/last-login-method.ts b/packages/expo/src/plugins/last-login-method.ts new file mode 100644 index 00000000000..941ac36c2e5 --- /dev/null +++ b/packages/expo/src/plugins/last-login-method.ts @@ -0,0 +1,96 @@ +import type { BetterAuthClientPlugin } from "@better-auth/core"; + +export interface LastLoginMethodClientConfig { + storage: { + setItem: (key: string, value: string) => any; + getItem: (key: string) => string | null; + deleteItemAsync: (key: string) => Promise; + }; + /** + * Prefix for local storage keys (e.g., "my-app_last_login_method") + * @default "better-auth" + */ + storagePrefix?: string | undefined; + /** + * Custom resolve method for retrieving the last login method + */ + customResolveMethod?: + | (( + url: string | URL, + ) => Promise | string | undefined | null) + | undefined; +} + +const paths = [ + "/callback/", + "/oauth2/callback/", + "/sign-in/email", + "/sign-up/email", +]; +const defaultResolveMethod = (url: string | URL) => { + const { pathname } = new URL(url.toString(), "http://localhost"); + + if (paths.some((p) => pathname.includes(p))) { + return pathname.split("/").pop(); + } + if (pathname.includes("siwe")) return "siwe"; + if (pathname.includes("/passkey/verify-authentication")) { + return "passkey"; + } + + return; +}; + +export const lastLoginMethodClient = (config: LastLoginMethodClientConfig) => { + const resolveMethod = config.customResolveMethod || defaultResolveMethod; + const storagePrefix = config.storagePrefix || "better-auth"; + const lastLoginMethodName = `${storagePrefix}_last_login_method`; + const storage = config.storage; + + return { + id: "last-login-method-expo", + fetchPlugins: [ + { + id: "last-login-method-expo", + name: "Last Login Method", + hooks: { + onResponse: async (ctx) => { + const lastMethod = await resolveMethod(ctx.request.url); + if (!lastMethod) { + return; + } + + await storage.setItem(lastLoginMethodName, lastMethod); + }, + }, + }, + ], + getActions() { + return { + /** + * Get the last used login method from storage + * + * @returns The last used login method or null if not found + */ + getLastUsedLoginMethod: (): string | null => { + return storage.getItem(lastLoginMethodName); + }, + /** + * Clear the last used login method from storage + */ + clearLastUsedLoginMethod: async () => { + await storage.deleteItemAsync(lastLoginMethodName); + }, + /** + * Check if a specific login method was the last used + * @param method The method to check + * @returns True if the method was the last used, false otherwise + */ + isLastUsedLoginMethod: (method: string): boolean => { + const lastMethod = storage.getItem(lastLoginMethodName); + return lastMethod === method; + }, + }; + }, + } satisfies BetterAuthClientPlugin; +}; diff --git a/packages/expo/test/last-login-method.test.ts b/packages/expo/test/last-login-method.test.ts new file mode 100644 index 00000000000..bb8025b96a8 --- /dev/null +++ b/packages/expo/test/last-login-method.test.ts @@ -0,0 +1,106 @@ +import { createAuthClient } from "better-auth/client"; +import { getTestInstance } from "better-auth/test"; +import { describe, expect, it } from "vitest"; +import type { LastLoginMethodClientConfig } from "../src/plugins/last-login-method"; +import { lastLoginMethodClient } from "../src/plugins/last-login-method"; + +const createMockStorage = () => { + const store = new Map(); + + return { + getItem: (key: string) => { + return store.get(key) ?? null; + }, + setItem: (key: string, value: string) => { + return store.set(key, value); + }, + deleteItemAsync: async (key: string) => { + store.delete(key); + }, + } satisfies LastLoginMethodClientConfig["storage"]; +}; + +describe("last-login-method expo", async () => { + const { customFetchImpl, testUser } = await getTestInstance({ + emailAndPassword: { + enabled: true, + }, + }); + + it("should resolve email login method and allow to clear storage", async () => { + const storage = createMockStorage(); + + const client = createAuthClient({ + baseURL: "http://localhost:3000", + plugins: [ + lastLoginMethodClient({ + storage, + }), + ], + fetchOptions: { + customFetchImpl, + }, + }); + + await client.signIn.email({ + email: testUser.email, + password: testUser.password, + }); + + expect(client.getLastUsedLoginMethod()).toStrictEqual("email"); + + client.clearLastUsedLoginMethod(); + + expect(client.getLastUsedLoginMethod()).toBeNull(); + }); + + it("should allow custom provider tracking", async () => { + const storage = createMockStorage(); + + const client = createAuthClient({ + baseURL: "http://localhost:3000", + plugins: [ + lastLoginMethodClient({ + storage, + customResolveMethod: (url) => { + return "custom"; + }, + }), + ], + fetchOptions: { + customFetchImpl, + }, + }); + + await client.signIn.email({ + email: testUser.email, + password: testUser.password, + }); + + expect(client.getLastUsedLoginMethod()).toStrictEqual("custom"); + }); + + it("should allow to define a custom storage prefix", async () => { + const storage = createMockStorage(); + + const client = createAuthClient({ + baseURL: "http://localhost:3000", + plugins: [ + lastLoginMethodClient({ + storage, + storagePrefix: "myapp", + }), + ], + fetchOptions: { + customFetchImpl, + }, + }); + + await client.signIn.email({ + email: testUser.email, + password: testUser.password, + }); + + expect(storage.getItem("myapp_last_login_method")).toStrictEqual("email"); + }); +}); diff --git a/packages/expo/tsdown.config.ts b/packages/expo/tsdown.config.ts index 91d19014ace..ccdab0bd860 100644 --- a/packages/expo/tsdown.config.ts +++ b/packages/expo/tsdown.config.ts @@ -3,7 +3,7 @@ import { defineConfig } from "tsdown"; export default defineConfig({ dts: { build: true, incremental: true }, format: ["esm"], - entry: ["./src/index.ts", "./src/client.ts"], + entry: ["./src/index.ts", "./src/client.ts", "./src/plugins/index.ts"], external: [ "better-auth", "better-call", diff --git a/packages/passkey/package.json b/packages/passkey/package.json index 3845ff89f70..30cfe656c5e 100644 --- a/packages/passkey/package.json +++ b/packages/passkey/package.json @@ -1,6 +1,6 @@ { "name": "@better-auth/passkey", - "version": "1.4.6-beta.3", + "version": "1.4.6-beta.4", "type": "module", "description": "Passkey plugin for Better Auth", "main": "dist/index.mjs", @@ -11,6 +11,7 @@ }, "scripts": { "test": "vitest", + "coverage": "vitest run --coverage", "lint:package": "publint run --strict", "build": "tsdown", "dev": "tsdown --watch", diff --git a/packages/scim/package.json b/packages/scim/package.json index 23e0f8c5aaf..9917e0867d7 100644 --- a/packages/scim/package.json +++ b/packages/scim/package.json @@ -1,7 +1,7 @@ { "name": "@better-auth/scim", "author": "Jonathan Samines", - "version": "1.4.6-beta.3", + "version": "1.4.6-beta.4", "type": "module", "main": "dist/index.mjs", "types": "dist/index.d.mts", @@ -22,6 +22,7 @@ "description": "SCIM plugin for Better Auth", "scripts": { "test": "vitest", + "coverage": "vitest run --coverage", "lint:package": "publint run --strict", "build": "tsdown", "dev": "tsdown --watch", diff --git a/packages/sso/package.json b/packages/sso/package.json index b752e42d716..de32e227326 100644 --- a/packages/sso/package.json +++ b/packages/sso/package.json @@ -1,7 +1,7 @@ { "name": "@better-auth/sso", "author": "Bereket Engida", - "version": "1.4.6-beta.3", + "version": "1.4.6-beta.4", "type": "module", "main": "dist/index.mjs", "types": "dist/index.d.mts", @@ -31,6 +31,7 @@ "description": "SSO plugin for Better Auth", "scripts": { "test": "vitest", + "coverage": "vitest run --coverage", "lint:package": "publint run --strict", "build": "tsdown", "dev": "tsdown --watch", diff --git a/packages/sso/src/oidc.test.ts b/packages/sso/src/oidc.test.ts index d32dbf387cc..e037b84d205 100644 --- a/packages/sso/src/oidc.test.ts +++ b/packages/sso/src/oidc.test.ts @@ -571,3 +571,167 @@ describe("provisioning", async (ctx) => { expect(res.url).toContain("http://localhost:8080/authorize"); }); }); + +describe("OIDC account linking with domainVerified", async () => { + const { auth, signInWithTestUser, customFetchImpl, cookieSetter } = + await getTestInstance({ + account: { + accountLinking: { + enabled: true, + trustedProviders: [], + }, + }, + plugins: [ + sso({ + domainVerification: { + enabled: true, + }, + }), + ], + }); + + const authClient = createAuthClient({ + plugins: [ssoClient()], + baseURL: "http://localhost:3000", + fetchOptions: { + customFetchImpl, + }, + }); + + beforeAll(async () => { + await server.issuer.keys.generate("RS256"); + await server.start(8080, "localhost"); + }); + + afterAll(async () => { + await server.stop().catch(() => {}); + }); + + async function simulateOAuthFlow(authUrl: string, headers: Headers) { + let location: string | null = null; + await betterFetch(authUrl, { + method: "GET", + redirect: "manual", + onError(context) { + location = context.response.headers.get("location"); + }, + }); + + if (!location) throw new Error("No redirect location found"); + + let callbackURL = ""; + const newHeaders = new Headers(); + await betterFetch(location, { + method: "GET", + customFetchImpl, + headers, + onError(context) { + callbackURL = context.response.headers.get("location") || ""; + cookieSetter(newHeaders)(context); + }, + }); + + return { callbackURL, headers: newHeaders }; + } + + it("should allow account linking when domain is verified and email domain matches", async () => { + const testEmail = "linking-test@verified-oidc.com"; + const testDomain = "verified-oidc.com"; + + server.service.on("beforeTokenSigning", (token) => { + token.payload.email = testEmail; + token.payload.email_verified = false; + token.payload.name = "Domain Verified User"; + token.payload.sub = "oidc-domain-verified-user"; + }); + + const { headers } = await signInWithTestUser(); + + const provider = await auth.api.registerSSOProvider({ + body: { + providerId: "domain-verified-oidc", + issuer: server.issuer.url!, + domain: testDomain, + oidcConfig: { + clientId: "test", + clientSecret: "test", + authorizationEndpoint: `${server.issuer.url}/authorize`, + tokenEndpoint: `${server.issuer.url}/token`, + jwksEndpoint: `${server.issuer.url}/jwks`, + discoveryEndpoint: `${server.issuer.url}/.well-known/openid-configuration`, + mapping: { + id: "sub", + email: "email", + emailVerified: "email_verified", + name: "name", + }, + }, + }, + headers, + }); + + expect(provider.domainVerified).toBe(false); + + const ctx = await auth.$context; + await ctx.adapter.update({ + model: "ssoProvider", + where: [{ field: "providerId", value: provider.providerId }], + update: { + domainVerified: true, + }, + }); + + const updatedProvider = await ctx.adapter.findOne<{ + domainVerified: boolean; + domain: string; + }>({ + model: "ssoProvider", + where: [{ field: "providerId", value: provider.providerId }], + }); + expect(updatedProvider?.domainVerified).toBe(true); + + await ctx.adapter.create({ + model: "user", + data: { + id: "existing-oidc-domain-user", + email: testEmail, + name: "Existing User", + emailVerified: true, + createdAt: new Date(), + updatedAt: new Date(), + }, + forceAllowId: true, + }); + + const newHeaders = new Headers(); + const res = await authClient.signIn.sso({ + providerId: "domain-verified-oidc", + callbackURL: "/dashboard", + fetchOptions: { + throw: true, + onSuccess: cookieSetter(newHeaders), + }, + }); + + expect(res.url).toContain("http://localhost:8080/authorize"); + + const { callbackURL } = await simulateOAuthFlow(res.url, newHeaders); + + expect(callbackURL).toContain("/dashboard"); + expect(callbackURL).not.toContain("error"); + + const accounts = await ctx.adapter.findMany<{ + providerId: string; + accountId: string; + userId: string; + }>({ + model: "account", + where: [{ field: "userId", value: "existing-oidc-domain-user" }], + }); + const linkedAccount = accounts.find( + (a) => a.providerId === "domain-verified-oidc", + ); + expect(linkedAccount).toBeTruthy(); + expect(linkedAccount?.accountId).toBe("oidc-domain-verified-user"); + }); +}); diff --git a/packages/sso/src/routes/sso.ts b/packages/sso/src/routes/sso.ts index f6e998ff5c8..07ecd554f37 100644 --- a/packages/sso/src/routes/sso.ts +++ b/packages/sso/src/routes/sso.ts @@ -1349,6 +1349,11 @@ export const callbackSSO = (options?: SSOOptions) => { }/error?error=invalid_provider&error_description=missing_user_info`, ); } + const isTrustedProvider = + "domainVerified" in provider && + (provider as { domainVerified?: boolean }).domainVerified === true && + validateEmailDomain(userInfo.email, provider.domain); + const linked = await handleOAuthUserInfo(ctx, { userInfo: { email: userInfo.email, @@ -1372,6 +1377,7 @@ export const callbackSSO = (options?: SSOOptions) => { callbackURL, disableSignUp: options?.disableImplicitSignUp && !requestSignUp, overrideUserInfo: config.overrideUserInfo, + isTrustedProvider, }); if (linked.error) { throw ctx.redirect( diff --git a/packages/sso/src/types.ts b/packages/sso/src/types.ts index 0d7e6372722..11da5c10497 100644 --- a/packages/sso/src/types.ts +++ b/packages/sso/src/types.ts @@ -232,7 +232,13 @@ export interface SSOOptions { * * If you want to allow account linking for specific trusted providers, enable the `accountLinking` option in your auth config and specify those * providers in the `trustedProviders` list. + * * @default false + * + * @deprecated This option is discouraged for new projects. Relying on provider-level `email_verified` is a weaker + * trust signal compared to using `trustedProviders` in `accountLinking` or enabling `domainVerification` for SSO. + * Existing configurations will continue to work, but new integrations should use explicit trust mechanisms. + * This option may be removed in a future major version. */ trustEmailVerified?: boolean | undefined; /** diff --git a/packages/stripe/package.json b/packages/stripe/package.json index f19b924ac0e..3cdfbe4c83f 100644 --- a/packages/stripe/package.json +++ b/packages/stripe/package.json @@ -1,7 +1,7 @@ { "name": "@better-auth/stripe", "author": "Bereket Engida", - "version": "1.4.6-beta.3", + "version": "1.4.6-beta.4", "type": "module", "main": "dist/index.mjs", "types": "dist/index.d.mts", @@ -21,6 +21,7 @@ "description": "Stripe plugin for Better Auth", "scripts": { "test": "vitest", + "coverage": "vitest run --coverage", "lint:package": "publint run --strict", "build": "tsdown", "dev": "tsdown --watch", diff --git a/packages/telemetry/package.json b/packages/telemetry/package.json index 2a91a4eda7d..d721a56d75b 100644 --- a/packages/telemetry/package.json +++ b/packages/telemetry/package.json @@ -1,6 +1,6 @@ { "name": "@better-auth/telemetry", - "version": "1.4.6-beta.3", + "version": "1.4.6-beta.4", "description": "Telemetry package for Better Auth", "type": "module", "repository": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 86f7d140f9b..08d0fcbaa6d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -13,14 +13,11 @@ catalogs: specifier: 1.1.5 version: 1.1.5 tsdown: - specifier: ^0.17.0 - version: 0.17.0 + specifier: ^0.17.2 + version: 0.17.2 typescript: specifier: ^5.9.3 version: 5.9.3 - vitest: - specifier: 4.0.15 - version: 4.0.15 react19: '@types/react': specifier: ^19.2.0 @@ -34,6 +31,10 @@ catalogs: react-dom: specifier: ^19.2.1 version: 19.2.1 + vitest: + vitest: + specifier: ^4.0.15 + version: 4.0.15 importers: @@ -48,15 +49,21 @@ importers: '@types/node': specifier: ^24.10.1 version: 24.10.1 + '@vitest/coverage-istanbul': + specifier: ^4.0.14 + version: 4.0.14(vitest@4.0.15(@opentelemetry/api@1.9.0)(@types/node@24.10.1)(happy-dom@20.0.10)(jiti@2.6.1)(less@4.4.1)(lightningcss@1.30.2)(msw@2.12.4(@types/node@24.10.1)(typescript@5.9.3))(sass@1.90.0)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.2)) bumpp: specifier: ^10.3.2 - version: 10.3.2(magicast@0.3.5) + version: 10.3.2(magicast@0.5.1) cspell: specifier: ^9.4.0 version: 9.4.0 knip: specifier: ^5.71.0 version: 5.71.0(@types/node@24.10.1)(typescript@5.9.3) + nyc: + specifier: ^17.1.0 + version: 17.1.0 publint: specifier: ^0.3.15 version: 0.3.15 @@ -70,7 +77,7 @@ importers: specifier: 'catalog:' version: 5.9.3 vitest: - specifier: 'catalog:' + specifier: catalog:vitest version: 4.0.15(@opentelemetry/api@1.9.0)(@types/node@24.10.1)(happy-dom@20.0.10)(jiti@2.6.1)(less@4.4.1)(lightningcss@1.30.2)(msw@2.12.4(@types/node@24.10.1)(typescript@5.9.3))(sass@1.90.0)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.2) demo/expo: @@ -1135,7 +1142,7 @@ importers: version: 18.6.1 tsdown: specifier: 'catalog:' - version: 0.17.0(oxc-resolver@11.15.0)(publint@0.3.15)(synckit@0.11.11)(typescript@5.9.3) + version: 0.17.2(oxc-resolver@11.15.0)(publint@0.3.15)(synckit@0.11.11)(typescript@5.9.3) type-fest: specifier: ^5.2.0 version: 5.2.0 @@ -1186,7 +1193,7 @@ importers: version: 12.4.1 c12: specifier: ^3.2.0 - version: 3.3.2(magicast@0.3.5) + version: 3.3.2(magicast@0.5.1) chalk: specifier: ^5.6.2 version: 5.6.2 @@ -1238,7 +1245,7 @@ importers: version: 2.6.1 tsdown: specifier: 'catalog:' - version: 0.17.0(oxc-resolver@11.15.0)(publint@0.3.15)(synckit@0.11.11)(typescript@5.9.3) + version: 0.17.2(oxc-resolver@11.15.0)(publint@0.3.15)(synckit@0.11.11)(typescript@5.9.3) tsx: specifier: ^4.20.6 version: 4.20.6 @@ -1278,7 +1285,7 @@ importers: version: 1.0.1 tsdown: specifier: 'catalog:' - version: 0.17.0(oxc-resolver@11.15.0)(publint@0.3.15)(synckit@0.11.11)(typescript@5.9.3) + version: 0.17.2(oxc-resolver@11.15.0)(publint@0.3.15)(synckit@0.11.11)(typescript@5.9.3) packages/expo: dependencies: @@ -1321,7 +1328,7 @@ importers: version: 0.80.2(@babel/core@7.28.4)(@react-native-community/cli@20.0.1(typescript@5.9.3))(@types/react@19.2.2)(react@19.2.1) tsdown: specifier: 'catalog:' - version: 0.17.0(oxc-resolver@11.15.0)(publint@0.3.15)(synckit@0.11.11)(typescript@5.9.3) + version: 0.17.2(oxc-resolver@11.15.0)(publint@0.3.15)(synckit@0.11.11)(typescript@5.9.3) packages/passkey: dependencies: @@ -1355,7 +1362,7 @@ importers: version: link:../better-auth tsdown: specifier: 'catalog:' - version: 0.17.0(oxc-resolver@11.15.0)(publint@0.3.15)(synckit@0.11.11)(typescript@5.9.3) + version: 0.17.2(oxc-resolver@11.15.0)(publint@0.3.15)(synckit@0.11.11)(typescript@5.9.3) packages/scim: dependencies: @@ -1377,7 +1384,7 @@ importers: version: link:../sso tsdown: specifier: 'catalog:' - version: 0.17.0(oxc-resolver@11.15.0)(publint@0.3.15)(synckit@0.11.11)(typescript@5.9.3) + version: 0.17.2(oxc-resolver@11.15.0)(publint@0.3.15)(synckit@0.11.11)(typescript@5.9.3) packages/sso: dependencies: @@ -1420,7 +1427,7 @@ importers: version: 8.2.0 tsdown: specifier: 'catalog:' - version: 0.17.0(oxc-resolver@11.15.0)(publint@0.3.15)(synckit@0.11.11)(typescript@5.9.3) + version: 0.17.2(oxc-resolver@11.15.0)(publint@0.3.15)(synckit@0.11.11)(typescript@5.9.3) packages/stripe: dependencies: @@ -1445,7 +1452,7 @@ importers: version: 20.0.0(@types/node@24.10.1) tsdown: specifier: 'catalog:' - version: 0.17.0(oxc-resolver@11.15.0)(publint@0.3.15)(synckit@0.11.11)(typescript@5.9.3) + version: 0.17.2(oxc-resolver@11.15.0)(publint@0.3.15)(synckit@0.11.11)(typescript@5.9.3) packages/telemetry: dependencies: @@ -1461,7 +1468,7 @@ importers: version: link:../core tsdown: specifier: 'catalog:' - version: 0.17.0(oxc-resolver@11.15.0)(publint@0.3.15)(synckit@0.11.11)(typescript@5.9.3) + version: 0.17.2(oxc-resolver@11.15.0)(publint@0.3.15)(synckit@0.11.11)(typescript@5.9.3) type-fest: specifier: ^5.2.0 version: 5.2.0 @@ -1484,7 +1491,7 @@ importers: specifier: ^6.8.1 version: 6.8.1 vitest: - specifier: 'catalog:' + specifier: catalog:vitest version: 4.0.15(@opentelemetry/api@1.9.0)(@types/node@24.10.1)(happy-dom@20.0.10)(jiti@2.6.1)(less@4.4.1)(lightningcss@1.30.2)(msw@2.12.4(@types/node@24.10.1)(typescript@5.9.3))(sass@1.90.0)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.2) packages: @@ -4218,10 +4225,6 @@ packages: '@oramacloud/client@2.1.4': resolution: {integrity: sha512-uNPFs4wq/iOPbggCwTkVNbIr64Vfd7ZS/h+cricXVnzXWocjDTfJ3wLL4lr0qiSu41g8z+eCAGBqJ30RO2O4AA==} - '@oxc-project/runtime@0.101.0': - resolution: {integrity: sha512-t3qpfVZIqSiLQ5Kqt/MC4Ge/WCOGrrcagAdzTcDaggupjiGxUx4nJF2v6wUCXWSzWHn5Ns7XLv13fCJEwCOERQ==} - engines: {node: ^20.19.0 || >=22.12.0} - '@oxc-project/types@0.101.0': resolution: {integrity: sha512-nuFhqlUzJX+gVIPPfuE6xurd4lST3mdcWOhyK/rZO0B9XWMKm79SuszIQEnSMmmDhq1DC8WWVYGVd+6F93o1gQ==} @@ -4576,8 +4579,8 @@ packages: resolution: {integrity: sha512-S+9ANAvUmjutrshV4jZjaiG8XQyuJIZ8a4utWmN/vW1sgQ9IfBnPndwkmQYw53QmouOIytT874u65HEmu6H5jw==} engines: {node: '>=18'} - '@quansync/fs@0.1.6': - resolution: {integrity: sha512-zoA8SqQO11qH9H8FCBR7NIbowYARIPmBz3nKjgAaOUDi/xPAAu1uAgebtV7KXHTc6CDZJVRZ1u4wIGvY5CWYaw==} + '@quansync/fs@1.0.0': + resolution: {integrity: sha512-4TJ3DFtlf1L5LDMaM6CanJ/0lckGNtJcMjQ1NAV6zDmA0tEHKZtxNKin8EgPaVX1YzljbxckyT2tJrpQKAtngQ==} '@radix-ui/number@1.1.1': resolution: {integrity: sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==} @@ -6846,6 +6849,11 @@ packages: peerDependencies: vite: ^4 || ^5 || ^6 || ^7 + '@vitest/coverage-istanbul@4.0.14': + resolution: {integrity: sha512-weQA5DR6/GaHL61WK0mnq9fzEeWxkpEawM4mp2WodMewLHKc1mCITJcmofNSMON2x0O951RjdnFsXsKi8WzSWg==} + peerDependencies: + vitest: 4.0.14 + '@vitest/expect@4.0.15': resolution: {integrity: sha512-Gfyva9/GxPAWXIWjyGDli9O+waHDC0Q0jaLdFP1qPAUUfo1FEXPXUfUkp3eZA0sSq340vPycSyOlYUeM15Ft1w==} @@ -7002,6 +7010,10 @@ packages: resolution: {integrity: sha512-jRR5wdylq8CkOe6hei19GGZnxM6rBGwFl3Bg0YItGDimvjGtAvdZk4Pu6Cl4u4Igsws4a1fd1Vq3ezrhn4KmFw==} engines: {node: '>= 14'} + aggregate-error@3.1.0: + resolution: {integrity: sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA==} + engines: {node: '>=8'} + ai@5.0.64: resolution: {integrity: sha512-a7H1z2Xz6NQdgx+FIdDlkenoPYBbxbmJSbRfnOFnYS1S1XraiHT8M85hLvz8d8zlxVtSSjiP+c4EjqwtAe72cg==} engines: {node: '>=18'} @@ -7067,6 +7079,10 @@ packages: appdirsjs@1.2.7: resolution: {integrity: sha512-Quji6+8kLBC3NnBeo14nPDq0+2jUs5s3/xEye+udFHumHhRk4M7aAMXp/PBJqkKYGuuyR9M/6Dq7d2AViiGmhw==} + append-transform@2.0.0: + resolution: {integrity: sha512-7yeyCEurROLQJFv5Xj4lEGTy0borxepjFv1g22oAdqFu//SrAlDl1O1Nxx15SH1RoliUml6p8dwJW9jvZughhg==} + engines: {node: '>=8'} + archiver-utils@5.0.2: resolution: {integrity: sha512-wuLJMmIBQYCsGZgYLTy5FIB2pF6Lfb6cXMSF8Qywwk3t20zWnAi7zLcQFdKQmIB8wyZpY5ER38x08GbwtR2cLA==} engines: {node: '>= 14'} @@ -7075,6 +7091,9 @@ packages: resolution: {integrity: sha512-ZcbTaIqJOfCc03QwD468Unz/5Ir8ATtvAHsK+FdXbDIbGfihqh9mrvdcYunQzqn4HrvWWaFyaxJhGZagaJJpPQ==} engines: {node: '>= 14'} + archy@1.0.0: + resolution: {integrity: sha512-Xg+9RwCg/0p32teKdGMPTPnVXKD0w3DfHnFTficozsAgsvq2XenPJq/MYpzzQ/v8zrOyJn6Ds39VA4JIDwFfqw==} + arg@5.0.2: resolution: {integrity: sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==} @@ -7424,6 +7443,10 @@ packages: resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} engines: {node: '>=8'} + caching-transform@4.0.0: + resolution: {integrity: sha512-kpqOvwXnjjN44D89K5ccQC+RUrsy7jB/XLlRrx0D7/2HNcTPqzsb6XgYoErwko6QsV184CA2YgS1fxDiiDZMWA==} + engines: {node: '>=8'} + call-bind-apply-helpers@1.0.2: resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} engines: {node: '>= 0.4'} @@ -7560,6 +7583,10 @@ packages: class-variance-authority@0.7.1: resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==} + clean-stack@2.2.0: + resolution: {integrity: sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A==} + engines: {node: '>=6'} + clear-module@4.1.2: resolution: {integrity: sha512-LWAxzHqdHsAZlPlEyJ2Poz6AIs384mPeqLVCru2p0BrP9G/kVGuhNyZYClLO6cXlnuJjzC8xtsJIuMjKqLXoAw==} engines: {node: '>=8'} @@ -7748,6 +7775,9 @@ packages: resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==} engines: {node: '>= 0.6'} + convert-source-map@1.9.0: + resolution: {integrity: sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==} + convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} @@ -8064,6 +8094,10 @@ packages: resolution: {integrity: sha512-WY/3TUME0x3KPYdRRxEJJvXRHV4PyPoUsxtZa78lwItwRQRHhd2U9xOscaT/YTf8uCXIAjeJOFBVEh/7FtD8Xg==} engines: {node: '>=18'} + default-require-extensions@3.0.1: + resolution: {integrity: sha512-eXTJmRbm2TIt9MgWTsOH1wEuhew6XGZcMeGKCtLedIg/NCsg1iBePXkceTdK4Fii7pzmN9tGsZhKzZ4h7O/fxw==} + engines: {node: '>=8'} + defaults@1.0.4: resolution: {integrity: sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A==} @@ -8637,6 +8671,9 @@ packages: es-toolkit@1.39.10: resolution: {integrity: sha512-E0iGnTtbDhkeczB0T+mxmoVlT4YNweEKBLq7oaU4p11mecdsZpNWOglI4895Vh4usbQ+LsJiuLuI2L0Vdmfm2w==} + es6-error@4.1.1: + resolution: {integrity: sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg==} + esast-util-from-estree@2.0.0: resolution: {integrity: sha512-4CyanoAudUSBAn5K13H4JhsMH6L9ZP7XbLVe/dKybkxMO7eDyLsT8UHl9TRNrU2Gr9nz+FovfSIjuXWJ81uVwQ==} @@ -9079,6 +9116,10 @@ packages: resolution: {integrity: sha512-/t88Ty3d5JWQbWYgaOGCCYfXRwV1+be02WqYYlL6h0lEiUAMPM8o8qKGO01YIkOHzka2up08wvgYD0mDiI+q3Q==} engines: {node: '>= 0.8'} + find-cache-dir@3.3.2: + resolution: {integrity: sha512-wXZV5emFEjrridIgED11OoUKLxiYjAcqot/NJdAkOhlJ+vGzwhOAfcG5OX1jP+S0PcjEn8bdMJv+g2jwQ3Onig==} + engines: {node: '>=8'} + find-up-simple@1.0.1: resolution: {integrity: sha512-afd4O7zpqHeRyg4PfDQsXmlDe2PfdHtJt6Akt8jOWaApLOZk5JXs6VMR29lz03pRe9mpykrRCYIYxaJYcfpncQ==} engines: {node: '>=18'} @@ -9116,6 +9157,10 @@ packages: fontfaceobserver@2.3.0: resolution: {integrity: sha512-6FPvD/IVyT4ZlNe7Wcn5Fb/4ChigpucKYSvD6a+0iMoLn2inpo711eyIcKjmDtE5XNcgAkSH9uN/nfAeZzHEfg==} + foreground-child@2.0.0: + resolution: {integrity: sha512-dCIq9FpEcyQyXKCkyzmlPTFNgrCzPudOe+mhvJU5zAtlBnGVy2yKxtfsxK2tQBThwq225jcvBjpw1Gr40uzZCA==} + engines: {node: '>=8.0.0'} + foreground-child@3.3.1: resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} engines: {node: '>=14'} @@ -9174,6 +9219,9 @@ packages: from@0.1.7: resolution: {integrity: sha512-twe20eF1OxVxp/ML/kq2p1uc6KvFK/+vs8WjEbeKmV2He22MKm7YF2ANIt+EOqhJ5L3K/SuuPhk0hWQDjOM23g==} + fromentries@1.3.2: + resolution: {integrity: sha512-cHEpEQHUg0f8XdtZCc2ZAhrHzKzT0MrFUTcvx+hfxYu7rGMDc5SKoXFh+n4YigxsHXRzc6OrCshdR1bWH6HHyg==} + fs-constants@1.0.0: resolution: {integrity: sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==} @@ -9448,6 +9496,10 @@ packages: resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} engines: {node: '>= 0.4'} + hasha@5.2.2: + resolution: {integrity: sha512-Hrp5vIK/xr5SkeN2onO32H0MgNZ0f17HRNH39WfL0SYUNOTZ5Lz1TJ8Pajo/87dYGEFlLMm7mIc/k/s6Bvz9HQ==} + engines: {node: '>=8'} + hasown@2.0.2: resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} engines: {node: '>= 0.4'} @@ -9519,6 +9571,9 @@ packages: html-entities@2.3.3: resolution: {integrity: sha512-DV5Ln36z34NNTDgnz0EWGBLZENelNAtkiFA4kyNOG2tDI6Mz1uSWiq1wAKdyjnJwyDiDO7Fa2SO1CTxPXL8VxA==} + html-escaper@2.0.2: + resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} + html-to-image@1.11.13: resolution: {integrity: sha512-cuOPoI7WApyhBElTTb9oqsawRvZ0rHhaHwghRLlTuffoD1B2aDemlCruLeZrUIIdvG7gs9xeELEPm6PhuASqrg==} @@ -9639,6 +9694,10 @@ packages: resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} engines: {node: '>=0.8.19'} + indent-string@4.0.0: + resolution: {integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==} + engines: {node: '>=8'} + index-to-position@1.1.0: resolution: {integrity: sha512-XPdx9Dq4t9Qk1mTMbWONJqU7boCoumEH7fRET37HX5+khDUl3J2W6PdALxhILYlIYx2amlwYcRPp28p0tSiojg==} engines: {node: '>=18'} @@ -9813,6 +9872,9 @@ packages: resolution: {integrity: sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A==} engines: {node: '>=18'} + is-typedarray@1.0.0: + resolution: {integrity: sha512-cyA56iCMHAh5CdzjJIa4aohJyeO1YbwLi3Jc35MmRU6poroFjIGZzUzupGiRPOjgHg9TLu43xbpwXk523fMxKA==} + is-unicode-supported@0.1.0: resolution: {integrity: sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==} engines: {node: '>=10'} @@ -9831,6 +9893,10 @@ packages: resolution: {integrity: sha512-ZhMwEosbFJkA0YhFnNDgTM4ZxDRsS6HqTo7qsZM08fehyRYIYa0yHu5R6mgo1n/8MgaPBXiPimPD77baVFYg+A==} engines: {node: '>=12.13'} + is-windows@1.0.2: + resolution: {integrity: sha512-eXK1UInq2bPmjyX6e3VHIzMLobc4J94i4AWn+Hpq3OU5KkrRC96OAcR3PRJ/pGu6m8TRnBHP9dkXQVsT/COVIA==} + engines: {node: '>=0.10.0'} + is-wsl@1.1.0: resolution: {integrity: sha512-gfygJYZ2gLTDlmbWMI0CE2MwnFzSN/2SZfkMlItC4K/JBlsWVDB0bO6XhqcY13YXE7iMcAJnzTCJjPiTeJJ0Mw==} engines: {node: '>=4'} @@ -9865,10 +9931,38 @@ packages: resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==} engines: {node: '>=8'} + istanbul-lib-hook@3.0.0: + resolution: {integrity: sha512-Pt/uge1Q9s+5VAZ+pCo16TYMWPBIl+oaNIjgLQxcX0itS6ueeaA+pEfThZpH8WxhFgCiEb8sAJY6MdUKgiIWaQ==} + engines: {node: '>=8'} + istanbul-lib-instrument@5.2.1: resolution: {integrity: sha512-pzqtp31nLv/XFOzXGuvhCb8qhjmTVo5vjVk19XE4CRlSWz0KoeJ3bw9XsA7nOp9YBf4qHjwBxkDzKcME/J29Yg==} engines: {node: '>=8'} + istanbul-lib-instrument@6.0.3: + resolution: {integrity: sha512-Vtgk7L/R2JHyyGW07spoFlB8/lpjiOLTjMdms6AFMraYt3BaJauod/NGrfnVG/y4Ix1JEuMRPDPEj2ua+zz1/Q==} + engines: {node: '>=10'} + + istanbul-lib-processinfo@2.0.3: + resolution: {integrity: sha512-NkwHbo3E00oybX6NGJi6ar0B29vxyvNwoC7eJ4G4Yq28UfY758Hgn/heV8VRFhevPED4LXfFz0DQ8z/0kw9zMg==} + engines: {node: '>=8'} + + istanbul-lib-report@3.0.1: + resolution: {integrity: sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==} + engines: {node: '>=10'} + + istanbul-lib-source-maps@4.0.1: + resolution: {integrity: sha512-n3s8EwkdFIJCG3BPKBYvskgXGoy88ARzvegkitk60NxRdwltLOTaH7CUiMRXvwYorl0Q712iEjcWB+fK/MrWVw==} + engines: {node: '>=10'} + + istanbul-lib-source-maps@5.0.6: + resolution: {integrity: sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A==} + engines: {node: '>=10'} + + istanbul-reports@3.2.0: + resolution: {integrity: sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==} + engines: {node: '>=8'} + jackspeak@3.4.3: resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==} @@ -10367,6 +10461,9 @@ packages: lodash.defaults@4.2.0: resolution: {integrity: sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==} + lodash.flattendeep@4.4.0: + resolution: {integrity: sha512-uHaJFihxmJcEX3kT4I23ABqKKalJ/zDrDg0lsFtc1h+3uw49SIJ5beyhx5ExVRti3AvKoOJngIj7xz3oylPdWQ==} + lodash.includes@4.3.0: resolution: {integrity: sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==} @@ -10474,6 +10571,9 @@ packages: magicast@0.3.5: resolution: {integrity: sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ==} + magicast@0.5.1: + resolution: {integrity: sha512-xrHS24IxaLrvuo613F719wvOIv9xPHFWQHuvGUBmPnCA/3MQxKI3b+r7n1jAoDHmsbC5bRhTZYR77invLAxVnw==} + mailchecker@6.0.18: resolution: {integrity: sha512-dSTFLe6VYpcvDXcDTPQ5ANCZFbeAFjxBukl+V8eZKKSaWwPsSPDWEbHIo6EBm8CnCU7qanEPdHW1H3WBYhTV1g==} engines: {node: '>=0.10'} @@ -10482,6 +10582,14 @@ packages: resolution: {integrity: sha512-LS9X+dc8KLxXCb8dni79fLIIUA5VyZoyjSMCwTluaXA0o27cCK0bhXkpgw+sTXVpPy/lSO57ilRixqk0vDmtRA==} engines: {node: '>=6'} + make-dir@3.1.0: + resolution: {integrity: sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==} + engines: {node: '>=8'} + + make-dir@4.0.0: + resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} + engines: {node: '>=10'} + makeerror@1.0.12: resolution: {integrity: sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg==} @@ -11232,6 +11340,10 @@ packages: node-mock-http@1.0.2: resolution: {integrity: sha512-zWaamgDUdo9SSLw47we78+zYw/bDr5gH8pH7oRRs8V3KmBtu8GLgGIbV2p/gRPd3LWpEOpjQj7X1FOU3VFMJ8g==} + node-preload@0.2.1: + resolution: {integrity: sha512-RM5oyBy45cLEoHqCeh+MNuFAxO0vTFBLskvQbOKnEE7YTTSN4tbN8QWDIPQ6L+WvKsB/qLEGpYe2ZZ9d4W9OIQ==} + engines: {node: '>=8'} + node-releases@2.0.21: resolution: {integrity: sha512-5b0pgg78U3hwXkCM8Z9b2FJdPZlr9Psr9V2gQPESdGHqbntyFJKFW4r5TeWGFzafGY3hzs1JC62VEQMbl1JFkw==} @@ -11293,6 +11405,11 @@ packages: number-flow@0.5.8: resolution: {integrity: sha512-FPr1DumWyGi5Nucoug14bC6xEz70A1TnhgSHhKyfqjgji2SOTz+iLJxKtv37N5JyJbteGYCm6NQ9p1O4KZ7iiA==} + nyc@17.1.0: + resolution: {integrity: sha512-U42vQ4czpKa0QdI1hu950XuNhYqgoM+ZF1HT+VuUHL9hPfDPVvNQyltmMqdE9bUHMVa+8yNbc3QKTj8zQhlVxQ==} + engines: {node: '>=18'} + hasBin: true + nypm@0.6.0: resolution: {integrity: sha512-mn8wBFV9G9+UFHIrq+pZ2r2zL4aPau/by3kJb3cM7+5tQHMt6HGQB8FDIeKFYp8o0D2pnH6nVsO88N4AmUxIWg==} engines: {node: ^14.16.0 || >=16.10.0} @@ -11451,6 +11568,10 @@ packages: resolution: {integrity: sha512-wPrq66Llhl7/4AGC6I+cqxT07LhXvWL08LNXz1fENOw0Ap4sRZZ/gZpTTJ5jpurzzzfS2W/Ge9BY3LgLjCShcw==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + p-map@3.0.0: + resolution: {integrity: sha512-d3qXVTF/s+W+CdJ5A29wywV2n8CQQYahlgz2bFiA+4eVNJbHJodPZ+/gXwPGh0bOqA+j8S+6+ckmvLGPk1QpxQ==} + engines: {node: '>=8'} + p-map@7.0.3: resolution: {integrity: sha512-VkndIv2fIB99swvQoA65bm+fsmt6UNdGeIB0oxBs+WhAhdh08QA04JXpI7rbB9r08/nkbysKoya9rtDERYOYMA==} engines: {node: '>=18'} @@ -11467,6 +11588,10 @@ packages: resolution: {integrity: sha512-lwx6u1CotQYPVju77R+D0vFomni/AqRfqLmqQ8hekklqZ6gAY9rONh7lBQ0uxWMkC2AuX9b2DVAl8To0NyP1JA==} engines: {node: '>=12'} + package-hash@4.0.0: + resolution: {integrity: sha512-whdkPIooSu/bASggZ96BWVvZTRMOFxnyUG5PnTSGKoJE2gd5mbVNmR2Nj20QFzxYYgAXpoqC+AiXzl+UMRh7zQ==} + engines: {node: '>=8'} + package-json-from-dist@1.0.1: resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} @@ -11658,6 +11783,10 @@ packages: resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==} engines: {node: '>= 6'} + pkg-dir@4.2.0: + resolution: {integrity: sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==} + engines: {node: '>=8'} + pkg-types@1.3.1: resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==} @@ -11776,6 +11905,11 @@ packages: engines: {node: '>=14'} hasBin: true + prettier@3.7.4: + resolution: {integrity: sha512-v6UNi1+3hSlVvv8fSaoUbggEM5VErKmmpGA7Pl3HF8V6uKY7rvClBOJlH6yNwQtfTueNkGVpOv/mtWL9L4bgRA==} + engines: {node: '>=14'} + hasBin: true + pretty-bytes@5.6.0: resolution: {integrity: sha512-FFw039TmrBqFK8ma/7OL3sDz/VytdtJr044/QUJtH0wK9lb9jLq9tJyIxUwtQJHwar2BqtiA4iCWSwo9JLkzFg==} engines: {node: '>=6'} @@ -11809,6 +11943,10 @@ packages: process-nextick-args@2.0.1: resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==} + process-on-spawn@1.1.0: + resolution: {integrity: sha512-JOnOPQ/8TZgjs1JIH/m9ni7FfimjNa/PRx7y/Wb5qdItsnhO0jE4AT7fC0HjC28DUQWDr50dwSYZLdRMlqDq3Q==} + engines: {node: '>=8'} + process@0.11.10: resolution: {integrity: sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==} engines: {node: '>= 0.6.0'} @@ -11888,8 +12026,8 @@ packages: quansync@0.2.11: resolution: {integrity: sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==} - quansync@0.3.0: - resolution: {integrity: sha512-dr5GyvHkdDbrAeXyl0MGi/jWKM6+/lZbNFVe+Ff7ivJi4RVry7O091VfXT/wuAVcF3FwNr86nwZVdxx8nELb2w==} + quansync@1.0.0: + resolution: {integrity: sha512-5xZacEEufv3HSTPQuchrvV6soaiACMFnq1H8wkVioctoH3TRha9Sz66lOxRwPK/qZj7HPiSveih9yAyh98gvqA==} query-string@7.1.3: resolution: {integrity: sha512-hh2WYhq4fi8+b+/2Kg9CEge4fDPvHS534aOOvOZeQ3+Vf2mCFsaFBYj0i+iXcAq6I9Vzp5fjMFBlONvayDC1qg==} @@ -12289,6 +12427,10 @@ packages: rehype-recma@1.0.0: resolution: {integrity: sha512-lqA4rGUf1JmacCNWWZx0Wv1dHqMwxzsDWYMTowuplHF3xH0N/MmrZ/G3BDZnzAkRmxDadujCjaKM2hqYdCBOGw==} + release-zalgo@1.0.0: + resolution: {integrity: sha512-gUAyHVHPPC5wdqX/LG4LWtRYtgjxyX78oanFNTMMyFEfOqdC54s3eE82imuWKbOeqYht2CrNf64Qb8vgmmtZGA==} + engines: {node: '>=4'} + remark-gfm@4.0.1: resolution: {integrity: sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==} @@ -12405,8 +12547,8 @@ packages: deprecated: Rimraf versions prior to v4 are no longer supported hasBin: true - rolldown-plugin-dts@0.18.2: - resolution: {integrity: sha512-jRz3SHwr69F/IGEDMHtWjwVjgZwo3PZEadmMt4uA/e3rbIytoLJhvktSKlIAy/4QeWhVL9XeuCJBC66wvBQRwg==} + rolldown-plugin-dts@0.18.3: + resolution: {integrity: sha512-rd1LZ0Awwfyn89UndUF/HoFF4oH9a5j+2ZeuKSJYM80vmeN/p0gslYMnHTQHBEXPhUlvAlqGA3tVgXB/1qFNDg==} engines: {node: '>=20.19.0'} peerDependencies: '@ts-macro/tsc': ^0.3.6 @@ -12749,6 +12891,10 @@ packages: sparse-bitfield@3.0.3: resolution: {integrity: sha512-kvzhi7vqKTfkh0PZU+2D2PIllw2ymqJKujUcyPMd9Y75Nv4nPbGJZXNhxsgdQab2BmlDct1YnfQCguEvHr7VsQ==} + spawn-wrap@2.0.0: + resolution: {integrity: sha512-EeajNjfN9zMnULLwhZZQU3GWBoFNkbngTUPfaawT4RkMiviTxcX0qfhVbGey39mfctfDHkWtuecgQ8NJcyQWHg==} + engines: {node: '>=8'} + spdx-correct@3.2.0: resolution: {integrity: sha512-kN9dJbvnySHULIluDHy32WHRUu3Og7B9sbY7tsFLctQkIqnMh3hErYgdMjTYuqmcXX+lK5T1lnUt3G7zNswmZA==} @@ -12883,6 +13029,10 @@ packages: resolution: {integrity: sha512-uCC2VHvQRYu+lMh4My/sFNmF2klFymLX1wHJeXnbEJERpV/ZsVuonzerjfrGpIGF7LBVa1O7i9kjiWvJiFck8g==} engines: {node: '>=0.10.0'} + strip-bom@4.0.0: + resolution: {integrity: sha512-3xurFv5tEgii33Zi8Jtp55wEIILR9eh34FAW00PZf+JnSsTmV/ioewSgQl97JHvgjoRGwPShsWm+IdrxB35d0w==} + engines: {node: '>=8'} + strip-final-newline@2.0.0: resolution: {integrity: sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==} engines: {node: '>=6'} @@ -13183,13 +13333,13 @@ packages: ts-morph@26.0.0: resolution: {integrity: sha512-ztMO++owQnz8c/gIENcM9XfCEzgoGphTv+nKpYNM1bgsdOVC/jRZuEBf6N+mLLDNg68Kl+GgUZfOySaRiG1/Ug==} - tsdown@0.17.0: - resolution: {integrity: sha512-NPZRrlC51X9Bb55ZTDwrWges8Dm1niCvNA5AYw7aix6pfnDnB4WR0neG5RPq75xIodg3hqlQUzzyrX7n4dmnJg==} + tsdown@0.17.2: + resolution: {integrity: sha512-SuU+0CWm/95KfXqojHTVuwcouIsdn7HpYcwDyOdKktJi285NxKwysjFUaxYLxpCNqqPvcFvokXLO4dZThRwzkw==} engines: {node: '>=20.19.0'} hasBin: true peerDependencies: '@arethetypeswrong/core': ^0.18.1 - '@vitejs/devtools': ^0.0.0-alpha.18 + '@vitejs/devtools': ^0.0.0-alpha.19 publint: ^0.3.0 typescript: ^5.0.0 unplugin-lightningcss: ^0.4.0 @@ -13271,6 +13421,10 @@ packages: resolution: {integrity: sha512-Ne2YiiGN8bmrmJJEuTWTLJR32nh/JdL1+PSicowtNb0WFpn59GK8/lfD61bVtzguz7b3PBt74nxpv/Pw5po5Rg==} engines: {node: '>=8'} + type-fest@0.8.1: + resolution: {integrity: sha512-4dbzIzqvjtgiM5rw1k5rEHtBANKmdudhGyBEajN01fEyhaAIhsoKNy6y7+IN93IfpFtwY9iqi7kD+xwKhQsNJA==} + engines: {node: '>=8'} + type-fest@4.41.0: resolution: {integrity: sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==} engines: {node: '>=16'} @@ -13287,6 +13441,9 @@ packages: resolution: {integrity: sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==} engines: {node: '>= 0.6'} + typedarray-to-buffer@3.1.5: + resolution: {integrity: sha512-zdu8XMNEDepKKR+XYOXAVPtWui0ly0NtohUscw+UmaHiAWT8hrV1rr//H6V+0DvJ3OQ19S979M0laLfX8rm82Q==} + typescript@5.9.3: resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} engines: {node: '>=14.17'} @@ -13309,8 +13466,8 @@ packages: ultrahtml@1.6.0: resolution: {integrity: sha512-R9fBn90VTJrqqLDwyMph+HGne8eqY1iPfYhPzZrvKpIfwkWZbcYlfpsb8B9dTvBfpy1/hqAD7Wi8EKfP9e8zdw==} - unconfig-core@7.4.1: - resolution: {integrity: sha512-Bp/bPZjV2Vl/fofoA2OYLSnw1Z0MOhCX7zHnVCYrazpfZvseBbGhwcNQMxsg185Mqh7VZQqK3C8hFG/Dyng+yA==} + unconfig-core@7.4.2: + resolution: {integrity: sha512-VgPCvLWugINbXvMQDf8Jh0mlbvNjNC6eSUziHsBCMpxR05OPrNrvDnyatdMjRgcHaaNsCqz+wjNXxNw1kRLHUg==} uncrypto@0.1.3: resolution: {integrity: sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q==} @@ -13420,8 +13577,8 @@ packages: resolution: {integrity: sha512-lkaSIlxceytPyt9yfb1h7L9jDFqwMqvUZeGsKB7Z8QrvAO3xZv2S+xMQQYzxk0AGJHcQhbcvhKEstrMy99jnuQ==} engines: {node: '>=18.12.0'} - unrun@0.2.16: - resolution: {integrity: sha512-DBkjUpQv9AQs1464XWnWQ97RuxPCu+CImvQMPmqFeHoL2Bi6C1BGPacMuXVw4VMIfQewNJZWUxPt5envG90oUA==} + unrun@0.2.19: + resolution: {integrity: sha512-DbwbJ9BvPEb3BeZnIpP9S5tGLO/JIgPQ3JrpMRFIfZMZfMG19f26OlLbC2ml8RRdrI2ZA7z2t+at5tsIHbh6Qw==} engines: {node: '>=20.19.0'} hasBin: true peerDependencies: @@ -13887,6 +14044,9 @@ packages: wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + write-file-atomic@3.0.3: + resolution: {integrity: sha512-AvHcyZ5JnSfq3ioSyjrBkH9yW4m7Ayk8/9My/DD9onKeu/94fwrMocemO2QAJFAlnnDN+ZDS+ZjAR5ua1/PV/Q==} + write-file-atomic@4.0.2: resolution: {integrity: sha512-7KxauUdBmSdWnmpaGFg+ppNjKF8uNLry8LyzjauQDOVONfFLNKrKvQOxZ/VuTIcS/gge/YNahf5RIIQWTSarlg==} engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0} @@ -17191,8 +17351,6 @@ snapshots: '@orama/orama': 3.1.14 lodash: 4.17.21 - '@oxc-project/runtime@0.101.0': {} - '@oxc-project/types@0.101.0': {} '@oxc-resolver/binding-android-arm-eabi@11.15.0': @@ -17458,9 +17616,9 @@ snapshots: '@publint/pack@0.1.2': {} - '@quansync/fs@0.1.6': + '@quansync/fs@1.0.0': dependencies: - quansync: 0.3.0 + quansync: 1.0.0 '@radix-ui/number@1.1.1': {} @@ -18245,7 +18403,7 @@ snapshots: '@react-email/render@1.2.1(react-dom@19.2.1(react@19.2.1))(react@19.2.1)': dependencies: html-to-text: 9.0.5 - prettier: 3.6.2 + prettier: 3.7.4 react: 19.2.1 react-dom: 19.2.1(react@19.2.1) react-promise-suspense: 0.3.4 @@ -20148,6 +20306,21 @@ snapshots: transitivePeerDependencies: - '@swc/helpers' + '@vitest/coverage-istanbul@4.0.14(vitest@4.0.15(@opentelemetry/api@1.9.0)(@types/node@24.10.1)(happy-dom@20.0.10)(jiti@2.6.1)(less@4.4.1)(lightningcss@1.30.2)(msw@2.12.4(@types/node@24.10.1)(typescript@5.9.3))(sass@1.90.0)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.2))': + dependencies: + '@istanbuljs/schema': 0.1.3 + istanbul-lib-coverage: 3.2.2 + istanbul-lib-instrument: 6.0.3 + istanbul-lib-report: 3.0.1 + istanbul-lib-source-maps: 5.0.6 + istanbul-reports: 3.2.0 + magicast: 0.5.1 + obug: 2.1.1 + tinyrainbow: 3.0.3 + vitest: 4.0.15(@opentelemetry/api@1.9.0)(@types/node@24.10.1)(happy-dom@20.0.10)(jiti@2.6.1)(less@4.4.1)(lightningcss@1.30.2)(msw@2.12.4(@types/node@24.10.1)(typescript@5.9.3))(sass@1.90.0)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.2) + transitivePeerDependencies: + - supports-color + '@vitest/expect@4.0.15': dependencies: '@standard-schema/spec': 1.0.0 @@ -20334,6 +20507,11 @@ snapshots: agent-base@7.1.3: {} + aggregate-error@3.1.0: + dependencies: + clean-stack: 2.2.0 + indent-string: 4.0.0 + ai@5.0.64(zod@4.1.13): dependencies: '@ai-sdk/gateway': 1.0.35(zod@4.1.13) @@ -20407,6 +20585,10 @@ snapshots: appdirsjs@1.2.7: optional: true + append-transform@2.0.0: + dependencies: + default-require-extensions: 3.0.1 + archiver-utils@5.0.2: dependencies: glob: 10.4.5 @@ -20427,6 +20609,8 @@ snapshots: tar-stream: 3.1.7 zip-stream: 6.0.1 + archy@1.0.0: {} + arg@5.0.2: {} argparse@1.0.10: @@ -20849,11 +21033,11 @@ snapshots: builtin-modules@3.3.0: {} - bumpp@10.3.2(magicast@0.3.5): + bumpp@10.3.2(magicast@0.5.1): dependencies: ansis: 4.2.0 args-tokenizer: 0.3.0 - c12: 3.3.2(magicast@0.3.5) + c12: 3.3.2(magicast@0.5.1) cac: 6.7.14 escalade: 3.2.0 jsonc-parser: 3.3.1 @@ -20892,8 +21076,32 @@ snapshots: optionalDependencies: magicast: 0.3.5 + c12@3.3.2(magicast@0.5.1): + dependencies: + chokidar: 4.0.3 + confbox: 0.2.2 + defu: 6.1.4 + dotenv: 17.2.3 + exsolve: 1.0.8 + giget: 2.0.0 + jiti: 2.6.1 + ohash: 2.0.11 + pathe: 2.0.3 + perfect-debounce: 2.0.0 + pkg-types: 2.3.0 + rc9: 2.1.2 + optionalDependencies: + magicast: 0.5.1 + cac@6.7.14: {} + caching-transform@4.0.0: + dependencies: + hasha: 5.2.2 + make-dir: 3.1.0 + package-hash: 4.0.0 + write-file-atomic: 3.0.3 + call-bind-apply-helpers@1.0.2: dependencies: es-errors: 1.3.0 @@ -21047,6 +21255,8 @@ snapshots: dependencies: clsx: 2.1.1 + clean-stack@2.2.0: {} + clear-module@4.1.2: dependencies: parent-module: 2.0.0 @@ -21080,7 +21290,6 @@ snapshots: string-width: 4.2.3 strip-ansi: 6.0.1 wrap-ansi: 6.2.0 - optional: true cliui@8.0.1: dependencies: @@ -21239,6 +21448,8 @@ snapshots: content-type@1.0.5: {} + convert-source-map@1.9.0: {} + convert-source-map@2.0.0: {} cookie-es@1.2.2: {} @@ -21526,8 +21737,7 @@ snapshots: dependencies: callsite: 1.0.0 - decamelize@1.2.0: - optional: true + decamelize@1.2.0: {} decimal.js-light@2.5.1: {} @@ -21552,6 +21762,10 @@ snapshots: bundle-name: 4.1.0 default-browser-id: 5.0.0 + default-require-extensions@3.0.1: + dependencies: + strip-bom: 4.0.0 + defaults@1.0.4: dependencies: clone: 1.0.4 @@ -21870,6 +22084,8 @@ snapshots: es-toolkit@1.39.10: {} + es6-error@4.1.1: {} + esast-util-from-estree@2.0.0: dependencies: '@types/estree-jsx': 1.0.5 @@ -22660,6 +22876,12 @@ snapshots: transitivePeerDependencies: - supports-color + find-cache-dir@3.3.2: + dependencies: + commondir: 1.0.1 + make-dir: 3.1.0 + pkg-dir: 4.2.0 + find-up-simple@1.0.1: {} find-up@4.1.0: @@ -22688,6 +22910,11 @@ snapshots: fontfaceobserver@2.3.0: {} + foreground-child@2.0.0: + dependencies: + cross-spawn: 7.0.6 + signal-exit: 3.0.7 + foreground-child@3.3.1: dependencies: cross-spawn: 7.0.6 @@ -22735,6 +22962,8 @@ snapshots: from@0.1.7: {} + fromentries@1.3.2: {} + fs-constants@1.0.0: {} fs-extra@8.1.0: @@ -23060,6 +23289,11 @@ snapshots: dependencies: has-symbols: 1.1.0 + hasha@5.2.2: + dependencies: + is-stream: 2.0.1 + type-fest: 0.8.1 + hasown@2.0.2: dependencies: function-bind: 1.1.2 @@ -23176,6 +23410,8 @@ snapshots: html-entities@2.3.3: {} + html-escaper@2.0.2: {} + html-to-image@1.11.13: {} html-to-text@9.0.5: @@ -23301,6 +23537,8 @@ snapshots: imurmurhash@0.1.4: {} + indent-string@4.0.0: {} + index-to-position@1.1.0: {} inflight@1.0.6: @@ -23436,6 +23674,8 @@ snapshots: is-stream@4.0.1: {} + is-typedarray@1.0.0: {} + is-unicode-supported@0.1.0: optional: true @@ -23448,6 +23688,8 @@ snapshots: is-what@4.1.16: {} + is-windows@1.0.2: {} + is-wsl@1.1.0: optional: true @@ -23473,6 +23715,10 @@ snapshots: istanbul-lib-coverage@3.2.2: {} + istanbul-lib-hook@3.0.0: + dependencies: + append-transform: 2.0.0 + istanbul-lib-instrument@5.2.1: dependencies: '@babel/core': 7.28.4 @@ -23483,6 +23729,52 @@ snapshots: transitivePeerDependencies: - supports-color + istanbul-lib-instrument@6.0.3: + dependencies: + '@babel/core': 7.28.4 + '@babel/parser': 7.28.5 + '@istanbuljs/schema': 0.1.3 + istanbul-lib-coverage: 3.2.2 + semver: 7.7.3 + transitivePeerDependencies: + - supports-color + + istanbul-lib-processinfo@2.0.3: + dependencies: + archy: 1.0.0 + cross-spawn: 7.0.6 + istanbul-lib-coverage: 3.2.2 + p-map: 3.0.0 + rimraf: 3.0.2 + uuid: 8.3.2 + + istanbul-lib-report@3.0.1: + dependencies: + istanbul-lib-coverage: 3.2.2 + make-dir: 4.0.0 + supports-color: 7.2.0 + + istanbul-lib-source-maps@4.0.1: + dependencies: + debug: 4.4.3 + istanbul-lib-coverage: 3.2.2 + source-map: 0.6.1 + transitivePeerDependencies: + - supports-color + + istanbul-lib-source-maps@5.0.6: + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + debug: 4.4.3 + istanbul-lib-coverage: 3.2.2 + transitivePeerDependencies: + - supports-color + + istanbul-reports@3.2.0: + dependencies: + html-escaper: 2.0.2 + istanbul-lib-report: 3.0.1 + jackspeak@3.4.3: dependencies: '@isaacs/cliui': 8.0.2 @@ -23983,6 +24275,8 @@ snapshots: lodash.defaults@4.2.0: {} + lodash.flattendeep@4.4.0: {} + lodash.includes@4.3.0: {} lodash.isarguments@3.1.0: {} @@ -24087,6 +24381,12 @@ snapshots: '@babel/types': 7.28.5 source-map-js: 1.2.1 + magicast@0.5.1: + dependencies: + '@babel/parser': 7.28.5 + '@babel/types': 7.28.5 + source-map-js: 1.2.1 + mailchecker@6.0.18: {} make-dir@2.1.0: @@ -24095,6 +24395,14 @@ snapshots: semver: 5.7.2 optional: true + make-dir@3.1.0: + dependencies: + semver: 6.3.1 + + make-dir@4.0.0: + dependencies: + semver: 7.7.3 + makeerror@1.0.12: dependencies: tmpl: 1.0.5 @@ -25494,6 +25802,10 @@ snapshots: node-mock-http@1.0.2: {} + node-preload@0.2.1: + dependencies: + process-on-spawn: 1.1.0 + node-releases@2.0.21: {} node-rsa@1.1.1: @@ -25555,6 +25867,38 @@ snapshots: dependencies: esm-env: 1.2.2 + nyc@17.1.0: + dependencies: + '@istanbuljs/load-nyc-config': 1.1.0 + '@istanbuljs/schema': 0.1.3 + caching-transform: 4.0.0 + convert-source-map: 1.9.0 + decamelize: 1.2.0 + find-cache-dir: 3.3.2 + find-up: 4.1.0 + foreground-child: 3.3.1 + get-package-type: 0.1.0 + glob: 7.2.3 + istanbul-lib-coverage: 3.2.2 + istanbul-lib-hook: 3.0.0 + istanbul-lib-instrument: 6.0.3 + istanbul-lib-processinfo: 2.0.3 + istanbul-lib-report: 3.0.1 + istanbul-lib-source-maps: 4.0.1 + istanbul-reports: 3.2.0 + make-dir: 3.1.0 + node-preload: 0.2.1 + p-map: 3.0.0 + process-on-spawn: 1.1.0 + resolve-from: 5.0.0 + rimraf: 3.0.2 + signal-exit: 3.0.7 + spawn-wrap: 2.0.0 + test-exclude: 6.0.0 + yargs: 15.4.1 + transitivePeerDependencies: + - supports-color + nypm@0.6.0: dependencies: citty: 0.1.6 @@ -25781,6 +26125,10 @@ snapshots: dependencies: p-limit: 4.0.0 + p-map@3.0.0: + dependencies: + aggregate-error: 3.1.0 + p-map@7.0.3: {} p-timeout@6.1.4: {} @@ -25791,6 +26139,13 @@ snapshots: dependencies: p-timeout: 6.1.4 + package-hash@4.0.0: + dependencies: + graceful-fs: 4.2.11 + hasha: 5.2.2 + lodash.flattendeep: 4.4.0 + release-zalgo: 1.0.0 + package-json-from-dist@1.0.1: {} package-manager-detector@1.6.0: {} @@ -25963,6 +26318,10 @@ snapshots: pirates@4.0.7: {} + pkg-dir@4.2.0: + dependencies: + find-up: 4.1.0 + pkg-types@1.3.1: dependencies: confbox: 0.1.8 @@ -26101,6 +26460,9 @@ snapshots: prettier@3.6.2: {} + prettier@3.7.4: + optional: true + pretty-bytes@5.6.0: {} pretty-bytes@6.1.1: {} @@ -26129,6 +26491,10 @@ snapshots: process-nextick-args@2.0.1: {} + process-on-spawn@1.1.0: + dependencies: + fromentries: 1.3.2 + process@0.11.10: {} progress@2.0.3: {} @@ -26204,7 +26570,7 @@ snapshots: quansync@0.2.11: {} - quansync@0.3.0: {} + quansync@1.0.0: {} query-string@7.1.3: dependencies: @@ -26829,6 +27195,10 @@ snapshots: transitivePeerDependencies: - supports-color + release-zalgo@1.0.0: + dependencies: + es6-error: 4.1.1 + remark-gfm@4.0.1: dependencies: '@types/mdast': 4.0.4 @@ -26885,8 +27255,7 @@ snapshots: require-from-string@2.0.2: {} - require-main-filename@2.0.0: - optional: true + require-main-filename@2.0.0: {} require-package-name@2.0.1: {} @@ -26962,7 +27331,7 @@ snapshots: dependencies: glob: 7.2.3 - rolldown-plugin-dts@0.18.2(oxc-resolver@11.15.0)(rolldown@1.0.0-beta.53)(typescript@5.9.3): + rolldown-plugin-dts@0.18.3(oxc-resolver@11.15.0)(rolldown@1.0.0-beta.53)(typescript@5.9.3): dependencies: '@babel/generator': 7.28.5 '@babel/parser': 7.28.5 @@ -27231,8 +27600,7 @@ snapshots: server-only@0.0.1: {} - set-blocking@2.0.0: - optional: true + set-blocking@2.0.0: {} set-cookie-parser@2.7.2: {} @@ -27452,6 +27820,15 @@ snapshots: dependencies: memory-pager: 1.5.0 + spawn-wrap@2.0.0: + dependencies: + foreground-child: 2.0.0 + is-windows: 1.0.2 + make-dir: 3.1.0 + rimraf: 3.0.2 + signal-exit: 3.0.7 + which: 2.0.2 + spdx-correct@3.2.0: dependencies: spdx-expression-parse: 3.0.1 @@ -27572,6 +27949,8 @@ snapshots: strip-bom-string@1.0.0: {} + strip-bom@4.0.0: {} + strip-final-newline@2.0.0: optional: true @@ -27885,7 +28264,7 @@ snapshots: '@ts-morph/common': 0.27.0 code-block-writer: 13.0.3 - tsdown@0.17.0(oxc-resolver@11.15.0)(publint@0.3.15)(synckit@0.11.11)(typescript@5.9.3): + tsdown@0.17.2(oxc-resolver@11.15.0)(publint@0.3.15)(synckit@0.11.11)(typescript@5.9.3): dependencies: ansis: 4.2.0 cac: 6.7.14 @@ -27894,13 +28273,13 @@ snapshots: import-without-cache: 0.2.2 obug: 2.1.1 rolldown: 1.0.0-beta.53 - rolldown-plugin-dts: 0.18.2(oxc-resolver@11.15.0)(rolldown@1.0.0-beta.53)(typescript@5.9.3) + rolldown-plugin-dts: 0.18.3(oxc-resolver@11.15.0)(rolldown@1.0.0-beta.53)(typescript@5.9.3) semver: 7.7.3 tinyexec: 1.0.2 tinyglobby: 0.2.15 tree-kill: 1.2.2 - unconfig-core: 7.4.1 - unrun: 0.2.16(synckit@0.11.11) + unconfig-core: 7.4.2 + unrun: 0.2.19(synckit@0.11.11) optionalDependencies: publint: 0.3.15 typescript: 5.9.3 @@ -27962,6 +28341,8 @@ snapshots: type-fest@0.7.1: {} + type-fest@0.8.1: {} + type-fest@4.41.0: {} type-fest@5.2.0: @@ -27979,6 +28360,10 @@ snapshots: media-typer: 1.1.0 mime-types: 3.0.2 + typedarray-to-buffer@3.1.5: + dependencies: + is-typedarray: 1.0.0 + typescript@5.9.3: {} ua-is-frozen@0.1.2: {} @@ -27999,10 +28384,10 @@ snapshots: ultrahtml@1.6.0: {} - unconfig-core@7.4.1: + unconfig-core@7.4.2: dependencies: - '@quansync/fs': 0.1.6 - quansync: 0.2.11 + '@quansync/fs': 1.0.0 + quansync: 1.0.0 uncrypto@0.1.3: {} @@ -28143,9 +28528,8 @@ snapshots: picomatch: 4.0.3 webpack-virtual-modules: 0.6.2 - unrun@0.2.16(synckit@0.11.11): + unrun@0.2.19(synckit@0.11.11): dependencies: - '@oxc-project/runtime': 0.101.0 rolldown: 1.0.0-beta.53 optionalDependencies: synckit: 0.11.11 @@ -28527,8 +28911,7 @@ snapshots: tr46: 0.0.3 webidl-conversions: 3.0.1 - which-module@2.0.1: - optional: true + which-module@2.0.1: {} which@2.0.2: dependencies: @@ -28627,6 +29010,13 @@ snapshots: wrappy@1.0.2: {} + write-file-atomic@3.0.3: + dependencies: + imurmurhash: 0.1.4 + is-typedarray: 1.0.0 + signal-exit: 3.0.7 + typedarray-to-buffer: 3.1.5 + write-file-atomic@4.0.2: dependencies: imurmurhash: 0.1.4 @@ -28696,8 +29086,7 @@ snapshots: xtend@4.0.2: {} - y18n@4.0.3: - optional: true + y18n@4.0.3: {} y18n@5.0.8: {} @@ -28711,7 +29100,6 @@ snapshots: dependencies: camelcase: 5.3.1 decamelize: 1.2.0 - optional: true yargs-parser@21.1.1: {} @@ -28728,7 +29116,6 @@ snapshots: which-module: 2.0.1 y18n: 4.0.3 yargs-parser: 18.1.3 - optional: true yargs@17.7.2: dependencies: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index d4d53853f80..a5fbeecebcc 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -8,9 +8,8 @@ packages: catalog: '@better-fetch/fetch': 1.1.18 better-call: 1.1.5 - tsdown: ^0.17.0 + tsdown: ^0.17.2 typescript: ^5.9.3 - vitest: 4.0.15 catalogs: react19: @@ -18,5 +17,8 @@ catalogs: '@types/react-dom': ^19.2.0 react: ^19.2.1 react-dom: ^19.2.1 + vitest: + '@vitest/coverage-v8': ^4.0.15 + vitest: ^4.0.15 neverBuiltDependencies: [] diff --git a/test/package.json b/test/package.json index 6d445e6e57c..da1dd6f6d49 100644 --- a/test/package.json +++ b/test/package.json @@ -4,6 +4,7 @@ "type": "module", "scripts": { "test": "vitest", + "coverage": "vitest run --coverage", "lint:package": "publint run --strict" }, "devDependencies": { @@ -12,6 +13,6 @@ "better-auth": "workspace:*", "msw": "^2.12.4", "openid-client": "^6.8.1", - "vitest": "catalog:" + "vitest": "catalog:vitest" } } diff --git a/turbo.json b/turbo.json index 33e54bdb627..e76b9e3a455 100644 --- a/turbo.json +++ b/turbo.json @@ -28,6 +28,10 @@ "test": { "dependsOn": ["build"] }, + "coverage": { + "dependsOn": ["build"], + "outputs": ["coverage"] + }, "e2e:smoke": { "dependsOn": ["build"] },