Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
c2fb780
feat: add prompts.categories config schema
TomasPalsson Sep 30, 2026
358df48
docs: add prompts.categories sample to example config
TomasPalsson Sep 30, 2026
5bd01cc
feat: add distinct custom prompt group categories query
TomasPalsson Sep 30, 2026
29a69ff
feat: load prompts config into AppConfig and serve default prompt cat…
TomasPalsson Sep 30, 2026
059eb4d
feat: validate prompt group category on create and update
TomasPalsson Sep 30, 2026
7f38173
feat: resolve prompt category labels, icons and colors from configure…
TomasPalsson Sep 30, 2026
43a4de6
fix: annotate prompt category schema types for isolated declarations
TomasPalsson Sep 30, 2026
7f1ea72
test: cover configured and custom categories in prompt filter and ski…
TomasPalsson Sep 30, 2026
11f80d6
feat: resolve per-user prompt categories and add categories handler f…
TomasPalsson Sep 30, 2026
bddcae0
feat: serve resolved per-user prompt categories and expose allowCusto…
TomasPalsson Sep 30, 2026
82c3e34
feat: create custom prompt categories from the category selector and …
TomasPalsson Sep 30, 2026
f4ef6e6
fix: show placeholder for a selected category missing from the list u…
TomasPalsson Sep 30, 2026
f6b03cb
fix: gate display of unlisted selected category on allowCustom
TomasPalsson Sep 30, 2026
019d6a1
🔀 chore: Merge dev into feat/configurable-prompt-categories
TomasPalsson Oct 1, 2026
9c6c01d
🔀 chore: Merge dev into feat/configurable-prompt-categories
TomasPalsson Oct 1, 2026
ab7d8aa
🔀 chore: Merge dev into feat/configurable-prompt-categories
TomasPalsson Oct 1, 2026
1526f95
🔀 chore: Merge dev into feat/configurable-prompt-categories
TomasPalsson Oct 2, 2026
9346004
🎨 style: Order Tailwind Classes in CategorySelector
TomasPalsson Oct 2, 2026
6de7e98
🐛 fix: Dedupe Prompt Categories by Exact Value and Trim Configured En…
TomasPalsson Oct 2, 2026
d7d5773
🐛 fix: Gate Stored Prompt Categories on Prompt Access and Content Policy
TomasPalsson Oct 2, 2026
a72ac72
🐛 fix: Refresh Prompt Categories After Prompt Group Deletion
TomasPalsson Oct 2, 2026
70c3206
🔀 chore: Merge dev into feat/configurable-prompt-categories
TomasPalsson Oct 2, 2026
5689ee4
🔀 chore: Merge dev into feat/configurable-prompt-categories
TomasPalsson Oct 4, 2026
94fbd14
🔀 chore: Merge dev into feat/configurable-prompt-categories
TomasPalsson Oct 5, 2026
41e93fe
🔀 chore: Merge dev into feat/configurable-prompt-categories
TomasPalsson Oct 6, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 26 additions & 10 deletions api/server/routes/categories.js
Original file line number Diff line number Diff line change
@@ -1,15 +1,31 @@
const express = require('express');
const { PermissionTypes, Permissions } = require('librechat-data-provider');
const { checkAccess, createGetPromptCategoriesHandler } = require('@librechat/api');
const { requireJwtAuth, configMiddleware } = require('~/server/middleware');
const {
getRoleByName,
getPromptGroupAccessContext,
getDistinctPromptGroupCategories,
} = require('~/models');

const router = express.Router();
const { requireJwtAuth } = require('~/server/middleware');
const { getCategories } = require('~/models');

router.get('/', requireJwtAuth, async (req, res) => {
try {
const categories = await getCategories();
res.status(200).send(categories);
} catch (error) {
res.status(500).send({ message: 'Failed to retrieve categories', error: error.message });
}
});
router.get(
'/',
requireJwtAuth,
configMiddleware,
createGetPromptCategoriesHandler({
getPromptGroupAccessContext,
getDistinctPromptGroupCategories,
Comment on lines +15 to +19

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Enforce prompt-use permission before exposing stored categories

This route authenticates the user but does not enforce PROMPTS.USE, unlike /api/prompts. getPromptGroupAccessContext resolves resource ACLs independently of that role permission. With custom categories enabled, a user denied access to the Prompt Library can still retrieve stored category names through retained VIEW ACLs or public grants. Gate stored-category discovery on the existing prompt-use permission check. Configured categories can remain available to shared consumers such as Skills.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in d7d5773. The route now checks PROMPTS.USE; a user without it gets configured categories only, with no stored reads. Covered by serves configured categories only to a user without prompt-use permission in api/server/routes/categories.test.js and the matching case in categories.spec.ts.

canUsePrompts: (req) =>
checkAccess({
req,
user: req.user,
permissionType: PermissionTypes.PROMPTS,
permissions: [Permissions.USE],
getRoleByName,
}),
}),
);

