* 🔓 fix: Accept Targeted assign:configs for Config Scope-Lifecycle Endpoints Three admin-config endpoints currently require broad manage:configs: PUT /:principalType/:principalId for empty-overrides scope creation, DELETE /:principalType/:principalId for scope removal, and PATCH /:principalType/:principalId/active for the active toggle. The capability model already defines assign:configs:user|group|role for delegated administrators and validates that shape in isValidCapability, but no handler accepts it, so a delegate granted assign:configs:role via /api/admin/grants cannot manage scope lifecycle for the principal type they were explicitly delegated. This aligns the server-side auth with the documented capability surface. Every destructive lateral path stays behind broad manage:configs: operations against the base config principal (__base__), non-empty PUT payloads that $set the full overrides field, and DELETE or toggle on a document whose existing overrides are non-empty (which would erase or neutralize sections the caller could not author). The new hasCapability dep on AdminConfigDeps is optional with a false default, so external consumers continue to get pre-PR behavior until they wire the resolver. * 🛡️ fix: Block Assign-Only Scope-Lifecycle When Existing Doc Has Tombstones The existing-overrides guard introduced in the prior commit only checked overrides, but configs also carry tombstones (suppressed inherited field paths) which are iterated during cascade resolution. An assign-only caller could delete, toggle, or empty-upsert a doc whose overrides is empty but whose tombstones is non-empty, which would erase or neutralize suppressions on fields they could not author. Extends the guard at all three call sites to treat a non-empty tombstones array as destructive state. * 🚨 fix: Log TOCTOU Race When Assign-Only Lifecycle Op Hits Non-Empty Doc The empty-state guard for assign-only callers performs a read-then-write across two DB roundtrips, so a concurrent broad-manage write can land between the guard and the destructive op. Adds post-write detection on the delete and toggle handlers: when the destructive op returns a doc whose state was non-empty at write time, emit logger.warn with the caller id, principal, and observed-state counts so ops can detect the race and restore from audit logs. A fully atomic fix would require extending deleteConfig, toggleConfigActive, and upsertConfig in packages/data-schemas/src/methods/config.ts to support compare-and-swap filters, which is a wider design change than this PR's auth scope. Empty-payload upsert is not covered because $set replaces overrides, so the post-write doc no longer reflects pre-write state. * 🔒 fix: Atomic Empty-State Filter for Assign-Only Scope-Lifecycle Writes Replaces the read-then-check guard with an atomic Mongo filter on the destructive write itself. Adds an options.expectEmpty parameter to deleteConfig, toggleConfigActive, and upsertConfig in the shared data-schemas layer. When set, the filter requires both overrides and tombstones to be empty before the write matches. The TOCTOU race window is eliminated: a concurrent write cannot land between the empty-state check and the destructive op because they are now a single atomic operation. For upsertConfig, the E11000 retry path returns null instead of falling back to a filterless update when expectEmpty is set, preserving the atomic property. Handlers fall back to findConfigByPrincipal only to disambiguate the null return between 404 (doc absent) and 403 (doc exists with non-empty state). The post-write logger.warn race detection added in the prior commit is removed as unreachable. |
||
|---|---|---|
| .. | ||
| misc/ferretdb | ||
| src | ||
| .gitignore | ||
| babel.config.cjs | ||
| jest.config.mjs | ||
| LICENSE | ||
| package.json | ||
| README.md | ||
| tsconfig.build.json | ||
| tsconfig.json | ||
| tsconfig.spec.json | ||
| tsdown.config.mjs | ||
LibreChat Data Schemas Package
This package provides the database schemas, models, types, and methods for LibreChat using Mongoose ODM.
📁 Package Structure
packages/data-schemas/
├── src/
│ ├── schema/ # Mongoose schema definitions
│ ├── models/ # Model factory functions
│ ├── types/ # TypeScript type definitions
│ ├── methods/ # Database operation methods
│ ├── common/ # Shared constants and enums
│ ├── config/ # Configuration files (winston, etc.)
│ └── index.ts # Main package exports
🏗️ Architecture Patterns
1. Schema Files (src/schema/)
Schema files define the Mongoose schema structure. They follow these conventions:
- Naming: Use lowercase filenames (e.g.,
user.ts,accessRole.ts) - Imports: Import types from
~/typesfor TypeScript support - Exports: Export only the schema as default
Example:
import { Schema } from 'mongoose';
import type { IUser } from '~/types';
const userSchema = new Schema<IUser>(
{
name: { type: String },
email: { type: String, required: true },
// ... other fields
},
{ timestamps: true }
);
export default userSchema;
2. Type Definitions (src/types/)
Type files define TypeScript interfaces and types. They follow these conventions:
- Base Type: Define a plain type without Mongoose Document properties
- Document Interface: Extend the base type with Document and
_id - Enums/Constants: Place related enums in the type file or
common/if shared
Example:
import type { Document, Types } from 'mongoose';
export type User = {
name?: string;
email: string;
// ... other fields
};
export type IUser = User &
Document & {
_id: Types.ObjectId;
};
3. Model Factory Functions (src/models/)
Model files create Mongoose models using factory functions. They follow these conventions:
- Function Name:
create[EntityName]Model - Singleton Pattern: Check if model exists before creating
- Type Safety: Use the corresponding interface from types
Example:
import userSchema from '~/schema/user';
import type * as t from '~/types';
export function createUserModel(mongoose: typeof import('mongoose')) {
return mongoose.models.User || mongoose.model<t.IUser>('User', userSchema);
}
4. Database Methods (src/methods/)
Method files contain database operations for each entity. They follow these conventions:
- Function Name:
create[EntityName]Methods - Return Type: Export a type for the methods object
- Operations: Include CRUD operations and entity-specific queries
Example:
import type { Model } from 'mongoose';
import type { IUser } from '~/types';
export function createUserMethods(mongoose: typeof import('mongoose')) {
async function findUserById(userId: string): Promise<IUser | null> {
const User = mongoose.models.User as Model<IUser>;
return await User.findById(userId).lean();
}
async function createUser(userData: Partial<IUser>): Promise<IUser> {
const User = mongoose.models.User as Model<IUser>;
return await User.create(userData);
}
return {
findUserById,
createUser,
// ... other methods
};
}
export type UserMethods = ReturnType<typeof createUserMethods>;
5. Main Exports (src/index.ts)
The main index file exports:
createModels()- Factory function for all modelscreateMethods()- Factory function for all methods- Type exports from
~/types - Shared utilities and constants
🚀 Adding a New Entity
To add a new entity to the data-schemas package, follow these steps:
Step 1: Create the Type Definition
Create src/types/[entityName].ts:
import type { Document, Types } from 'mongoose';
export type EntityName = {
/** Field description */
fieldName: string;
// ... other fields
};
export type IEntityName = EntityName &
Document & {
_id: Types.ObjectId;
};
Step 2: Update Types Index
Add to src/types/index.ts:
export * from './entityName';
Step 3: Create the Schema
Create src/schema/[entityName].ts:
import { Schema } from 'mongoose';
import type { IEntityName } from '~/types';
const entityNameSchema = new Schema<IEntityName>(
{
fieldName: { type: String, required: true },
// ... other fields
},
{ timestamps: true }
);
export default entityNameSchema;
Step 4: Create the Model Factory
Create src/models/[entityName].ts:
import entityNameSchema from '~/schema/entityName';
import type * as t from '~/types';
export function createEntityNameModel(mongoose: typeof import('mongoose')) {
return (
mongoose.models.EntityName ||
mongoose.model<t.IEntityName>('EntityName', entityNameSchema)
);
}
Step 5: Update Models Index
Add to src/models/index.ts:
- Import the factory function:
import { createEntityNameModel } from './entityName';
- Add to the return object in
createModels():
EntityName: createEntityNameModel(mongoose),
Step 6: Create Database Methods
Create src/methods/[entityName].ts:
import type { Model, Types } from 'mongoose';
import type { IEntityName } from '~/types';
export function createEntityNameMethods(mongoose: typeof import('mongoose')) {
async function findEntityById(id: string | Types.ObjectId): Promise<IEntityName | null> {
const EntityName = mongoose.models.EntityName as Model<IEntityName>;
return await EntityName.findById(id).lean();
}
// ... other methods
return {
findEntityById,
// ... other methods
};
}
export type EntityNameMethods = ReturnType<typeof createEntityNameMethods>;
Step 7: Update Methods Index
Add to src/methods/index.ts:
- Import the methods:
import { createEntityNameMethods, type EntityNameMethods } from './entityName';
- Add to the return object in
createMethods():
...createEntityNameMethods(mongoose),
- Add to the
AllMethodstype:
export type AllMethods = UserMethods &
// ... other methods
EntityNameMethods;
📝 Best Practices
- Consistent Naming: Use lowercase for filenames, PascalCase for types/interfaces
- Type Safety: Always use TypeScript types, avoid
any - JSDoc Comments: Document complex fields and methods
- Indexes: Define database indexes in schema files for query performance
- Validation: Use Mongoose schema validation for data integrity
- Lean Queries: Use
.lean()for read operations when you don't need Mongoose document methods
🔧 Common Patterns
Enums and Constants
Place shared enums in src/common/:
// src/common/permissions.ts
export enum PermissionBits {
VIEW = 1,
EDIT = 2,
DELETE = 4,
SHARE = 8,
}
Compound Indexes
For complex queries, add compound indexes:
schema.index({ field1: 1, field2: 1 });
schema.index(
{ uniqueField: 1 },
{
unique: true,
partialFilterExpression: { uniqueField: { $exists: true } }
}
);
Virtual Properties
Add computed properties using virtuals:
schema.virtual('fullName').get(function() {
return `${this.firstName} ${this.lastName}`;
});
🧪 Testing
When adding new entities, ensure:
- Types compile without errors
- Models can be created successfully
- Methods handle edge cases (null checks, validation)
- Indexes are properly defined for query patterns