From 3668a58450d17d1b6a433f845d47555293515e82 Mon Sep 17 00:00:00 2001 From: viktormarinho Date: Mon, 8 Dec 2025 17:31:14 -0300 Subject: [PATCH 01/56] dynamic org resources --- ORGANIZATION_PLUGIN_RBAC_RESEARCH.md | 796 +++++++++++++ docs/content/docs/plugins/organization.mdx | 459 ++++++++ .../organization-custom-resources.test.ts | 429 +++++++ package.json | 24 + .../src/plugins/organization/error-codes.ts | 12 + .../plugins/organization/has-permission.ts | 9 +- .../organization/load-resources.test.ts | 214 ++++ .../plugins/organization/load-resources.ts | 199 ++++ .../src/plugins/organization/organization.ts | 82 ++ .../routes/crud-access-control.ts | 165 ++- .../organization/routes/crud-resources.ts | 1041 +++++++++++++++++ .../src/plugins/organization/schema.ts | 48 +- .../src/plugins/organization/types.ts | 54 + 13 files changed, 3522 insertions(+), 10 deletions(-) create mode 100644 ORGANIZATION_PLUGIN_RBAC_RESEARCH.md create mode 100644 e2e/smoke/test/organization-custom-resources.test.ts create mode 100644 packages/better-auth/src/plugins/organization/load-resources.test.ts create mode 100644 packages/better-auth/src/plugins/organization/load-resources.ts create mode 100644 packages/better-auth/src/plugins/organization/routes/crud-resources.ts 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/docs/content/docs/plugins/organization.mdx b/docs/content/docs/plugins/organization.mdx index 4367c4edfa4..d594683a306 100644 --- a/docs/content/docs/plugins/organization.mdx +++ b/docs/content/docs/plugins/organization.mdx @@ -1769,6 +1769,465 @@ 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 lowercase 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 (lowercase alphanumeric with underscores, not reserved names). + + +#### Resource Name Validation + +Resource names must follow these rules: + +- Must be lowercase alphanumeric with underscores only +- Length between 1 and 50 characters +- Cannot be a reserved name (organization, member, invitation, team, ac by default) + +**Valid names:** +- `project` +- `task_123` +- `my_custom_resource` + +**Invalid names:** +- `Project` (contains uppercase) +- `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. + +#### 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/e2e/smoke/test/organization-custom-resources.test.ts b/e2e/smoke/test/organization-custom-resources.test.ts new file mode 100644 index 00000000000..06597471d1e --- /dev/null +++ b/e2e/smoke/test/organization-custom-resources.test.ts @@ -0,0 +1,429 @@ +import { getTestInstance } from "../../../packages/better-auth/src/test-utils/test-instance"; +import { createAuthClient } from "../../../packages/better-auth/src/client"; +import { organizationClient } from "../../../packages/better-auth/src/plugins/organization/client"; +import { organization } from "../../../packages/better-auth/src/plugins/organization"; +import { createAccessControl } from "../../../packages/better-auth/src/plugins/access"; +import { defaultStatements, ownerAc, adminAc, memberAc } from "../../../packages/better-auth/src/plugins/organization/access"; +import { describe, expect, it, beforeAll } from "vitest"; + +describe("organization custom resources integration", async () => { + 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: "InvalidName", reason: "contains uppercase" }, + { 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 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..27b53a9dce1 100644 --- a/package.json +++ b/package.json @@ -34,5 +34,29 @@ "turbo": "^2.6.3", "typescript": "catalog:", "vitest": "catalog:" + }, + "workspaces": { + "packages": [ + "packages/**", + "docs", + "demo/*", + "e2e/**", + "test" + ], + "catalog": { + "@better-fetch/fetch": "1.1.18", + "better-call": "1.1.5", + "tsdown": "^0.17.0", + "typescript": "^5.9.3", + "vitest": "4.0.15" + }, + "catalogs": { + "react19": { + "@types/react": "^19.2.0", + "@types/react-dom": "^19.2.0", + "react": "^19.2.1", + "react-dom": "^19.2.1" + } + } } } diff --git a/packages/better-auth/src/plugins/organization/error-codes.ts b/packages/better-auth/src/plugins/organization/error-codes.ts index 3c1bafe61df..ced1fe22e1e 100644 --- a/packages/better-auth/src/plugins/organization/error-codes.ts +++ b/packages/better-auth/src/plugins/organization/error-codes.ts @@ -88,4 +88,16 @@ 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..559a0947103 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,11 @@ 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 +71,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..09fd5b4212e --- /dev/null +++ b/packages/better-auth/src/plugins/organization/load-resources.test.ts @@ -0,0 +1,214 @@ +import { describe, expect, it, beforeEach } from "vitest"; +import { + validateResourceName, + getReservedResourceNames, + getDefaultReservedResourceNames, + invalidateResourceCache, + clearAllResourceCache, +} 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 lowercase 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, + }); + }); + + it("should reject names with uppercase letters", () => { + const options: OrganizationOptions = {}; + const result = validateResourceName("Project", options); + expect(result.valid).toBe(false); + expect(result.error).toContain("lowercase"); + }); + + 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("lowercase 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("lowercase 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 reject mixed case even with valid characters", () => { + const options: OrganizationOptions = {}; + expect(validateResourceName("camelCase", options).valid).toBe(false); + expect(validateResourceName("PascalCase", options).valid).toBe(false); + expect(validateResourceName("UPPERCASE", options).valid).toBe(false); + }); + }); +}); + 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..a137965f722 --- /dev/null +++ b/packages/better-auth/src/plugins/organization/load-resources.ts @@ -0,0 +1,199 @@ +import type { GenericEndpointContext } from "@better-auth/core"; +import * as z from "zod"; +import { APIError } from "../../api"; +import { createAccessControl, type AccessControl, type Statements } 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 result = z.array(z.string()).safeParse(JSON.parse(resource.permissions)); + + if (!result.success) { + ctx.context.logger.error( + "[loadCustomResources] Invalid permissions for resource " + resource.resource, + { + permissions: JSON.parse(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: + * - Must be lowercase alphanumeric with underscores + * - Length between 1 and 50 characters + * - Cannot be a reserved name + * - Custom validation function if provided + */ +export function validateResourceName( + name: string, + options: OrganizationOptions, +): { valid: boolean; error?: string } { + // Length validation first + if (name.length < 1 || name.length > 50) { + return { + valid: false, + error: "Resource name must be between 1 and 50 characters", + }; + } + + // Basic format validation + if (!/^[a-z0-9_]+$/.test(name)) { + return { + valid: false, + error: "Resource name must be lowercase 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`, + }; + } + + // Custom validation if provided + if (options.dynamicAccessControl?.resourceNameValidation) { + const customResult = options.dynamicAccessControl.resourceNameValidation(name); + if (typeof customResult === "boolean") { + return customResult + ? { valid: true } + : { valid: false, error: "Resource name failed custom validation" }; + } + return customResult; + } + + 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..db3d24de0eb 100644 --- a/packages/better-auth/src/plugins/organization/organization.ts +++ b/packages/better-auth/src/plugins/organization/organization.ts @@ -19,6 +19,13 @@ import { listOrgRoles, updateOrgRole, } from "./routes/crud-access-control"; +import { + createOrgResource, + deleteOrgResource, + getOrgResource, + listOrgResources, + updateOrgResource, +} from "./routes/crud-resources"; import { acceptInvitation, cancelInvitation, @@ -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,55 @@ 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: { @@ -1064,6 +1145,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..2489a0a6222 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,12 @@ 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, getReservedResourceNames, 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 @@ -232,7 +233,7 @@ export const createOrgRole = (options: O) => { }); } - await checkForInvalidResources({ ac, ctx, permission }); + await checkForInvalidResources({ ac, ctx, permission, organizationId, options }); await checkIfMemberHasPermission({ ctx, @@ -977,7 +978,13 @@ export const updateOrgRole = (options: O) => { if (ctx.body.data.permission) { let newPermission = ctx.body.data.permission; - await checkForInvalidResources({ ac, ctx, permission: newPermission }); + await checkForInvalidResources({ + ac, + ctx, + permission: newPermission, + organizationId, + options, + }); await checkIfMemberHasPermission({ ctx, @@ -1051,27 +1058,169 @@ async function checkForInvalidResources({ ac, ctx, permission, + organizationId, + options, }: { ac: AccessControl; ctx: GenericEndpointContext; permission: Record; + organizationId: string; + options: OrganizationOptions; }) { - const validResources = Object.keys(ac.statements); + // 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) { + + // If custom resources are enabled, auto-create missing resources + if (missingResources.length > 0 && options.dynamicAccessControl?.enableCustomResources) { + const reservedNames = getReservedResourceNames(options); + + for (const resourceName of missingResources) { + // Validate the resource name + 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 resource name already exists (shouldn't happen, but double-check) + 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) { + // Get the permissions for this resource from the permission object + const resourcePermissions = permission[resourceName] || []; + + // Create the resource + await ctx.context.adapter.create< + Omit & { permissions: string } + >({ + model: "organizationResource", + data: { + createdAt: new Date(), + organizationId, + permissions: JSON.stringify(resourcePermissions), + resource: resourceName, + }, + }); + + ctx.context.logger.info( + `[Dynamic Access Control] Auto-created resource "${resourceName}" for organization ${organizationId}`, + { + resourceName, + organizationId, + permissions: resourcePermissions, + }, + ); + } + } + + // Invalidate cache so new resources are picked up + if (missingResources.length > 0) { + invalidateResourceCache(organizationId); + } + + // Reload statements after creating resources + const updatedStatements = await getOrganizationStatements(organizationId, options, ctx); + + // Now validate that the provided permissions for each resource are valid + for (const [resource, permissions] of Object.entries(permission)) { + const validPermissions = updatedStatements[resource as keyof Statements]; + if (!validPermissions) { + // This shouldn't happen after auto-creation, but just in case + ctx.context.logger.error( + `[Dynamic Access Control] Resource "${resource}" still not found after auto-creation.`, + { + resource, + }, + ); + throw new APIError("INTERNAL_SERVER_ERROR", { + message: `Failed to create resource "${resource}"`, + }); + } + + const invalidPermissions = permissions.filter( + (p) => !validPermissions.includes(p), + ); + if (invalidPermissions.length > 0) { + ctx.context.logger.error( + `[Dynamic Access Control] The provided permissions include invalid actions for resource "${resource}".`, + { + resource, + invalidPermissions, + validPermissions, + }, + ); + throw new APIError("BAD_REQUEST", { + message: `Invalid permissions for resource "${resource}": ${invalidPermissions.join(", ")}`, + }); + } + } + } else if (missingResources.length > 0) { + // Custom resources not enabled, so throw error for missing resources 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, }); + } else { + // All resources exist, validate permissions + for (const [resource, permissions] of Object.entries(permission)) { + const validPermissions = orgStatements[resource as keyof Statements]; + if (!validPermissions) continue; + + const invalidPermissions = permissions.filter( + (p) => !validPermissions.includes(p), + ); + if (invalidPermissions.length > 0) { + ctx.context.logger.error( + `[Dynamic Access Control] The provided permissions include invalid actions for resource "${resource}".`, + { + resource, + invalidPermissions, + validPermissions, + }, + ); + throw new APIError("BAD_REQUEST", { + message: `Invalid permissions for resource "${resource}": ${invalidPermissions.join(", ")}`, + }); + } + } } } 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..f831be70263 --- /dev/null +++ b/packages/better-auth/src/plugins/organization/routes/crud-resources.ts @@ -0,0 +1,1041 @@ +import type { GenericEndpointContext } from "@better-auth/core"; +import { createAuthEndpoint } from "@better-auth/core/api"; +import type { Where } from "@better-auth/core/db/adapter"; +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) { + for (const key in additionalFields) { + additionalFields[key]!.required = false; + } + } + const additionalFieldsSchema = toZodSchema({ + fields: additionalFields, + isClientSide: true, + }); + type AdditionalFields = AllPartial extends true + ? Partial< + InferAdditionalFieldsFromPluginOptions<"organizationResource", O> + > + : 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.YOU_MUST_BE_IN_AN_ORGANIZATION_TO_CREATE_A_ROLE, + }); + } + + // 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, + }); + } + + // Normalize resource name (lowercase) + resourceName = resourceName.toLowerCase(); + + // 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.toLowerCase(); + 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.toLowerCase(); + + // 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 = JSON.parse(role.permission); + return 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: JSON.parse(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.toLowerCase(); + + // 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: JSON.parse( + customResource.permissions as never as string, + ) 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..3ff76218edb 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,7 +235,15 @@ export type OrganizationSchema = "organizationRole", OrganizationRoleDefaultFields >; - } & { + } & (O["dynamicAccessControl"] extends { enableCustomResources: true } + ? { + organizationResource: InferSchema< + O["schema"] extends BetterAuthPluginDBSchema ? O["schema"] : {}, + "organizationResource", + OrganizationResourceDefaultFields + >; + } + : {}) & { session: { fields: InferSchema< O["schema"] extends BetterAuthPluginDBSchema ? O["schema"] : {}, @@ -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..39a9b010c37 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,50 @@ 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 + * alongside the default resources (organization, member, invitation, team, ac). + * + * @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. + * + * @param name - The resource name to validate + * @returns true if valid, false otherwise, or an object with valid flag and error message + * + * @example + * ```ts + * resourceNameValidation: (name) => { + * if (name.length > 50) { + * return { valid: false, error: "Resource name too long" }; + * } + * return true; + * } + * ``` + */ + resourceNameValidation?: + | (( + name: string, + ) => boolean | { valid: boolean; error?: string }) + | undefined; } | undefined; /** @@ -316,6 +361,15 @@ export interface OrganizationOptions { [key in string]: DBFieldAttribute; }; }; + organizationResource?: { + modelName?: string; + fields?: { + [key in keyof Omit]?: string; + }; + additionalFields?: { + [key in string]: DBFieldAttribute; + }; + }; } | undefined; /** From ee599ca57d6879cac9a0d4f4d2f79efe5ba45721 Mon Sep 17 00:00:00 2001 From: viktormarinho Date: Mon, 8 Dec 2025 17:37:37 -0300 Subject: [PATCH 02/56] chore: release v1.5.0 --- packages/better-auth/package.json | 2 +- packages/cli/package.json | 2 +- packages/core/package.json | 2 +- packages/expo/package.json | 2 +- packages/passkey/package.json | 2 +- packages/scim/package.json | 2 +- packages/sso/package.json | 2 +- packages/stripe/package.json | 2 +- packages/telemetry/package.json | 2 +- 9 files changed, 9 insertions(+), 9 deletions(-) diff --git a/packages/better-auth/package.json b/packages/better-auth/package.json index ce850a113e2..8222c131da4 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.5.0", "description": "The most comprehensive authentication framework for TypeScript.", "type": "module", "license": "MIT", diff --git a/packages/cli/package.json b/packages/cli/package.json index 40335432c73..22a86947946 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.5.0", "type": "module", "description": "The CLI for Better Auth", "module": "dist/index.mjs", diff --git a/packages/core/package.json b/packages/core/package.json index 32c501ca0af..a25052a8b4e 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.5.0", "description": "The most comprehensive authentication framework for TypeScript.", "type": "module", "repository": { diff --git a/packages/expo/package.json b/packages/expo/package.json index 69edb1aa79f..b0279afc54c 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.5.0", "type": "module", "description": "Better Auth integration for Expo and React Native applications.", "main": "dist/index.mjs", diff --git a/packages/passkey/package.json b/packages/passkey/package.json index 3845ff89f70..dc84a01a9d6 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.5.0", "type": "module", "description": "Passkey plugin for Better Auth", "main": "dist/index.mjs", diff --git a/packages/scim/package.json b/packages/scim/package.json index 23e0f8c5aaf..2854686c896 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.5.0", "type": "module", "main": "dist/index.mjs", "types": "dist/index.d.mts", diff --git a/packages/sso/package.json b/packages/sso/package.json index b752e42d716..a691eed064a 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.5.0", "type": "module", "main": "dist/index.mjs", "types": "dist/index.d.mts", diff --git a/packages/stripe/package.json b/packages/stripe/package.json index f19b924ac0e..00b744bf0b9 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.5.0", "type": "module", "main": "dist/index.mjs", "types": "dist/index.d.mts", diff --git a/packages/telemetry/package.json b/packages/telemetry/package.json index 2a91a4eda7d..c94187303c8 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.5.0", "description": "Telemetry package for Better Auth", "type": "module", "repository": { From 39f5504f40f0f33ba0d15788c48b14769f995740 Mon Sep 17 00:00:00 2001 From: viktormarinho Date: Mon, 8 Dec 2025 17:45:02 -0300 Subject: [PATCH 03/56] chore: release v1.5.1 --- packages/better-auth/package.json | 4 ++-- packages/cli/package.json | 2 +- packages/core/package.json | 2 +- packages/expo/package.json | 2 +- packages/passkey/package.json | 2 +- packages/scim/package.json | 2 +- packages/sso/package.json | 2 +- packages/stripe/package.json | 2 +- packages/telemetry/package.json | 2 +- 9 files changed, 10 insertions(+), 10 deletions(-) diff --git a/packages/better-auth/package.json b/packages/better-auth/package.json index 8222c131da4..374dbc5a11d 100644 --- a/packages/better-auth/package.json +++ b/packages/better-auth/package.json @@ -1,6 +1,6 @@ { - "name": "better-auth", - "version": "1.5.0", + "name": "@decocms/better-auth", + "version": "1.5.1", "description": "The most comprehensive authentication framework for TypeScript.", "type": "module", "license": "MIT", diff --git a/packages/cli/package.json b/packages/cli/package.json index 22a86947946..1385d82dd0f 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@better-auth/cli", - "version": "1.5.0", + "version": "1.5.1", "type": "module", "description": "The CLI for Better Auth", "module": "dist/index.mjs", diff --git a/packages/core/package.json b/packages/core/package.json index a25052a8b4e..062b9c2250a 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@better-auth/core", - "version": "1.5.0", + "version": "1.5.1", "description": "The most comprehensive authentication framework for TypeScript.", "type": "module", "repository": { diff --git a/packages/expo/package.json b/packages/expo/package.json index b0279afc54c..ba5b5e2f708 100644 --- a/packages/expo/package.json +++ b/packages/expo/package.json @@ -1,6 +1,6 @@ { "name": "@better-auth/expo", - "version": "1.5.0", + "version": "1.5.1", "type": "module", "description": "Better Auth integration for Expo and React Native applications.", "main": "dist/index.mjs", diff --git a/packages/passkey/package.json b/packages/passkey/package.json index dc84a01a9d6..924ad46e306 100644 --- a/packages/passkey/package.json +++ b/packages/passkey/package.json @@ -1,6 +1,6 @@ { "name": "@better-auth/passkey", - "version": "1.5.0", + "version": "1.5.1", "type": "module", "description": "Passkey plugin for Better Auth", "main": "dist/index.mjs", diff --git a/packages/scim/package.json b/packages/scim/package.json index 2854686c896..c78bdfed904 100644 --- a/packages/scim/package.json +++ b/packages/scim/package.json @@ -1,7 +1,7 @@ { "name": "@better-auth/scim", "author": "Jonathan Samines", - "version": "1.5.0", + "version": "1.5.1", "type": "module", "main": "dist/index.mjs", "types": "dist/index.d.mts", diff --git a/packages/sso/package.json b/packages/sso/package.json index a691eed064a..9e2fc9c7b3b 100644 --- a/packages/sso/package.json +++ b/packages/sso/package.json @@ -1,7 +1,7 @@ { "name": "@better-auth/sso", "author": "Bereket Engida", - "version": "1.5.0", + "version": "1.5.1", "type": "module", "main": "dist/index.mjs", "types": "dist/index.d.mts", diff --git a/packages/stripe/package.json b/packages/stripe/package.json index 00b744bf0b9..35309950883 100644 --- a/packages/stripe/package.json +++ b/packages/stripe/package.json @@ -1,7 +1,7 @@ { "name": "@better-auth/stripe", "author": "Bereket Engida", - "version": "1.5.0", + "version": "1.5.1", "type": "module", "main": "dist/index.mjs", "types": "dist/index.d.mts", diff --git a/packages/telemetry/package.json b/packages/telemetry/package.json index c94187303c8..1e7dabde0de 100644 --- a/packages/telemetry/package.json +++ b/packages/telemetry/package.json @@ -1,6 +1,6 @@ { "name": "@better-auth/telemetry", - "version": "1.5.0", + "version": "1.5.1", "description": "Telemetry package for Better Auth", "type": "module", "repository": { From 757b7669a0c2b1ff190c83b42d769be3bf4715a8 Mon Sep 17 00:00:00 2001 From: viktormarinho Date: Mon, 8 Dec 2025 17:48:37 -0300 Subject: [PATCH 04/56] chore: release v1.5.2 --- packages/better-auth/package.json | 6 +++--- packages/cli/package.json | 2 +- packages/core/package.json | 2 +- packages/expo/package.json | 2 +- packages/passkey/package.json | 2 +- packages/scim/package.json | 2 +- packages/sso/package.json | 2 +- packages/stripe/package.json | 2 +- packages/telemetry/package.json | 2 +- 9 files changed, 11 insertions(+), 11 deletions(-) diff --git a/packages/better-auth/package.json b/packages/better-auth/package.json index 374dbc5a11d..79c319f20c2 100644 --- a/packages/better-auth/package.json +++ b/packages/better-auth/package.json @@ -1,6 +1,6 @@ { "name": "@decocms/better-auth", - "version": "1.5.1", + "version": "1.5.2", "description": "The most comprehensive authentication framework for TypeScript.", "type": "module", "license": "MIT", @@ -443,8 +443,8 @@ } }, "dependencies": { - "@better-auth/core": "workspace:*", - "@better-auth/telemetry": "workspace:*", + "@better-auth/core": "1.4.5", + "@better-auth/telemetry": "1.4.5", "@better-auth/utils": "0.3.0", "@better-fetch/fetch": "catalog:", "@noble/ciphers": "^2.0.0", diff --git a/packages/cli/package.json b/packages/cli/package.json index 1385d82dd0f..4f63bf983a7 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@better-auth/cli", - "version": "1.5.1", + "version": "1.5.2", "type": "module", "description": "The CLI for Better Auth", "module": "dist/index.mjs", diff --git a/packages/core/package.json b/packages/core/package.json index 062b9c2250a..009dc0f1bd6 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@better-auth/core", - "version": "1.5.1", + "version": "1.5.2", "description": "The most comprehensive authentication framework for TypeScript.", "type": "module", "repository": { diff --git a/packages/expo/package.json b/packages/expo/package.json index ba5b5e2f708..3417522ea4e 100644 --- a/packages/expo/package.json +++ b/packages/expo/package.json @@ -1,6 +1,6 @@ { "name": "@better-auth/expo", - "version": "1.5.1", + "version": "1.5.2", "type": "module", "description": "Better Auth integration for Expo and React Native applications.", "main": "dist/index.mjs", diff --git a/packages/passkey/package.json b/packages/passkey/package.json index 924ad46e306..e942f4f3e6a 100644 --- a/packages/passkey/package.json +++ b/packages/passkey/package.json @@ -1,6 +1,6 @@ { "name": "@better-auth/passkey", - "version": "1.5.1", + "version": "1.5.2", "type": "module", "description": "Passkey plugin for Better Auth", "main": "dist/index.mjs", diff --git a/packages/scim/package.json b/packages/scim/package.json index c78bdfed904..eb88a654fdf 100644 --- a/packages/scim/package.json +++ b/packages/scim/package.json @@ -1,7 +1,7 @@ { "name": "@better-auth/scim", "author": "Jonathan Samines", - "version": "1.5.1", + "version": "1.5.2", "type": "module", "main": "dist/index.mjs", "types": "dist/index.d.mts", diff --git a/packages/sso/package.json b/packages/sso/package.json index 9e2fc9c7b3b..a32b8bb6964 100644 --- a/packages/sso/package.json +++ b/packages/sso/package.json @@ -1,7 +1,7 @@ { "name": "@better-auth/sso", "author": "Bereket Engida", - "version": "1.5.1", + "version": "1.5.2", "type": "module", "main": "dist/index.mjs", "types": "dist/index.d.mts", diff --git a/packages/stripe/package.json b/packages/stripe/package.json index 35309950883..813d2f73576 100644 --- a/packages/stripe/package.json +++ b/packages/stripe/package.json @@ -1,7 +1,7 @@ { "name": "@better-auth/stripe", "author": "Bereket Engida", - "version": "1.5.1", + "version": "1.5.2", "type": "module", "main": "dist/index.mjs", "types": "dist/index.d.mts", diff --git a/packages/telemetry/package.json b/packages/telemetry/package.json index 1e7dabde0de..e981d15e400 100644 --- a/packages/telemetry/package.json +++ b/packages/telemetry/package.json @@ -1,6 +1,6 @@ { "name": "@better-auth/telemetry", - "version": "1.5.1", + "version": "1.5.2", "description": "Telemetry package for Better Auth", "type": "module", "repository": { From e67d66dc2da97b0846738d834dbc5b84aa475b83 Mon Sep 17 00:00:00 2001 From: viktormarinho Date: Mon, 8 Dec 2025 17:49:36 -0300 Subject: [PATCH 05/56] chore: release v1.5.3 --- packages/better-auth/package.json | 2 +- packages/cli/package.json | 2 +- packages/core/package.json | 2 +- packages/expo/package.json | 2 +- packages/passkey/package.json | 2 +- packages/scim/package.json | 2 +- packages/sso/package.json | 2 +- packages/stripe/package.json | 2 +- packages/telemetry/package.json | 2 +- 9 files changed, 9 insertions(+), 9 deletions(-) diff --git a/packages/better-auth/package.json b/packages/better-auth/package.json index 79c319f20c2..1d2385476e4 100644 --- a/packages/better-auth/package.json +++ b/packages/better-auth/package.json @@ -1,6 +1,6 @@ { "name": "@decocms/better-auth", - "version": "1.5.2", + "version": "1.5.3", "description": "The most comprehensive authentication framework for TypeScript.", "type": "module", "license": "MIT", diff --git a/packages/cli/package.json b/packages/cli/package.json index 4f63bf983a7..d62f5c92987 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@better-auth/cli", - "version": "1.5.2", + "version": "1.5.3", "type": "module", "description": "The CLI for Better Auth", "module": "dist/index.mjs", diff --git a/packages/core/package.json b/packages/core/package.json index 009dc0f1bd6..7c9f292dd3a 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@better-auth/core", - "version": "1.5.2", + "version": "1.5.3", "description": "The most comprehensive authentication framework for TypeScript.", "type": "module", "repository": { diff --git a/packages/expo/package.json b/packages/expo/package.json index 3417522ea4e..a05b6f021b2 100644 --- a/packages/expo/package.json +++ b/packages/expo/package.json @@ -1,6 +1,6 @@ { "name": "@better-auth/expo", - "version": "1.5.2", + "version": "1.5.3", "type": "module", "description": "Better Auth integration for Expo and React Native applications.", "main": "dist/index.mjs", diff --git a/packages/passkey/package.json b/packages/passkey/package.json index e942f4f3e6a..e92caeddcdb 100644 --- a/packages/passkey/package.json +++ b/packages/passkey/package.json @@ -1,6 +1,6 @@ { "name": "@better-auth/passkey", - "version": "1.5.2", + "version": "1.5.3", "type": "module", "description": "Passkey plugin for Better Auth", "main": "dist/index.mjs", diff --git a/packages/scim/package.json b/packages/scim/package.json index eb88a654fdf..3d249926205 100644 --- a/packages/scim/package.json +++ b/packages/scim/package.json @@ -1,7 +1,7 @@ { "name": "@better-auth/scim", "author": "Jonathan Samines", - "version": "1.5.2", + "version": "1.5.3", "type": "module", "main": "dist/index.mjs", "types": "dist/index.d.mts", diff --git a/packages/sso/package.json b/packages/sso/package.json index a32b8bb6964..d53ee7f750f 100644 --- a/packages/sso/package.json +++ b/packages/sso/package.json @@ -1,7 +1,7 @@ { "name": "@better-auth/sso", "author": "Bereket Engida", - "version": "1.5.2", + "version": "1.5.3", "type": "module", "main": "dist/index.mjs", "types": "dist/index.d.mts", diff --git a/packages/stripe/package.json b/packages/stripe/package.json index 813d2f73576..9916c999587 100644 --- a/packages/stripe/package.json +++ b/packages/stripe/package.json @@ -1,7 +1,7 @@ { "name": "@better-auth/stripe", "author": "Bereket Engida", - "version": "1.5.2", + "version": "1.5.3", "type": "module", "main": "dist/index.mjs", "types": "dist/index.d.mts", diff --git a/packages/telemetry/package.json b/packages/telemetry/package.json index e981d15e400..6e913b9ba94 100644 --- a/packages/telemetry/package.json +++ b/packages/telemetry/package.json @@ -1,6 +1,6 @@ { "name": "@better-auth/telemetry", - "version": "1.5.2", + "version": "1.5.3", "description": "Telemetry package for Better Auth", "type": "module", "repository": { From 23e2edb6fc59f5fbf6470523217f02980eb0f3ab Mon Sep 17 00:00:00 2001 From: Jonathan Samines Date: Mon, 8 Dec 2025 14:49:53 -0600 Subject: [PATCH 06/56] chore: configure code coverage for project (#6339) Co-authored-by: Taesu <166604494+bytaesu@users.noreply.github.com> --- .github/workflows/ci.yml | 2 +- biome.json | 1 + package.json | 10 +- packages/better-auth/package.json | 1 + packages/cli/package.json | 1 + packages/core/package.json | 3 +- packages/expo/package.json | 1 + packages/passkey/package.json | 1 + packages/scim/package.json | 1 + packages/sso/package.json | 1 + packages/stripe/package.json | 1 + pnpm-lock.yaml | 430 ++++++++++++++++++++++++++++-- pnpm-workspace.yaml | 5 +- test/package.json | 3 +- turbo.json | 4 + 15 files changed, 438 insertions(+), 27 deletions(-) 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/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/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..cef241f35ef 100644 --- a/packages/better-auth/package.json +++ b/packages/better-auth/package.json @@ -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/cli/package.json b/packages/cli/package.json index 40335432c73..23e9ca0aec3 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -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/core/package.json b/packages/core/package.json index 32c501ca0af..2c152ceafbd 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -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/expo/package.json b/packages/expo/package.json index 69edb1aa79f..238f077cf1c 100644 --- a/packages/expo/package.json +++ b/packages/expo/package.json @@ -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", diff --git a/packages/passkey/package.json b/packages/passkey/package.json index 3845ff89f70..102aa0054ef 100644 --- a/packages/passkey/package.json +++ b/packages/passkey/package.json @@ -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..72dc1dec7a8 100644 --- a/packages/scim/package.json +++ b/packages/scim/package.json @@ -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..c35599e0f65 100644 --- a/packages/sso/package.json +++ b/packages/sso/package.json @@ -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/stripe/package.json b/packages/stripe/package.json index f19b924ac0e..5a97e223f5a 100644 --- a/packages/stripe/package.json +++ b/packages/stripe/package.json @@ -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/pnpm-lock.yaml b/pnpm-lock.yaml index 86f7d140f9b..605d93acf2d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -18,9 +18,6 @@ catalogs: 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: @@ -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 @@ -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: @@ -6846,6 +6853,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 +7014,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 +7083,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 +7095,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 +7447,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 +7587,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 +7779,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 +8098,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 +8675,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 +9120,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 +9161,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 +9223,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 +9500,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 +9575,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 +9698,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 +9876,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 +9897,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 +9935,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 +10465,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 +10575,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 +10586,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 +11344,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 +11409,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 +11572,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 +11592,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 +11787,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==} @@ -11809,6 +11942,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'} @@ -12289,6 +12426,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==} @@ -12749,6 +12890,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 +13028,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'} @@ -13271,6 +13420,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 +13440,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'} @@ -13887,6 +14043,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} @@ -20148,6 +20307,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 +20508,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 +20586,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 +20610,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 +21034,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 +21077,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 +21256,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 +21291,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 +21449,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 +21738,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 +21763,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 +22085,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 +22877,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 +22911,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 +22963,8 @@ snapshots: from@0.1.7: {} + fromentries@1.3.2: {} + fs-constants@1.0.0: {} fs-extra@8.1.0: @@ -23060,6 +23290,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 +23411,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 +23538,8 @@ snapshots: imurmurhash@0.1.4: {} + indent-string@4.0.0: {} + index-to-position@1.1.0: {} inflight@1.0.6: @@ -23436,6 +23675,8 @@ snapshots: is-stream@4.0.1: {} + is-typedarray@1.0.0: {} + is-unicode-supported@0.1.0: optional: true @@ -23448,6 +23689,8 @@ snapshots: is-what@4.1.16: {} + is-windows@1.0.2: {} + is-wsl@1.1.0: optional: true @@ -23473,6 +23716,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 +23730,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 +24276,8 @@ snapshots: lodash.defaults@4.2.0: {} + lodash.flattendeep@4.4.0: {} + lodash.includes@4.3.0: {} lodash.isarguments@3.1.0: {} @@ -24087,6 +24382,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 +24396,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 +25803,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 +25868,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 +26126,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 +26140,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 +26319,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 @@ -26129,6 +26489,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: {} @@ -26829,6 +27193,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 +27253,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: {} @@ -27231,8 +27598,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 +27818,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 +27947,8 @@ snapshots: strip-bom-string@1.0.0: {} + strip-bom@4.0.0: {} + strip-final-newline@2.0.0: optional: true @@ -27962,6 +28339,8 @@ snapshots: type-fest@0.7.1: {} + type-fest@0.8.1: {} + type-fest@4.41.0: {} type-fest@5.2.0: @@ -27979,6 +28358,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: {} @@ -28527,8 +28910,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 +29009,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 +29085,7 @@ snapshots: xtend@4.0.2: {} - y18n@4.0.3: - optional: true + y18n@4.0.3: {} y18n@5.0.8: {} @@ -28711,7 +29099,6 @@ snapshots: dependencies: camelcase: 5.3.1 decamelize: 1.2.0 - optional: true yargs-parser@21.1.1: {} @@ -28728,7 +29115,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..2908c9d6ad9 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -10,9 +10,12 @@ catalog: better-call: 1.1.5 tsdown: ^0.17.0 typescript: ^5.9.3 - vitest: 4.0.15 catalogs: + vitest: + vitest: ^4.0.15 + '@vitest/coverage-v8': ^4.0.15 + react19: '@types/react': ^19.2.0 '@types/react-dom': ^19.2.0 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"] }, From 744903ecbbbc51b5566e4423f5d318a9f517532c Mon Sep 17 00:00:00 2001 From: viktormarinho Date: Mon, 8 Dec 2025 18:04:00 -0300 Subject: [PATCH 07/56] chore: release v1.5.4 --- packages/better-auth/package.json | 2 +- packages/cli/package.json | 4 ++-- packages/core/package.json | 2 +- packages/expo/package.json | 6 +++--- packages/passkey/package.json | 6 +++--- packages/scim/package.json | 4 ++-- packages/sso/package.json | 6 +++--- packages/stripe/package.json | 6 +++--- packages/telemetry/package.json | 2 +- 9 files changed, 19 insertions(+), 19 deletions(-) diff --git a/packages/better-auth/package.json b/packages/better-auth/package.json index 1d2385476e4..fc57dbfd0c5 100644 --- a/packages/better-auth/package.json +++ b/packages/better-auth/package.json @@ -1,6 +1,6 @@ { "name": "@decocms/better-auth", - "version": "1.5.3", + "version": "1.5.4", "description": "The most comprehensive authentication framework for TypeScript.", "type": "module", "license": "MIT", diff --git a/packages/cli/package.json b/packages/cli/package.json index d62f5c92987..0f51647aaba 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@better-auth/cli", - "version": "1.5.3", + "version": "1.5.4", "type": "module", "description": "The CLI for Better Auth", "module": "dist/index.mjs", @@ -58,7 +58,7 @@ "@mrleebo/prisma-ast": "^0.13.0", "@prisma/client": "^5.22.0", "@types/pg": "^8.15.5", - "better-auth": "workspace:^", + "@decocms/better-auth": "workspace:^", "better-sqlite3": "^12.2.0", "c12": "^3.2.0", "chalk": "^5.6.2", diff --git a/packages/core/package.json b/packages/core/package.json index 7c9f292dd3a..e4adf31532d 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@better-auth/core", - "version": "1.5.3", + "version": "1.5.4", "description": "The most comprehensive authentication framework for TypeScript.", "type": "module", "repository": { diff --git a/packages/expo/package.json b/packages/expo/package.json index a05b6f021b2..6bb83599bb2 100644 --- a/packages/expo/package.json +++ b/packages/expo/package.json @@ -1,6 +1,6 @@ { "name": "@better-auth/expo", - "version": "1.5.3", + "version": "1.5.4", "type": "module", "description": "Better Auth integration for Expo and React Native applications.", "main": "dist/index.mjs", @@ -56,7 +56,7 @@ "@better-auth/core": "workspace:*", "@better-fetch/fetch": "catalog:", "@types/better-sqlite3": "^7.6.13", - "better-auth": "workspace:*", + "@decocms/better-auth": "workspace:*", "better-sqlite3": "^12.2.0", "expo-constants": "~17.1.7", "expo-network": "^8.0.7", @@ -66,7 +66,7 @@ "tsdown": "catalog:" }, "peerDependencies": { - "better-auth": "workspace:*", + "@decocms/better-auth": "workspace:*", "@better-auth/core": "workspace:*", "expo-constants": ">=17.0.0", "expo-linking": ">=7.0.0", diff --git a/packages/passkey/package.json b/packages/passkey/package.json index e92caeddcdb..a4b41ea0c82 100644 --- a/packages/passkey/package.json +++ b/packages/passkey/package.json @@ -1,6 +1,6 @@ { "name": "@better-auth/passkey", - "version": "1.5.3", + "version": "1.5.4", "type": "module", "description": "Passkey plugin for Better Auth", "main": "dist/index.mjs", @@ -40,7 +40,7 @@ }, "devDependencies": { "@better-auth/core": "workspace:*", - "better-auth": "workspace:*", + "@decocms/better-auth": "workspace:*", "tsdown": "catalog:" }, "dependencies": { @@ -52,7 +52,7 @@ "@better-auth/core": "workspace:*", "@better-auth/utils": "0.3.0", "@better-fetch/fetch": "catalog:", - "better-auth": "workspace:*", + "@decocms/better-auth": "workspace:*", "better-call": "catalog:", "nanostores": "^1.0.1" }, diff --git a/packages/scim/package.json b/packages/scim/package.json index 3d249926205..bda42493ff7 100644 --- a/packages/scim/package.json +++ b/packages/scim/package.json @@ -1,7 +1,7 @@ { "name": "@better-auth/scim", "author": "Jonathan Samines", - "version": "1.5.3", + "version": "1.5.4", "type": "module", "main": "dist/index.mjs", "types": "dist/index.d.mts", @@ -59,6 +59,6 @@ "tsdown": "catalog:" }, "peerDependencies": { - "better-auth": "workspace:*" + "@decocms/better-auth": "workspace:*" } } diff --git a/packages/sso/package.json b/packages/sso/package.json index d53ee7f750f..7c2ecc36ea1 100644 --- a/packages/sso/package.json +++ b/packages/sso/package.json @@ -1,7 +1,7 @@ { "name": "@better-auth/sso", "author": "Bereket Engida", - "version": "1.5.3", + "version": "1.5.4", "type": "module", "main": "dist/index.mjs", "types": "dist/index.d.mts", @@ -69,13 +69,13 @@ "@types/body-parser": "^1.19.6", "@types/express": "^5.0.5", "better-call": "catalog:", - "better-auth": "workspace:*", + "@decocms/better-auth": "workspace:*", "body-parser": "^2.2.1", "express": "^5.1.0", "oauth2-mock-server": "^8.2.0", "tsdown": "catalog:" }, "peerDependencies": { - "better-auth": "workspace:*" + "@decocms/better-auth": "workspace:*" } } diff --git a/packages/stripe/package.json b/packages/stripe/package.json index 9916c999587..67bd6da7798 100644 --- a/packages/stripe/package.json +++ b/packages/stripe/package.json @@ -1,7 +1,7 @@ { "name": "@better-auth/stripe", "author": "Bereket Engida", - "version": "1.5.3", + "version": "1.5.4", "type": "module", "main": "dist/index.mjs", "types": "dist/index.d.mts", @@ -57,12 +57,12 @@ }, "peerDependencies": { "@better-auth/core": "workspace:*", - "better-auth": "workspace:*", + "@decocms/better-auth": "workspace:*", "stripe": "^18 || ^19 || ^20" }, "devDependencies": { "@better-auth/core": "workspace:*", - "better-auth": "workspace:*", + "@decocms/better-auth": "workspace:*", "better-call": "catalog:", "stripe": "^20.0.0", "tsdown": "catalog:" diff --git a/packages/telemetry/package.json b/packages/telemetry/package.json index 6e913b9ba94..3d012e54c6d 100644 --- a/packages/telemetry/package.json +++ b/packages/telemetry/package.json @@ -1,6 +1,6 @@ { "name": "@better-auth/telemetry", - "version": "1.5.3", + "version": "1.5.4", "description": "Telemetry package for Better Auth", "type": "module", "repository": { From 8ea6cf95eea5aff6b2ad0d56257d5d9d9b8b186d Mon Sep 17 00:00:00 2001 From: viktormarinho Date: Mon, 8 Dec 2025 18:07:40 -0300 Subject: [PATCH 08/56] chore: release v1.5.5 --- packages/better-auth/package.json | 6 +++--- packages/cli/package.json | 2 +- packages/core/package.json | 2 +- packages/expo/package.json | 2 +- packages/passkey/package.json | 2 +- packages/scim/package.json | 2 +- packages/sso/package.json | 2 +- packages/stripe/package.json | 2 +- packages/telemetry/package.json | 2 +- 9 files changed, 11 insertions(+), 11 deletions(-) diff --git a/packages/better-auth/package.json b/packages/better-auth/package.json index fc57dbfd0c5..58f18f36dd7 100644 --- a/packages/better-auth/package.json +++ b/packages/better-auth/package.json @@ -1,6 +1,6 @@ { "name": "@decocms/better-auth", - "version": "1.5.4", + "version": "1.5.5", "description": "The most comprehensive authentication framework for TypeScript.", "type": "module", "license": "MIT", @@ -443,8 +443,8 @@ } }, "dependencies": { - "@better-auth/core": "1.4.5", - "@better-auth/telemetry": "1.4.5", + "@better-auth/core": "1.4.6-beta.3", + "@better-auth/telemetry": "1.4.6-beta.3", "@better-auth/utils": "0.3.0", "@better-fetch/fetch": "catalog:", "@noble/ciphers": "^2.0.0", diff --git a/packages/cli/package.json b/packages/cli/package.json index 0f51647aaba..f16be8ea20f 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@better-auth/cli", - "version": "1.5.4", + "version": "1.5.5", "type": "module", "description": "The CLI for Better Auth", "module": "dist/index.mjs", diff --git a/packages/core/package.json b/packages/core/package.json index e4adf31532d..c2b49966e9a 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@better-auth/core", - "version": "1.5.4", + "version": "1.5.5", "description": "The most comprehensive authentication framework for TypeScript.", "type": "module", "repository": { diff --git a/packages/expo/package.json b/packages/expo/package.json index 6bb83599bb2..13dcba230bb 100644 --- a/packages/expo/package.json +++ b/packages/expo/package.json @@ -1,6 +1,6 @@ { "name": "@better-auth/expo", - "version": "1.5.4", + "version": "1.5.5", "type": "module", "description": "Better Auth integration for Expo and React Native applications.", "main": "dist/index.mjs", diff --git a/packages/passkey/package.json b/packages/passkey/package.json index a4b41ea0c82..0b9c9fbacd9 100644 --- a/packages/passkey/package.json +++ b/packages/passkey/package.json @@ -1,6 +1,6 @@ { "name": "@better-auth/passkey", - "version": "1.5.4", + "version": "1.5.5", "type": "module", "description": "Passkey plugin for Better Auth", "main": "dist/index.mjs", diff --git a/packages/scim/package.json b/packages/scim/package.json index bda42493ff7..200ee3ceb5f 100644 --- a/packages/scim/package.json +++ b/packages/scim/package.json @@ -1,7 +1,7 @@ { "name": "@better-auth/scim", "author": "Jonathan Samines", - "version": "1.5.4", + "version": "1.5.5", "type": "module", "main": "dist/index.mjs", "types": "dist/index.d.mts", diff --git a/packages/sso/package.json b/packages/sso/package.json index 7c2ecc36ea1..60d7b813627 100644 --- a/packages/sso/package.json +++ b/packages/sso/package.json @@ -1,7 +1,7 @@ { "name": "@better-auth/sso", "author": "Bereket Engida", - "version": "1.5.4", + "version": "1.5.5", "type": "module", "main": "dist/index.mjs", "types": "dist/index.d.mts", diff --git a/packages/stripe/package.json b/packages/stripe/package.json index 67bd6da7798..69bf4e8a749 100644 --- a/packages/stripe/package.json +++ b/packages/stripe/package.json @@ -1,7 +1,7 @@ { "name": "@better-auth/stripe", "author": "Bereket Engida", - "version": "1.5.4", + "version": "1.5.5", "type": "module", "main": "dist/index.mjs", "types": "dist/index.d.mts", diff --git a/packages/telemetry/package.json b/packages/telemetry/package.json index 3d012e54c6d..c4682cea33c 100644 --- a/packages/telemetry/package.json +++ b/packages/telemetry/package.json @@ -1,6 +1,6 @@ { "name": "@better-auth/telemetry", - "version": "1.5.4", + "version": "1.5.5", "description": "Telemetry package for Better Auth", "type": "module", "repository": { From 6f2831163b94a388df0b23bd3e0ae1acabe381f4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Paola=20Estefan=C3=ADa=20de=20Campos?= <84341268+Paola3stefania@users.noreply.github.com> Date: Mon, 8 Dec 2025 18:12:14 -0300 Subject: [PATCH 09/56] feat(sso): use domain verified flag to trust providers automatically --- docs/content/docs/plugins/sso.mdx | 4 +- .../src/oauth2/link-account.test.ts | 161 +++++++++++++++++ .../better-auth/src/oauth2/link-account.ts | 17 +- packages/sso/src/oidc.test.ts | 164 ++++++++++++++++++ packages/sso/src/routes/sso.ts | 6 + 5 files changed, 341 insertions(+), 11 deletions(-) diff --git a/docs/content/docs/plugins/sso.mdx b/docs/content/docs/plugins/sso.mdx index 6c5ba388fac..37ae4a5d0b9 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. 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/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( From b4afd8d34ae4efbcb9904428b5b2bc8a450109f2 Mon Sep 17 00:00:00 2001 From: viktormarinho Date: Mon, 8 Dec 2025 18:15:14 -0300 Subject: [PATCH 10/56] chore: release v1.5.6 --- packages/better-auth/package.json | 2 +- packages/cli/package.json | 2 +- packages/core/package.json | 2 +- packages/expo/package.json | 2 +- packages/passkey/package.json | 2 +- packages/scim/package.json | 2 +- packages/sso/package.json | 2 +- packages/stripe/package.json | 2 +- packages/telemetry/package.json | 2 +- 9 files changed, 9 insertions(+), 9 deletions(-) diff --git a/packages/better-auth/package.json b/packages/better-auth/package.json index 58f18f36dd7..89f02a02f1c 100644 --- a/packages/better-auth/package.json +++ b/packages/better-auth/package.json @@ -1,6 +1,6 @@ { "name": "@decocms/better-auth", - "version": "1.5.5", + "version": "1.5.6", "description": "The most comprehensive authentication framework for TypeScript.", "type": "module", "license": "MIT", diff --git a/packages/cli/package.json b/packages/cli/package.json index f16be8ea20f..f928c1e8cda 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@better-auth/cli", - "version": "1.5.5", + "version": "1.5.6", "type": "module", "description": "The CLI for Better Auth", "module": "dist/index.mjs", diff --git a/packages/core/package.json b/packages/core/package.json index c2b49966e9a..1d499ff270c 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@better-auth/core", - "version": "1.5.5", + "version": "1.5.6", "description": "The most comprehensive authentication framework for TypeScript.", "type": "module", "repository": { diff --git a/packages/expo/package.json b/packages/expo/package.json index 13dcba230bb..14b1d57f292 100644 --- a/packages/expo/package.json +++ b/packages/expo/package.json @@ -1,6 +1,6 @@ { "name": "@better-auth/expo", - "version": "1.5.5", + "version": "1.5.6", "type": "module", "description": "Better Auth integration for Expo and React Native applications.", "main": "dist/index.mjs", diff --git a/packages/passkey/package.json b/packages/passkey/package.json index 0b9c9fbacd9..307efdd2546 100644 --- a/packages/passkey/package.json +++ b/packages/passkey/package.json @@ -1,6 +1,6 @@ { "name": "@better-auth/passkey", - "version": "1.5.5", + "version": "1.5.6", "type": "module", "description": "Passkey plugin for Better Auth", "main": "dist/index.mjs", diff --git a/packages/scim/package.json b/packages/scim/package.json index 200ee3ceb5f..971c7688ab6 100644 --- a/packages/scim/package.json +++ b/packages/scim/package.json @@ -1,7 +1,7 @@ { "name": "@better-auth/scim", "author": "Jonathan Samines", - "version": "1.5.5", + "version": "1.5.6", "type": "module", "main": "dist/index.mjs", "types": "dist/index.d.mts", diff --git a/packages/sso/package.json b/packages/sso/package.json index 60d7b813627..25c7cfd49a4 100644 --- a/packages/sso/package.json +++ b/packages/sso/package.json @@ -1,7 +1,7 @@ { "name": "@better-auth/sso", "author": "Bereket Engida", - "version": "1.5.5", + "version": "1.5.6", "type": "module", "main": "dist/index.mjs", "types": "dist/index.d.mts", diff --git a/packages/stripe/package.json b/packages/stripe/package.json index 69bf4e8a749..aad8c0b78ba 100644 --- a/packages/stripe/package.json +++ b/packages/stripe/package.json @@ -1,7 +1,7 @@ { "name": "@better-auth/stripe", "author": "Bereket Engida", - "version": "1.5.5", + "version": "1.5.6", "type": "module", "main": "dist/index.mjs", "types": "dist/index.d.mts", diff --git a/packages/telemetry/package.json b/packages/telemetry/package.json index c4682cea33c..913d7fbbe45 100644 --- a/packages/telemetry/package.json +++ b/packages/telemetry/package.json @@ -1,6 +1,6 @@ { "name": "@better-auth/telemetry", - "version": "1.5.5", + "version": "1.5.6", "description": "Telemetry package for Better Auth", "type": "module", "repository": { From 988f048c86b41f442ff51a8ff68c66574c232629 Mon Sep 17 00:00:00 2001 From: viktormarinho Date: Mon, 8 Dec 2025 18:20:36 -0300 Subject: [PATCH 11/56] chore: release v1.5.7 --- packages/better-auth/package.json | 2 +- packages/cli/package.json | 2 +- packages/core/package.json | 2 +- packages/expo/package.json | 2 +- packages/passkey/package.json | 2 +- packages/scim/package.json | 2 +- packages/sso/package.json | 2 +- packages/stripe/package.json | 2 +- packages/telemetry/package.json | 2 +- 9 files changed, 9 insertions(+), 9 deletions(-) diff --git a/packages/better-auth/package.json b/packages/better-auth/package.json index 89f02a02f1c..88d249b5e89 100644 --- a/packages/better-auth/package.json +++ b/packages/better-auth/package.json @@ -1,6 +1,6 @@ { "name": "@decocms/better-auth", - "version": "1.5.6", + "version": "1.5.7", "description": "The most comprehensive authentication framework for TypeScript.", "type": "module", "license": "MIT", diff --git a/packages/cli/package.json b/packages/cli/package.json index f928c1e8cda..b188994ec4b 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@better-auth/cli", - "version": "1.5.6", + "version": "1.5.7", "type": "module", "description": "The CLI for Better Auth", "module": "dist/index.mjs", diff --git a/packages/core/package.json b/packages/core/package.json index 1d499ff270c..9642a5e8dcf 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@better-auth/core", - "version": "1.5.6", + "version": "1.5.7", "description": "The most comprehensive authentication framework for TypeScript.", "type": "module", "repository": { diff --git a/packages/expo/package.json b/packages/expo/package.json index 14b1d57f292..cefcdfea0e5 100644 --- a/packages/expo/package.json +++ b/packages/expo/package.json @@ -1,6 +1,6 @@ { "name": "@better-auth/expo", - "version": "1.5.6", + "version": "1.5.7", "type": "module", "description": "Better Auth integration for Expo and React Native applications.", "main": "dist/index.mjs", diff --git a/packages/passkey/package.json b/packages/passkey/package.json index 307efdd2546..b9ecaca0879 100644 --- a/packages/passkey/package.json +++ b/packages/passkey/package.json @@ -1,6 +1,6 @@ { "name": "@better-auth/passkey", - "version": "1.5.6", + "version": "1.5.7", "type": "module", "description": "Passkey plugin for Better Auth", "main": "dist/index.mjs", diff --git a/packages/scim/package.json b/packages/scim/package.json index 971c7688ab6..080a44dff1a 100644 --- a/packages/scim/package.json +++ b/packages/scim/package.json @@ -1,7 +1,7 @@ { "name": "@better-auth/scim", "author": "Jonathan Samines", - "version": "1.5.6", + "version": "1.5.7", "type": "module", "main": "dist/index.mjs", "types": "dist/index.d.mts", diff --git a/packages/sso/package.json b/packages/sso/package.json index 25c7cfd49a4..00d1dc4a5c8 100644 --- a/packages/sso/package.json +++ b/packages/sso/package.json @@ -1,7 +1,7 @@ { "name": "@better-auth/sso", "author": "Bereket Engida", - "version": "1.5.6", + "version": "1.5.7", "type": "module", "main": "dist/index.mjs", "types": "dist/index.d.mts", diff --git a/packages/stripe/package.json b/packages/stripe/package.json index aad8c0b78ba..607244276bb 100644 --- a/packages/stripe/package.json +++ b/packages/stripe/package.json @@ -1,7 +1,7 @@ { "name": "@better-auth/stripe", "author": "Bereket Engida", - "version": "1.5.6", + "version": "1.5.7", "type": "module", "main": "dist/index.mjs", "types": "dist/index.d.mts", diff --git a/packages/telemetry/package.json b/packages/telemetry/package.json index 913d7fbbe45..2a4b9d87876 100644 --- a/packages/telemetry/package.json +++ b/packages/telemetry/package.json @@ -1,6 +1,6 @@ { "name": "@better-auth/telemetry", - "version": "1.5.6", + "version": "1.5.7", "description": "Telemetry package for Better Auth", "type": "module", "repository": { From e7b48654f9b2a418d5a3ff5e91bffe8ac75bdae0 Mon Sep 17 00:00:00 2001 From: viktormarinho Date: Mon, 8 Dec 2025 18:26:37 -0300 Subject: [PATCH 12/56] chore: release v1.5.8 --- packages/better-auth/package.json | 2 +- packages/cli/package.json | 2 +- packages/core/package.json | 2 +- packages/expo/package.json | 2 +- packages/passkey/package.json | 2 +- packages/scim/package.json | 2 +- packages/sso/package.json | 2 +- packages/stripe/package.json | 2 +- packages/telemetry/package.json | 2 +- 9 files changed, 9 insertions(+), 9 deletions(-) diff --git a/packages/better-auth/package.json b/packages/better-auth/package.json index 88d249b5e89..d58e1dcaeb2 100644 --- a/packages/better-auth/package.json +++ b/packages/better-auth/package.json @@ -1,6 +1,6 @@ { "name": "@decocms/better-auth", - "version": "1.5.7", + "version": "1.5.8", "description": "The most comprehensive authentication framework for TypeScript.", "type": "module", "license": "MIT", diff --git a/packages/cli/package.json b/packages/cli/package.json index b188994ec4b..3d7f38f2128 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@better-auth/cli", - "version": "1.5.7", + "version": "1.5.8", "type": "module", "description": "The CLI for Better Auth", "module": "dist/index.mjs", diff --git a/packages/core/package.json b/packages/core/package.json index 9642a5e8dcf..664c691752e 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@better-auth/core", - "version": "1.5.7", + "version": "1.5.8", "description": "The most comprehensive authentication framework for TypeScript.", "type": "module", "repository": { diff --git a/packages/expo/package.json b/packages/expo/package.json index cefcdfea0e5..59adfe48ea8 100644 --- a/packages/expo/package.json +++ b/packages/expo/package.json @@ -1,6 +1,6 @@ { "name": "@better-auth/expo", - "version": "1.5.7", + "version": "1.5.8", "type": "module", "description": "Better Auth integration for Expo and React Native applications.", "main": "dist/index.mjs", diff --git a/packages/passkey/package.json b/packages/passkey/package.json index b9ecaca0879..0df4234219d 100644 --- a/packages/passkey/package.json +++ b/packages/passkey/package.json @@ -1,6 +1,6 @@ { "name": "@better-auth/passkey", - "version": "1.5.7", + "version": "1.5.8", "type": "module", "description": "Passkey plugin for Better Auth", "main": "dist/index.mjs", diff --git a/packages/scim/package.json b/packages/scim/package.json index 080a44dff1a..130dc732bbe 100644 --- a/packages/scim/package.json +++ b/packages/scim/package.json @@ -1,7 +1,7 @@ { "name": "@better-auth/scim", "author": "Jonathan Samines", - "version": "1.5.7", + "version": "1.5.8", "type": "module", "main": "dist/index.mjs", "types": "dist/index.d.mts", diff --git a/packages/sso/package.json b/packages/sso/package.json index 00d1dc4a5c8..6f28c55c307 100644 --- a/packages/sso/package.json +++ b/packages/sso/package.json @@ -1,7 +1,7 @@ { "name": "@better-auth/sso", "author": "Bereket Engida", - "version": "1.5.7", + "version": "1.5.8", "type": "module", "main": "dist/index.mjs", "types": "dist/index.d.mts", diff --git a/packages/stripe/package.json b/packages/stripe/package.json index 607244276bb..7271aca2eae 100644 --- a/packages/stripe/package.json +++ b/packages/stripe/package.json @@ -1,7 +1,7 @@ { "name": "@better-auth/stripe", "author": "Bereket Engida", - "version": "1.5.7", + "version": "1.5.8", "type": "module", "main": "dist/index.mjs", "types": "dist/index.d.mts", diff --git a/packages/telemetry/package.json b/packages/telemetry/package.json index 2a4b9d87876..56e129259ed 100644 --- a/packages/telemetry/package.json +++ b/packages/telemetry/package.json @@ -1,6 +1,6 @@ { "name": "@better-auth/telemetry", - "version": "1.5.7", + "version": "1.5.8", "description": "Telemetry package for Better Auth", "type": "module", "repository": { From 44e9f403cb4217c5ec9987f77b96950447ebce30 Mon Sep 17 00:00:00 2001 From: viktormarinho Date: Mon, 8 Dec 2025 18:31:24 -0300 Subject: [PATCH 13/56] chore: release v1.5.9 --- packages/better-auth/package.json | 2 +- packages/cli/package.json | 2 +- packages/core/package.json | 2 +- packages/expo/package.json | 2 +- packages/passkey/package.json | 2 +- packages/scim/package.json | 2 +- packages/sso/package.json | 2 +- packages/stripe/package.json | 2 +- packages/telemetry/package.json | 2 +- 9 files changed, 9 insertions(+), 9 deletions(-) diff --git a/packages/better-auth/package.json b/packages/better-auth/package.json index d58e1dcaeb2..eb881d41e99 100644 --- a/packages/better-auth/package.json +++ b/packages/better-auth/package.json @@ -1,6 +1,6 @@ { "name": "@decocms/better-auth", - "version": "1.5.8", + "version": "1.5.9", "description": "The most comprehensive authentication framework for TypeScript.", "type": "module", "license": "MIT", diff --git a/packages/cli/package.json b/packages/cli/package.json index 3d7f38f2128..6106a12734f 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@better-auth/cli", - "version": "1.5.8", + "version": "1.5.9", "type": "module", "description": "The CLI for Better Auth", "module": "dist/index.mjs", diff --git a/packages/core/package.json b/packages/core/package.json index 664c691752e..655b860938f 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@better-auth/core", - "version": "1.5.8", + "version": "1.5.9", "description": "The most comprehensive authentication framework for TypeScript.", "type": "module", "repository": { diff --git a/packages/expo/package.json b/packages/expo/package.json index 59adfe48ea8..d3255525ed7 100644 --- a/packages/expo/package.json +++ b/packages/expo/package.json @@ -1,6 +1,6 @@ { "name": "@better-auth/expo", - "version": "1.5.8", + "version": "1.5.9", "type": "module", "description": "Better Auth integration for Expo and React Native applications.", "main": "dist/index.mjs", diff --git a/packages/passkey/package.json b/packages/passkey/package.json index 0df4234219d..8e3774a1577 100644 --- a/packages/passkey/package.json +++ b/packages/passkey/package.json @@ -1,6 +1,6 @@ { "name": "@better-auth/passkey", - "version": "1.5.8", + "version": "1.5.9", "type": "module", "description": "Passkey plugin for Better Auth", "main": "dist/index.mjs", diff --git a/packages/scim/package.json b/packages/scim/package.json index 130dc732bbe..eb49bdb4777 100644 --- a/packages/scim/package.json +++ b/packages/scim/package.json @@ -1,7 +1,7 @@ { "name": "@better-auth/scim", "author": "Jonathan Samines", - "version": "1.5.8", + "version": "1.5.9", "type": "module", "main": "dist/index.mjs", "types": "dist/index.d.mts", diff --git a/packages/sso/package.json b/packages/sso/package.json index 6f28c55c307..ac5132e2041 100644 --- a/packages/sso/package.json +++ b/packages/sso/package.json @@ -1,7 +1,7 @@ { "name": "@better-auth/sso", "author": "Bereket Engida", - "version": "1.5.8", + "version": "1.5.9", "type": "module", "main": "dist/index.mjs", "types": "dist/index.d.mts", diff --git a/packages/stripe/package.json b/packages/stripe/package.json index 7271aca2eae..5b0b24f0af0 100644 --- a/packages/stripe/package.json +++ b/packages/stripe/package.json @@ -1,7 +1,7 @@ { "name": "@better-auth/stripe", "author": "Bereket Engida", - "version": "1.5.8", + "version": "1.5.9", "type": "module", "main": "dist/index.mjs", "types": "dist/index.d.mts", diff --git a/packages/telemetry/package.json b/packages/telemetry/package.json index 56e129259ed..29555ffd80b 100644 --- a/packages/telemetry/package.json +++ b/packages/telemetry/package.json @@ -1,6 +1,6 @@ { "name": "@better-auth/telemetry", - "version": "1.5.8", + "version": "1.5.9", "description": "Telemetry package for Better Auth", "type": "module", "repository": { From b36c8499d24d94e11cbede030af07ad6ed6c45dc Mon Sep 17 00:00:00 2001 From: viktormarinho Date: Mon, 8 Dec 2025 18:34:08 -0300 Subject: [PATCH 14/56] wip --- demo/expo/package.json | 2 +- demo/nextjs/package.json | 2 +- demo/oidc-client/package.json | 2 +- demo/stateless/package.json | 2 +- docs/content/docs/plugins/organization.mdx | 46 +++- e2e/integration/package.json | 2 +- e2e/integration/solid-vinxi/package.json | 2 +- e2e/integration/vanilla-node/package.json | 2 +- e2e/smoke/package.json | 2 +- .../test/fixtures/cloudflare/package.json | 2 +- .../tsconfig-declaration/package.json | 2 +- .../package.json | 2 +- .../package.json | 2 +- .../package.json | 2 +- e2e/smoke/test/fixtures/vite/package.json | 2 +- .../organization-custom-resources.test.ts | 17 +- .../organization/load-resources.test.ts | 24 +- .../plugins/organization/load-resources.ts | 4 +- .../routes/crud-access-control.ts | 130 ++++++++-- .../organization/routes/crud-resources.ts | 9 +- .../src/plugins/organization/types.ts | 28 +++ pnpm-lock.yaml | 225 ++++++++++++++---- test/package.json | 2 +- 23 files changed, 409 insertions(+), 104 deletions(-) diff --git a/demo/expo/package.json b/demo/expo/package.json index ace76d4be40..8c92819c39b 100644 --- a/demo/expo/package.json +++ b/demo/expo/package.json @@ -24,7 +24,7 @@ "@rn-primitives/types": "^1.1.0", "@types/better-sqlite3": "^7.6.12", "babel-plugin-transform-import-meta": "^2.2.1", - "better-auth": "workspace:*", + "@decocms/better-auth": "workspace:*", "better-sqlite3": "^11.6.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", diff --git a/demo/nextjs/package.json b/demo/nextjs/package.json index c0fe0185f32..44c88eb1f97 100644 --- a/demo/nextjs/package.json +++ b/demo/nextjs/package.json @@ -50,7 +50,7 @@ "@react-email/components": "^1.0.1", "@tanstack/react-query": "^5.85.9", "@types/better-sqlite3": "^7.6.13", - "better-auth": "workspace:*", + "@decocms/better-auth": "workspace:*", "better-sqlite3": "^12.2.0", "canvas-confetti": "^1.9.3", "class-variance-authority": "^0.7.1", diff --git a/demo/oidc-client/package.json b/demo/oidc-client/package.json index 1d1bbeaee35..48370be538e 100644 --- a/demo/oidc-client/package.json +++ b/demo/oidc-client/package.json @@ -13,7 +13,7 @@ "@radix-ui/react-avatar": "^1.1.10", "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-slot": "^1.2.3", - "better-auth": "workspace:*", + "@decocms/better-auth": "workspace:*", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "geist": "^1.4.2", diff --git a/demo/stateless/package.json b/demo/stateless/package.json index c12fd802d9f..f875fba7458 100644 --- a/demo/stateless/package.json +++ b/demo/stateless/package.json @@ -10,7 +10,7 @@ "typecheck": "tsc --noEmit" }, "dependencies": { - "better-auth": "workspace:*", + "@decocms/better-auth": "workspace:*", "next": "16.0.7", "react": "catalog:react19", "react-dom": "catalog:react19" diff --git a/docs/content/docs/plugins/organization.mdx b/docs/content/docs/plugins/organization.mdx index d594683a306..7eca7317774 100644 --- a/docs/content/docs/plugins/organization.mdx +++ b/docs/content/docs/plugins/organization.mdx @@ -1848,7 +1848,7 @@ To create a custom resource for an organization: type createOrgResource = { /** * The name of the resource to create. - * Must be lowercase alphanumeric with underscores only. + * Must be alphanumeric with underscores only. * Length between 1 and 50 characters. */ resource: string = "project" @@ -2042,24 +2042,25 @@ This is particularly useful when: - Migrating existing data structures - Auto-creation only works when `enableCustomResources` is `true`. The resource names must still pass validation (lowercase alphanumeric with underscores, not reserved names). + 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 lowercase alphanumeric with underscores only +- 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` -- `my_custom_resource` +- `myCustomResource` +- `MY_RESOURCE` **Invalid names:** -- `Project` (contains uppercase) - `my-resource` (contains dash) - `my resource` (contains space) - `organization` (reserved name) @@ -2145,6 +2146,41 @@ To manage custom resources, users need the following permissions from the `ac` r 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 }) => { + // Example: Users with "*" wildcard permission can do anything + const memberPermissions = JSON.parse(member.permission || "{}"); + if (memberPermissions["*"]?.includes("*")) { + return "yes"; // Allow without further checks + } + + // Custom logic: Only admins can create roles with more than 5 resources + if (Object.keys(permission).length > 5 && member.role !== "admin") { + return { + allowed: false, + message: "Only admins can create roles with more than 5 resources" + }; + } + + // Use default permission logic (check ac:create) + return "default"; + } + } +}) +``` + +The callback can return: +- `"yes"` - Allow the action (skip default permission checks) +- `"default"` - Use the default permission logic (check for `ac: ["create"]`) +- `{ allowed: false, message: string }` - Deny with a custom error message + #### Database Schema When custom resources are enabled, a new `organizationResource` table is added: diff --git a/e2e/integration/package.json b/e2e/integration/package.json index 2b71b5ca97c..1741f79e017 100644 --- a/e2e/integration/package.json +++ b/e2e/integration/package.json @@ -4,7 +4,7 @@ "e2e:integration": "playwright test" }, "dependencies": { - "better-auth": "workspace:*" + "@decocms/better-auth": "workspace:*" }, "devDependencies": { "@playwright/test": "^1.56.1" diff --git a/e2e/integration/solid-vinxi/package.json b/e2e/integration/solid-vinxi/package.json index 375a968b139..4a4ee86bed8 100644 --- a/e2e/integration/solid-vinxi/package.json +++ b/e2e/integration/solid-vinxi/package.json @@ -10,7 +10,7 @@ "dependencies": { "@solidjs/router": "^0.15.3", "@solidjs/start": "^1.1.7", - "better-auth": "workspace:*", + "@decocms/better-auth": "workspace:*", "better-sqlite3": "^12.2.0", "solid-js": "^1.9.7", "vinxi": "^0.5.8" diff --git a/e2e/integration/vanilla-node/package.json b/e2e/integration/vanilla-node/package.json index 8cbaf8c381a..ca9533321f4 100644 --- a/e2e/integration/vanilla-node/package.json +++ b/e2e/integration/vanilla-node/package.json @@ -10,7 +10,7 @@ "vite": "^7.2.4" }, "dependencies": { - "better-auth": "workspace:*", + "@decocms/better-auth": "workspace:*", "better-sqlite3": "^12.2.0", "kysely": "^0.28.5", "kysely-postgres-js": "^3.0.0", diff --git a/e2e/smoke/package.json b/e2e/smoke/package.json index dce488e17e3..a73201ec26e 100644 --- a/e2e/smoke/package.json +++ b/e2e/smoke/package.json @@ -2,7 +2,7 @@ "name": "smoke", "type": "module", "dependencies": { - "better-auth": "workspace:*" + "@decocms/better-auth": "workspace:*" }, "scripts": { "e2e:smoke": "node --test ./test/*.spec.ts" diff --git a/e2e/smoke/test/fixtures/cloudflare/package.json b/e2e/smoke/test/fixtures/cloudflare/package.json index 31091d8940e..60cbd4eaf62 100644 --- a/e2e/smoke/test/fixtures/cloudflare/package.json +++ b/e2e/smoke/test/fixtures/cloudflare/package.json @@ -2,7 +2,7 @@ "name": "cloudflare", "private": true, "dependencies": { - "better-auth": "workspace:*", + "@decocms/better-auth": "workspace:*", "drizzle-orm": "^0.44.5", "hono": "^4.9.7" }, diff --git a/e2e/smoke/test/fixtures/tsconfig-declaration/package.json b/e2e/smoke/test/fixtures/tsconfig-declaration/package.json index c6b14d1b5c6..d41ac494d54 100644 --- a/e2e/smoke/test/fixtures/tsconfig-declaration/package.json +++ b/e2e/smoke/test/fixtures/tsconfig-declaration/package.json @@ -9,7 +9,7 @@ "@better-auth/sso": "workspace:*", "@better-auth/stripe": "workspace:*", "@better-auth/passkey": "workspace:*", - "better-auth": "workspace:*", + "@decocms/better-auth": "workspace:*", "stripe": "^20.0.0" } } diff --git a/e2e/smoke/test/fixtures/tsconfig-exact-optional-property-types/package.json b/e2e/smoke/test/fixtures/tsconfig-exact-optional-property-types/package.json index 03a0e0b8b82..254a7592d5a 100644 --- a/e2e/smoke/test/fixtures/tsconfig-exact-optional-property-types/package.json +++ b/e2e/smoke/test/fixtures/tsconfig-exact-optional-property-types/package.json @@ -6,7 +6,7 @@ "typecheck": "tsc --project tsconfig.json" }, "dependencies": { - "better-auth": "workspace:*", + "@decocms/better-auth": "workspace:*", "@better-auth/sso": "workspace:*" } } diff --git a/e2e/smoke/test/fixtures/tsconfig-isolated-module-bundler/package.json b/e2e/smoke/test/fixtures/tsconfig-isolated-module-bundler/package.json index a246350bfcd..5f7abd4e35c 100644 --- a/e2e/smoke/test/fixtures/tsconfig-isolated-module-bundler/package.json +++ b/e2e/smoke/test/fixtures/tsconfig-isolated-module-bundler/package.json @@ -6,7 +6,7 @@ "typecheck": "tsc --project tsconfig.json" }, "dependencies": { - "better-auth": "workspace:*", + "@decocms/better-auth": "workspace:*", "better-auth-harmony": "^1.2.5" } } diff --git a/e2e/smoke/test/fixtures/tsconfig-verbatim-module-syntax-node10/package.json b/e2e/smoke/test/fixtures/tsconfig-verbatim-module-syntax-node10/package.json index dd9415d4b8d..94c2d820473 100644 --- a/e2e/smoke/test/fixtures/tsconfig-verbatim-module-syntax-node10/package.json +++ b/e2e/smoke/test/fixtures/tsconfig-verbatim-module-syntax-node10/package.json @@ -7,6 +7,6 @@ }, "dependencies": { "@better-auth/expo": "workspace:*", - "better-auth": "workspace:*" + "@decocms/better-auth": "workspace:*" } } diff --git a/e2e/smoke/test/fixtures/vite/package.json b/e2e/smoke/test/fixtures/vite/package.json index 45d37b9f4d2..125e017a02b 100644 --- a/e2e/smoke/test/fixtures/vite/package.json +++ b/e2e/smoke/test/fixtures/vite/package.json @@ -5,7 +5,7 @@ "build": "vite build" }, "dependencies": { - "better-auth": "workspace:*" + "@decocms/better-auth": "workspace:*" }, "devDependencies": { "vite": "^7.2.4" diff --git a/e2e/smoke/test/organization-custom-resources.test.ts b/e2e/smoke/test/organization-custom-resources.test.ts index 06597471d1e..9943d855282 100644 --- a/e2e/smoke/test/organization-custom-resources.test.ts +++ b/e2e/smoke/test/organization-custom-resources.test.ts @@ -278,7 +278,6 @@ describe("organization custom resources integration", async () => { const testCases = [ { name: "Invalid-Name", reason: "contains dash" }, { name: "Invalid Name", reason: "contains space" }, - { name: "InvalidName", reason: "contains uppercase" }, { name: "", reason: "empty" }, ]; @@ -301,6 +300,22 @@ describe("organization custom resources integration", async () => { } }); + 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"]; diff --git a/packages/better-auth/src/plugins/organization/load-resources.test.ts b/packages/better-auth/src/plugins/organization/load-resources.test.ts index 09fd5b4212e..92b9e257e57 100644 --- a/packages/better-auth/src/plugins/organization/load-resources.test.ts +++ b/packages/better-auth/src/plugins/organization/load-resources.test.ts @@ -51,7 +51,7 @@ describe("load-resources utility functions", () => { }); describe("validateResourceName", () => { - it("should accept valid lowercase alphanumeric names", () => { + it("should accept valid alphanumeric names", () => { const options: OrganizationOptions = {}; expect(validateResourceName("project", options)).toEqual({ valid: true }); expect(validateResourceName("task123", options)).toEqual({ @@ -60,13 +60,13 @@ describe("load-resources utility functions", () => { expect(validateResourceName("my_resource", options)).toEqual({ valid: true, }); - }); - - it("should reject names with uppercase letters", () => { - const options: OrganizationOptions = {}; - const result = validateResourceName("Project", options); - expect(result.valid).toBe(false); - expect(result.error).toContain("lowercase"); + 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", () => { @@ -203,11 +203,11 @@ describe("load-resources utility functions", () => { expect(validateResourceName("123", options).valid).toBe(true); }); - it("should reject mixed case even with valid characters", () => { + it("should accept mixed case with valid characters", () => { const options: OrganizationOptions = {}; - expect(validateResourceName("camelCase", options).valid).toBe(false); - expect(validateResourceName("PascalCase", options).valid).toBe(false); - expect(validateResourceName("UPPERCASE", options).valid).toBe(false); + 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 index a137965f722..1222a694d05 100644 --- a/packages/better-auth/src/plugins/organization/load-resources.ts +++ b/packages/better-auth/src/plugins/organization/load-resources.ts @@ -167,10 +167,10 @@ export function validateResourceName( } // Basic format validation - if (!/^[a-z0-9_]+$/.test(name)) { + if (!/^[a-zA-Z0-9_]+$/.test(name)) { return { valid: false, - error: "Resource name must be lowercase alphanumeric with underscores only", + error: "Resource name must be alphanumeric with underscores only", }; } 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 2489a0a6222..145971dc80b 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 @@ -174,19 +174,66 @@ export const createOrgRole = (options: O) => { }); } - const canCreateRole = await hasPermission( - { - options, + // Check if user can create role using custom callback or default logic + let canCreateRole = false; + let customDenialMessage: string | undefined; + let skipDelegationCheck = false; // Track if we should skip delegation check + + if (options.dynamicAccessControl?.canCreateRole) { + const callbackResult = await options.dynamicAccessControl.canCreateRole({ organizationId, - permissions: { - ac: ["create"], + userId: user.id, + member, + permission, + roleName, + }); + + if (callbackResult === "yes") { + canCreateRole = true; + skipDelegationCheck = true; // Skip delegation check when callback says "yes" + ctx.context.logger.info( + `[Dynamic Access Control] Custom canCreateRole callback allowed role creation for user ${user.id}, skipping delegation check`, + { + userId: user.id, + organizationId, + roleName, + }, + ); + } else if (callbackResult === "default") { + // Fall through to default logic below + } else { + // callbackResult is { allowed: false, message: string } + canCreateRole = false; + customDenialMessage = callbackResult.message; + ctx.context.logger.error( + `[Dynamic Access Control] Custom canCreateRole callback denied role creation: ${callbackResult.message}`, + { + userId: user.id, + organizationId, + roleName, + }, + ); + } + } + + // If callback returned "default" or doesn't exist, use default permission check + if (!canCreateRole && !customDenialMessage) { + canCreateRole = await hasPermission( + { + options, + organizationId, + permissions: { + ac: ["create"], + }, + role: member.role, }, - role: member.role, - }, - ctx, - ); + ctx, + ); + } + if (!canCreateRole) { ctx.context.logger.error( + customDenialMessage || `[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.`, { userId: user.id, @@ -195,7 +242,7 @@ export const createOrgRole = (options: O) => { }, ); throw new APIError("FORBIDDEN", { - message: + message: customDenialMessage || ORGANIZATION_ERROR_CODES.YOU_ARE_NOT_ALLOWED_TO_CREATE_A_ROLE, }); } @@ -233,17 +280,30 @@ export const createOrgRole = (options: O) => { }); } - await checkForInvalidResources({ ac, ctx, permission, organizationId, options }); + const autoCreatedResources = await checkForInvalidResources({ ac, ctx, permission, organizationId, options }); - await checkIfMemberHasPermission({ - ctx, - member, - options, - organizationId, - permissionRequired: permission, - user, - action: "create", - }); + // Only perform delegation check if not skipped by custom callback + if (!skipDelegationCheck) { + await checkIfMemberHasPermission({ + ctx, + member, + options, + organizationId, + permissionRequired: permission, + user, + action: "create", + skipResourcesForDelegationCheck: autoCreatedResources, + }); + } else { + ctx.context.logger.info( + `[Dynamic Access Control] Skipping delegation check for role creation due to custom canCreateRole callback`, + { + userId: user.id, + organizationId, + roleName, + }, + ); + } await checkIfRoleNameIsTakenByRoleInDB({ ctx, @@ -978,7 +1038,7 @@ export const updateOrgRole = (options: O) => { if (ctx.body.data.permission) { let newPermission = ctx.body.data.permission; - await checkForInvalidResources({ + const autoCreatedResources = await checkForInvalidResources({ ac, ctx, permission: newPermission, @@ -994,6 +1054,7 @@ export const updateOrgRole = (options: O) => { permissionRequired: newPermission, user, action: "update", + skipResourcesForDelegationCheck: autoCreatedResources, }); updateData.permission = newPermission; @@ -1066,7 +1127,7 @@ async function checkForInvalidResources({ permission: Record; organizationId: string; options: OrganizationOptions; -}) { +}): Promise { // Get organization-specific statements (merged default + custom) const orgStatements = await getOrganizationStatements(organizationId, options, ctx); const validResources = Object.keys(orgStatements); @@ -1076,6 +1137,7 @@ async function checkForInvalidResources({ const missingResources = providedResources.filter( (r) => !validResources.includes(r), ); + const autoCreatedResources: string[] = []; // If custom resources are enabled, auto-create missing resources if (missingResources.length > 0 && options.dynamicAccessControl?.enableCustomResources) { @@ -1141,6 +1203,9 @@ async function checkForInvalidResources({ permissions: resourcePermissions, }, ); + + // Track that this resource was auto-created + autoCreatedResources.push(resourceName); } } @@ -1185,6 +1250,9 @@ async function checkForInvalidResources({ }); } } + + // Return the list of auto-created resources + return autoCreatedResources; } else if (missingResources.length > 0) { // Custom resources not enabled, so throw error for missing resources ctx.context.logger.error( @@ -1222,6 +1290,9 @@ async function checkForInvalidResources({ } } } + + // Return empty array if no resources were auto-created + return []; } async function checkIfMemberHasPermission({ @@ -1232,6 +1303,7 @@ async function checkIfMemberHasPermission({ member, user, action, + skipResourcesForDelegationCheck = [], }: { ctx: GenericEndpointContext; permissionRequired: Record; @@ -1240,6 +1312,7 @@ async function checkIfMemberHasPermission({ member: Member; user: User; action: "create" | "update" | "delete" | "read" | "list" | "get"; + skipResourcesForDelegationCheck?: string[]; }) { const hasNecessaryPermissions: { resource: { [x: string]: string[] }; @@ -1247,6 +1320,19 @@ async function checkIfMemberHasPermission({ }[] = []; const permissionEntries = Object.entries(permission); for await (const [resource, permissions] of permissionEntries) { + // Skip delegation check for auto-created resources + // Users don't need to have permissions for resources that were just created + if (skipResourcesForDelegationCheck.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 index f831be70263..1decfaa32ba 100644 --- a/packages/better-auth/src/plugins/organization/routes/crud-resources.ts +++ b/packages/better-auth/src/plugins/organization/routes/crud-resources.ts @@ -182,8 +182,7 @@ export const createOrgResource = (options: O) => }); } - // Normalize resource name (lowercase) - resourceName = resourceName.toLowerCase(); + // Resource name is used as-is (no normalization) // Validate resource name const validation = validateResourceName(resourceName, options); @@ -420,7 +419,7 @@ export const updateOrgResource = ( }); } - const resourceName = ctx.body.resource.toLowerCase(); + const resourceName = ctx.body.resource; const permissions = ctx.body.permissions; const additionalFields = ctx.body.additionalFields; @@ -620,7 +619,7 @@ export const deleteOrgResource = ( }); } - const resourceName = ctx.body.resource.toLowerCase(); + const resourceName = ctx.body.resource; // Check if resource is reserved (can't delete reserved resources) const reservedNames = getReservedResourceNames(options); @@ -972,7 +971,7 @@ export const getOrgResource = (options: O) => { }); } - const resourceName = ctx.query.resource.toLowerCase(); + const resourceName = ctx.query.resource; // Check if it's a default resource const defaultResources = options.ac?.statements || {}; diff --git a/packages/better-auth/src/plugins/organization/types.ts b/packages/better-auth/src/plugins/organization/types.ts index 39a9b010c37..f330080ae64 100644 --- a/packages/better-auth/src/plugins/organization/types.ts +++ b/packages/better-auth/src/plugins/organization/types.ts @@ -129,6 +129,34 @@ export interface OrganizationOptions { name: string, ) => boolean | { valid: boolean; error?: string }) | undefined; + /** + * Custom logic to determine if a user can create a role. + * + * @returns + * - "yes" - Allow the action (skip default checks) + * - "default" - Use default permission logic + * - { allowed: false, message: string } - Deny with custom error message + * + * @example + * ```ts + * canCreateRole: async ({ member, organizationId }) => { + * // Users with "*" permission can do anything + * if (member.role === "superadmin") return "yes"; + * + * // Use default logic for others + * return "default"; + * } + * ``` + */ + canCreateRole?: (data: { + organizationId: string; + userId: string; + member: Member & Record; + permission: Record; + roleName: string; + }) => Promise< + "yes" | "default" | { allowed: false; message: string } + > | "yes" | "default" | { allowed: false; message: string }; } | undefined; /** diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 86f7d140f9b..205d1ceef54 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -78,6 +78,9 @@ importers: '@better-auth/expo': specifier: workspace:* version: link:../../packages/expo + '@decocms/better-auth': + specifier: workspace:* + version: link:../../packages/better-auth '@expo/metro-runtime': specifier: ^6.1.2 version: 6.1.2(expo@54.0.21)(react-dom@19.2.1(react@19.2.1))(react-native@0.81.5(@babel/core@7.28.4)(@react-native-community/cli@20.0.1(typescript@5.9.3))(@react-native/metro-config@0.81.0(@babel/core@7.28.4))(@types/react@19.2.2)(react@19.2.1))(react@19.2.1) @@ -111,9 +114,6 @@ importers: babel-plugin-transform-import-meta: specifier: ^2.2.1 version: 2.3.3(@babel/core@7.28.4) - better-auth: - specifier: workspace:* - version: link:../../packages/better-auth better-sqlite3: specifier: ^11.6.0 version: 11.10.0 @@ -232,6 +232,9 @@ importers: '@better-auth/stripe': specifier: workspace:* version: link:../../packages/stripe + '@decocms/better-auth': + specifier: workspace:* + version: link:../../packages/better-auth '@hookform/resolvers': specifier: ^5.2.1 version: 5.2.1(react-hook-form@7.62.0(react@19.2.1)) @@ -337,9 +340,6 @@ importers: '@types/better-sqlite3': specifier: ^7.6.13 version: 7.6.13 - better-auth: - specifier: workspace:* - version: link:../../packages/better-auth better-sqlite3: specifier: ^12.2.0 version: 12.4.1 @@ -461,6 +461,9 @@ importers: demo/oidc-client: dependencies: + '@decocms/better-auth': + specifier: workspace:* + version: link:../../packages/better-auth '@radix-ui/react-avatar': specifier: ^1.1.10 version: 1.1.10(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.1(react@19.2.1))(react@19.2.1) @@ -470,9 +473,6 @@ importers: '@radix-ui/react-slot': specifier: ^1.2.3 version: 1.2.3(@types/react@19.2.2)(react@19.2.1) - better-auth: - specifier: workspace:* - version: link:../../packages/better-auth class-variance-authority: specifier: ^0.7.1 version: 0.7.1 @@ -531,7 +531,7 @@ importers: demo/stateless: dependencies: - better-auth: + '@decocms/better-auth': specifier: workspace:* version: link:../../packages/better-auth next: @@ -860,7 +860,7 @@ importers: e2e/integration: dependencies: - better-auth: + '@decocms/better-auth': specifier: workspace:* version: link:../../packages/better-auth devDependencies: @@ -870,15 +870,15 @@ importers: e2e/integration/solid-vinxi: dependencies: + '@decocms/better-auth': + specifier: workspace:* + version: link:../../../packages/better-auth '@solidjs/router': specifier: ^0.15.3 version: 0.15.3(solid-js@1.9.9) '@solidjs/start': specifier: ^1.1.7 version: 1.1.7(289b7b32f8ebc3ad2c4aa2de3cbda9c6) - better-auth: - specifier: workspace:* - version: link:../../../packages/better-auth better-sqlite3: specifier: ^12.2.0 version: 12.4.1 @@ -901,7 +901,7 @@ importers: e2e/integration/vanilla-node: dependencies: - better-auth: + '@decocms/better-auth': specifier: workspace:* version: link:../../../packages/better-auth better-sqlite3: @@ -926,13 +926,13 @@ importers: e2e/smoke: dependencies: - better-auth: + '@decocms/better-auth': specifier: workspace:* version: link:../../packages/better-auth e2e/smoke/test/fixtures/cloudflare: dependencies: - better-auth: + '@decocms/better-auth': specifier: workspace:* version: link:../../../../../packages/better-auth drizzle-orm: @@ -966,7 +966,7 @@ importers: '@better-auth/stripe': specifier: workspace:* version: link:../../../../../packages/stripe - better-auth: + '@decocms/better-auth': specifier: workspace:* version: link:../../../../../packages/better-auth stripe: @@ -978,31 +978,31 @@ importers: '@better-auth/sso': specifier: workspace:* version: link:../../../../../packages/sso - better-auth: + '@decocms/better-auth': specifier: workspace:* version: link:../../../../../packages/better-auth e2e/smoke/test/fixtures/tsconfig-isolated-module-bundler: dependencies: - better-auth: + '@decocms/better-auth': specifier: workspace:* version: link:../../../../../packages/better-auth better-auth-harmony: specifier: ^1.2.5 - version: 1.2.5(better-auth@packages+better-auth) + version: 1.2.5(better-auth@1.4.5(@lynx-js/react@0.114.0(@types/react@19.2.2))(@sveltejs/kit@2.37.1(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.1.3(svelte@5.38.2)(vite@7.2.6(@types/node@24.10.1)(jiti@2.6.1)(less@4.4.1)(lightningcss@1.30.2)(sass@1.90.0)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.2)))(svelte@5.38.2)(vite@7.2.6(@types/node@24.10.1)(jiti@2.6.1)(less@4.4.1)(lightningcss@1.30.2)(sass@1.90.0)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.2)))(@tanstack/react-start@1.139.8(react-dom@19.2.1(react@19.2.1))(react@19.2.1)(vite@7.2.6(@types/node@24.10.1)(jiti@2.6.1)(less@4.4.1)(lightningcss@1.30.2)(sass@1.90.0)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.2)))(next@16.0.7(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.1(react@19.2.1))(react@19.2.1)(sass@1.90.0))(react-dom@19.2.1(react@19.2.1))(react@19.2.1)(solid-js@1.9.9)(svelte@5.38.2)(vue@3.5.19(typescript@5.9.3))) e2e/smoke/test/fixtures/tsconfig-verbatim-module-syntax-node10: dependencies: '@better-auth/expo': specifier: workspace:* version: link:../../../../../packages/expo - better-auth: + '@decocms/better-auth': specifier: workspace:* version: link:../../../../../packages/better-auth e2e/smoke/test/fixtures/vite: dependencies: - better-auth: + '@decocms/better-auth': specifier: workspace:* version: link:../../../../../packages/better-auth devDependencies: @@ -1013,11 +1013,11 @@ importers: packages/better-auth: dependencies: '@better-auth/core': - specifier: workspace:* - version: link:../core + specifier: 1.4.6-beta.3 + version: 1.4.6-beta.3(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.18)(better-call@1.1.5(zod@4.1.13))(jose@6.1.0)(kysely@0.28.5)(nanostores@1.0.1) '@better-auth/telemetry': - specifier: workspace:* - version: link:../telemetry + specifier: 1.4.6-beta.3 + version: 1.4.6-beta.3(@better-auth/core@1.4.6-beta.3(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.18)(better-call@1.1.5(zod@4.1.13))(jose@6.1.0)(kysely@0.28.5)(nanostores@1.0.1)) '@better-auth/utils': specifier: 0.3.0 version: 0.3.0 @@ -1169,6 +1169,9 @@ importers: '@clack/prompts': specifier: ^0.11.0 version: 0.11.0 + '@decocms/better-auth': + specifier: workspace:^ + version: link:../better-auth '@mrleebo/prisma-ast': specifier: ^0.13.0 version: 0.13.0 @@ -1178,9 +1181,6 @@ importers: '@types/pg': specifier: ^8.15.5 version: 8.15.5 - better-auth: - specifier: workspace:^ - version: link:../better-auth better-sqlite3: specifier: ^12.2.0 version: 12.4.1 @@ -1295,12 +1295,12 @@ importers: '@better-auth/core': specifier: workspace:* version: link:../core + '@decocms/better-auth': + specifier: workspace:* + version: link:../better-auth '@types/better-sqlite3': specifier: ^7.6.13 version: 7.6.13 - better-auth: - specifier: workspace:* - version: link:../better-auth better-sqlite3: specifier: ^12.2.0 version: 12.4.1 @@ -1350,7 +1350,7 @@ importers: '@better-auth/core': specifier: workspace:* version: link:../core - better-auth: + '@decocms/better-auth': specifier: workspace:* version: link:../better-auth tsdown: @@ -1362,7 +1362,7 @@ importers: '@better-auth/utils': specifier: 0.3.0 version: 0.3.0 - better-auth: + '@decocms/better-auth': specifier: workspace:* version: link:../better-auth better-call: @@ -1397,15 +1397,15 @@ importers: specifier: ^4.1.12 version: 4.1.13 devDependencies: + '@decocms/better-auth': + specifier: workspace:* + version: link:../better-auth '@types/body-parser': specifier: ^1.19.6 version: 1.19.6 '@types/express': specifier: ^5.0.5 version: 5.0.5 - better-auth: - specifier: workspace:* - version: link:../better-auth better-call: specifier: 'catalog:' version: 1.1.5(zod@4.1.13) @@ -1434,7 +1434,7 @@ importers: '@better-auth/core': specifier: workspace:* version: link:../core - better-auth: + '@decocms/better-auth': specifier: workspace:* version: link:../better-auth better-call: @@ -1474,7 +1474,7 @@ importers: '@better-fetch/fetch': specifier: 'catalog:' version: 1.1.18 - better-auth: + '@decocms/better-auth': specifier: workspace:* version: link:../packages/better-auth msw: @@ -2357,6 +2357,36 @@ packages: resolution: {integrity: sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==} engines: {node: '>=6.9.0'} + '@better-auth/core@1.4.5': + resolution: {integrity: sha512-dQ3hZOkUJzeBXfVEPTm2LVbzmWwka1nqd9KyWmB2OMlMfjr7IdUeBX4T7qJctF67d7QDhlX95jMoxu6JG0Eucw==} + peerDependencies: + '@better-auth/utils': 0.3.0 + '@better-fetch/fetch': 1.1.18 + better-call: 1.1.4 + jose: ^6.1.0 + kysely: ^0.28.5 + nanostores: ^1.0.1 + + '@better-auth/core@1.4.6-beta.3': + resolution: {integrity: sha512-yjiu7wva4a0HFiWNWoaKfazLXMOx0+2mpyIbB5A1ov1Ta/YXZAg7jeh9A9uyBGXI8Y2qBVMYExumXVp1m1Xz+w==} + peerDependencies: + '@better-auth/utils': 0.3.0 + '@better-fetch/fetch': 1.1.18 + better-call: 1.1.4 + jose: ^6.1.0 + kysely: ^0.28.5 + nanostores: ^1.0.1 + + '@better-auth/telemetry@1.4.5': + resolution: {integrity: sha512-r3NyksbaBYA10SC86JA6QwmZfHwFutkUGcphgWGfu6MVx1zutYmZehIeC8LxTjOWZqqF9FI8vLjglWBHvPQeTg==} + peerDependencies: + '@better-auth/core': 1.4.5 + + '@better-auth/telemetry@1.4.6-beta.3': + resolution: {integrity: sha512-97xIVEV6qf45IA6eXSwoD7jj95yVgVTOazsV000Q3jIcCLExSMzjQKOMniGiWsNF2UF+ZjpxWtixZ/HK98gXUw==} + peerDependencies: + '@better-auth/core': 1.4.6-beta.3 + '@better-auth/utils@0.3.0': resolution: {integrity: sha512-W+Adw6ZA6mgvnSnhOki270rwJ42t4XzSK6YWGF//BbVXL6SwCLWfyzBc1lN2m/4RM28KubdBKQ4X5VMoLRNPQw==} @@ -7276,6 +7306,46 @@ packages: peerDependencies: better-auth: ^1.0.3 + better-auth@1.4.5: + resolution: {integrity: sha512-pHV2YE0OogRHvoA6pndHXCei4pcep/mjY7psSaHVrRgjBtumVI68SV1g9U9XPRZ4KkoGca9jfwuv+bB2UILiFw==} + peerDependencies: + '@lynx-js/react': '*' + '@sveltejs/kit': ^2.0.0 + '@tanstack/react-start': ^1.0.0 + next: ^14.0.0 || ^15.0.0 || ^16.0.0 + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + solid-js: ^1.0.0 + svelte: ^4.0.0 || ^5.0.0 + vue: ^3.0.0 + peerDependenciesMeta: + '@lynx-js/react': + optional: true + '@sveltejs/kit': + optional: true + '@tanstack/react-start': + optional: true + next: + optional: true + react: + optional: true + react-dom: + optional: true + solid-js: + optional: true + svelte: + optional: true + vue: + optional: true + + better-call@1.1.4: + resolution: {integrity: sha512-NJouLY6IVKv0nDuFoc6FcbKDFzEnmgMNofC9F60Mwx1Ecm7X6/Ecyoe5b+JSVZ42F/0n46/M89gbYP1ZCVv8xQ==} + peerDependencies: + zod: ^4.0.0 + peerDependenciesMeta: + zod: + optional: true + better-call@1.1.5: resolution: {integrity: sha512-nQJ3S87v6wApbDwbZ++FrQiSiVxWvZdjaO+2v6lZJAG2WWggkB2CziUDjPciz3eAt9TqfRursIQMZIcpkBnvlw==} peerDependencies: @@ -15259,6 +15329,40 @@ snapshots: '@babel/helper-string-parser': 7.27.1 '@babel/helper-validator-identifier': 7.28.5 + '@better-auth/core@1.4.5(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.18)(better-call@1.1.4(zod@4.1.13))(jose@6.1.0)(kysely@0.28.5)(nanostores@1.0.1)': + dependencies: + '@better-auth/utils': 0.3.0 + '@better-fetch/fetch': 1.1.18 + '@standard-schema/spec': 1.0.0 + better-call: 1.1.4(zod@4.1.13) + jose: 6.1.0 + kysely: 0.28.5 + nanostores: 1.0.1 + zod: 4.1.13 + + '@better-auth/core@1.4.6-beta.3(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.18)(better-call@1.1.5(zod@4.1.13))(jose@6.1.0)(kysely@0.28.5)(nanostores@1.0.1)': + dependencies: + '@better-auth/utils': 0.3.0 + '@better-fetch/fetch': 1.1.18 + '@standard-schema/spec': 1.0.0 + better-call: 1.1.5(zod@4.1.13) + jose: 6.1.0 + kysely: 0.28.5 + nanostores: 1.0.1 + zod: 4.1.13 + + '@better-auth/telemetry@1.4.5(@better-auth/core@1.4.5(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.18)(better-call@1.1.4(zod@4.1.13))(jose@6.1.0)(kysely@0.28.5)(nanostores@1.0.1))': + dependencies: + '@better-auth/core': 1.4.5(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.18)(better-call@1.1.4(zod@4.1.13))(jose@6.1.0)(kysely@0.28.5)(nanostores@1.0.1) + '@better-auth/utils': 0.3.0 + '@better-fetch/fetch': 1.1.18 + + '@better-auth/telemetry@1.4.6-beta.3(@better-auth/core@1.4.6-beta.3(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.18)(better-call@1.1.5(zod@4.1.13))(jose@6.1.0)(kysely@0.28.5)(nanostores@1.0.1))': + dependencies: + '@better-auth/core': 1.4.6-beta.3(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.18)(better-call@1.1.5(zod@4.1.13))(jose@6.1.0)(kysely@0.28.5)(nanostores@1.0.1) + '@better-auth/utils': 0.3.0 + '@better-fetch/fetch': 1.1.18 + '@better-auth/utils@0.3.0': {} '@better-fetch/fetch@1.1.18': {} @@ -18687,7 +18791,9 @@ snapshots: metro-runtime: 0.83.3 transitivePeerDependencies: - '@babel/core' + - bufferutil - supports-color + - utf-8-validate optional: true '@react-native/normalize-colors@0.74.89': {} @@ -20671,13 +20777,48 @@ snapshots: dependencies: safe-buffer: 5.1.2 - better-auth-harmony@1.2.5(better-auth@packages+better-auth): + better-auth-harmony@1.2.5(better-auth@1.4.5(@lynx-js/react@0.114.0(@types/react@19.2.2))(@sveltejs/kit@2.37.1(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.1.3(svelte@5.38.2)(vite@7.2.6(@types/node@24.10.1)(jiti@2.6.1)(less@4.4.1)(lightningcss@1.30.2)(sass@1.90.0)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.2)))(svelte@5.38.2)(vite@7.2.6(@types/node@24.10.1)(jiti@2.6.1)(less@4.4.1)(lightningcss@1.30.2)(sass@1.90.0)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.2)))(@tanstack/react-start@1.139.8(react-dom@19.2.1(react@19.2.1))(react@19.2.1)(vite@7.2.6(@types/node@24.10.1)(jiti@2.6.1)(less@4.4.1)(lightningcss@1.30.2)(sass@1.90.0)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.2)))(next@16.0.7(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.1(react@19.2.1))(react@19.2.1)(sass@1.90.0))(react-dom@19.2.1(react@19.2.1))(react@19.2.1)(solid-js@1.9.9)(svelte@5.38.2)(vue@3.5.19(typescript@5.9.3))): dependencies: - better-auth: link:packages/better-auth + better-auth: 1.4.5(@lynx-js/react@0.114.0(@types/react@19.2.2))(@sveltejs/kit@2.37.1(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.1.3(svelte@5.38.2)(vite@7.2.6(@types/node@24.10.1)(jiti@2.6.1)(less@4.4.1)(lightningcss@1.30.2)(sass@1.90.0)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.2)))(svelte@5.38.2)(vite@7.2.6(@types/node@24.10.1)(jiti@2.6.1)(less@4.4.1)(lightningcss@1.30.2)(sass@1.90.0)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.2)))(@tanstack/react-start@1.139.8(react-dom@19.2.1(react@19.2.1))(react@19.2.1)(vite@7.2.6(@types/node@24.10.1)(jiti@2.6.1)(less@4.4.1)(lightningcss@1.30.2)(sass@1.90.0)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.2)))(next@16.0.7(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.1(react@19.2.1))(react@19.2.1)(sass@1.90.0))(react-dom@19.2.1(react@19.2.1))(react@19.2.1)(solid-js@1.9.9)(svelte@5.38.2)(vue@3.5.19(typescript@5.9.3)) libphonenumber-js: 1.12.24 mailchecker: 6.0.18 validator: 13.15.15 + better-auth@1.4.5(@lynx-js/react@0.114.0(@types/react@19.2.2))(@sveltejs/kit@2.37.1(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.1.3(svelte@5.38.2)(vite@7.2.6(@types/node@24.10.1)(jiti@2.6.1)(less@4.4.1)(lightningcss@1.30.2)(sass@1.90.0)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.2)))(svelte@5.38.2)(vite@7.2.6(@types/node@24.10.1)(jiti@2.6.1)(less@4.4.1)(lightningcss@1.30.2)(sass@1.90.0)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.2)))(@tanstack/react-start@1.139.8(react-dom@19.2.1(react@19.2.1))(react@19.2.1)(vite@7.2.6(@types/node@24.10.1)(jiti@2.6.1)(less@4.4.1)(lightningcss@1.30.2)(sass@1.90.0)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.2)))(next@16.0.7(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.1(react@19.2.1))(react@19.2.1)(sass@1.90.0))(react-dom@19.2.1(react@19.2.1))(react@19.2.1)(solid-js@1.9.9)(svelte@5.38.2)(vue@3.5.19(typescript@5.9.3)): + dependencies: + '@better-auth/core': 1.4.5(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.18)(better-call@1.1.4(zod@4.1.13))(jose@6.1.0)(kysely@0.28.5)(nanostores@1.0.1) + '@better-auth/telemetry': 1.4.5(@better-auth/core@1.4.5(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.18)(better-call@1.1.4(zod@4.1.13))(jose@6.1.0)(kysely@0.28.5)(nanostores@1.0.1)) + '@better-auth/utils': 0.3.0 + '@better-fetch/fetch': 1.1.18 + '@noble/ciphers': 2.0.0 + '@noble/hashes': 2.0.0 + better-call: 1.1.4(zod@4.1.13) + defu: 6.1.4 + jose: 6.1.0 + kysely: 0.28.5 + ms: 4.0.0-nightly.202508271359 + nanostores: 1.0.1 + zod: 4.1.13 + optionalDependencies: + '@lynx-js/react': 0.114.0(@types/react@19.2.2) + '@sveltejs/kit': 2.37.1(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.1.3(svelte@5.38.2)(vite@7.2.6(@types/node@24.10.1)(jiti@2.6.1)(less@4.4.1)(lightningcss@1.30.2)(sass@1.90.0)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.2)))(svelte@5.38.2)(vite@7.2.6(@types/node@24.10.1)(jiti@2.6.1)(less@4.4.1)(lightningcss@1.30.2)(sass@1.90.0)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.2)) + '@tanstack/react-start': 1.139.8(react-dom@19.2.1(react@19.2.1))(react@19.2.1)(vite@7.2.6(@types/node@24.10.1)(jiti@2.6.1)(less@4.4.1)(lightningcss@1.30.2)(sass@1.90.0)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.2)) + next: 16.0.7(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.1(react@19.2.1))(react@19.2.1)(sass@1.90.0) + react: 19.2.1 + react-dom: 19.2.1(react@19.2.1) + solid-js: 1.9.9 + svelte: 5.38.2 + vue: 3.5.19(typescript@5.9.3) + + better-call@1.1.4(zod@4.1.13): + dependencies: + '@better-auth/utils': 0.3.0 + '@better-fetch/fetch': 1.1.18 + rou3: 0.7.10 + set-cookie-parser: 2.7.2 + optionalDependencies: + zod: 4.1.13 + better-call@1.1.5(zod@4.1.13): dependencies: '@better-auth/utils': 0.3.0 diff --git a/test/package.json b/test/package.json index 6d445e6e57c..0e9af187a0e 100644 --- a/test/package.json +++ b/test/package.json @@ -9,7 +9,7 @@ "devDependencies": { "@better-auth/core": "workspace:*", "@better-fetch/fetch": "catalog:", - "better-auth": "workspace:*", + "@decocms/better-auth": "workspace:*", "msw": "^2.12.4", "openid-client": "^6.8.1", "vitest": "catalog:" From ae03a52f96f7efd1d1561c0875458a98231b3c4e Mon Sep 17 00:00:00 2001 From: Jonathan Samines Date: Mon, 8 Dec 2025 15:49:31 -0600 Subject: [PATCH 15/56] chore: add nonce check and verifying jwt claims for google (#6614) --- packages/core/src/social-providers/google.ts | 63 ++++++++++++++------ 1 file changed, 46 insertions(+), 17 deletions(-) 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); +}; From 4d5e298511017092504fa4b73d8dc57b56a206ed Mon Sep 17 00:00:00 2001 From: Taesu <166604494+bytaesu@users.noreply.github.com> Date: Tue, 9 Dec 2025 06:59:36 +0900 Subject: [PATCH 16/56] docs: fix enterprise page ui logo (#6620) --- docs/app/enterprise/_components/enterprise-form.tsx | 6 +++--- docs/app/enterprise/_components/enterprise-hero.tsx | 13 +++++-------- 2 files changed, 8 insertions(+), 11 deletions(-) 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..d4e47210b45 100644 --- a/docs/app/enterprise/_components/enterprise-hero.tsx +++ b/docs/app/enterprise/_components/enterprise-hero.tsx @@ -11,7 +11,7 @@ export function EnterpriseHero() {