module.exports = router;
217 changes: 217 additions & 0 deletions api/server/routes/categories.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,217 @@
const express = require('express');
const request = require('supertest');
const mongoose = require('mongoose');
const { MongoMemoryServer } = require('mongodb-memory-server');
const {
SystemRoles,
ResourceType,
AccessRoleIds,
PrincipalType,
PermissionBits,
PermissionTypes,
Permissions,
} = require('librechat-data-provider');
let mockBaseConfig = {};

const mockHrOverride = {
principalType: 'role',
principalId: 'HR',
priority: 10,
overrides: {
prompts: {
categories: {
enableDefaultCategories: false,
list: [{ value: 'benefits', label: 'Benefits' }],
},
},
},
};

jest.mock('~/server/services/Config', () => ({
getAppConfig: jest.fn(async ({ role } = {}) => {
const { mergeConfigOverrides } = require('@librechat/data-schemas');
return role === 'HR' ? mergeConfigOverrides(mockBaseConfig, [mockHrOverride]) : mockBaseConfig;
}),
}));

jest.mock('~/models', () => {
const mongoose = require('mongoose');
const { createMethods } = require('@librechat/data-schemas');
const methods = createMethods(mongoose, {
removeAllPermissions: async ({ resourceType, resourceId }) => {
await mongoose.models.AclEntry?.deleteMany({ resourceType, resourceId });
},
});
return {
...methods,
getPromptGroupAccessContext: jest.fn(methods.getPromptGroupAccessContext),
getDistinctPromptGroupCategories: jest.fn(methods.getDistinctPromptGroupCategories),
};
});

jest.mock('~/server/middleware', () => ({
requireJwtAuth: (req, res, next) => next(),
configMiddleware: jest.requireActual('~/server/middleware/config/app'),
}));

const builtins = [
{ label: 'com_ui_idea', value: 'idea' },
{ label: 'com_ui_travel', value: 'travel' },
{ label: 'com_ui_teach_or_explain', value: 'teach_or_explain' },
{ label: 'com_ui_write', value: 'write' },
{ label: 'com_ui_shop', value: 'shop' },
{ label: 'com_ui_code', value: 'code' },
{ label: 'com_ui_misc', value: 'misc' },
{ label: 'com_ui_roleplay', value: 'roleplay' },
{ label: 'com_ui_finance', value: 'finance' },
];

let app;
let mongoServer;
let models;
let users;
let currentUser;

beforeAll(async () => {
mongoServer = await MongoMemoryServer.create();
await mongoose.connect(mongoServer.getUri());

const { AccessRole, User, Role } = require('~/db/models');
await Role.create({
name: 'NO_PROMPTS',
permissions: { [PermissionTypes.PROMPTS]: { [Permissions.USE]: false } },
});
await AccessRole.create({
accessRoleId: AccessRoleIds.PROMPTGROUP_OWNER,
name: 'Owner',
resourceType: ResourceType.PROMPTGROUP,
permBits:
PermissionBits.VIEW | PermissionBits.EDIT | PermissionBits.DELETE | PermissionBits.SHARE,
});
users = {
a: await User.create({ name: 'A', email: 'a@example.com', role: SystemRoles.USER }),
b: await User.create({ name: 'B', email: 'b@example.com', role: SystemRoles.USER }),
hr: await User.create({ name: 'HR', email: 'hr@example.com', role: 'HR' }),
noPrompts: await User.create({ name: 'NP', email: 'np@example.com', role: 'NO_PROMPTS' }),
};
models = require('~/models');

app = express();
app.use((req, res, next) => {
req.user = {
id: currentUser._id.toString(),
_id: currentUser._id,
role: currentUser.role,
};
next();
});
app.use('/api/categories', require('./categories'));
});

beforeEach(() => {
currentUser = users.a;
mockBaseConfig = {};
jest.clearAllMocks();
});

afterAll(async () => {
await mongoose.disconnect();
await mongoServer.stop();
});

describe('GET /api/categories', () => {
it('defaults unchanged when no prompts config is set', async () => {
const res = await request(app).get('/api/categories');

expect(res.status).toBe(200);
expect(res.body).toEqual(builtins);
});

it('applies the role override per requesting role', async () => {
currentUser = users.hr;
const hr = await request(app).get('/api/categories');
expect(hr.status).toBe(200);
expect(hr.body).toEqual([{ value: 'benefits', label: 'Benefits' }]);

currentUser = users.a;
const user = await request(app).get('/api/categories');
expect(user.body).toEqual(builtins);
});

it('gives no error leak when the custom category read fails', async () => {
mockBaseConfig = { prompts: { categories: { allowCustom: true } } };
models.getDistinctPromptGroupCategories.mockRejectedValueOnce(new Error('secret db detail'));

const res = await request(app).get('/api/categories');

expect(res.status).toBe(500);
expect(res.body).toEqual({ message: 'Failed to retrieve categories' });
expect(res.text).not.toContain('secret');
});

it('keeps custom categories access scoped to the requesting user', async () => {
mockBaseConfig = { prompts: { categories: { allowCustom: true } } };
const { grantPermission } = require('~/server/services/PermissionService');
const { group } = await models.createPromptGroup({
prompt: { prompt: 'secret text', type: 'text' },
group: { name: 'private group', category: 'A-Private' },
author: users.a._id.toString(),
authorName: users.a.name,
});
await grantPermission({
principalType: PrincipalType.USER,
principalId: users.a._id,
resourceType: ResourceType.PROMPTGROUP,
resourceId: group._id,
accessRoleId: AccessRoleIds.PROMPTGROUP_OWNER,
grantedBy: users.a._id,
});

currentUser = users.b;
const forB = await request(app).get('/api/categories');
expect(forB.body.map((c) => c.value)).not.toContain('A-Private');

currentUser = users.a;
const forA = await request(app).get('/api/categories');
expect(forA.body).toContainEqual({ value: 'A-Private', label: 'A-Private', custom: true });
});

it('serves configured categories only to a user without prompt-use permission', async () => {
mockBaseConfig = {
prompts: {
categories: { allowCustom: true, enableDefaultCategories: false, list: [{ value: 'hr' }] },
},
};
const { grantPermission } = require('~/server/services/PermissionService');
const { group } = await models.createPromptGroup({
prompt: { prompt: 'text', type: 'text' },
group: { name: 'shared group', category: 'Stored' },
author: users.noPrompts._id.toString(),
authorName: users.noPrompts.name,
});
await grantPermission({
principalType: PrincipalType.USER,
principalId: users.noPrompts._id,
resourceType: ResourceType.PROMPTGROUP,
resourceId: group._id,
accessRoleId: AccessRoleIds.PROMPTGROUP_OWNER,
grantedBy: users.noPrompts._id,
});

currentUser = users.noPrompts;
const denied = await request(app).get('/api/categories');
expect(denied.status).toBe(200);
expect(denied.body).toEqual([{ value: 'hr', label: 'hr' }]);
expect(models.getDistinctPromptGroupCategories).not.toHaveBeenCalled();
});

it('no reads when off: custom categories disabled', async () => {
mockBaseConfig = { prompts: { categories: { allowCustom: false } } };

const res = await request(app).get('/api/categories');

expect(res.status).toBe(200);
expect(models.getPromptGroupAccessContext).not.toHaveBeenCalled();
expect(models.getDistinctPromptGroupCategories).not.toHaveBeenCalled();
});
});
2 changes: 2 additions & 0 deletions api/server/routes/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ const {
resolveCodeEnvironmentMoveCapabilities,
resolveCodeWorkspaceInheritanceCapability,
resolveCodeEnvironmentTransitionVersion,
getPromptCategoriesStartupConfig,
loadConversationListLimits,
} = require('@librechat/api');
const {
Expand Down Expand Up @@ -315,6 +316,7 @@ router.get('/', async function (req, res) {
appConfig,
endpoint: EModelEndpoint.agents,
}),
promptCategories: getPromptCategoriesStartupConfig(appConfig),
turnstile: appConfig?.turnstileConfig,
modelSpecs: sanitizeModelSpecs(excludeHiddenModelSpecs(appConfig?.modelSpecs)),
balance: balanceConfig,
Expand Down
35 changes: 35 additions & 0 deletions api/server/routes/prompts.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -313,6 +313,31 @@ describe('Prompt Routes - ACL Permissions', () => {
await expect(PromptGroup.countDocuments()).resolves.toBe(0);
});

it('should reject a reserved category on create', async () => {
const response = await request(app)
.post('/api/prompts')
.send({
prompt: { prompt: 'Category prompt', type: 'text' },
group: { name: 'Category Group', category: 'sys__x' },
});

expect(response.status).toBe(400);
await expect(PromptGroup.countDocuments()).resolves.toBe(0);
});

it('should store a valid custom category trimmed on create', async () => {
const response = await request(app)
.post('/api/prompts')
.send({
prompt: { prompt: 'Category prompt', type: 'text' },
group: { name: 'Category Group', category: ' Onboarding ' },
});

expect(response.status).toBe(200);
const stored = await PromptGroup.findOne({ name: 'Category Group' }).lean();
expect(stored.category).toBe('Onboarding');
});

it('should create a prompt and grant owner permissions', async () => {
const promptData = {
prompt: {
Expand Down Expand Up @@ -943,6 +968,16 @@ describe('Prompt Routes - ACL Permissions', () => {
await AclEntry.deleteMany({});
});

it('should reject an over-long category on update and keep the stored category', async () => {
await request(app)
.patch(`/api/prompts/groups/${testGroup._id}`)
.send({ category: 'a'.repeat(101) })
.expect(400);

const stored = await PromptGroup.findById(testGroup._id).lean();
expect(stored.category).toBe('security-test');
});

it('should allow updating allowed fields (name, category, oneliner)', async () => {
const updateData = {
name: 'Updated Group Name',
Expand Down
Loading