- BETTER AUTH{" "} + BETTER-AUTH.{" "} ENTERPRISE @@ -92,16 +92,13 @@ export function EnterpriseHero() { className="inline-block cursor-pointer hover:opacity-100 transition-opacity" > - + From fdd386eabc7f0b757acaa5b4d9355d439c0dcb62 Mon Sep 17 00:00:00 2001 From: Taesu <166604494+bytaesu@users.noreply.github.com> Date: Tue, 9 Dec 2025 06:59:50 +0900 Subject: [PATCH 17/56] docs: improve ThemeToggle component and header layout (#6451) Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com> --- docs/components/mobile-search-icon.tsx | 2 +- docs/components/nav-bar.tsx | 2 +- docs/components/nav-mobile.tsx | 19 ++- docs/components/theme-toggle.tsx | 207 +++++++++++++++++++++++++ docs/components/theme-toggler.tsx | 189 ---------------------- 5 files changed, 218 insertions(+), 201 deletions(-) create mode 100644 docs/components/theme-toggle.tsx delete mode 100644 docs/components/theme-toggler.tsx 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/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 - - - - ); -} From d57572d96256c717c6d666ec7891cb804ade6ef1 Mon Sep 17 00:00:00 2001 From: Taesu <166604494+bytaesu@users.noreply.github.com> Date: Tue, 9 Dec 2025 07:18:55 +0900 Subject: [PATCH 18/56] docs: add WorkOS migration guide (#6577) Co-authored-by: Bereket Engida Co-authored-by: Bereket Engida <86073083+Bekacru@users.noreply.github.com> --- .cspell/custom-words.txt | 3 +- .cspell/third-party.txt | 3 +- .../_components/enterprise-hero.tsx | 4 +- docs/components/sidebar-content.tsx | 179 +++-- .../docs/guides/workos-migration-guide.mdx | 712 ++++++++++++++++++ 5 files changed, 822 insertions(+), 79 deletions(-) create mode 100644 docs/content/docs/guides/workos-migration-guide.mdx 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/docs/app/enterprise/_components/enterprise-hero.tsx b/docs/app/enterprise/_components/enterprise-hero.tsx index d4e47210b45..606b1a44e17 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 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/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 From 9d3d1d4c619c47007fbee249449f2bc5f51ca76c Mon Sep 17 00:00:00 2001 From: Maxwell <145994855+ping-maxwell@users.noreply.github.com> Date: Tue, 9 Dec 2025 09:50:52 +1000 Subject: [PATCH 19/56] fix: array field handling across adapters and schema generation (#6601) Co-authored-by: Bereket Engida <86073083+Bekacru@users.noreply.github.com> Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com> --- .../drizzle-adapter/drizzle-adapter.ts | 6 +- .../adapters/kysely-adapter/kysely-adapter.ts | 6 +- .../adapters/prisma-adapter/prisma-adapter.ts | 4 + .../better-auth/src/adapters/tests/basic.ts | 85 +++++++++++++++++++ .../__snapshots__/create-context.test.ts.snap | 1 + packages/better-auth/src/db/get-migration.ts | 33 ++++--- packages/cli/src/generators/drizzle.ts | 14 ++- packages/cli/src/generators/prisma.ts | 20 ++++- packages/core/src/db/adapter/factory.ts | 6 +- packages/core/src/db/adapter/index.ts | 8 ++ 10 files changed, 158 insertions(+), 25 deletions(-) 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..05c21f88e63 100644 --- a/packages/better-auth/src/adapters/kysely-adapter/kysely-adapter.ts +++ b/packages/better-auth/src/adapters/kysely-adapter/kysely-adapter.ts @@ -605,7 +605,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..9147e7f2656 100644 --- a/packages/better-auth/src/adapters/prisma-adapter/prisma-adapter.ts +++ b/packages/better-auth/src/adapters/prisma-adapter/prisma-adapter.ts @@ -414,6 +414,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/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/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/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. * From 24f486e2c53b6b69c3caae933c48345a5f852d00 Mon Sep 17 00:00:00 2001 From: Taesu <166604494+bytaesu@users.noreply.github.com> Date: Tue, 9 Dec 2025 08:51:56 +0900 Subject: [PATCH 20/56] docs: align trusted by section layout (#6621) --- docs/app/enterprise/_components/enterprise-hero.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/app/enterprise/_components/enterprise-hero.tsx b/docs/app/enterprise/_components/enterprise-hero.tsx index 606b1a44e17..33a5553cb1b 100644 --- a/docs/app/enterprise/_components/enterprise-hero.tsx +++ b/docs/app/enterprise/_components/enterprise-hero.tsx @@ -25,8 +25,8 @@ export function EnterpriseHero() {

{/* Trusted By Section */} -
-

+

+

Trusted by teams at

@@ -148,7 +148,7 @@ export function EnterpriseHero() { From 880e7c76cdfb5aa5829f46e2d8b1f32841a71701 Mon Sep 17 00:00:00 2001 From: Alex Yang Date: Tue, 9 Dec 2025 08:52:45 +0900 Subject: [PATCH 21/56] fix: storeStateStrategy default to database if provided (#6619) --- packages/better-auth/src/context/create-context.ts | 4 +++- packages/better-auth/src/oauth2/state.ts | 6 ++---- 2 files changed, 5 insertions(+), 5 deletions(-) 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/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(), From c56622b38f2e49c0ede68807267b254a7eef6fbe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Paola=20Estefan=C3=ADa=20de=20Campos?= <84341268+Paola3stefania@users.noreply.github.com> Date: Mon, 8 Dec 2025 20:53:20 -0300 Subject: [PATCH 22/56] fix(sso): deprecate trustEmailVerified (#6616) --- docs/content/docs/plugins/sso.mdx | 2 -- packages/sso/src/types.ts | 6 ++++++ 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/docs/content/docs/plugins/sso.mdx b/docs/content/docs/plugins/sso.mdx index 37ae4a5d0b9..0530f7cf0e8 100644 --- a/docs/content/docs/plugins/sso.mdx +++ b/docs/content/docs/plugins/sso.mdx @@ -872,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. Date: Tue, 9 Dec 2025 00:54:23 +0100 Subject: [PATCH 23/56] fix(username): await username validator (#6611) --- .../better-auth/src/plugins/username/index.ts | 6 +++-- .../src/plugins/username/username.test.ts | 22 ++++++++++++++++++- 2 files changed, 25 insertions(+), 3 deletions(-) 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) => { From 44aa11d3f91e3c77e8a3a0ea32dfe38c82f0c03a Mon Sep 17 00:00:00 2001 From: Jaren Goldberg Date: Mon, 8 Dec 2025 18:54:41 -0500 Subject: [PATCH 24/56] docs: add `better-auth-university` community plugin (#6594) Co-authored-by: Bereket Engida <86073083+Bekacru@users.noreply.github.com> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- docs/content/docs/plugins/community-plugins.mdx | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/content/docs/plugins/community-plugins.mdx b/docs/content/docs/plugins/community-plugins.mdx index 7bc308b4e7a..3b71d5c7c7a 100644 --- a/docs/content/docs/plugins/community-plugins.mdx +++ b/docs/content/docs/plugins/community-plugins.mdx @@ -21,3 +21,4 @@ To create your own custom plugin, get started by reading our [plugins documentat | [better-auth-credentials-plugin](https://github.com/erickweil/better-auth-credentials-plugin) | LDAP authentication plugin for Better Auth. | [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) | From 13efb8540b39351bfa01aaf6e36bc57a025c4136 Mon Sep 17 00:00:00 2001 From: Brendan Delfortrie <124538338+delfortrie@users.noreply.github.com> Date: Mon, 8 Dec 2025 19:00:08 -0500 Subject: [PATCH 25/56] fix: should always remove 2FA verification token after successful verification (#6604) --- .../src/plugins/two-factor/two-factor.test.ts | 22 ++++++++++++++++++- .../plugins/two-factor/verify-two-factor.ts | 12 ++++++---- 2 files changed, 29 insertions(+), 5 deletions(-) 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, From 56abd4f222c614f700ac6eb21f1c0a35215a2070 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A9l=20Solano?= Date: Tue, 9 Dec 2025 01:01:08 +0100 Subject: [PATCH 26/56] fix(kysely): wrong affected row count in updateMany & deleteMany (#6572) --- .../src/adapters/kysely-adapter/kysely-adapter.ts | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) 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 05c21f88e63..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, }; From 51c119f76ecc850ff12fac3b42d78c7dfa79a717 Mon Sep 17 00:00:00 2001 From: Rodrigo Ehlers Date: Tue, 9 Dec 2025 01:02:18 +0100 Subject: [PATCH 27/56] docs: remove/rephrase wrong statement about permissions and projects (#6586) --- docs/content/docs/plugins/organization.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/content/docs/plugins/organization.mdx b/docs/content/docs/plugins/organization.mdx index 4367c4edfa4..383e16086dd 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 From 5fb835ab28e7d08b919d9791bf37535c9d06e967 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A9l=20Solano?= Date: Tue, 9 Dec 2025 01:05:38 +0100 Subject: [PATCH 28/56] feat(admin): prevent impersonating admins by default [breaking] (#6454) Co-authored-by: Bereket Engida <86073083+Bekacru@users.noreply.github.com> --- docs/content/docs/plugins/admin.mdx | 10 ++++++++ .../src/plugins/admin/admin.test.ts | 24 +++++++++++++++++++ .../src/plugins/admin/error-codes.ts | 1 + .../better-auth/src/plugins/admin/routes.ts | 24 +++++++++++++++++-- .../better-auth/src/plugins/admin/types.ts | 6 +++++ 5 files changed, 63 insertions(+), 2 deletions(-) diff --git a/docs/content/docs/plugins/admin.mdx b/docs/content/docs/plugins/admin.mdx index 8508396446b..1dc1c5b73a7 100644 --- a/docs/content/docs/plugins/admin.mdx +++ b/docs/content/docs/plugins/admin.mdx @@ -820,3 +820,13 @@ admin({ bannedUserMessage: "Custom banned user message", }); ``` + +### allowImpersonatingAdmins + +Whether to allow impersonating other admin users. Defaults to `false`. + +```ts title="auth.ts" +admin({ + allowImpersonatingAdmins: true, +}); +``` 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 = From e8a5559cd846b8bd77c1fce7a9256fd7f4b95804 Mon Sep 17 00:00:00 2001 From: Bereket Engida <86073083+Bekacru@users.noreply.github.com> Date: Mon, 8 Dec 2025 16:05:57 -0800 Subject: [PATCH 29/56] fix(prisma): use findFirst instead of findMany for findOne (#6429) --- .../src/adapters/prisma-adapter/prisma-adapter.ts | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) 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 9147e7f2656..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) { From 137863c5b2e4ad2e3acc97ddb68cad192d4b83f4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A9l=20Solano?= Date: Tue, 9 Dec 2025 01:22:25 +0100 Subject: [PATCH 30/56] feat(multi-session): allow to infer additional fields (#6585) --- docs/content/docs/plugins/multi-session.mdx | 60 ++++++++++++++++++- .../src/plugins/multi-session/client.ts | 24 +++++++- .../src/plugins/multi-session/index.ts | 28 ++++++++- 3 files changed, 107 insertions(+), 5 deletions(-) diff --git a/docs/content/docs/plugins/multi-session.mdx b/docs/content/docs/plugins/multi-session.mdx index 0b8c8c7fac3..c613569a59d 100644 --- a/docs/content/docs/plugins/multi-session.mdx +++ b/docs/content/docs/plugins/multi-session.mdx @@ -102,12 +102,15 @@ type revokeDeviceSession = { When a user logs out, the plugin will revoke all active sessions for the user. You can do this by calling the existing `signOut` method, which handles revoking all sessions automatically. +## Options + ### Max Sessions You can specify the maximum number of sessions a user can have by passing the `maximumSessions` option to the plugin. By default, the plugin allows 5 sessions per device. ```ts title="auth.ts" import { betterAuth } from "better-auth" +import { multiSession } from "better-auth/plugins" export const auth = betterAuth({ plugins: [ @@ -116,4 +119,59 @@ export const auth = betterAuth({ }) ] }) -``` \ No newline at end of file +``` + +### Additional Fields + +You can infer additional fields for the `user` and `session` schema by passing the `schema` option to both plugins. + + + Note that this only affects type inference and does not modify the actual database schema. + Make sure that you [extend the core schema](/docs/concepts/database#extending-core-schema) accordingly. + + +```ts title="auth.ts" +import { betterAuth } from "better-auth" +import { multiSession } from "better-auth/plugins" + +export const auth = betterAuth({ + plugins: [ + multiSession({ + schema: { + user: { + additionalFields: { + lang: { + type: "string", + required: false, + defaultValue: "en" + } + } + } + } + }) + ] +}) +``` + +```ts title="auth-client.ts" +import { createAuthClient } from "better-auth/client" +import { multiSessionClient } from "better-auth/client/plugins" + +export const authClient = createAuthClient({ + plugins: [ + multiSessionClient({ + schema: { + user: { + additionalFields: { + lang: { + type: "string", + required: false, + defaultValue: "en" + } + } + } + } + }) + ] +}) +``` diff --git a/packages/better-auth/src/plugins/multi-session/client.ts b/packages/better-auth/src/plugins/multi-session/client.ts index 82c411a4496..78dfcba0f82 100644 --- a/packages/better-auth/src/plugins/multi-session/client.ts +++ b/packages/better-auth/src/plugins/multi-session/client.ts @@ -1,10 +1,30 @@ import type { BetterAuthClientPlugin } from "@better-auth/core"; +import type { DBFieldAttribute } from "@better-auth/core/db"; import type { multiSession } from "."; -export const multiSessionClient = () => { +export type MultiSessionClientOptions = { + schema?: + | { + user?: + | { + additionalFields?: Record | undefined; + } + | undefined; + session?: + | { + additionalFields?: Record | undefined; + } + | undefined; + } + | undefined; +}; + +export const multiSessionClient = ( + options?: O | undefined, +) => { return { id: "multi-session", - $InferServerPlugin: {} as ReturnType, + $InferServerPlugin: {} as ReturnType>, atomListeners: [ { matcher(path) { diff --git a/packages/better-auth/src/plugins/multi-session/index.ts b/packages/better-auth/src/plugins/multi-session/index.ts index 8bd3cd329d1..7ad391b1a69 100644 --- a/packages/better-auth/src/plugins/multi-session/index.ts +++ b/packages/better-auth/src/plugins/multi-session/index.ts @@ -3,6 +3,7 @@ import { createAuthEndpoint, createAuthMiddleware, } from "@better-auth/core/api"; +import type { DBFieldAttribute, Session, User } from "@better-auth/core/db"; import { defineErrorCodes } from "@better-auth/core/utils"; import * as z from "zod"; import { APIError, sessionMiddleware } from "../../api"; @@ -12,6 +13,7 @@ import { parseSetCookieHeader, setSessionCookie, } from "../../cookies"; +import type { InferAdditionalFieldsFromPluginOptions } from "../../db"; export interface MultiSessionConfig { /** @@ -20,6 +22,20 @@ export interface MultiSessionConfig { * @default 5 */ maximumSessions?: number | undefined; + schema?: + | { + user?: + | { + additionalFields?: Record | undefined; + } + | undefined; + session?: + | { + additionalFields?: Record | undefined; + } + | undefined; + } + | undefined; } const ERROR_CODES = defineErrorCodes({ @@ -38,7 +54,9 @@ const revokeDeviceSessionBodySchema = z.object({ }), }); -export const multiSession = (options?: MultiSessionConfig | undefined) => { +export const multiSession = ( + options?: O | undefined, +) => { const opts = { maximumSessions: 5, ...options, @@ -101,7 +119,13 @@ export const multiSession = (options?: MultiSessionConfig | undefined) => { }, [] as typeof validSessions, ); - return ctx.json(uniqueUserSessions); + return ctx.json( + uniqueUserSessions as { + user: User & InferAdditionalFieldsFromPluginOptions<"user", O>; + session: Session & + InferAdditionalFieldsFromPluginOptions<"session", O>; + }[], + ); }, ), /** From 381f25fb5ca6e5e7e579b1a4476d5445bba07015 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A9l=20Solano?= Date: Tue, 9 Dec 2025 01:24:56 +0100 Subject: [PATCH 31/56] feat(expo): last-login-method client plugin (#6413) Co-authored-by: Alex Yang --- .../docs/plugins/last-login-method.mdx | 27 +++++ packages/expo/package.json | 8 ++ packages/expo/src/plugins/index.ts | 1 + .../expo/src/plugins/last-login-method.ts | 96 ++++++++++++++++ packages/expo/test/last-login-method.test.ts | 106 ++++++++++++++++++ packages/expo/tsdown.config.ts | 2 +- 6 files changed, 239 insertions(+), 1 deletion(-) create mode 100644 packages/expo/src/plugins/index.ts create mode 100644 packages/expo/src/plugins/last-login-method.ts create mode 100644 packages/expo/test/last-login-method.test.ts 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/packages/expo/package.json b/packages/expo/package.json index 238f077cf1c..150e405738a 100644 --- a/packages/expo/package.json +++ b/packages/expo/package.json @@ -30,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": { @@ -39,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", From 57ca9e4cc65f5de0850974bd7b4cff86fd47b36e Mon Sep 17 00:00:00 2001 From: Bereket Engida Date: Mon, 8 Dec 2025 16:37:47 -0800 Subject: [PATCH 32/56] chore: release v1.4.6-beta.4 --- packages/better-auth/package.json | 2 +- packages/cli/package.json | 2 +- packages/core/package.json | 2 +- packages/expo/package.json | 2 +- packages/passkey/package.json | 2 +- packages/scim/package.json | 2 +- packages/sso/package.json | 2 +- packages/stripe/package.json | 2 +- packages/telemetry/package.json | 2 +- 9 files changed, 9 insertions(+), 9 deletions(-) diff --git a/packages/better-auth/package.json b/packages/better-auth/package.json index cef241f35ef..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", diff --git a/packages/cli/package.json b/packages/cli/package.json index 23e9ca0aec3..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", diff --git a/packages/core/package.json b/packages/core/package.json index 2c152ceafbd..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": { diff --git a/packages/expo/package.json b/packages/expo/package.json index 150e405738a..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", diff --git a/packages/passkey/package.json b/packages/passkey/package.json index 102aa0054ef..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", diff --git a/packages/scim/package.json b/packages/scim/package.json index 72dc1dec7a8..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", diff --git a/packages/sso/package.json b/packages/sso/package.json index c35599e0f65..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", diff --git a/packages/stripe/package.json b/packages/stripe/package.json index 5a97e223f5a..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", 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": { From e26fc6fc29d994907d8243d0818251c8b9c97f2b Mon Sep 17 00:00:00 2001 From: Bereket Engida Date: Mon, 8 Dec 2025 16:45:49 -0800 Subject: [PATCH 33/56] Revert "feat(multi-session): allow to infer additional fields (#6585)" This reverts commit 137863c5b2e4ad2e3acc97ddb68cad192d4b83f4. --- docs/content/docs/plugins/multi-session.mdx | 60 +------------------ .../src/plugins/multi-session/client.ts | 24 +------- .../src/plugins/multi-session/index.ts | 28 +-------- 3 files changed, 5 insertions(+), 107 deletions(-) diff --git a/docs/content/docs/plugins/multi-session.mdx b/docs/content/docs/plugins/multi-session.mdx index c613569a59d..0b8c8c7fac3 100644 --- a/docs/content/docs/plugins/multi-session.mdx +++ b/docs/content/docs/plugins/multi-session.mdx @@ -102,15 +102,12 @@ type revokeDeviceSession = { When a user logs out, the plugin will revoke all active sessions for the user. You can do this by calling the existing `signOut` method, which handles revoking all sessions automatically. -## Options - ### Max Sessions You can specify the maximum number of sessions a user can have by passing the `maximumSessions` option to the plugin. By default, the plugin allows 5 sessions per device. ```ts title="auth.ts" import { betterAuth } from "better-auth" -import { multiSession } from "better-auth/plugins" export const auth = betterAuth({ plugins: [ @@ -119,59 +116,4 @@ export const auth = betterAuth({ }) ] }) -``` - -### Additional Fields - -You can infer additional fields for the `user` and `session` schema by passing the `schema` option to both plugins. - - - Note that this only affects type inference and does not modify the actual database schema. - Make sure that you [extend the core schema](/docs/concepts/database#extending-core-schema) accordingly. - - -```ts title="auth.ts" -import { betterAuth } from "better-auth" -import { multiSession } from "better-auth/plugins" - -export const auth = betterAuth({ - plugins: [ - multiSession({ - schema: { - user: { - additionalFields: { - lang: { - type: "string", - required: false, - defaultValue: "en" - } - } - } - } - }) - ] -}) -``` - -```ts title="auth-client.ts" -import { createAuthClient } from "better-auth/client" -import { multiSessionClient } from "better-auth/client/plugins" - -export const authClient = createAuthClient({ - plugins: [ - multiSessionClient({ - schema: { - user: { - additionalFields: { - lang: { - type: "string", - required: false, - defaultValue: "en" - } - } - } - } - }) - ] -}) -``` +``` \ No newline at end of file diff --git a/packages/better-auth/src/plugins/multi-session/client.ts b/packages/better-auth/src/plugins/multi-session/client.ts index 78dfcba0f82..82c411a4496 100644 --- a/packages/better-auth/src/plugins/multi-session/client.ts +++ b/packages/better-auth/src/plugins/multi-session/client.ts @@ -1,30 +1,10 @@ import type { BetterAuthClientPlugin } from "@better-auth/core"; -import type { DBFieldAttribute } from "@better-auth/core/db"; import type { multiSession } from "."; -export type MultiSessionClientOptions = { - schema?: - | { - user?: - | { - additionalFields?: Record | undefined; - } - | undefined; - session?: - | { - additionalFields?: Record | undefined; - } - | undefined; - } - | undefined; -}; - -export const multiSessionClient = ( - options?: O | undefined, -) => { +export const multiSessionClient = () => { return { id: "multi-session", - $InferServerPlugin: {} as ReturnType>, + $InferServerPlugin: {} as ReturnType, atomListeners: [ { matcher(path) { diff --git a/packages/better-auth/src/plugins/multi-session/index.ts b/packages/better-auth/src/plugins/multi-session/index.ts index 7ad391b1a69..8bd3cd329d1 100644 --- a/packages/better-auth/src/plugins/multi-session/index.ts +++ b/packages/better-auth/src/plugins/multi-session/index.ts @@ -3,7 +3,6 @@ import { createAuthEndpoint, createAuthMiddleware, } from "@better-auth/core/api"; -import type { DBFieldAttribute, Session, User } from "@better-auth/core/db"; import { defineErrorCodes } from "@better-auth/core/utils"; import * as z from "zod"; import { APIError, sessionMiddleware } from "../../api"; @@ -13,7 +12,6 @@ import { parseSetCookieHeader, setSessionCookie, } from "../../cookies"; -import type { InferAdditionalFieldsFromPluginOptions } from "../../db"; export interface MultiSessionConfig { /** @@ -22,20 +20,6 @@ export interface MultiSessionConfig { * @default 5 */ maximumSessions?: number | undefined; - schema?: - | { - user?: - | { - additionalFields?: Record | undefined; - } - | undefined; - session?: - | { - additionalFields?: Record | undefined; - } - | undefined; - } - | undefined; } const ERROR_CODES = defineErrorCodes({ @@ -54,9 +38,7 @@ const revokeDeviceSessionBodySchema = z.object({ }), }); -export const multiSession = ( - options?: O | undefined, -) => { +export const multiSession = (options?: MultiSessionConfig | undefined) => { const opts = { maximumSessions: 5, ...options, @@ -119,13 +101,7 @@ export const multiSession = ( }, [] as typeof validSessions, ); - return ctx.json( - uniqueUserSessions as { - user: User & InferAdditionalFieldsFromPluginOptions<"user", O>; - session: Session & - InferAdditionalFieldsFromPluginOptions<"session", O>; - }[], - ); + return ctx.json(uniqueUserSessions); }, ), /** From fb14141ce3f6f17c53e8f878662bdfd24201bf9f Mon Sep 17 00:00:00 2001 From: Bereket Engida Date: Mon, 8 Dec 2025 16:58:23 -0800 Subject: [PATCH 34/56] chore: add context7 config --- context7.json | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 context7.json 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" +} From 4cb358750ef49b3b09f933ee957eb49c7a85bda0 Mon Sep 17 00:00:00 2001 From: Bereket Engida <86073083+Bekacru@users.noreply.github.com> Date: Mon, 8 Dec 2025 21:46:16 -0800 Subject: [PATCH 35/56] chore: cleanup account cookie and state on signout (#6624) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- packages/better-auth/src/cookies/index.ts | 26 ++++++++++++++++++- .../better-auth/src/cookies/session-store.ts | 2 +- 2 files changed, 26 insertions(+), 2 deletions(-) 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, From d3646df32936e5ef03d6baa5cdb0c02ac0a9236e Mon Sep 17 00:00:00 2001 From: Cryze <111439736+NotCryze@users.noreply.github.com> Date: Tue, 9 Dec 2025 06:56:25 +0100 Subject: [PATCH 36/56] docs: correct typo in backup code recovery method description (#6374) --- docs/content/docs/plugins/2fa.mdx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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. Date: Tue, 9 Dec 2025 07:56:36 +0200 Subject: [PATCH 37/56] docs: creem subscription database schema changes (#6375) --- docs/content/docs/plugins/creem.mdx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) 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 | | --------------------- | ------- | -------------------------------- | From a3086cca16d3f5712dc31889341e9cc9ae1c590f Mon Sep 17 00:00:00 2001 From: Martin Riviere <51818567+martinriviere@users.noreply.github.com> Date: Tue, 9 Dec 2025 07:01:26 +0100 Subject: [PATCH 38/56] fix(magic-link): handle query params in errorCallbackUrl (#6383) --- .../src/plugins/magic-link/index.ts | 29 ++++++++---------- .../src/plugins/magic-link/magic-link.test.ts | 30 +++++++++++++++++++ 2 files changed, 42 insertions(+), 17 deletions(-) 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({ From a9c986a27ec4802c3002a875b9d590d4a285a070 Mon Sep 17 00:00:00 2001 From: Matteo Badini Date: Tue, 9 Dec 2025 07:01:57 +0100 Subject: [PATCH 39/56] perf: add index on organizations slug field (#6303) Co-authored-by: matteobadini --- packages/better-auth/src/plugins/organization/organization.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/better-auth/src/plugins/organization/organization.ts b/packages/better-auth/src/plugins/organization/organization.ts index 14d3cc0c50e..7cf395ed9bc 100644 --- a/packages/better-auth/src/plugins/organization/organization.ts +++ b/packages/better-auth/src/plugins/organization/organization.ts @@ -1043,6 +1043,7 @@ export function organization( unique: true, sortable: true, fieldName: options?.schema?.organization?.fields?.slug, + index: true, }, logo: { type: "string", From b1102e3458c6ff4fa352cf0c13ec9bd22977e099 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9sar=20Rodr=C3=ADguez=20Vilagr=C3=A1?= <128821412+CesarRodrigu@users.noreply.github.com> Date: Tue, 9 Dec 2025 07:07:53 +0100 Subject: [PATCH 40/56] feat: Add Refresh Token Support to Kick OAuth Provider (#6263) --- packages/core/src/social-providers/kick.ts | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) 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); From e884e9aeae257591c932689ddedf921f7253cbed Mon Sep 17 00:00:00 2001 From: Alex Yang Date: Tue, 9 Dec 2025 16:11:24 +0900 Subject: [PATCH 41/56] chore: bump tsdown (#6623) --- pnpm-lock.yaml | 89 +++++++++++++++++++++++---------------------- pnpm-workspace.yaml | 9 ++--- 2 files changed, 49 insertions(+), 49 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 605d93acf2d..08d0fcbaa6d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -13,8 +13,8 @@ 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 @@ -1142,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 @@ -1245,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 @@ -1285,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: @@ -1328,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: @@ -1362,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: @@ -1384,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: @@ -1427,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: @@ -1452,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: @@ -1468,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 @@ -4225,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==} @@ -4583,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==} @@ -11909,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'} @@ -12025,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==} @@ -12546,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 @@ -13332,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 @@ -13465,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==} @@ -13576,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: @@ -17350,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': @@ -17617,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': {} @@ -18404,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 @@ -26461,6 +26460,9 @@ snapshots: prettier@3.6.2: {} + prettier@3.7.4: + optional: true + pretty-bytes@5.6.0: {} pretty-bytes@6.1.1: {} @@ -26568,7 +26570,7 @@ snapshots: quansync@0.2.11: {} - quansync@0.3.0: {} + quansync@1.0.0: {} query-string@7.1.3: dependencies: @@ -27329,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 @@ -28262,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 @@ -28271,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 @@ -28382,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: {} @@ -28526,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 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 2908c9d6ad9..a5fbeecebcc 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -8,18 +8,17 @@ 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 catalogs: - vitest: - vitest: ^4.0.15 - '@vitest/coverage-v8': ^4.0.15 - react19: '@types/react': ^19.2.0 '@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: [] From 1de61b21adde46bd956b635f1f55195203f3d514 Mon Sep 17 00:00:00 2001 From: viktormarinho Date: Tue, 9 Dec 2025 12:01:27 -0300 Subject: [PATCH 42/56] undo unnececesary version bumps --- packages/cli/package.json | 2 +- packages/core/package.json | 2 +- packages/expo/package.json | 2 +- packages/passkey/package.json | 2 +- packages/scim/package.json | 2 +- packages/sso/package.json | 2 +- packages/stripe/package.json | 2 +- packages/telemetry/package.json | 2 +- 8 files changed, 8 insertions(+), 8 deletions(-) diff --git a/packages/cli/package.json b/packages/cli/package.json index 6106a12734f..452543a9fb8 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@better-auth/cli", - "version": "1.5.9", + "version": "1.4.6-beta.3", "type": "module", "description": "The CLI for Better Auth", "module": "dist/index.mjs", diff --git a/packages/core/package.json b/packages/core/package.json index 655b860938f..32c501ca0af 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@better-auth/core", - "version": "1.5.9", + "version": "1.4.6-beta.3", "description": "The most comprehensive authentication framework for TypeScript.", "type": "module", "repository": { diff --git a/packages/expo/package.json b/packages/expo/package.json index d3255525ed7..618d825b093 100644 --- a/packages/expo/package.json +++ b/packages/expo/package.json @@ -1,6 +1,6 @@ { "name": "@better-auth/expo", - "version": "1.5.9", + "version": "1.4.6-beta.3", "type": "module", "description": "Better Auth integration for Expo and React Native applications.", "main": "dist/index.mjs", diff --git a/packages/passkey/package.json b/packages/passkey/package.json index 8e3774a1577..2f66b835567 100644 --- a/packages/passkey/package.json +++ b/packages/passkey/package.json @@ -1,6 +1,6 @@ { "name": "@better-auth/passkey", - "version": "1.5.9", + "version": "1.4.6-beta.3", "type": "module", "description": "Passkey plugin for Better Auth", "main": "dist/index.mjs", diff --git a/packages/scim/package.json b/packages/scim/package.json index eb49bdb4777..ed488659cf5 100644 --- a/packages/scim/package.json +++ b/packages/scim/package.json @@ -1,7 +1,7 @@ { "name": "@better-auth/scim", "author": "Jonathan Samines", - "version": "1.5.9", + "version": "1.4.6-beta.3", "type": "module", "main": "dist/index.mjs", "types": "dist/index.d.mts", diff --git a/packages/sso/package.json b/packages/sso/package.json index ac5132e2041..1362b392853 100644 --- a/packages/sso/package.json +++ b/packages/sso/package.json @@ -1,7 +1,7 @@ { "name": "@better-auth/sso", "author": "Bereket Engida", - "version": "1.5.9", + "version": "1.4.6-beta.3", "type": "module", "main": "dist/index.mjs", "types": "dist/index.d.mts", diff --git a/packages/stripe/package.json b/packages/stripe/package.json index 5b0b24f0af0..b31686f1708 100644 --- a/packages/stripe/package.json +++ b/packages/stripe/package.json @@ -1,7 +1,7 @@ { "name": "@better-auth/stripe", "author": "Bereket Engida", - "version": "1.5.9", + "version": "1.4.6-beta.3", "type": "module", "main": "dist/index.mjs", "types": "dist/index.d.mts", diff --git a/packages/telemetry/package.json b/packages/telemetry/package.json index 29555ffd80b..2a91a4eda7d 100644 --- a/packages/telemetry/package.json +++ b/packages/telemetry/package.json @@ -1,6 +1,6 @@ { "name": "@better-auth/telemetry", - "version": "1.5.9", + "version": "1.4.6-beta.3", "description": "Telemetry package for Better Auth", "type": "module", "repository": { From e3155e47d4d46762847993853c9fa1ccf4ffd04b Mon Sep 17 00:00:00 2001 From: viktormarinho Date: Tue, 9 Dec 2025 12:09:21 -0300 Subject: [PATCH 43/56] undo fork renaming --- demo/expo/package.json | 2 +- demo/nextjs/package.json | 2 +- demo/oidc-client/package.json | 2 +- demo/stateless/package.json | 2 +- e2e/integration/package.json | 2 +- e2e/integration/solid-vinxi/package.json | 2 +- e2e/integration/vanilla-node/package.json | 2 +- e2e/smoke/package.json | 2 +- .../test/fixtures/cloudflare/package.json | 2 +- .../tsconfig-declaration/package.json | 2 +- .../package.json | 2 +- .../package.json | 2 +- .../package.json | 2 +- e2e/smoke/test/fixtures/vite/package.json | 2 +- packages/better-auth/package.json | 4 +- packages/cli/package.json | 2 +- packages/expo/package.json | 4 +- packages/passkey/package.json | 4 +- packages/scim/package.json | 2 +- packages/sso/package.json | 4 +- packages/stripe/package.json | 4 +- pnpm-lock.yaml | 42 +++++++++---------- test/package.json | 2 +- 23 files changed, 48 insertions(+), 48 deletions(-) diff --git a/demo/expo/package.json b/demo/expo/package.json index 8c92819c39b..ace76d4be40 100644 --- a/demo/expo/package.json +++ b/demo/expo/package.json @@ -24,7 +24,7 @@ "@rn-primitives/types": "^1.1.0", "@types/better-sqlite3": "^7.6.12", "babel-plugin-transform-import-meta": "^2.2.1", - "@decocms/better-auth": "workspace:*", + "better-auth": "workspace:*", "better-sqlite3": "^11.6.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", diff --git a/demo/nextjs/package.json b/demo/nextjs/package.json index 44c88eb1f97..c0fe0185f32 100644 --- a/demo/nextjs/package.json +++ b/demo/nextjs/package.json @@ -50,7 +50,7 @@ "@react-email/components": "^1.0.1", "@tanstack/react-query": "^5.85.9", "@types/better-sqlite3": "^7.6.13", - "@decocms/better-auth": "workspace:*", + "better-auth": "workspace:*", "better-sqlite3": "^12.2.0", "canvas-confetti": "^1.9.3", "class-variance-authority": "^0.7.1", diff --git a/demo/oidc-client/package.json b/demo/oidc-client/package.json index 48370be538e..1d1bbeaee35 100644 --- a/demo/oidc-client/package.json +++ b/demo/oidc-client/package.json @@ -13,7 +13,7 @@ "@radix-ui/react-avatar": "^1.1.10", "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-slot": "^1.2.3", - "@decocms/better-auth": "workspace:*", + "better-auth": "workspace:*", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "geist": "^1.4.2", diff --git a/demo/stateless/package.json b/demo/stateless/package.json index f875fba7458..c12fd802d9f 100644 --- a/demo/stateless/package.json +++ b/demo/stateless/package.json @@ -10,7 +10,7 @@ "typecheck": "tsc --noEmit" }, "dependencies": { - "@decocms/better-auth": "workspace:*", + "better-auth": "workspace:*", "next": "16.0.7", "react": "catalog:react19", "react-dom": "catalog:react19" diff --git a/e2e/integration/package.json b/e2e/integration/package.json index 1741f79e017..2b71b5ca97c 100644 --- a/e2e/integration/package.json +++ b/e2e/integration/package.json @@ -4,7 +4,7 @@ "e2e:integration": "playwright test" }, "dependencies": { - "@decocms/better-auth": "workspace:*" + "better-auth": "workspace:*" }, "devDependencies": { "@playwright/test": "^1.56.1" diff --git a/e2e/integration/solid-vinxi/package.json b/e2e/integration/solid-vinxi/package.json index 4a4ee86bed8..375a968b139 100644 --- a/e2e/integration/solid-vinxi/package.json +++ b/e2e/integration/solid-vinxi/package.json @@ -10,7 +10,7 @@ "dependencies": { "@solidjs/router": "^0.15.3", "@solidjs/start": "^1.1.7", - "@decocms/better-auth": "workspace:*", + "better-auth": "workspace:*", "better-sqlite3": "^12.2.0", "solid-js": "^1.9.7", "vinxi": "^0.5.8" diff --git a/e2e/integration/vanilla-node/package.json b/e2e/integration/vanilla-node/package.json index ca9533321f4..8cbaf8c381a 100644 --- a/e2e/integration/vanilla-node/package.json +++ b/e2e/integration/vanilla-node/package.json @@ -10,7 +10,7 @@ "vite": "^7.2.4" }, "dependencies": { - "@decocms/better-auth": "workspace:*", + "better-auth": "workspace:*", "better-sqlite3": "^12.2.0", "kysely": "^0.28.5", "kysely-postgres-js": "^3.0.0", diff --git a/e2e/smoke/package.json b/e2e/smoke/package.json index a73201ec26e..dce488e17e3 100644 --- a/e2e/smoke/package.json +++ b/e2e/smoke/package.json @@ -2,7 +2,7 @@ "name": "smoke", "type": "module", "dependencies": { - "@decocms/better-auth": "workspace:*" + "better-auth": "workspace:*" }, "scripts": { "e2e:smoke": "node --test ./test/*.spec.ts" diff --git a/e2e/smoke/test/fixtures/cloudflare/package.json b/e2e/smoke/test/fixtures/cloudflare/package.json index 60cbd4eaf62..31091d8940e 100644 --- a/e2e/smoke/test/fixtures/cloudflare/package.json +++ b/e2e/smoke/test/fixtures/cloudflare/package.json @@ -2,7 +2,7 @@ "name": "cloudflare", "private": true, "dependencies": { - "@decocms/better-auth": "workspace:*", + "better-auth": "workspace:*", "drizzle-orm": "^0.44.5", "hono": "^4.9.7" }, diff --git a/e2e/smoke/test/fixtures/tsconfig-declaration/package.json b/e2e/smoke/test/fixtures/tsconfig-declaration/package.json index d41ac494d54..c6b14d1b5c6 100644 --- a/e2e/smoke/test/fixtures/tsconfig-declaration/package.json +++ b/e2e/smoke/test/fixtures/tsconfig-declaration/package.json @@ -9,7 +9,7 @@ "@better-auth/sso": "workspace:*", "@better-auth/stripe": "workspace:*", "@better-auth/passkey": "workspace:*", - "@decocms/better-auth": "workspace:*", + "better-auth": "workspace:*", "stripe": "^20.0.0" } } diff --git a/e2e/smoke/test/fixtures/tsconfig-exact-optional-property-types/package.json b/e2e/smoke/test/fixtures/tsconfig-exact-optional-property-types/package.json index 254a7592d5a..03a0e0b8b82 100644 --- a/e2e/smoke/test/fixtures/tsconfig-exact-optional-property-types/package.json +++ b/e2e/smoke/test/fixtures/tsconfig-exact-optional-property-types/package.json @@ -6,7 +6,7 @@ "typecheck": "tsc --project tsconfig.json" }, "dependencies": { - "@decocms/better-auth": "workspace:*", + "better-auth": "workspace:*", "@better-auth/sso": "workspace:*" } } diff --git a/e2e/smoke/test/fixtures/tsconfig-isolated-module-bundler/package.json b/e2e/smoke/test/fixtures/tsconfig-isolated-module-bundler/package.json index 5f7abd4e35c..a246350bfcd 100644 --- a/e2e/smoke/test/fixtures/tsconfig-isolated-module-bundler/package.json +++ b/e2e/smoke/test/fixtures/tsconfig-isolated-module-bundler/package.json @@ -6,7 +6,7 @@ "typecheck": "tsc --project tsconfig.json" }, "dependencies": { - "@decocms/better-auth": "workspace:*", + "better-auth": "workspace:*", "better-auth-harmony": "^1.2.5" } } diff --git a/e2e/smoke/test/fixtures/tsconfig-verbatim-module-syntax-node10/package.json b/e2e/smoke/test/fixtures/tsconfig-verbatim-module-syntax-node10/package.json index 94c2d820473..dd9415d4b8d 100644 --- a/e2e/smoke/test/fixtures/tsconfig-verbatim-module-syntax-node10/package.json +++ b/e2e/smoke/test/fixtures/tsconfig-verbatim-module-syntax-node10/package.json @@ -7,6 +7,6 @@ }, "dependencies": { "@better-auth/expo": "workspace:*", - "@decocms/better-auth": "workspace:*" + "better-auth": "workspace:*" } } diff --git a/e2e/smoke/test/fixtures/vite/package.json b/e2e/smoke/test/fixtures/vite/package.json index 125e017a02b..45d37b9f4d2 100644 --- a/e2e/smoke/test/fixtures/vite/package.json +++ b/e2e/smoke/test/fixtures/vite/package.json @@ -5,7 +5,7 @@ "build": "vite build" }, "dependencies": { - "@decocms/better-auth": "workspace:*" + "better-auth": "workspace:*" }, "devDependencies": { "vite": "^7.2.4" diff --git a/packages/better-auth/package.json b/packages/better-auth/package.json index eb881d41e99..f4b7660bd62 100644 --- a/packages/better-auth/package.json +++ b/packages/better-auth/package.json @@ -1,6 +1,6 @@ { - "name": "@decocms/better-auth", - "version": "1.5.9", + "name": "better-auth", + "version": "1.4.6-beta.3", "description": "The most comprehensive authentication framework for TypeScript.", "type": "module", "license": "MIT", diff --git a/packages/cli/package.json b/packages/cli/package.json index 452543a9fb8..40335432c73 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -58,7 +58,7 @@ "@mrleebo/prisma-ast": "^0.13.0", "@prisma/client": "^5.22.0", "@types/pg": "^8.15.5", - "@decocms/better-auth": "workspace:^", + "better-auth": "workspace:^", "better-sqlite3": "^12.2.0", "c12": "^3.2.0", "chalk": "^5.6.2", diff --git a/packages/expo/package.json b/packages/expo/package.json index 618d825b093..69edb1aa79f 100644 --- a/packages/expo/package.json +++ b/packages/expo/package.json @@ -56,7 +56,7 @@ "@better-auth/core": "workspace:*", "@better-fetch/fetch": "catalog:", "@types/better-sqlite3": "^7.6.13", - "@decocms/better-auth": "workspace:*", + "better-auth": "workspace:*", "better-sqlite3": "^12.2.0", "expo-constants": "~17.1.7", "expo-network": "^8.0.7", @@ -66,7 +66,7 @@ "tsdown": "catalog:" }, "peerDependencies": { - "@decocms/better-auth": "workspace:*", + "better-auth": "workspace:*", "@better-auth/core": "workspace:*", "expo-constants": ">=17.0.0", "expo-linking": ">=7.0.0", diff --git a/packages/passkey/package.json b/packages/passkey/package.json index 2f66b835567..3845ff89f70 100644 --- a/packages/passkey/package.json +++ b/packages/passkey/package.json @@ -40,7 +40,7 @@ }, "devDependencies": { "@better-auth/core": "workspace:*", - "@decocms/better-auth": "workspace:*", + "better-auth": "workspace:*", "tsdown": "catalog:" }, "dependencies": { @@ -52,7 +52,7 @@ "@better-auth/core": "workspace:*", "@better-auth/utils": "0.3.0", "@better-fetch/fetch": "catalog:", - "@decocms/better-auth": "workspace:*", + "better-auth": "workspace:*", "better-call": "catalog:", "nanostores": "^1.0.1" }, diff --git a/packages/scim/package.json b/packages/scim/package.json index ed488659cf5..23e0f8c5aaf 100644 --- a/packages/scim/package.json +++ b/packages/scim/package.json @@ -59,6 +59,6 @@ "tsdown": "catalog:" }, "peerDependencies": { - "@decocms/better-auth": "workspace:*" + "better-auth": "workspace:*" } } diff --git a/packages/sso/package.json b/packages/sso/package.json index 1362b392853..b752e42d716 100644 --- a/packages/sso/package.json +++ b/packages/sso/package.json @@ -69,13 +69,13 @@ "@types/body-parser": "^1.19.6", "@types/express": "^5.0.5", "better-call": "catalog:", - "@decocms/better-auth": "workspace:*", + "better-auth": "workspace:*", "body-parser": "^2.2.1", "express": "^5.1.0", "oauth2-mock-server": "^8.2.0", "tsdown": "catalog:" }, "peerDependencies": { - "@decocms/better-auth": "workspace:*" + "better-auth": "workspace:*" } } diff --git a/packages/stripe/package.json b/packages/stripe/package.json index b31686f1708..f19b924ac0e 100644 --- a/packages/stripe/package.json +++ b/packages/stripe/package.json @@ -57,12 +57,12 @@ }, "peerDependencies": { "@better-auth/core": "workspace:*", - "@decocms/better-auth": "workspace:*", + "better-auth": "workspace:*", "stripe": "^18 || ^19 || ^20" }, "devDependencies": { "@better-auth/core": "workspace:*", - "@decocms/better-auth": "workspace:*", + "better-auth": "workspace:*", "better-call": "catalog:", "stripe": "^20.0.0", "tsdown": "catalog:" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 205d1ceef54..f89139ce427 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -78,7 +78,7 @@ importers: '@better-auth/expo': specifier: workspace:* version: link:../../packages/expo - '@decocms/better-auth': + 'better-auth': specifier: workspace:* version: link:../../packages/better-auth '@expo/metro-runtime': @@ -232,7 +232,7 @@ importers: '@better-auth/stripe': specifier: workspace:* version: link:../../packages/stripe - '@decocms/better-auth': + 'better-auth': specifier: workspace:* version: link:../../packages/better-auth '@hookform/resolvers': @@ -461,7 +461,7 @@ importers: demo/oidc-client: dependencies: - '@decocms/better-auth': + 'better-auth': specifier: workspace:* version: link:../../packages/better-auth '@radix-ui/react-avatar': @@ -531,7 +531,7 @@ importers: demo/stateless: dependencies: - '@decocms/better-auth': + 'better-auth': specifier: workspace:* version: link:../../packages/better-auth next: @@ -860,7 +860,7 @@ importers: e2e/integration: dependencies: - '@decocms/better-auth': + 'better-auth': specifier: workspace:* version: link:../../packages/better-auth devDependencies: @@ -870,7 +870,7 @@ importers: e2e/integration/solid-vinxi: dependencies: - '@decocms/better-auth': + 'better-auth': specifier: workspace:* version: link:../../../packages/better-auth '@solidjs/router': @@ -901,7 +901,7 @@ importers: e2e/integration/vanilla-node: dependencies: - '@decocms/better-auth': + 'better-auth': specifier: workspace:* version: link:../../../packages/better-auth better-sqlite3: @@ -926,13 +926,13 @@ importers: e2e/smoke: dependencies: - '@decocms/better-auth': + 'better-auth': specifier: workspace:* version: link:../../packages/better-auth e2e/smoke/test/fixtures/cloudflare: dependencies: - '@decocms/better-auth': + 'better-auth': specifier: workspace:* version: link:../../../../../packages/better-auth drizzle-orm: @@ -966,7 +966,7 @@ importers: '@better-auth/stripe': specifier: workspace:* version: link:../../../../../packages/stripe - '@decocms/better-auth': + 'better-auth': specifier: workspace:* version: link:../../../../../packages/better-auth stripe: @@ -978,13 +978,13 @@ importers: '@better-auth/sso': specifier: workspace:* version: link:../../../../../packages/sso - '@decocms/better-auth': + 'better-auth': specifier: workspace:* version: link:../../../../../packages/better-auth e2e/smoke/test/fixtures/tsconfig-isolated-module-bundler: dependencies: - '@decocms/better-auth': + 'better-auth': specifier: workspace:* version: link:../../../../../packages/better-auth better-auth-harmony: @@ -996,13 +996,13 @@ importers: '@better-auth/expo': specifier: workspace:* version: link:../../../../../packages/expo - '@decocms/better-auth': + 'better-auth': specifier: workspace:* version: link:../../../../../packages/better-auth e2e/smoke/test/fixtures/vite: dependencies: - '@decocms/better-auth': + 'better-auth': specifier: workspace:* version: link:../../../../../packages/better-auth devDependencies: @@ -1169,7 +1169,7 @@ importers: '@clack/prompts': specifier: ^0.11.0 version: 0.11.0 - '@decocms/better-auth': + 'better-auth': specifier: workspace:^ version: link:../better-auth '@mrleebo/prisma-ast': @@ -1295,7 +1295,7 @@ importers: '@better-auth/core': specifier: workspace:* version: link:../core - '@decocms/better-auth': + 'better-auth': specifier: workspace:* version: link:../better-auth '@types/better-sqlite3': @@ -1350,7 +1350,7 @@ importers: '@better-auth/core': specifier: workspace:* version: link:../core - '@decocms/better-auth': + 'better-auth': specifier: workspace:* version: link:../better-auth tsdown: @@ -1362,7 +1362,7 @@ importers: '@better-auth/utils': specifier: 0.3.0 version: 0.3.0 - '@decocms/better-auth': + 'better-auth': specifier: workspace:* version: link:../better-auth better-call: @@ -1397,7 +1397,7 @@ importers: specifier: ^4.1.12 version: 4.1.13 devDependencies: - '@decocms/better-auth': + 'better-auth': specifier: workspace:* version: link:../better-auth '@types/body-parser': @@ -1434,7 +1434,7 @@ importers: '@better-auth/core': specifier: workspace:* version: link:../core - '@decocms/better-auth': + 'better-auth': specifier: workspace:* version: link:../better-auth better-call: @@ -1474,7 +1474,7 @@ importers: '@better-fetch/fetch': specifier: 'catalog:' version: 1.1.18 - '@decocms/better-auth': + 'better-auth': specifier: workspace:* version: link:../packages/better-auth msw: diff --git a/test/package.json b/test/package.json index 0e9af187a0e..6d445e6e57c 100644 --- a/test/package.json +++ b/test/package.json @@ -9,7 +9,7 @@ "devDependencies": { "@better-auth/core": "workspace:*", "@better-fetch/fetch": "catalog:", - "@decocms/better-auth": "workspace:*", + "better-auth": "workspace:*", "msw": "^2.12.4", "openid-client": "^6.8.1", "vitest": "catalog:" From 6095296ca7957546301b62c0135fd880b4572b16 Mon Sep 17 00:00:00 2001 From: viktormarinho Date: Tue, 9 Dec 2025 12:09:49 -0300 Subject: [PATCH 44/56] undo version pinning --- packages/better-auth/package.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/better-auth/package.json b/packages/better-auth/package.json index f4b7660bd62..ce850a113e2 100644 --- a/packages/better-auth/package.json +++ b/packages/better-auth/package.json @@ -443,8 +443,8 @@ } }, "dependencies": { - "@better-auth/core": "1.4.6-beta.3", - "@better-auth/telemetry": "1.4.6-beta.3", + "@better-auth/core": "workspace:*", + "@better-auth/telemetry": "workspace:*", "@better-auth/utils": "0.3.0", "@better-fetch/fetch": "catalog:", "@noble/ciphers": "^2.0.0", From dd5fc871e76fed366603cfb4b170085bccc3ef29 Mon Sep 17 00:00:00 2001 From: viktormarinho Date: Tue, 9 Dec 2025 13:21:48 -0300 Subject: [PATCH 45/56] Implement auto-expansion of permissions for custom resources in dynamic access control --- .../routes/crud-access-control.ts | 108 ++++++++++++++++-- 1 file changed, 97 insertions(+), 11 deletions(-) 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 145971dc80b..6fda0439efa 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 @@ -1268,6 +1268,10 @@ async function checkForInvalidResources({ }); } else { // All resources exist, validate permissions + // If custom resources are enabled, auto-expand permissions for custom resources + const defaultStatements = ac.statements; + const needsUpdate: Array<{ resource: string; newPermissions: string[] }> = []; + for (const [resource, permissions] of Object.entries(permission)) { const validPermissions = orgStatements[resource as keyof Statements]; if (!validPermissions) continue; @@ -1275,20 +1279,102 @@ async function checkForInvalidResources({ const invalidPermissions = permissions.filter( (p) => !validPermissions.includes(p), ); + if (invalidPermissions.length > 0) { - ctx.context.logger.error( - `[Dynamic Access Control] The provided permissions include invalid actions for resource "${resource}".`, - { - resource, - invalidPermissions, - validPermissions, - }, - ); - throw new APIError("BAD_REQUEST", { - message: `Invalid permissions for resource "${resource}": ${invalidPermissions.join(", ")}`, - }); + // Check if this is a custom resource (not a default one) + const isCustomResource = !defaultStatements[resource as keyof Statements]; + + if (isCustomResource && options.dynamicAccessControl?.enableCustomResources) { + // Auto-expand the custom resource with new permissions + const existingResource = await ctx.context.adapter.findOne({ + model: "organizationResource", + where: [ + { + field: "organizationId", + value: organizationId, + operator: "eq", + connector: "AND", + }, + { + field: "resource", + value: resource, + operator: "eq", + connector: "AND", + }, + ], + }); + + if (existingResource) { + // Parse existing permissions + const existingPermissions: string[] = JSON.parse( + existingResource.permissions as never as string + ); + + // Merge with new permissions + const mergedPermissions = Array.from( + new Set([...existingPermissions, ...invalidPermissions]) + ); + + // Update the resource + await ctx.context.adapter.update< + Omit & { permissions: string } + >({ + model: "organizationResource", + where: [ + { + field: "organizationId", + value: organizationId, + operator: "eq", + connector: "AND", + }, + { + field: "resource", + value: resource, + 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 "${resource}"`, + { + resource, + organizationId, + oldPermissions: existingPermissions, + newPermissions: invalidPermissions, + mergedPermissions, + }, + ); + + needsUpdate.push({ resource, newPermissions: mergedPermissions }); + } + } else { + // Not a custom resource or custom resources disabled - throw error + 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(", ")}`, + }); + } } } + + // Invalidate cache if any resources were updated + if (needsUpdate.length > 0) { + invalidateResourceCache(organizationId); + } } // Return empty array if no resources were auto-created From 72a41b5941730453da07f1c155b814d155af3e25 Mon Sep 17 00:00:00 2001 From: viktormarinho Date: Tue, 9 Dec 2025 13:27:24 -0300 Subject: [PATCH 46/56] format lint etc and refactor canCreateRole --- docs/content/docs/plugins/organization.mdx | 30 ++- .../organization-custom-resources.test.ts | 24 +- .../src/plugins/organization/error-codes.ts | 18 +- .../plugins/organization/has-permission.ts | 6 +- .../organization/load-resources.test.ts | 14 +- .../plugins/organization/load-resources.ts | 24 +- .../src/plugins/organization/organization.ts | 17 +- .../routes/crud-access-control.ts | 227 ++++++++++-------- .../organization/routes/crud-resources.ts | 38 +-- .../src/plugins/organization/schema.ts | 16 +- .../src/plugins/organization/types.ts | 55 +++-- pnpm-lock.yaml | 225 ++++------------- 12 files changed, 320 insertions(+), 374 deletions(-) diff --git a/docs/content/docs/plugins/organization.mdx b/docs/content/docs/plugins/organization.mdx index 7eca7317774..a378db25d10 100644 --- a/docs/content/docs/plugins/organization.mdx +++ b/docs/content/docs/plugins/organization.mdx @@ -2155,31 +2155,39 @@ organization({ dynamicAccessControl: { enabled: true, canCreateRole: async ({ member, organizationId, permission, roleName }) => { - // Example: Users with "*" wildcard permission can do anything - const memberPermissions = JSON.parse(member.permission || "{}"); - if (memberPermissions["*"]?.includes("*")) { - return "yes"; // Allow without further checks + // 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 { - allowed: false, + allow: false, message: "Only admins can create roles with more than 5 resources" }; } - // Use default permission logic (check ac:create) - return "default"; + // 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 can return: -- `"yes"` - Allow the action (skip default permission checks) -- `"default"` - Use the default permission logic (check for `ac: ["create"]`) -- `{ allowed: false, message: string }` - Deny with a custom error message +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 diff --git a/e2e/smoke/test/organization-custom-resources.test.ts b/e2e/smoke/test/organization-custom-resources.test.ts index 9943d855282..ee41f1eae8a 100644 --- a/e2e/smoke/test/organization-custom-resources.test.ts +++ b/e2e/smoke/test/organization-custom-resources.test.ts @@ -1,10 +1,15 @@ -import { getTestInstance } from "../../../packages/better-auth/src/test-utils/test-instance"; +import { beforeAll, describe, expect, it } from "vitest"; import { createAuthClient } from "../../../packages/better-auth/src/client"; -import { organizationClient } from "../../../packages/better-auth/src/plugins/organization/client"; -import { organization } from "../../../packages/better-auth/src/plugins/organization"; import { createAccessControl } from "../../../packages/better-auth/src/plugins/access"; -import { defaultStatements, ownerAc, adminAc, memberAc } from "../../../packages/better-auth/src/plugins/organization/access"; -import { describe, expect, it, beforeAll } from "vitest"; +import { organization } from "../../../packages/better-auth/src/plugins/organization"; +import { + adminAc, + defaultStatements, + memberAc, + ownerAc, +} from "../../../packages/better-auth/src/plugins/organization/access"; +import { organizationClient } from "../../../packages/better-auth/src/plugins/organization/client"; +import { getTestInstance } from "../../../packages/better-auth/src/test-utils/test-instance"; describe("organization custom resources integration", async () => { const ac = createAccessControl({ @@ -317,7 +322,13 @@ describe("organization custom resources integration", async () => { }); it("should reject reserved resource names", async () => { - const reservedNames = ["organization", "member", "invitation", "team", "ac"]; + const reservedNames = [ + "organization", + "member", + "invitation", + "team", + "ac", + ]; for (const name of reservedNames) { const result = await authClient.organization.createOrgResource( @@ -441,4 +452,3 @@ describe("organization custom resources integration", async () => { expect(result.error?.message).toContain("not allowed"); }); }); - diff --git a/packages/better-auth/src/plugins/organization/error-codes.ts b/packages/better-auth/src/plugins/organization/error-codes.ts index ced1fe22e1e..bca274b1ab9 100644 --- a/packages/better-auth/src/plugins/organization/error-codes.ts +++ b/packages/better-auth/src/plugins/organization/error-codes.ts @@ -88,16 +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", + 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", + 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 559a0947103..da4cd96bd92 100644 --- a/packages/better-auth/src/plugins/organization/has-permission.ts +++ b/packages/better-auth/src/plugins/organization/has-permission.ts @@ -35,7 +35,11 @@ export const hasPermission = async ( ) { // Get the organization-specific AC instance (with merged default + custom resources) const orgAc = input.options.dynamicAccessControl?.enableCustomResources - ? await getOrganizationAccessControl(input.organizationId, input.options, ctx) + ? await getOrganizationAccessControl( + input.organizationId, + input.options, + ctx, + ) : input.options.ac; // Load roles from database diff --git a/packages/better-auth/src/plugins/organization/load-resources.test.ts b/packages/better-auth/src/plugins/organization/load-resources.test.ts index 92b9e257e57..60c56ae9954 100644 --- a/packages/better-auth/src/plugins/organization/load-resources.test.ts +++ b/packages/better-auth/src/plugins/organization/load-resources.test.ts @@ -1,10 +1,10 @@ -import { describe, expect, it, beforeEach } from "vitest"; +import { beforeEach, describe, expect, it } from "vitest"; import { - validateResourceName, - getReservedResourceNames, + clearAllResourceCache, getDefaultReservedResourceNames, + getReservedResourceNames, invalidateResourceCache, - clearAllResourceCache, + validateResourceName, } from "./load-resources"; import type { OrganizationOptions } from "./types"; @@ -166,7 +166,10 @@ describe("load-resources utility functions", () => { const result1 = validateResourceName("short", options); expect(result1.valid).toBe(true); - const result2 = validateResourceName("verylongnamethatexceedstwentycharacters", options); + const result2 = validateResourceName( + "verylongnamethatexceedstwentycharacters", + options, + ); expect(result2.valid).toBe(false); expect(result2.error).toContain("custom validation"); }); @@ -211,4 +214,3 @@ describe("load-resources utility functions", () => { }); }); }); - diff --git a/packages/better-auth/src/plugins/organization/load-resources.ts b/packages/better-auth/src/plugins/organization/load-resources.ts index 1222a694d05..400fbf2061e 100644 --- a/packages/better-auth/src/plugins/organization/load-resources.ts +++ b/packages/better-auth/src/plugins/organization/load-resources.ts @@ -1,7 +1,8 @@ import type { GenericEndpointContext } from "@better-auth/core"; import * as z from "zod"; import { APIError } from "../../api"; -import { createAccessControl, type AccessControl, type Statements } from "../access"; +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"; @@ -46,11 +47,14 @@ export async function loadCustomResources( const statements: Record = {}; for (const resource of resources) { - const result = z.array(z.string()).safeParse(JSON.parse(resource.permissions)); + const result = z + .array(z.string()) + .safeParse(JSON.parse(resource.permissions)); if (!result.success) { ctx.context.logger.error( - "[loadCustomResources] Invalid permissions for resource " + resource.resource, + "[loadCustomResources] Invalid permissions for resource " + + resource.resource, { permissions: JSON.parse(resource.permissions), }, @@ -112,7 +116,11 @@ export async function getOrganizationAccessControl( options: OrganizationOptions, ctx: GenericEndpointContext, ): Promise { - const statements = await getOrganizationStatements(organizationId, options, ctx); + const statements = await getOrganizationStatements( + organizationId, + options, + ctx, + ); return createAccessControl(statements); } @@ -140,7 +148,9 @@ export function getDefaultReservedResourceNames(): string[] { /** * Get reserved resource names from config or defaults */ -export function getReservedResourceNames(options: OrganizationOptions): string[] { +export function getReservedResourceNames( + options: OrganizationOptions, +): string[] { return ( options.dynamicAccessControl?.reservedResourceNames || getDefaultReservedResourceNames() @@ -185,7 +195,8 @@ export function validateResourceName( // Custom validation if provided if (options.dynamicAccessControl?.resourceNameValidation) { - const customResult = options.dynamicAccessControl.resourceNameValidation(name); + const customResult = + options.dynamicAccessControl.resourceNameValidation(name); if (typeof customResult === "boolean") { return customResult ? { valid: true } @@ -196,4 +207,3 @@ export function validateResourceName( return { valid: true }; } - diff --git a/packages/better-auth/src/plugins/organization/organization.ts b/packages/better-auth/src/plugins/organization/organization.ts index db3d24de0eb..e4d77e42bf4 100644 --- a/packages/better-auth/src/plugins/organization/organization.ts +++ b/packages/better-auth/src/plugins/organization/organization.ts @@ -19,13 +19,6 @@ import { listOrgRoles, updateOrgRole, } from "./routes/crud-access-control"; -import { - createOrgResource, - deleteOrgResource, - getOrgResource, - listOrgResources, - updateOrgResource, -} from "./routes/crud-resources"; import { acceptInvitation, cancelInvitation, @@ -53,6 +46,13 @@ import { setActiveOrganization, updateOrganization, } from "./routes/crud-org"; +import { + createOrgResource, + deleteOrgResource, + getOrgResource, + listOrgResources, + updateOrgResource, +} from "./routes/crud-resources"; import { addTeamMember, createTeam, @@ -1077,7 +1077,8 @@ export function organization( resource: { type: "string", required: true, - fieldName: options?.schema?.organizationResource?.fields?.resource, + fieldName: + options?.schema?.organizationResource?.fields?.resource, index: true, }, permissions: { 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 6fda0439efa..f09f664daeb 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 @@ -10,7 +10,12 @@ import type { AccessControl, Statements } from "../../access"; import { orgSessionMiddleware } from "../call"; import { ORGANIZATION_ERROR_CODES } from "../error-codes"; import { hasPermission } from "../has-permission"; -import { getOrganizationStatements, getReservedResourceNames, invalidateResourceCache, validateResourceName } from "../load-resources"; +import { + getOrganizationStatements, + getReservedResourceNames, + invalidateResourceCache, + validateResourceName, +} from "../load-resources"; import type { Member, OrganizationResource, OrganizationRole } from "../schema"; import type { OrganizationOptions } from "../types"; @@ -174,13 +179,11 @@ export const createOrgRole = (options: O) => { }); } - // Check if user can create role using custom callback or default logic - let canCreateRole = false; - let customDenialMessage: string | undefined; - let skipDelegationCheck = false; // Track if we should skip delegation check + // Check custom hook if provided + let bypassDefaultChecks = false; if (options.dynamicAccessControl?.canCreateRole) { - const callbackResult = await options.dynamicAccessControl.canCreateRole({ + const result = await options.dynamicAccessControl.canCreateRole({ organizationId, userId: user.id, member, @@ -188,37 +191,37 @@ export const createOrgRole = (options: O) => { roleName, }); - if (callbackResult === "yes") { - canCreateRole = true; - skipDelegationCheck = true; // Skip delegation check when callback says "yes" - ctx.context.logger.info( - `[Dynamic Access Control] Custom canCreateRole callback allowed role creation for user ${user.id}, skipping delegation check`, - { - userId: user.id, - organizationId, - roleName, - }, - ); - } else if (callbackResult === "default") { - // Fall through to default logic below - } else { - // callbackResult is { allowed: false, message: string } - canCreateRole = false; - customDenialMessage = callbackResult.message; + // Handle denial + if (!result.allow) { ctx.context.logger.error( - `[Dynamic Access Control] Custom canCreateRole callback denied role creation: ${callbackResult.message}`, + `[Dynamic Access Control] Custom canCreateRole callback denied role creation`, { userId: user.id, organizationId, roleName, + reason: result.message, }, ); + throw new APIError("FORBIDDEN", { + message: + result.message || + ORGANIZATION_ERROR_CODES.YOU_ARE_NOT_ALLOWED_TO_CREATE_A_ROLE, + }); + } + + // Handle bypass + if (result.bypass) { + bypassDefaultChecks = true; + ctx.context.logger.info( + `[Dynamic Access Control] Bypassing default permission and delegation checks for role creation`, + { userId: user.id, organizationId, roleName }, + ); } } - // If callback returned "default" or doesn't exist, use default permission check - if (!canCreateRole && !customDenialMessage) { - canCreateRole = await hasPermission( + // Perform default checks unless explicitly bypassed + if (!bypassDefaultChecks) { + const canCreateRole = await hasPermission( { options, organizationId, @@ -229,22 +232,21 @@ export const createOrgRole = (options: O) => { }, ctx, ); - } - if (!canCreateRole) { - ctx.context.logger.error( - customDenialMessage || - `[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.`, - { - userId: user.id, - organizationId, - role: member.role, - }, - ); - throw new APIError("FORBIDDEN", { - message: customDenialMessage || - ORGANIZATION_ERROR_CODES.YOU_ARE_NOT_ALLOWED_TO_CREATE_A_ROLE, - }); + 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.`, + { + userId: user.id, + organizationId, + role: member.role, + }, + ); + throw new APIError("FORBIDDEN", { + message: + ORGANIZATION_ERROR_CODES.YOU_ARE_NOT_ALLOWED_TO_CREATE_A_ROLE, + }); + } } const maximumRolesPerOrganization = @@ -280,10 +282,16 @@ export const createOrgRole = (options: O) => { }); } - const autoCreatedResources = await checkForInvalidResources({ ac, ctx, permission, organizationId, options }); + const autoCreatedResources = await checkForInvalidResources({ + ac, + ctx, + permission, + organizationId, + options, + }); - // Only perform delegation check if not skipped by custom callback - if (!skipDelegationCheck) { + // Perform delegation check unless bypassed + if (!bypassDefaultChecks) { await checkIfMemberHasPermission({ ctx, member, @@ -294,15 +302,6 @@ export const createOrgRole = (options: O) => { action: "create", skipResourcesForDelegationCheck: autoCreatedResources, }); - } else { - ctx.context.logger.info( - `[Dynamic Access Control] Skipping delegation check for role creation due to custom canCreateRole callback`, - { - userId: user.id, - organizationId, - roleName, - }, - ); } await checkIfRoleNameIsTakenByRoleInDB({ @@ -1038,9 +1037,9 @@ export const updateOrgRole = (options: O) => { if (ctx.body.data.permission) { let newPermission = ctx.body.data.permission; - const autoCreatedResources = await checkForInvalidResources({ - ac, - ctx, + const autoCreatedResources = await checkForInvalidResources({ + ac, + ctx, permission: newPermission, organizationId, options, @@ -1129,10 +1128,14 @@ async function checkForInvalidResources({ options: OrganizationOptions; }): Promise { // Get organization-specific statements (merged default + custom) - const orgStatements = await getOrganizationStatements(organizationId, options, ctx); + const orgStatements = await getOrganizationStatements( + organizationId, + options, + ctx, + ); const validResources = Object.keys(orgStatements); const providedResources = Object.keys(permission); - + // Find resources that don't exist yet const missingResources = providedResources.filter( (r) => !validResources.includes(r), @@ -1140,9 +1143,12 @@ async function checkForInvalidResources({ const autoCreatedResources: string[] = []; // If custom resources are enabled, auto-create missing resources - if (missingResources.length > 0 && options.dynamicAccessControl?.enableCustomResources) { + if ( + missingResources.length > 0 && + options.dynamicAccessControl?.enableCustomResources + ) { const reservedNames = getReservedResourceNames(options); - + for (const resourceName of missingResources) { // Validate the resource name const validation = validateResourceName(resourceName, options); @@ -1160,23 +1166,24 @@ async function checkForInvalidResources({ } // Check if resource name already exists (shouldn't happen, but double-check) - 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", - }, - ], - }); + 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) { // Get the permissions for this resource from the permission object @@ -1215,8 +1222,12 @@ async function checkForInvalidResources({ } // Reload statements after creating resources - const updatedStatements = await getOrganizationStatements(organizationId, options, ctx); - + const updatedStatements = await getOrganizationStatements( + organizationId, + options, + ctx, + ); + // Now validate that the provided permissions for each resource are valid for (const [resource, permissions] of Object.entries(permission)) { const validPermissions = updatedStatements[resource as keyof Statements]; @@ -1270,7 +1281,8 @@ async function checkForInvalidResources({ // All resources exist, validate permissions // If custom resources are enabled, auto-expand permissions for custom resources const defaultStatements = ac.statements; - const needsUpdate: Array<{ resource: string; newPermissions: string[] }> = []; + const needsUpdate: Array<{ resource: string; newPermissions: string[] }> = + []; for (const [resource, permissions] of Object.entries(permission)) { const validPermissions = orgStatements[resource as keyof Statements]; @@ -1282,42 +1294,49 @@ async function checkForInvalidResources({ if (invalidPermissions.length > 0) { // Check if this is a custom resource (not a default one) - const isCustomResource = !defaultStatements[resource as keyof Statements]; - - if (isCustomResource && options.dynamicAccessControl?.enableCustomResources) { + const isCustomResource = + !defaultStatements[resource as keyof Statements]; + + if ( + isCustomResource && + options.dynamicAccessControl?.enableCustomResources + ) { // Auto-expand the custom resource with new permissions - const existingResource = await ctx.context.adapter.findOne({ - model: "organizationResource", - where: [ - { - field: "organizationId", - value: organizationId, - operator: "eq", - connector: "AND", - }, - { - field: "resource", - value: resource, - operator: "eq", - connector: "AND", - }, - ], - }); + const existingResource = + await ctx.context.adapter.findOne({ + model: "organizationResource", + where: [ + { + field: "organizationId", + value: organizationId, + operator: "eq", + connector: "AND", + }, + { + field: "resource", + value: resource, + operator: "eq", + connector: "AND", + }, + ], + }); if (existingResource) { // Parse existing permissions const existingPermissions: string[] = JSON.parse( - existingResource.permissions as never as string + existingResource.permissions as never as string, ); // Merge with new permissions const mergedPermissions = Array.from( - new Set([...existingPermissions, ...invalidPermissions]) + new Set([...existingPermissions, ...invalidPermissions]), ); // Update the resource await ctx.context.adapter.update< - Omit & { permissions: string } + Omit & { + permissions: string; + } >({ model: "organizationResource", where: [ diff --git a/packages/better-auth/src/plugins/organization/routes/crud-resources.ts b/packages/better-auth/src/plugins/organization/routes/crud-resources.ts index 1decfaa32ba..28db6e49ad9 100644 --- a/packages/better-auth/src/plugins/organization/routes/crud-resources.ts +++ b/packages/better-auth/src/plugins/organization/routes/crud-resources.ts @@ -1,6 +1,4 @@ -import type { GenericEndpointContext } from "@better-auth/core"; import { createAuthEndpoint } from "@better-auth/core/api"; -import type { Where } from "@better-auth/core/db/adapter"; import * as z from "zod"; import { APIError } from "../../../api"; import type { InferAdditionalFieldsFromPluginOptions } from "../../../db"; @@ -45,9 +43,7 @@ const getAdditionalFields = < isClientSide: true, }); type AdditionalFields = AllPartial extends true - ? Partial< - InferAdditionalFieldsFromPluginOptions<"organizationResource", O> - > + ? Partial> : InferAdditionalFieldsFromPluginOptions<"organizationResource", O>; type ReturnAdditionalFields = InferAdditionalFieldsFromPluginOptions< "organizationResource", @@ -75,7 +71,9 @@ const baseCreateResourceSchema = z.object({ }), }); -export const createOrgResource = (options: O) => { +export const createOrgResource = ( + options: O, +) => { const { additionalFieldsSchema, $AdditionalFields, $ReturnAdditionalFields } = getAdditionalFields(options, false); type AdditionalFields = typeof $AdditionalFields; @@ -195,7 +193,8 @@ export const createOrgResource = (options: O) => }, ); throw new APIError("BAD_REQUEST", { - message: validation.error || ORGANIZATION_ERROR_CODES.INVALID_RESOURCE_NAME, + message: + validation.error || ORGANIZATION_ERROR_CODES.INVALID_RESOURCE_NAME, }); } @@ -759,7 +758,8 @@ export const listOrgResources = (options: O) => { async (ctx) => { const { session, user } = ctx.context.session; - const organizationId = ctx.query?.organizationId ?? session.activeOrganizationId; + 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.`, @@ -857,10 +857,11 @@ export const listOrgResources = (options: O) => { permissions: JSON.parse(r.permissions) as string[], isCustom: true, isProtected: false, - })) as (OrganizationResource & ReturnAdditionalFields & { - isCustom: boolean; - isProtected: boolean; - })[]; + })) as (OrganizationResource & + ReturnAdditionalFields & { + isCustom: boolean; + isProtected: boolean; + })[]; return ctx.json({ resources: [...defaultResourceList, ...customResourceList], @@ -902,7 +903,8 @@ export const getOrgResource = (options: O) => { async (ctx) => { const { session, user } = ctx.context.session; - const organizationId = ctx.query.organizationId ?? session.activeOrganizationId; + 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.`, @@ -1029,12 +1031,12 @@ export const getOrgResource = (options: O) => { ) as string[], isCustom: true, isProtected: false, - } as OrganizationResource & ReturnAdditionalFields & { - isCustom: boolean; - isProtected: boolean; - }, + } 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 3ff76218edb..3482069c845 100644 --- a/packages/better-auth/src/plugins/organization/schema.ts +++ b/packages/better-auth/src/plugins/organization/schema.ts @@ -244,14 +244,14 @@ export type OrganizationSchema = >; } : {}) & { - session: { - fields: InferSchema< - O["schema"] extends BetterAuthPluginDBSchema ? O["schema"] : {}, - "session", - SessionDefaultFields - >["fields"]; - }; - } + session: { + fields: InferSchema< + O["schema"] extends BetterAuthPluginDBSchema ? O["schema"] : {}, + "session", + SessionDefaultFields + >["fields"]; + }; + } : {} & (O["teams"] extends { enabled: true } ? { team: InferSchema< diff --git a/packages/better-auth/src/plugins/organization/types.ts b/packages/better-auth/src/plugins/organization/types.ts index f330080ae64..d09dfc63edc 100644 --- a/packages/better-auth/src/plugins/organization/types.ts +++ b/packages/better-auth/src/plugins/organization/types.ts @@ -12,6 +12,27 @@ import type { TeamMember, } from "./schema"; +/** + * Result from canCreateRole hook + * Uses discriminated union for type safety and clear intent + */ +export type CreateRoleAuthResult = + | { + allow: true; + /** + * Bypass default permission and delegation checks. + * ⚠️ Use with caution - only for trusted roles/scenarios. + */ + bypass?: boolean; + } + | { + allow: false; + /** + * Custom error message explaining why creation is denied + */ + message?: string; + }; + export interface OrganizationOptions { /** * Configure whether new users are able to create new organizations. @@ -89,7 +110,7 @@ export interface OrganizationOptions { * Whether to enable custom resources per organization. * * When enabled, organizations can define their own resources and permissions - * alongside the default resources (organization, member, invitation, team, ac). + * dynamically alongside the statically defined resources * * @default false */ @@ -125,26 +146,32 @@ export interface OrganizationOptions { * ``` */ resourceNameValidation?: - | (( - name: string, - ) => boolean | { valid: boolean; error?: string }) + | ((name: string) => boolean | { valid: boolean; error?: string }) | undefined; /** * Custom logic to determine if a user can create a role. * - * @returns - * - "yes" - Allow the action (skip default checks) - * - "default" - Use default permission logic - * - { allowed: false, message: string } - Deny with custom error message + * @returns Authorization result + * - `{ 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 custom error message * * @example * ```ts * canCreateRole: async ({ member, organizationId }) => { - * // Users with "*" permission can do anything - * if (member.role === "superadmin") return "yes"; - * + * // Check subscription + * const subscription = await getSubscription(organizationId); + * if (!subscription.premium) { + * return { allow: false, message: "Premium required" }; + * } + * + * // Superadmin can bypass all checks + * if (member.role === "superadmin") { + * return { allow: true, bypass: true }; + * } + * * // Use default logic for others - * return "default"; + * return { allow: true }; * } * ``` */ @@ -154,9 +181,7 @@ export interface OrganizationOptions { member: Member & Record; permission: Record; roleName: string; - }) => Promise< - "yes" | "default" | { allowed: false; message: string } - > | "yes" | "default" | { allowed: false; message: string }; + }) => Promise | CreateRoleAuthResult; } | undefined; /** diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f89139ce427..86f7d140f9b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -78,9 +78,6 @@ importers: '@better-auth/expo': specifier: workspace:* version: link:../../packages/expo - 'better-auth': - specifier: workspace:* - version: link:../../packages/better-auth '@expo/metro-runtime': specifier: ^6.1.2 version: 6.1.2(expo@54.0.21)(react-dom@19.2.1(react@19.2.1))(react-native@0.81.5(@babel/core@7.28.4)(@react-native-community/cli@20.0.1(typescript@5.9.3))(@react-native/metro-config@0.81.0(@babel/core@7.28.4))(@types/react@19.2.2)(react@19.2.1))(react@19.2.1) @@ -114,6 +111,9 @@ importers: babel-plugin-transform-import-meta: specifier: ^2.2.1 version: 2.3.3(@babel/core@7.28.4) + better-auth: + specifier: workspace:* + version: link:../../packages/better-auth better-sqlite3: specifier: ^11.6.0 version: 11.10.0 @@ -232,9 +232,6 @@ importers: '@better-auth/stripe': specifier: workspace:* version: link:../../packages/stripe - 'better-auth': - specifier: workspace:* - version: link:../../packages/better-auth '@hookform/resolvers': specifier: ^5.2.1 version: 5.2.1(react-hook-form@7.62.0(react@19.2.1)) @@ -340,6 +337,9 @@ importers: '@types/better-sqlite3': specifier: ^7.6.13 version: 7.6.13 + better-auth: + specifier: workspace:* + version: link:../../packages/better-auth better-sqlite3: specifier: ^12.2.0 version: 12.4.1 @@ -461,9 +461,6 @@ importers: demo/oidc-client: dependencies: - 'better-auth': - specifier: workspace:* - version: link:../../packages/better-auth '@radix-ui/react-avatar': specifier: ^1.1.10 version: 1.1.10(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.1(react@19.2.1))(react@19.2.1) @@ -473,6 +470,9 @@ importers: '@radix-ui/react-slot': specifier: ^1.2.3 version: 1.2.3(@types/react@19.2.2)(react@19.2.1) + better-auth: + specifier: workspace:* + version: link:../../packages/better-auth class-variance-authority: specifier: ^0.7.1 version: 0.7.1 @@ -531,7 +531,7 @@ importers: demo/stateless: dependencies: - 'better-auth': + better-auth: specifier: workspace:* version: link:../../packages/better-auth next: @@ -860,7 +860,7 @@ importers: e2e/integration: dependencies: - 'better-auth': + better-auth: specifier: workspace:* version: link:../../packages/better-auth devDependencies: @@ -870,15 +870,15 @@ importers: e2e/integration/solid-vinxi: dependencies: - 'better-auth': - specifier: workspace:* - version: link:../../../packages/better-auth '@solidjs/router': specifier: ^0.15.3 version: 0.15.3(solid-js@1.9.9) '@solidjs/start': specifier: ^1.1.7 version: 1.1.7(289b7b32f8ebc3ad2c4aa2de3cbda9c6) + better-auth: + specifier: workspace:* + version: link:../../../packages/better-auth better-sqlite3: specifier: ^12.2.0 version: 12.4.1 @@ -901,7 +901,7 @@ importers: e2e/integration/vanilla-node: dependencies: - 'better-auth': + better-auth: specifier: workspace:* version: link:../../../packages/better-auth better-sqlite3: @@ -926,13 +926,13 @@ importers: e2e/smoke: dependencies: - 'better-auth': + better-auth: specifier: workspace:* version: link:../../packages/better-auth e2e/smoke/test/fixtures/cloudflare: dependencies: - 'better-auth': + better-auth: specifier: workspace:* version: link:../../../../../packages/better-auth drizzle-orm: @@ -966,7 +966,7 @@ importers: '@better-auth/stripe': specifier: workspace:* version: link:../../../../../packages/stripe - 'better-auth': + better-auth: specifier: workspace:* version: link:../../../../../packages/better-auth stripe: @@ -978,31 +978,31 @@ importers: '@better-auth/sso': specifier: workspace:* version: link:../../../../../packages/sso - 'better-auth': + better-auth: specifier: workspace:* version: link:../../../../../packages/better-auth e2e/smoke/test/fixtures/tsconfig-isolated-module-bundler: dependencies: - 'better-auth': + better-auth: specifier: workspace:* version: link:../../../../../packages/better-auth better-auth-harmony: specifier: ^1.2.5 - version: 1.2.5(better-auth@1.4.5(@lynx-js/react@0.114.0(@types/react@19.2.2))(@sveltejs/kit@2.37.1(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.1.3(svelte@5.38.2)(vite@7.2.6(@types/node@24.10.1)(jiti@2.6.1)(less@4.4.1)(lightningcss@1.30.2)(sass@1.90.0)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.2)))(svelte@5.38.2)(vite@7.2.6(@types/node@24.10.1)(jiti@2.6.1)(less@4.4.1)(lightningcss@1.30.2)(sass@1.90.0)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.2)))(@tanstack/react-start@1.139.8(react-dom@19.2.1(react@19.2.1))(react@19.2.1)(vite@7.2.6(@types/node@24.10.1)(jiti@2.6.1)(less@4.4.1)(lightningcss@1.30.2)(sass@1.90.0)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.2)))(next@16.0.7(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.1(react@19.2.1))(react@19.2.1)(sass@1.90.0))(react-dom@19.2.1(react@19.2.1))(react@19.2.1)(solid-js@1.9.9)(svelte@5.38.2)(vue@3.5.19(typescript@5.9.3))) + version: 1.2.5(better-auth@packages+better-auth) e2e/smoke/test/fixtures/tsconfig-verbatim-module-syntax-node10: dependencies: '@better-auth/expo': specifier: workspace:* version: link:../../../../../packages/expo - 'better-auth': + better-auth: specifier: workspace:* version: link:../../../../../packages/better-auth e2e/smoke/test/fixtures/vite: dependencies: - 'better-auth': + better-auth: specifier: workspace:* version: link:../../../../../packages/better-auth devDependencies: @@ -1013,11 +1013,11 @@ importers: packages/better-auth: dependencies: '@better-auth/core': - specifier: 1.4.6-beta.3 - version: 1.4.6-beta.3(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.18)(better-call@1.1.5(zod@4.1.13))(jose@6.1.0)(kysely@0.28.5)(nanostores@1.0.1) + specifier: workspace:* + version: link:../core '@better-auth/telemetry': - specifier: 1.4.6-beta.3 - version: 1.4.6-beta.3(@better-auth/core@1.4.6-beta.3(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.18)(better-call@1.1.5(zod@4.1.13))(jose@6.1.0)(kysely@0.28.5)(nanostores@1.0.1)) + specifier: workspace:* + version: link:../telemetry '@better-auth/utils': specifier: 0.3.0 version: 0.3.0 @@ -1169,9 +1169,6 @@ importers: '@clack/prompts': specifier: ^0.11.0 version: 0.11.0 - 'better-auth': - specifier: workspace:^ - version: link:../better-auth '@mrleebo/prisma-ast': specifier: ^0.13.0 version: 0.13.0 @@ -1181,6 +1178,9 @@ importers: '@types/pg': specifier: ^8.15.5 version: 8.15.5 + better-auth: + specifier: workspace:^ + version: link:../better-auth better-sqlite3: specifier: ^12.2.0 version: 12.4.1 @@ -1295,12 +1295,12 @@ importers: '@better-auth/core': specifier: workspace:* version: link:../core - 'better-auth': - specifier: workspace:* - version: link:../better-auth '@types/better-sqlite3': specifier: ^7.6.13 version: 7.6.13 + better-auth: + specifier: workspace:* + version: link:../better-auth better-sqlite3: specifier: ^12.2.0 version: 12.4.1 @@ -1350,7 +1350,7 @@ importers: '@better-auth/core': specifier: workspace:* version: link:../core - 'better-auth': + better-auth: specifier: workspace:* version: link:../better-auth tsdown: @@ -1362,7 +1362,7 @@ importers: '@better-auth/utils': specifier: 0.3.0 version: 0.3.0 - 'better-auth': + better-auth: specifier: workspace:* version: link:../better-auth better-call: @@ -1397,15 +1397,15 @@ importers: specifier: ^4.1.12 version: 4.1.13 devDependencies: - 'better-auth': - specifier: workspace:* - version: link:../better-auth '@types/body-parser': specifier: ^1.19.6 version: 1.19.6 '@types/express': specifier: ^5.0.5 version: 5.0.5 + better-auth: + specifier: workspace:* + version: link:../better-auth better-call: specifier: 'catalog:' version: 1.1.5(zod@4.1.13) @@ -1434,7 +1434,7 @@ importers: '@better-auth/core': specifier: workspace:* version: link:../core - 'better-auth': + better-auth: specifier: workspace:* version: link:../better-auth better-call: @@ -1474,7 +1474,7 @@ importers: '@better-fetch/fetch': specifier: 'catalog:' version: 1.1.18 - 'better-auth': + better-auth: specifier: workspace:* version: link:../packages/better-auth msw: @@ -2357,36 +2357,6 @@ packages: resolution: {integrity: sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==} engines: {node: '>=6.9.0'} - '@better-auth/core@1.4.5': - resolution: {integrity: sha512-dQ3hZOkUJzeBXfVEPTm2LVbzmWwka1nqd9KyWmB2OMlMfjr7IdUeBX4T7qJctF67d7QDhlX95jMoxu6JG0Eucw==} - peerDependencies: - '@better-auth/utils': 0.3.0 - '@better-fetch/fetch': 1.1.18 - better-call: 1.1.4 - jose: ^6.1.0 - kysely: ^0.28.5 - nanostores: ^1.0.1 - - '@better-auth/core@1.4.6-beta.3': - resolution: {integrity: sha512-yjiu7wva4a0HFiWNWoaKfazLXMOx0+2mpyIbB5A1ov1Ta/YXZAg7jeh9A9uyBGXI8Y2qBVMYExumXVp1m1Xz+w==} - peerDependencies: - '@better-auth/utils': 0.3.0 - '@better-fetch/fetch': 1.1.18 - better-call: 1.1.4 - jose: ^6.1.0 - kysely: ^0.28.5 - nanostores: ^1.0.1 - - '@better-auth/telemetry@1.4.5': - resolution: {integrity: sha512-r3NyksbaBYA10SC86JA6QwmZfHwFutkUGcphgWGfu6MVx1zutYmZehIeC8LxTjOWZqqF9FI8vLjglWBHvPQeTg==} - peerDependencies: - '@better-auth/core': 1.4.5 - - '@better-auth/telemetry@1.4.6-beta.3': - resolution: {integrity: sha512-97xIVEV6qf45IA6eXSwoD7jj95yVgVTOazsV000Q3jIcCLExSMzjQKOMniGiWsNF2UF+ZjpxWtixZ/HK98gXUw==} - peerDependencies: - '@better-auth/core': 1.4.6-beta.3 - '@better-auth/utils@0.3.0': resolution: {integrity: sha512-W+Adw6ZA6mgvnSnhOki270rwJ42t4XzSK6YWGF//BbVXL6SwCLWfyzBc1lN2m/4RM28KubdBKQ4X5VMoLRNPQw==} @@ -7306,46 +7276,6 @@ packages: peerDependencies: better-auth: ^1.0.3 - better-auth@1.4.5: - resolution: {integrity: sha512-pHV2YE0OogRHvoA6pndHXCei4pcep/mjY7psSaHVrRgjBtumVI68SV1g9U9XPRZ4KkoGca9jfwuv+bB2UILiFw==} - peerDependencies: - '@lynx-js/react': '*' - '@sveltejs/kit': ^2.0.0 - '@tanstack/react-start': ^1.0.0 - next: ^14.0.0 || ^15.0.0 || ^16.0.0 - react: ^18.0.0 || ^19.0.0 - react-dom: ^18.0.0 || ^19.0.0 - solid-js: ^1.0.0 - svelte: ^4.0.0 || ^5.0.0 - vue: ^3.0.0 - peerDependenciesMeta: - '@lynx-js/react': - optional: true - '@sveltejs/kit': - optional: true - '@tanstack/react-start': - optional: true - next: - optional: true - react: - optional: true - react-dom: - optional: true - solid-js: - optional: true - svelte: - optional: true - vue: - optional: true - - better-call@1.1.4: - resolution: {integrity: sha512-NJouLY6IVKv0nDuFoc6FcbKDFzEnmgMNofC9F60Mwx1Ecm7X6/Ecyoe5b+JSVZ42F/0n46/M89gbYP1ZCVv8xQ==} - peerDependencies: - zod: ^4.0.0 - peerDependenciesMeta: - zod: - optional: true - better-call@1.1.5: resolution: {integrity: sha512-nQJ3S87v6wApbDwbZ++FrQiSiVxWvZdjaO+2v6lZJAG2WWggkB2CziUDjPciz3eAt9TqfRursIQMZIcpkBnvlw==} peerDependencies: @@ -15329,40 +15259,6 @@ snapshots: '@babel/helper-string-parser': 7.27.1 '@babel/helper-validator-identifier': 7.28.5 - '@better-auth/core@1.4.5(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.18)(better-call@1.1.4(zod@4.1.13))(jose@6.1.0)(kysely@0.28.5)(nanostores@1.0.1)': - dependencies: - '@better-auth/utils': 0.3.0 - '@better-fetch/fetch': 1.1.18 - '@standard-schema/spec': 1.0.0 - better-call: 1.1.4(zod@4.1.13) - jose: 6.1.0 - kysely: 0.28.5 - nanostores: 1.0.1 - zod: 4.1.13 - - '@better-auth/core@1.4.6-beta.3(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.18)(better-call@1.1.5(zod@4.1.13))(jose@6.1.0)(kysely@0.28.5)(nanostores@1.0.1)': - dependencies: - '@better-auth/utils': 0.3.0 - '@better-fetch/fetch': 1.1.18 - '@standard-schema/spec': 1.0.0 - better-call: 1.1.5(zod@4.1.13) - jose: 6.1.0 - kysely: 0.28.5 - nanostores: 1.0.1 - zod: 4.1.13 - - '@better-auth/telemetry@1.4.5(@better-auth/core@1.4.5(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.18)(better-call@1.1.4(zod@4.1.13))(jose@6.1.0)(kysely@0.28.5)(nanostores@1.0.1))': - dependencies: - '@better-auth/core': 1.4.5(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.18)(better-call@1.1.4(zod@4.1.13))(jose@6.1.0)(kysely@0.28.5)(nanostores@1.0.1) - '@better-auth/utils': 0.3.0 - '@better-fetch/fetch': 1.1.18 - - '@better-auth/telemetry@1.4.6-beta.3(@better-auth/core@1.4.6-beta.3(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.18)(better-call@1.1.5(zod@4.1.13))(jose@6.1.0)(kysely@0.28.5)(nanostores@1.0.1))': - dependencies: - '@better-auth/core': 1.4.6-beta.3(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.18)(better-call@1.1.5(zod@4.1.13))(jose@6.1.0)(kysely@0.28.5)(nanostores@1.0.1) - '@better-auth/utils': 0.3.0 - '@better-fetch/fetch': 1.1.18 - '@better-auth/utils@0.3.0': {} '@better-fetch/fetch@1.1.18': {} @@ -18791,9 +18687,7 @@ snapshots: metro-runtime: 0.83.3 transitivePeerDependencies: - '@babel/core' - - bufferutil - supports-color - - utf-8-validate optional: true '@react-native/normalize-colors@0.74.89': {} @@ -20777,48 +20671,13 @@ snapshots: dependencies: safe-buffer: 5.1.2 - better-auth-harmony@1.2.5(better-auth@1.4.5(@lynx-js/react@0.114.0(@types/react@19.2.2))(@sveltejs/kit@2.37.1(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.1.3(svelte@5.38.2)(vite@7.2.6(@types/node@24.10.1)(jiti@2.6.1)(less@4.4.1)(lightningcss@1.30.2)(sass@1.90.0)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.2)))(svelte@5.38.2)(vite@7.2.6(@types/node@24.10.1)(jiti@2.6.1)(less@4.4.1)(lightningcss@1.30.2)(sass@1.90.0)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.2)))(@tanstack/react-start@1.139.8(react-dom@19.2.1(react@19.2.1))(react@19.2.1)(vite@7.2.6(@types/node@24.10.1)(jiti@2.6.1)(less@4.4.1)(lightningcss@1.30.2)(sass@1.90.0)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.2)))(next@16.0.7(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.1(react@19.2.1))(react@19.2.1)(sass@1.90.0))(react-dom@19.2.1(react@19.2.1))(react@19.2.1)(solid-js@1.9.9)(svelte@5.38.2)(vue@3.5.19(typescript@5.9.3))): + better-auth-harmony@1.2.5(better-auth@packages+better-auth): dependencies: - better-auth: 1.4.5(@lynx-js/react@0.114.0(@types/react@19.2.2))(@sveltejs/kit@2.37.1(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.1.3(svelte@5.38.2)(vite@7.2.6(@types/node@24.10.1)(jiti@2.6.1)(less@4.4.1)(lightningcss@1.30.2)(sass@1.90.0)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.2)))(svelte@5.38.2)(vite@7.2.6(@types/node@24.10.1)(jiti@2.6.1)(less@4.4.1)(lightningcss@1.30.2)(sass@1.90.0)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.2)))(@tanstack/react-start@1.139.8(react-dom@19.2.1(react@19.2.1))(react@19.2.1)(vite@7.2.6(@types/node@24.10.1)(jiti@2.6.1)(less@4.4.1)(lightningcss@1.30.2)(sass@1.90.0)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.2)))(next@16.0.7(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.1(react@19.2.1))(react@19.2.1)(sass@1.90.0))(react-dom@19.2.1(react@19.2.1))(react@19.2.1)(solid-js@1.9.9)(svelte@5.38.2)(vue@3.5.19(typescript@5.9.3)) + better-auth: link:packages/better-auth libphonenumber-js: 1.12.24 mailchecker: 6.0.18 validator: 13.15.15 - better-auth@1.4.5(@lynx-js/react@0.114.0(@types/react@19.2.2))(@sveltejs/kit@2.37.1(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.1.3(svelte@5.38.2)(vite@7.2.6(@types/node@24.10.1)(jiti@2.6.1)(less@4.4.1)(lightningcss@1.30.2)(sass@1.90.0)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.2)))(svelte@5.38.2)(vite@7.2.6(@types/node@24.10.1)(jiti@2.6.1)(less@4.4.1)(lightningcss@1.30.2)(sass@1.90.0)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.2)))(@tanstack/react-start@1.139.8(react-dom@19.2.1(react@19.2.1))(react@19.2.1)(vite@7.2.6(@types/node@24.10.1)(jiti@2.6.1)(less@4.4.1)(lightningcss@1.30.2)(sass@1.90.0)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.2)))(next@16.0.7(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.1(react@19.2.1))(react@19.2.1)(sass@1.90.0))(react-dom@19.2.1(react@19.2.1))(react@19.2.1)(solid-js@1.9.9)(svelte@5.38.2)(vue@3.5.19(typescript@5.9.3)): - dependencies: - '@better-auth/core': 1.4.5(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.18)(better-call@1.1.4(zod@4.1.13))(jose@6.1.0)(kysely@0.28.5)(nanostores@1.0.1) - '@better-auth/telemetry': 1.4.5(@better-auth/core@1.4.5(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.18)(better-call@1.1.4(zod@4.1.13))(jose@6.1.0)(kysely@0.28.5)(nanostores@1.0.1)) - '@better-auth/utils': 0.3.0 - '@better-fetch/fetch': 1.1.18 - '@noble/ciphers': 2.0.0 - '@noble/hashes': 2.0.0 - better-call: 1.1.4(zod@4.1.13) - defu: 6.1.4 - jose: 6.1.0 - kysely: 0.28.5 - ms: 4.0.0-nightly.202508271359 - nanostores: 1.0.1 - zod: 4.1.13 - optionalDependencies: - '@lynx-js/react': 0.114.0(@types/react@19.2.2) - '@sveltejs/kit': 2.37.1(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.1.3(svelte@5.38.2)(vite@7.2.6(@types/node@24.10.1)(jiti@2.6.1)(less@4.4.1)(lightningcss@1.30.2)(sass@1.90.0)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.2)))(svelte@5.38.2)(vite@7.2.6(@types/node@24.10.1)(jiti@2.6.1)(less@4.4.1)(lightningcss@1.30.2)(sass@1.90.0)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.2)) - '@tanstack/react-start': 1.139.8(react-dom@19.2.1(react@19.2.1))(react@19.2.1)(vite@7.2.6(@types/node@24.10.1)(jiti@2.6.1)(less@4.4.1)(lightningcss@1.30.2)(sass@1.90.0)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.2)) - next: 16.0.7(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.1(react@19.2.1))(react@19.2.1)(sass@1.90.0) - react: 19.2.1 - react-dom: 19.2.1(react@19.2.1) - solid-js: 1.9.9 - svelte: 5.38.2 - vue: 3.5.19(typescript@5.9.3) - - better-call@1.1.4(zod@4.1.13): - dependencies: - '@better-auth/utils': 0.3.0 - '@better-fetch/fetch': 1.1.18 - rou3: 0.7.10 - set-cookie-parser: 2.7.2 - optionalDependencies: - zod: 4.1.13 - better-call@1.1.5(zod@4.1.13): dependencies: '@better-auth/utils': 0.3.0 From 7ab68967b9944997fbb0891a653a67fbfe8d7584 Mon Sep 17 00:00:00 2001 From: viktormarinho Date: Tue, 9 Dec 2025 14:06:15 -0300 Subject: [PATCH 47/56] refactor access control stuff --- .../routes/crud-access-control.ts | 532 +++++++++++------- .../organization/routes/crud-resources.ts | 4 +- 2 files changed, 318 insertions(+), 218 deletions(-) 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 f09f664daeb..18a8d6f0a45 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 @@ -12,7 +12,6 @@ import { ORGANIZATION_ERROR_CODES } from "../error-codes"; import { hasPermission } from "../has-permission"; import { getOrganizationStatements, - getReservedResourceNames, invalidateResourceCache, validateResourceName, } from "../load-resources"; @@ -1140,34 +1139,82 @@ async function checkForInvalidResources({ const missingResources = providedResources.filter( (r) => !validResources.includes(r), ); - const autoCreatedResources: string[] = []; - // If custom resources are enabled, auto-create missing resources + // Guard: Handle missing resources when custom resources not enabled if ( missingResources.length > 0 && - options.dynamicAccessControl?.enableCustomResources + !options.dynamicAccessControl?.enableCustomResources ) { - const reservedNames = getReservedResourceNames(options); + ctx.context.logger.error( + `[Dynamic Access Control] The provided permission includes invalid resources.`, + { + providedResources, + validResources, + missingResources, + }, + ); + throw new APIError("BAD_REQUEST", { + message: ORGANIZATION_ERROR_CODES.INVALID_RESOURCE, + }); + } - for (const resourceName of missingResources) { - // Validate the resource name - 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}`, - }); - } + // Auto-create missing resources if custom resources enabled + if (missingResources.length > 0) { + return await autoCreateMissingResources({ + missingResources, + permission, + organizationId, + options, + ctx, + }); + } - // Check if resource name already exists (shouldn't happen, but double-check) - const existingResource = - await ctx.context.adapter.findOne({ + // All resources exist, validate and auto-expand permissions if needed + return await validateAndExpandPermissions({ + permission, + orgStatements, + ac, + organizationId, + options, + ctx, + }); +} + +async function autoCreateMissingResources({ + missingResources, + permission, + organizationId, + options, + ctx, +}: { + missingResources: string[]; + permission: Record; + organizationId: string; + options: OrganizationOptions; + ctx: GenericEndpointContext; +}): Promise { + // Validate all resource names first + for (const resourceName of missingResources) { + 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 for existing resources in parallel + const existingResourceChecks = await Promise.all( + missingResources.map((resourceName) => + ctx.context.adapter + .findOne({ model: "organizationResource", where: [ { @@ -1183,220 +1230,273 @@ async function checkForInvalidResources({ connector: "AND", }, ], - }); + }) + .then((existing: OrganizationResource | null) => ({ + resourceName, + existing, + })), + ), + ); - if (!existingResource) { - // Get the permissions for this resource from the permission object - const resourcePermissions = permission[resourceName] || []; + // Create resources that don't exist in parallel + const resourcesToCreate = existingResourceChecks.filter( + (r: { resourceName: string; existing: OrganizationResource | null }) => + !r.existing, + ); + const autoCreatedResources: string[] = []; - // Create the resource - await ctx.context.adapter.create< - Omit & { permissions: string } - >({ - model: "organizationResource", - data: { - createdAt: new Date(), - organizationId, - permissions: JSON.stringify(resourcePermissions), - resource: resourceName, - }, - }); + if (resourcesToCreate.length > 0) { + await Promise.all( + resourcesToCreate.map( + async ({ + resourceName, + }: { + resourceName: string; + existing: OrganizationResource | null; + }) => { + const resourcePermissions = permission[resourceName] || []; + + await ctx.context.adapter.create< + Omit & { permissions: string } + >({ + model: "organizationResource", + data: { + createdAt: new Date(), + organizationId, + permissions: JSON.stringify(resourcePermissions), + resource: resourceName, + }, + }); - ctx.context.logger.info( - `[Dynamic Access Control] Auto-created resource "${resourceName}" for organization ${organizationId}`, - { - resourceName, - organizationId, - permissions: resourcePermissions, - }, - ); + ctx.context.logger.info( + `[Dynamic Access Control] Auto-created resource "${resourceName}" for organization ${organizationId}`, + { + resourceName, + organizationId, + permissions: resourcePermissions, + }, + ); - // Track that this resource was auto-created - autoCreatedResources.push(resourceName); - } - } + autoCreatedResources.push(resourceName); + }, + ), + ); // Invalidate cache so new resources are picked up - if (missingResources.length > 0) { - invalidateResourceCache(organizationId); + invalidateResourceCache(organizationId); + } + + // Reload statements after creating resources + const updatedStatements = await getOrganizationStatements( + organizationId, + options, + ctx, + ); + + // Validate that the provided permissions for each resource are valid + for (const [resource, permissions] of Object.entries(permission)) { + const validPermissions = updatedStatements[resource as keyof Statements]; + + if (!validPermissions) { + ctx.context.logger.error( + `[Dynamic Access Control] Resource "${resource}" still not found after auto-creation.`, + { resource }, + ); + throw new APIError("INTERNAL_SERVER_ERROR", { + message: `Failed to create resource "${resource}"`, + }); } - // Reload statements after creating resources - const updatedStatements = await getOrganizationStatements( - organizationId, - options, - ctx, + const invalidPermissions = permissions.filter( + (p) => !validPermissions.includes(p), ); - // Now validate that the provided permissions for each resource are valid - for (const [resource, permissions] of Object.entries(permission)) { - const validPermissions = updatedStatements[resource as keyof Statements]; - if (!validPermissions) { - // This shouldn't happen after auto-creation, but just in case - ctx.context.logger.error( - `[Dynamic Access Control] Resource "${resource}" still not found after auto-creation.`, - { - resource, - }, - ); - throw new APIError("INTERNAL_SERVER_ERROR", { - message: `Failed to create resource "${resource}"`, - }); - } - - const invalidPermissions = permissions.filter( - (p) => !validPermissions.includes(p), + if (invalidPermissions.length > 0) { + ctx.context.logger.error( + `[Dynamic Access Control] The provided permissions include invalid actions for resource "${resource}".`, + { + resource, + invalidPermissions, + validPermissions, + }, ); - if (invalidPermissions.length > 0) { - ctx.context.logger.error( - `[Dynamic Access Control] The provided permissions include invalid actions for resource "${resource}".`, - { - resource, - invalidPermissions, - validPermissions, - }, - ); - throw new APIError("BAD_REQUEST", { - message: `Invalid permissions for resource "${resource}": ${invalidPermissions.join(", ")}`, - }); - } + throw new APIError("BAD_REQUEST", { + message: `Invalid permissions for resource "${resource}": ${invalidPermissions.join(", ")}`, + }); } + } - // Return the list of auto-created resources - return autoCreatedResources; - } else if (missingResources.length > 0) { - // Custom resources not enabled, so throw error for missing resources - ctx.context.logger.error( - `[Dynamic Access Control] The provided permission includes invalid resources.`, - { - providedResources, - validResources, - missingResources, - }, + return autoCreatedResources; +} + +async function validateAndExpandPermissions({ + permission, + orgStatements, + ac, + organizationId, + options, + ctx, +}: { + permission: Record; + orgStatements: Statements; + ac: AccessControl; + organizationId: string; + options: OrganizationOptions; + ctx: GenericEndpointContext; +}): Promise { + const defaultStatements = ac.statements; + const resourcesToExpand: Array<{ + resource: string; + existingPermissions: string[]; + invalidPermissions: 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; + + const invalidPermissions = permissions.filter( + (p) => !validPermissions.includes(p), ); - throw new APIError("BAD_REQUEST", { - message: ORGANIZATION_ERROR_CODES.INVALID_RESOURCE, - }); - } else { - // All resources exist, validate permissions - // If custom resources are enabled, auto-expand permissions for custom resources - const defaultStatements = ac.statements; - const needsUpdate: Array<{ resource: string; newPermissions: string[] }> = - []; - - for (const [resource, permissions] of Object.entries(permission)) { - const validPermissions = orgStatements[resource as keyof Statements]; - if (!validPermissions) continue; - - const invalidPermissions = permissions.filter( - (p) => !validPermissions.includes(p), + + if (invalidPermissions.length === 0) continue; + + // Check if this is a custom resource (not a default one) + const isCustomResource = !defaultStatements[resource as keyof Statements]; + + 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, + }); + } + + // No resources need expansion + if (resourcesToExpand.length === 0) { + return []; + } + + // Fetch existing resources in parallel + 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, + })), + ), + ); + + // Update resources with expanded permissions in parallel + await Promise.all( + existingResources.map( + async ({ + resource, + existing, + }: { + resource: string; + existing: OrganizationResource | null; + }) => { + if (!existing) return; + + const resourceToExpand = resourcesToExpand.find( + (r) => r.resource === resource, + ); + if (!resourceToExpand) return; + + // Parse existing permissions + const existingPermissions: string[] = JSON.parse( + existing.permissions as never as string, + ); - if (invalidPermissions.length > 0) { - // Check if this is a custom resource (not a default one) - const isCustomResource = - !defaultStatements[resource as keyof Statements]; - - if ( - isCustomResource && - options.dynamicAccessControl?.enableCustomResources - ) { - // Auto-expand the custom resource with new permissions - const existingResource = - await ctx.context.adapter.findOne({ - model: "organizationResource", - where: [ - { - field: "organizationId", - value: organizationId, - operator: "eq", - connector: "AND", - }, - { - field: "resource", - value: resource, - operator: "eq", - connector: "AND", - }, - ], - }); - - if (existingResource) { - // Parse existing permissions - const existingPermissions: string[] = JSON.parse( - existingResource.permissions as never as string, - ); - - // Merge with new permissions - const mergedPermissions = Array.from( - new Set([...existingPermissions, ...invalidPermissions]), - ); - - // Update the resource - await ctx.context.adapter.update< - Omit & { - permissions: string; - } - >({ - model: "organizationResource", - where: [ - { - field: "organizationId", - value: organizationId, - operator: "eq", - connector: "AND", - }, - { - field: "resource", - value: resource, - 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 "${resource}"`, - { - resource, - organizationId, - oldPermissions: existingPermissions, - newPermissions: invalidPermissions, - mergedPermissions, - }, - ); - - needsUpdate.push({ resource, newPermissions: mergedPermissions }); + // Merge with new permissions + const mergedPermissions = Array.from( + new Set([ + ...existingPermissions, + ...resourceToExpand.invalidPermissions, + ]), + ); + + // Update the resource + await ctx.context.adapter.update< + Omit & { + permissions: string; } - } else { - // Not a custom resource or custom resources disabled - throw error - ctx.context.logger.error( - `[Dynamic Access Control] The provided permissions include invalid actions for resource "${resource}".`, + >({ + model: "organizationResource", + where: [ { - resource, - invalidPermissions, - validPermissions, - isCustomResource, + field: "organizationId", + value: organizationId, + operator: "eq", + connector: "AND", }, - ); - throw new APIError("BAD_REQUEST", { - message: `Invalid permissions for resource "${resource}": ${invalidPermissions.join(", ")}`, - }); - } - } - } + { + field: "resource", + value: resource, + operator: "eq", + connector: "AND", + }, + ], + update: { + permissions: JSON.stringify(mergedPermissions), + updatedAt: new Date(), + }, + }); - // Invalidate cache if any resources were updated - if (needsUpdate.length > 0) { - invalidateResourceCache(organizationId); - } - } + ctx.context.logger.info( + `[Dynamic Access Control] Auto-expanded permissions for custom resource "${resource}"`, + { + resource, + organizationId, + oldPermissions: existingPermissions, + newPermissions: resourceToExpand.invalidPermissions, + mergedPermissions, + }, + ); + }, + ), + ); + + // Invalidate cache after updates + invalidateResourceCache(organizationId); - // Return empty array if no resources were auto-created return []; } diff --git a/packages/better-auth/src/plugins/organization/routes/crud-resources.ts b/packages/better-auth/src/plugins/organization/routes/crud-resources.ts index 28db6e49ad9..ed8e001edd1 100644 --- a/packages/better-auth/src/plugins/organization/routes/crud-resources.ts +++ b/packages/better-auth/src/plugins/organization/routes/crud-resources.ts @@ -111,13 +111,13 @@ export const createOrgResource = ( // 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.YOU_MUST_BE_IN_AN_ORGANIZATION_TO_CREATE_A_ROLE, + message: ORGANIZATION_ERROR_CODES.NO_ACTIVE_ORGANIZATION, }); } From 40fc49b88d5143c2fd2eb6ee4d9a05872f622659 Mon Sep 17 00:00:00 2001 From: viktormarinho Date: Tue, 9 Dec 2025 14:06:52 -0300 Subject: [PATCH 48/56] revert root package.json changes --- package.json | 24 ------------------------ 1 file changed, 24 deletions(-) diff --git a/package.json b/package.json index 27b53a9dce1..6dd9b44868c 100644 --- a/package.json +++ b/package.json @@ -34,29 +34,5 @@ "turbo": "^2.6.3", "typescript": "catalog:", "vitest": "catalog:" - }, - "workspaces": { - "packages": [ - "packages/**", - "docs", - "demo/*", - "e2e/**", - "test" - ], - "catalog": { - "@better-fetch/fetch": "1.1.18", - "better-call": "1.1.5", - "tsdown": "^0.17.0", - "typescript": "^5.9.3", - "vitest": "4.0.15" - }, - "catalogs": { - "react19": { - "@types/react": "^19.2.0", - "@types/react-dom": "^19.2.0", - "react": "^19.2.1", - "react-dom": "^19.2.1" - } - } } } From eec20236534ae1455c8c7dc31f9fe2ecc9a0d8cb Mon Sep 17 00:00:00 2001 From: viktormarinho Date: Tue, 9 Dec 2025 14:10:27 -0300 Subject: [PATCH 49/56] fix tests --- .../src/plugins/organization/load-resources.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/better-auth/src/plugins/organization/load-resources.test.ts b/packages/better-auth/src/plugins/organization/load-resources.test.ts index 60c56ae9954..966786f7fc3 100644 --- a/packages/better-auth/src/plugins/organization/load-resources.test.ts +++ b/packages/better-auth/src/plugins/organization/load-resources.test.ts @@ -73,14 +73,14 @@ describe("load-resources utility functions", () => { const options: OrganizationOptions = {}; const result = validateResourceName("project-name", options); expect(result.valid).toBe(false); - expect(result.error).toContain("lowercase alphanumeric"); + 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("lowercase alphanumeric"); + expect(result.error).toContain("alphanumeric"); }); it("should reject empty names", () => { From 0669eccfa4e50d325e713f75086efba1f58c7771 Mon Sep 17 00:00:00 2001 From: viktormarinho Date: Tue, 9 Dec 2025 14:14:11 -0300 Subject: [PATCH 50/56] use safe json parse --- .../better-auth/src/plugins/organization/load-resources.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/better-auth/src/plugins/organization/load-resources.ts b/packages/better-auth/src/plugins/organization/load-resources.ts index 400fbf2061e..d52427554cb 100644 --- a/packages/better-auth/src/plugins/organization/load-resources.ts +++ b/packages/better-auth/src/plugins/organization/load-resources.ts @@ -6,6 +6,7 @@ import { createAccessControl } from "../access"; import { defaultStatements } from "./access/statement"; import type { OrganizationResource } from "./schema"; import type { OrganizationOptions } from "./types"; +import { safeJSONParse } from "@better-auth/core/utils"; /** * In-memory cache for custom resources per organization @@ -47,16 +48,17 @@ export async function loadCustomResources( const statements: Record = {}; for (const resource of resources) { + const permissions = safeJSONParse(resource.permissions); const result = z .array(z.string()) - .safeParse(JSON.parse(resource.permissions)); + .safeParse(permissions); if (!result.success) { ctx.context.logger.error( "[loadCustomResources] Invalid permissions for resource " + resource.resource, { - permissions: JSON.parse(resource.permissions), + permissions, }, ); throw new APIError("INTERNAL_SERVER_ERROR", { From 650832d240e7b3f60b2424582097f03fa1097d7c Mon Sep 17 00:00:00 2001 From: viktormarinho Date: Tue, 9 Dec 2025 14:15:25 -0300 Subject: [PATCH 51/56] use more safe json parse --- .../src/plugins/organization/routes/crud-resources.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/packages/better-auth/src/plugins/organization/routes/crud-resources.ts b/packages/better-auth/src/plugins/organization/routes/crud-resources.ts index ed8e001edd1..da97fc05672 100644 --- a/packages/better-auth/src/plugins/organization/routes/crud-resources.ts +++ b/packages/better-auth/src/plugins/organization/routes/crud-resources.ts @@ -13,6 +13,7 @@ import { } from "../load-resources"; import type { Member, OrganizationResource, OrganizationRole } from "../schema"; import type { OrganizationOptions } from "../types"; +import { safeJSONParse } from "@better-auth/core/utils"; const DEFAULT_MAXIMUM_RESOURCES_PER_ORGANIZATION = 50; @@ -680,7 +681,7 @@ export const deleteOrgResource = ( }); const rolesWithResource = rolesUsingResource.filter((role) => { - const permissions = JSON.parse(role.permission); + const permissions = safeJSONParse(role.permission) as Record; return resourceName in permissions; }); @@ -854,7 +855,7 @@ export const listOrgResources = (options: O) => { const customResourceList = customResources.map((r) => ({ ...r, - permissions: JSON.parse(r.permissions) as string[], + permissions: safeJSONParse(r.permissions) as string[], isCustom: true, isProtected: false, })) as (OrganizationResource & @@ -1026,8 +1027,8 @@ export const getOrgResource = (options: O) => { return ctx.json({ resource: { ...customResource, - permissions: JSON.parse( - customResource.permissions as never as string, + permissions: safeJSONParse( + customResource.permissions, ) as string[], isCustom: true, isProtected: false, From bf7f59c4eb3d42da688d53d2c9af068550ea45a2 Mon Sep 17 00:00:00 2001 From: viktormarinho Date: Tue, 9 Dec 2025 15:56:26 -0300 Subject: [PATCH 52/56] cubic fixes --- .../plugins/organization/load-resources.ts | 6 ++--- .../routes/crud-access-control.ts | 2 +- .../organization/routes/crud-resources.ts | 24 ++++++++++++------- 3 files changed, 18 insertions(+), 14 deletions(-) diff --git a/packages/better-auth/src/plugins/organization/load-resources.ts b/packages/better-auth/src/plugins/organization/load-resources.ts index d52427554cb..e950d9fcb58 100644 --- a/packages/better-auth/src/plugins/organization/load-resources.ts +++ b/packages/better-auth/src/plugins/organization/load-resources.ts @@ -49,9 +49,7 @@ export async function loadCustomResources( for (const resource of resources) { const permissions = safeJSONParse(resource.permissions); - const result = z - .array(z.string()) - .safeParse(permissions); + const result = z.array(z.string()).safeParse(permissions); if (!result.success) { ctx.context.logger.error( @@ -161,7 +159,7 @@ export function getReservedResourceNames( /** * Validate a resource name according to the rules: - * - Must be lowercase alphanumeric with underscores + * - Must be alphanumeric with underscores * - Length between 1 and 50 characters * - Cannot be a reserved name * - Custom validation function if provided 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 18a8d6f0a45..f973c1f58ff 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 @@ -1497,7 +1497,7 @@ async function validateAndExpandPermissions({ // Invalidate cache after updates invalidateResourceCache(organizationId); - return []; + return resourcesToExpand.map((r) => r.resource); } async function checkIfMemberHasPermission({ diff --git a/packages/better-auth/src/plugins/organization/routes/crud-resources.ts b/packages/better-auth/src/plugins/organization/routes/crud-resources.ts index da97fc05672..524b783bb44 100644 --- a/packages/better-auth/src/plugins/organization/routes/crud-resources.ts +++ b/packages/better-auth/src/plugins/organization/routes/crud-resources.ts @@ -35,9 +35,13 @@ const getAdditionalFields = < let additionalFields = options?.schema?.organizationResource?.additionalFields || {}; if (shouldBePartial) { - for (const key in additionalFields) { - additionalFields[key]!.required = false; - } + // 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, @@ -681,8 +685,11 @@ export const deleteOrgResource = ( }); const rolesWithResource = rolesUsingResource.filter((role) => { - const permissions = safeJSONParse(role.permission) as Record; - return resourceName in permissions; + const permissions = safeJSONParse(role.permission) as Record< + string, + string[] + >; + return permissions !== null && resourceName in permissions; }); if (rolesWithResource.length > 0) { @@ -855,7 +862,7 @@ export const listOrgResources = (options: O) => { const customResourceList = customResources.map((r) => ({ ...r, - permissions: safeJSONParse(r.permissions) as string[], + permissions: (safeJSONParse(r.permissions) as string[]) ?? [], isCustom: true, isProtected: false, })) as (OrganizationResource & @@ -1027,9 +1034,8 @@ export const getOrgResource = (options: O) => { return ctx.json({ resource: { ...customResource, - permissions: safeJSONParse( - customResource.permissions, - ) as string[], + permissions: + (safeJSONParse(customResource.permissions) as string[]) ?? [], isCustom: true, isProtected: false, } as OrganizationResource & From a80666fd033170b4ea4d69fa704a8c2b1ee654c6 Mon Sep 17 00:00:00 2001 From: viktormarinho Date: Tue, 9 Dec 2025 15:58:25 -0300 Subject: [PATCH 53/56] run lint fix --- packages/better-auth/src/plugins/organization/load-resources.ts | 2 +- .../src/plugins/organization/routes/crud-resources.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/better-auth/src/plugins/organization/load-resources.ts b/packages/better-auth/src/plugins/organization/load-resources.ts index e950d9fcb58..d493466773e 100644 --- a/packages/better-auth/src/plugins/organization/load-resources.ts +++ b/packages/better-auth/src/plugins/organization/load-resources.ts @@ -1,4 +1,5 @@ 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"; @@ -6,7 +7,6 @@ import { createAccessControl } from "../access"; import { defaultStatements } from "./access/statement"; import type { OrganizationResource } from "./schema"; import type { OrganizationOptions } from "./types"; -import { safeJSONParse } from "@better-auth/core/utils"; /** * In-memory cache for custom resources per organization diff --git a/packages/better-auth/src/plugins/organization/routes/crud-resources.ts b/packages/better-auth/src/plugins/organization/routes/crud-resources.ts index 524b783bb44..e872e045151 100644 --- a/packages/better-auth/src/plugins/organization/routes/crud-resources.ts +++ b/packages/better-auth/src/plugins/organization/routes/crud-resources.ts @@ -1,4 +1,5 @@ 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"; @@ -13,7 +14,6 @@ import { } from "../load-resources"; import type { Member, OrganizationResource, OrganizationRole } from "../schema"; import type { OrganizationOptions } from "../types"; -import { safeJSONParse } from "@better-auth/core/utils"; const DEFAULT_MAXIMUM_RESOURCES_PER_ORGANIZATION = 50; From e9c46e5669daf1976f6ebc67002bd707ed2a7c3d Mon Sep 17 00:00:00 2001 From: viktormarinho Date: Wed, 10 Dec 2025 16:34:27 -0300 Subject: [PATCH 54/56] fix and refactor some stuff --- .../routes/crud-access-control.ts | 712 +++++++++++------- .../src/plugins/organization/types.ts | 98 ++- 2 files changed, 513 insertions(+), 297 deletions(-) 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 f973c1f58ff..30d8a18f0a2 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 @@ -26,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; @@ -178,74 +204,31 @@ export const createOrgRole = (options: O) => { }); } - // Check custom hook if provided - let bypassDefaultChecks = false; - - if (options.dynamicAccessControl?.canCreateRole) { - const result = await options.dynamicAccessControl.canCreateRole({ + const canCreateRole = await hasPermission( + { + options, organizationId, - userId: user.id, - member, - permission, - roleName, - }); - - // Handle denial - if (!result.allow) { - ctx.context.logger.error( - `[Dynamic Access Control] Custom canCreateRole callback denied role creation`, - { - userId: user.id, - organizationId, - roleName, - reason: result.message, - }, - ); - throw new APIError("FORBIDDEN", { - message: - result.message || - ORGANIZATION_ERROR_CODES.YOU_ARE_NOT_ALLOWED_TO_CREATE_A_ROLE, - }); - } - - // Handle bypass - if (result.bypass) { - bypassDefaultChecks = true; - ctx.context.logger.info( - `[Dynamic Access Control] Bypassing default permission and delegation checks for role creation`, - { userId: user.id, organizationId, roleName }, - ); - } - } + permissions: { + ac: ["create"], + }, + role: member.role, + }, + ctx, + ); - // Perform default checks unless explicitly bypassed - if (!bypassDefaultChecks) { - const canCreateRole = await hasPermission( + 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.`, { - options, + userId: user.id, organizationId, - permissions: { - ac: ["create"], - }, role: member.role, }, - 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.`, - { - userId: user.id, - organizationId, - role: member.role, - }, - ); - throw new APIError("FORBIDDEN", { - message: - ORGANIZATION_ERROR_CODES.YOU_ARE_NOT_ALLOWED_TO_CREATE_A_ROLE, - }); - } + throw new APIError("FORBIDDEN", { + message: + ORGANIZATION_ERROR_CODES.YOU_ARE_NOT_ALLOWED_TO_CREATE_A_ROLE, + }); } const maximumRolesPerOrganization = @@ -281,32 +264,44 @@ export const createOrgRole = (options: O) => { }); } - const autoCreatedResources = await checkForInvalidResources({ + // 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, + }); + + // Step 1: Calculate what resource changes are needed (read-only) + const resourceChanges = await calculateRequiredResourceChanges({ ac, ctx, permission, organizationId, options, + member, + user, }); - // Perform delegation check unless bypassed - if (!bypassDefaultChecks) { - await checkIfMemberHasPermission({ - ctx, - member, - options, - organizationId, - permissionRequired: permission, - user, - action: "create", - skipResourcesForDelegationCheck: autoCreatedResources, - }); - } - - await checkIfRoleNameIsTakenByRoleInDB({ + // Step 2: Check permission delegation (read-only) + await checkPermissionDelegation({ ctx, + member, + options, organizationId, - role: roleName, + permissionRequired: permission, + user, + action: "create", + resourcesToSkipDelegation: resourceChanges.resourcesToSkipDelegation, + }); + + // Step 3: Apply resource changes (mutations) + await applyResourceChanges({ + resourceChanges, + organizationId, + options, + ctx, + user, }); const newRole = ac.newRole(permission); @@ -1033,18 +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; - const autoCreatedResources = await checkForInvalidResources({ + // 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, @@ -1052,29 +1080,19 @@ export const updateOrgRole = (options: O) => { permissionRequired: newPermission, user, action: "update", - skipResourcesForDelegationCheck: autoCreatedResources, + 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; } // ----- @@ -1113,19 +1131,28 @@ 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; organizationId: string; options: OrganizationOptions; -}): Promise { + member: Member; + user: User; +}): Promise { // Get organization-specific statements (merged default + custom) const orgStatements = await getOrganizationStatements( organizationId, @@ -1158,43 +1185,70 @@ async function checkForInvalidResources({ }); } - // Auto-create missing resources if custom resources enabled - if (missingResources.length > 0) { - return await autoCreateMissingResources({ - missingResources, + // 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, + }), + ]); - // All resources exist, validate and auto-expand permissions if needed - return await validateAndExpandPermissions({ - permission, - orgStatements, - ac, - organizationId, - options, - ctx, - }); + // Merge both plans + return { + toCreate: creationPlan.toCreate, + toExpand: expansionPlan.toExpand, + resourcesToSkipDelegation: [ + ...creationPlan.resourcesToSkipDelegation, + ...expansionPlan.resourcesToSkipDelegation, + ], + }; } -async function autoCreateMissingResources({ +/** + * 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; -}): Promise { - // Validate all resource names first + 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( @@ -1208,6 +1262,48 @@ async function autoCreateMissingResources({ 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 @@ -1238,104 +1334,46 @@ async function autoCreateMissingResources({ ), ); - // Create resources that don't exist in parallel - const resourcesToCreate = existingResourceChecks.filter( - (r: { resourceName: string; existing: OrganizationResource | null }) => - !r.existing, - ); - const autoCreatedResources: string[] = []; - - if (resourcesToCreate.length > 0) { - await Promise.all( - resourcesToCreate.map( - async ({ - resourceName, - }: { - resourceName: string; - existing: OrganizationResource | null; - }) => { - const resourcePermissions = permission[resourceName] || []; - - await ctx.context.adapter.create< - Omit & { permissions: string } - >({ - model: "organizationResource", - data: { - createdAt: new Date(), - organizationId, - permissions: JSON.stringify(resourcePermissions), - resource: resourceName, - }, - }); - - ctx.context.logger.info( - `[Dynamic Access Control] Auto-created resource "${resourceName}" for organization ${organizationId}`, - { - resourceName, - organizationId, - permissions: resourcePermissions, - }, - ); - - autoCreatedResources.push(resourceName); - }, - ), - ); - - // Invalidate cache so new resources are picked up - invalidateResourceCache(organizationId); - } - - // Reload statements after creating resources - const updatedStatements = await getOrganizationStatements( - organizationId, - options, - ctx, - ); - - // Validate that the provided permissions for each resource are valid - for (const [resource, permissions] of Object.entries(permission)) { - const validPermissions = updatedStatements[resource as keyof Statements]; - - if (!validPermissions) { - ctx.context.logger.error( - `[Dynamic Access Control] Resource "${resource}" still not found after auto-creation.`, - { resource }, - ); - throw new APIError("INTERNAL_SERVER_ERROR", { - message: `Failed to create resource "${resource}"`, - }); - } - - const invalidPermissions = permissions.filter( - (p) => !validPermissions.includes(p), + // 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] || [], + }), ); - if (invalidPermissions.length > 0) { - ctx.context.logger.error( - `[Dynamic Access Control] The provided permissions include invalid actions for resource "${resource}".`, - { - resource, - invalidPermissions, - validPermissions, - }, - ); - throw new APIError("BAD_REQUEST", { - message: `Invalid permissions for resource "${resource}": ${invalidPermissions.join(", ")}`, - }); - } - } - - return autoCreatedResources; + return { + toCreate, + toExpand: [], + resourcesToSkipDelegation: toCreate.map( + (r: { resourceName: string; permissions: string[] }) => r.resourceName, + ), + }; } -async function validateAndExpandPermissions({ +/** + * Plan resource expansion - validates and returns what permissions need to be expanded. + * Does NOT mutate the database. + */ +async function planResourceExpansion({ permission, orgStatements, ac, organizationId, options, ctx, + member, + user, }: { permission: Record; orgStatements: Statements; @@ -1343,7 +1381,9 @@ async function validateAndExpandPermissions({ organizationId: string; options: OrganizationOptions; ctx: GenericEndpointContext; -}): Promise { + member: Member; + user: User; +}): Promise { const defaultStatements = ac.statements; const resourcesToExpand: Array<{ resource: string; @@ -1392,10 +1432,63 @@ async function validateAndExpandPermissions({ // No resources need expansion if (resourcesToExpand.length === 0) { - return []; + return { + toCreate: [], + toExpand: [], + resourcesToSkipDelegation: [], + }; } - // Fetch existing resources in parallel + // 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 @@ -1423,29 +1516,32 @@ async function validateAndExpandPermissions({ ), ); - // Update resources with expanded permissions in parallel - await Promise.all( - existingResources.map( - async ({ + // 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; }) => { - if (!existing) return; - const resourceToExpand = resourcesToExpand.find( (r) => r.resource === resource, - ); - if (!resourceToExpand) return; + )!; - // Parse existing permissions const existingPermissions: string[] = JSON.parse( - existing.permissions as never as string, + existing!.permissions as never as string, ); - // Merge with new permissions const mergedPermissions = Array.from( new Set([ ...existingPermissions, @@ -1453,54 +1549,156 @@ async function validateAndExpandPermissions({ ]), ); - // Update the resource - await ctx.context.adapter.update< - Omit & { - permissions: string; - } + return { + resourceName: resource, + existingPermissions, + newPermissions: resourceToExpand.invalidPermissions, + mergedPermissions, + }; + }, + ); + + return { + toCreate: [], + toExpand, + resourcesToSkipDelegation: toExpand.map( + (r: { + resourceName: string; + existingPermissions: string[]; + newPermissions: string[]; + mergedPermissions: string[]; + }) => r.resourceName, + ), + }; +} + +/** + * 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", - where: [ - { - field: "organizationId", - value: organizationId, - operator: "eq", - connector: "AND", - }, - { - field: "resource", - value: resource, - operator: "eq", - connector: "AND", - }, - ], - update: { - permissions: JSON.stringify(mergedPermissions), - updatedAt: new Date(), + data: { + createdAt: new Date(), + organizationId, + permissions: JSON.stringify(permissions), + resource: resourceName, }, }); ctx.context.logger.info( - `[Dynamic Access Control] Auto-expanded permissions for custom resource "${resource}"`, + `[Dynamic Access Control] Auto-created resource "${resourceName}" for organization ${organizationId}`, { - resource, + resourceName, organizationId, - oldPermissions: existingPermissions, - newPermissions: resourceToExpand.invalidPermissions, - mergedPermissions, + permissions, }, ); - }, - ), - ); - // Invalidate cache after updates - invalidateResourceCache(organizationId); + // 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(), + }, + }); - return resourcesToExpand.map((r) => r.resource); + 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, @@ -1508,7 +1706,7 @@ async function checkIfMemberHasPermission({ member, user, action, - skipResourcesForDelegationCheck = [], + resourcesToSkipDelegation, }: { ctx: GenericEndpointContext; permissionRequired: Record; @@ -1517,7 +1715,7 @@ async function checkIfMemberHasPermission({ member: Member; user: User; action: "create" | "update" | "delete" | "read" | "list" | "get"; - skipResourcesForDelegationCheck?: string[]; + resourcesToSkipDelegation: string[]; }) { const hasNecessaryPermissions: { resource: { [x: string]: string[] }; @@ -1525,9 +1723,9 @@ async function checkIfMemberHasPermission({ }[] = []; const permissionEntries = Object.entries(permission); for await (const [resource, permissions] of permissionEntries) { - // Skip delegation check for auto-created resources - // Users don't need to have permissions for resources that were just created - if (skipResourcesForDelegationCheck.includes(resource)) { + // 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}"`, { diff --git a/packages/better-auth/src/plugins/organization/types.ts b/packages/better-auth/src/plugins/organization/types.ts index d09dfc63edc..8e224e0b76b 100644 --- a/packages/better-auth/src/plugins/organization/types.ts +++ b/packages/better-auth/src/plugins/organization/types.ts @@ -12,27 +12,6 @@ import type { TeamMember, } from "./schema"; -/** - * Result from canCreateRole hook - * Uses discriminated union for type safety and clear intent - */ -export type CreateRoleAuthResult = - | { - allow: true; - /** - * Bypass default permission and delegation checks. - * ⚠️ Use with caution - only for trusted roles/scenarios. - */ - bypass?: boolean; - } - | { - allow: false; - /** - * Custom error message explaining why creation is denied - */ - message?: string; - }; - export interface OrganizationOptions { /** * Configure whether new users are able to create new organizations. @@ -149,39 +128,78 @@ export interface OrganizationOptions { | ((name: string) => boolean | { valid: boolean; error?: string }) | undefined; /** - * Custom logic to determine if a user can create a role. + * 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 and continue with default permission/delegation checks - * - `{ allow: true, bypass: true }` - Allow and skip all checks (⚠️ use with caution) + * - `{ allow: true }` - Allow resource creation * - `{ allow: false, message: "..." }` - Deny with custom error message * * @example * ```ts - * canCreateRole: async ({ member, organizationId }) => { - * // Check subscription - * const subscription = await getSubscription(organizationId); - * if (!subscription.premium) { - * return { allow: false, message: "Premium required" }; + * canCreateResource: async ({ member, organizationId, resourceName }) => { + * // Check subscription or quota + * const quota = await checkResourceQuota(organizationId); + * if (!quota.canCreate) { + * return { allow: false, message: "Resource quota exceeded" }; * } - * - * // Superadmin can bypass all checks - * if (member.role === "superadmin") { - * return { allow: true, bypass: true }; - * } - * - * // Use default logic for others * return { allow: true }; * } * ``` */ - canCreateRole?: (data: { + canCreateResource?: (params: { organizationId: string; userId: string; member: Member & Record; - permission: Record; - roleName: string; - }) => Promise | CreateRoleAuthResult; + 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; /** From 93672906f0c88dc4f6f79dfab80c67aa0e2a5f01 Mon Sep 17 00:00:00 2001 From: viktormarinho Date: Wed, 10 Dec 2025 17:01:21 -0300 Subject: [PATCH 55/56] custom validate overrides --- .../plugins/organization/load-resources.ts | 28 +++++++------------ .../src/plugins/organization/types.ts | 18 +++++++++--- 2 files changed, 24 insertions(+), 22 deletions(-) diff --git a/packages/better-auth/src/plugins/organization/load-resources.ts b/packages/better-auth/src/plugins/organization/load-resources.ts index d493466773e..fb9ca6a0231 100644 --- a/packages/better-auth/src/plugins/organization/load-resources.ts +++ b/packages/better-auth/src/plugins/organization/load-resources.ts @@ -159,16 +159,21 @@ export function getReservedResourceNames( /** * Validate a resource name according to the rules: - * - Must be alphanumeric with underscores - * - Length between 1 and 50 characters - * - Cannot be a reserved name - * - Custom validation function if provided + * - 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 } { - // Length validation first + // Use custom validation if provided + if (options.dynamicAccessControl?.resourceNameValidation) { + return options.dynamicAccessControl.resourceNameValidation(name); + } + if (name.length < 1 || name.length > 50) { return { valid: false, @@ -176,7 +181,6 @@ export function validateResourceName( }; } - // Basic format validation if (!/^[a-zA-Z0-9_]+$/.test(name)) { return { valid: false, @@ -193,17 +197,5 @@ export function validateResourceName( }; } - // Custom validation if provided - if (options.dynamicAccessControl?.resourceNameValidation) { - const customResult = - options.dynamicAccessControl.resourceNameValidation(name); - if (typeof customResult === "boolean") { - return customResult - ? { valid: true } - : { valid: false, error: "Resource name failed custom validation" }; - } - return customResult; - } - return { valid: true }; } diff --git a/packages/better-auth/src/plugins/organization/types.ts b/packages/better-auth/src/plugins/organization/types.ts index 8e224e0b76b..f21ec472d2b 100644 --- a/packages/better-auth/src/plugins/organization/types.ts +++ b/packages/better-auth/src/plugins/organization/types.ts @@ -110,22 +110,32 @@ export interface OrganizationOptions { 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 true if valid, false otherwise, or an object with valid flag and error message + * @returns An object with valid flag and optional error message * * @example * ```ts + * // This completely replaces all default validation * resourceNameValidation: (name) => { - * if (name.length > 50) { + * if (name.length > 100) { * return { valid: false, error: "Resource name too long" }; * } - * return true; + * if (!/^[a-z-]+$/.test(name)) { + * return { valid: false, error: "Only lowercase letters and hyphens allowed" }; + * } + * return { valid: true }; * } * ``` */ resourceNameValidation?: - | ((name: string) => boolean | { valid: boolean; error?: string }) + | ((name: string) => { valid: boolean; error?: string }) | undefined; /** * Roles allowed to create new custom resources. From 1497a85f124cf067834bc4151a68097a1a47b05d Mon Sep 17 00:00:00 2001 From: viktormarinho Date: Wed, 10 Dec 2025 17:31:36 -0300 Subject: [PATCH 56/56] fix update role removing dynamic permissions --- .../routes/crud-access-control.ts | 31 +++++++++++-------- 1 file changed, 18 insertions(+), 13 deletions(-) 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 30d8a18f0a2..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 @@ -1363,6 +1363,7 @@ async function planResourceCreation({ /** * 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({ @@ -1391,20 +1392,28 @@ async function planResourceExpansion({ 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; - // Check if this is a custom resource (not a default one) - const isCustomResource = !defaultStatements[resource as keyof Statements]; - if ( !isCustomResource || !options.dynamicAccessControl?.enableCustomResources @@ -1430,12 +1439,13 @@ async function planResourceExpansion({ }); } - // No resources need expansion + // If no resources need expansion but there are custom resources, + // still return them for delegation skip if (resourcesToExpand.length === 0) { return { toCreate: [], toExpand: [], - resourcesToSkipDelegation: [], + resourcesToSkipDelegation: customResourcesInPermissions, }; } @@ -1561,14 +1571,9 @@ async function planResourceExpansion({ return { toCreate: [], toExpand, - resourcesToSkipDelegation: toExpand.map( - (r: { - resourceName: string; - existingPermissions: string[]; - newPermissions: string[]; - mergedPermissions: string[]; - }) => r.resourceName, - ), + // 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, }; }