🔗 feat: Add Granular Access Control to Shared Links via ACL System (#13051)

* feat: Add granular access control to shared links via ACL system

* fix(shared-links): preserve isPublic on failed migration grants

Transient ACL failures during auto-migration permanently stranded
links — $unset ran unconditionally, removing the legacy flag that
triggers retry. Now only $unset isPublic after all grants succeed.

* fix(config): skip isPublic unset for failed ACL grants

Bulk migration unconditionally removed isPublic from all links,
even those whose ACL writes failed. Failed links then lost the
legacy marker needed for auto-migration retry. Now tracks failed
link IDs per-batch and excludes them from the $unset step.

Also adds sharedLink to AccessRole resourceType schema enum —
was missing, only worked because seedDefaultRoles uses
findOneAndUpdate which bypasses validation.

* ci(config): add jest config and PR workflow for migration tests

config/__tests__/ specs depend on api/jest.config.js module
mappings but had no dedicated runner. Adds config/jest.config.js
extending api config with absolutized paths, npm test:config
script, and a GitHub Actions workflow triggered by changes to
config/, api/models/, api/db/, or packages/ ACL code.

* fix(permissions): honor boolean sharedLinks config

SHARED_LINKS has no USE permission, so boolean config produced
an empty update payload — gate conditions only matched object
form, making `sharedLinks: false` a no-op on existing perms.

* fix(share): resolve role before creating shared link

Role lookup between create and grant left an orphaned link
without ACL entries if getRoleByName threw — retry then hit "Share already exists" with no recovery path.

* fix: Restore Public ACL Access Checks

* fix: Type Public ACL Lookup

* fix: Preserve Private Legacy Shared Links

* chore: Promote Shared Link Permission Migration

* fix: Address Shared Link Review Findings

* fix: Repair Shared Link CI Follow-Up

* fix: Narrow Shared Link Mongoose Test Mock

* fix: Address Shared Link Review Follow-Ups

* fix: Close Shared Link Review Gaps

* fix: Guard Missing Shared Link Permission Backfill

* test: Add Shared Link Mock E2E

* test: Stabilize Shared Link Mock E2E

---------

Co-authored-by: Danny Avila <danny@librechat.ai>
This commit is contained in:
Atef Bellaaj 2026-06-03 20:17:17 +02:00 committed by GitHub
parent 1fa28ec45b
commit 86fe79c37d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
70 changed files with 3057 additions and 288 deletions

88
.github/workflows/config-review.yml vendored Normal file
View file

@ -0,0 +1,88 @@
name: Config Migration Tests
on:
pull_request:
paths:
- 'config/**'
- 'api/models/**'
- 'api/db/**'
- 'packages/data-schemas/src/**'
- 'packages/data-provider/src/**'
- 'packages/api/src/acl/**'
- 'packages/api/src/shared-links/**'
env:
NODE_ENV: CI
NODE_OPTIONS: '--max-old-space-size=${{ secrets.NODE_MAX_OLD_SPACE_SIZE || 6144 }}'
jobs:
test-config:
name: 'Tests: config migrations'
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v4
- name: Use Node.js 20.19
uses: actions/setup-node@v4
with:
node-version: '20.19'
- name: Restore node_modules cache
id: cache-node-modules
uses: actions/cache@v4
with:
path: |
node_modules
api/node_modules
packages/api/node_modules
packages/data-provider/node_modules
packages/data-schemas/node_modules
key: node-modules-backend-${{ runner.os }}-20.19-${{ hashFiles('package-lock.json') }}
- name: Install dependencies
if: steps.cache-node-modules.outputs.cache-hit != 'true'
run: npm ci
- name: Restore data-provider build cache
id: cache-data-provider
uses: actions/cache@v4
with:
path: packages/data-provider/dist
key: build-data-provider-${{ runner.os }}-${{ hashFiles('packages/data-provider/src/**', 'packages/data-provider/tsconfig*.json', 'packages/data-provider/rollup.config.js', 'packages/data-provider/package.json') }}
- name: Build data-provider
if: steps.cache-data-provider.outputs.cache-hit != 'true'
run: npm run build:data-provider
- name: Restore data-schemas build cache
id: cache-data-schemas
uses: actions/cache@v4
with:
path: packages/data-schemas/dist
key: build-data-schemas-${{ runner.os }}-${{ hashFiles('packages/data-schemas/src/**', 'packages/data-schemas/tsconfig*.json', 'packages/data-schemas/rollup.config.js', 'packages/data-schemas/package.json', 'packages/data-provider/src/**', 'packages/data-provider/tsconfig*.json', 'packages/data-provider/rollup.config.js', 'packages/data-provider/package.json') }}
- name: Build data-schemas
if: steps.cache-data-schemas.outputs.cache-hit != 'true'
run: npm run build:data-schemas
- name: Restore api build cache
id: cache-api
uses: actions/cache@v4
with:
path: packages/api/dist
key: build-api-${{ runner.os }}-${{ hashFiles('packages/api/src/**', 'packages/api/tsconfig*.json', 'packages/api/server-rollup.config.js', 'packages/api/package.json', 'packages/data-provider/src/**', 'packages/data-provider/tsconfig*.json', 'packages/data-provider/rollup.config.js', 'packages/data-provider/package.json', 'packages/data-schemas/src/**', 'packages/data-schemas/tsconfig*.json', 'packages/data-schemas/rollup.config.js', 'packages/data-schemas/package.json') }}
- name: Build api
if: steps.cache-api.outputs.cache-hit != 'true'
run: npm run build:api
- name: Create empty auth.json file
run: |
mkdir -p api/data
echo '{}' > api/data/auth.json
- name: Prepare .env.test file
run: cp api/test/.env.test.example api/test/.env.test
- name: Run config migration tests
run: npm run test:config

View file

@ -134,8 +134,8 @@ const updateResourcePermissions = async (req, res) => {
revokedPrincipals.push(...removed);
}
// If public is disabled, add public to revoked list
if (!isPublic) {
// If public is explicitly disabled, add public to revoked list
if (isPublic === false) {
revokedPrincipals.push({
type: PrincipalType.PUBLIC,
id: null,
@ -167,7 +167,7 @@ const updateResourcePermissions = async (req, res) => {
message: 'Permissions updated successfully',
results: {
principals: results.granted,
public: isPublic || false,
...(isPublic !== undefined ? { public: isPublic } : {}),
publicAccessRoleId: isPublic ? publicAccessRoleId : undefined,
},
};

View file

@ -7,6 +7,7 @@ const {
MCPTokenStorage,
normalizeHttpError,
extractWebSearchEnvVars,
deleteAllSharedLinksWithCleanup,
} = require('@librechat/api');
const {
Tools,
@ -359,7 +360,7 @@ const deleteUserController = async (req, res) => {
}
await deleteUserPluginAuth(user.id, null, true);
await db.deleteUserById(user.id);
await db.deleteAllSharedLinks(user.id);
await deleteAllSharedLinksWithCleanup(user.id);
await deleteUserFiles(req);
await db.deleteFiles(null, user.id);
await db.deleteToolCalls(user.id);

View file

@ -3,6 +3,7 @@ const mockDeleteMessages = jest.fn();
const mockDeleteAllUserSessions = jest.fn();
const mockDeleteUserById = jest.fn();
const mockDeleteAllSharedLinks = jest.fn();
const mockDeleteAllSharedLinksWithCleanup = jest.fn();
const mockDeletePresets = jest.fn();
const mockDeleteUserKey = jest.fn();
const mockDeleteConvos = jest.fn();
@ -38,6 +39,7 @@ jest.mock('@librechat/api', () => ({
extractWebSearchEnvVars: jest.fn(),
needsRefresh: jest.fn(),
getNewS3URL: jest.fn(),
deleteAllSharedLinksWithCleanup: (...args) => mockDeleteAllSharedLinksWithCleanup(...args),
}));
jest.mock('~/models', () => ({
@ -126,6 +128,7 @@ function stubDeletionMocks() {
mockDeleteUserPluginAuth.mockResolvedValue();
mockDeleteUserById.mockResolvedValue();
mockDeleteAllSharedLinks.mockResolvedValue();
mockDeleteAllSharedLinksWithCleanup.mockResolvedValue({ deletedCount: 0 });
mockGetFiles.mockResolvedValue([]);
mockProcessDeleteRequest.mockResolvedValue({ deletedFileIds: [], failedFileIds: [] });
mockDeleteFiles.mockResolvedValue();

View file

@ -16,6 +16,7 @@ const HANDLED_RESOURCE_TYPES = {
[ResourceType.PROMPTGROUP]: 'deleteUserPrompts',
[ResourceType.MCPSERVER]: 'deleteUserMcpServers',
[ResourceType.SKILL]: 'deleteUserSkills',
[ResourceType.SHARED_LINK]: 'deleteAllSharedLinksWithCleanup',
};
/**

View file

@ -0,0 +1,6 @@
const mongoose = require('mongoose');
const { createSharedLinkAccessMiddleware } = require('@librechat/api');
const canAccessSharedLink = createSharedLinkAccessMiddleware({ mongoose });
module.exports = canAccessSharedLink;

View file

@ -12,6 +12,8 @@ module.exports = {
})),
logAxiosError: jest.fn(),
restoreTenantContextFromReq: jest.fn((req, res, next) => next()),
deleteConvoSharedLinksWithCleanup: jest.fn(),
deleteAllSharedLinksWithCleanup: jest.fn(),
...overrides,
}),

View file

@ -21,13 +21,11 @@ jest.mock('~/server/services/Endpoints/assistants', () => require(MOCKS).assista
describe('Convos Routes', () => {
let app;
let convosRouter;
const { deleteToolCalls, deleteConvos, saveConvo } = require('~/models');
const {
deleteAllSharedLinks,
deleteConvoSharedLink,
deleteToolCalls,
deleteConvos,
saveConvo,
} = require('~/models');
deleteAllSharedLinksWithCleanup,
deleteConvoSharedLinksWithCleanup,
} = require('@librechat/api');
beforeAll(() => {
convosRouter = require('../convos');
@ -57,7 +55,7 @@ describe('Convos Routes', () => {
deleteConvos.mockResolvedValue(mockDbResponse);
deleteToolCalls.mockResolvedValue({ deletedCount: 10 });
deleteAllSharedLinks.mockResolvedValue({
deleteAllSharedLinksWithCleanup.mockResolvedValue({
message: 'All shared links deleted successfully',
deletedCount: 3,
});
@ -75,12 +73,12 @@ describe('Convos Routes', () => {
expect(deleteToolCalls).toHaveBeenCalledWith('test-user-123');
expect(deleteToolCalls).toHaveBeenCalledTimes(1);
/** Verify deleteAllSharedLinks was called with correct userId */
expect(deleteAllSharedLinks).toHaveBeenCalledWith('test-user-123');
expect(deleteAllSharedLinks).toHaveBeenCalledTimes(1);
/** Verify deleteAllSharedLinksWithCleanup was called with correct userId */
expect(deleteAllSharedLinksWithCleanup).toHaveBeenCalledWith('test-user-123');
expect(deleteAllSharedLinksWithCleanup).toHaveBeenCalledTimes(1);
});
it('should call deleteAllSharedLinks even when no conversations exist', async () => {
it('should call deleteAllSharedLinksWithCleanup even when no conversations exist', async () => {
const mockDbResponse = {
deletedCount: 0,
message: 'No conversations to delete',
@ -88,7 +86,7 @@ describe('Convos Routes', () => {
deleteConvos.mockResolvedValue(mockDbResponse);
deleteToolCalls.mockResolvedValue({ deletedCount: 0 });
deleteAllSharedLinks.mockResolvedValue({
deleteAllSharedLinksWithCleanup.mockResolvedValue({
message: 'All shared links deleted successfully',
deletedCount: 0,
});
@ -96,7 +94,7 @@ describe('Convos Routes', () => {
const response = await request(app).delete('/api/convos/all');
expect(response.status).toBe(201);
expect(deleteAllSharedLinks).toHaveBeenCalledWith('test-user-123');
expect(deleteAllSharedLinksWithCleanup).toHaveBeenCalledWith('test-user-123');
});
it('should return 500 if deleteConvos fails', async () => {
@ -123,10 +121,10 @@ describe('Convos Routes', () => {
expect(response.text).toBe('Error clearing conversations');
});
it('should return 500 if deleteAllSharedLinks fails', async () => {
it('should return 500 if deleteAllSharedLinksWithCleanup fails', async () => {
deleteConvos.mockResolvedValue({ deletedCount: 5 });
deleteToolCalls.mockResolvedValue({ deletedCount: 10 });
deleteAllSharedLinks.mockRejectedValue(new Error('Shared links deletion failed'));
deleteAllSharedLinksWithCleanup.mockRejectedValue(new Error('Shared links deletion failed'));
const response = await request(app).delete('/api/convos/all');
@ -138,12 +136,12 @@ describe('Convos Routes', () => {
/** First user */
deleteConvos.mockResolvedValue({ deletedCount: 3 });
deleteToolCalls.mockResolvedValue({ deletedCount: 5 });
deleteAllSharedLinks.mockResolvedValue({ deletedCount: 2 });
deleteAllSharedLinksWithCleanup.mockResolvedValue({ deletedCount: 2 });
let response = await request(app).delete('/api/convos/all');
expect(response.status).toBe(201);
expect(deleteAllSharedLinks).toHaveBeenCalledWith('test-user-123');
expect(deleteAllSharedLinksWithCleanup).toHaveBeenCalledWith('test-user-123');
jest.clearAllMocks();
@ -158,12 +156,12 @@ describe('Convos Routes', () => {
deleteConvos.mockResolvedValue({ deletedCount: 7 });
deleteToolCalls.mockResolvedValue({ deletedCount: 12 });
deleteAllSharedLinks.mockResolvedValue({ deletedCount: 4 });
deleteAllSharedLinksWithCleanup.mockResolvedValue({ deletedCount: 4 });
response = await request(app2).delete('/api/convos/all');
expect(response.status).toBe(201);
expect(deleteAllSharedLinks).toHaveBeenCalledWith('test-user-456');
expect(deleteAllSharedLinksWithCleanup).toHaveBeenCalledWith('test-user-456');
});
it('should execute deletions in correct sequence', async () => {
@ -179,15 +177,19 @@ describe('Convos Routes', () => {
return Promise.resolve({ deletedCount: 10 });
});
deleteAllSharedLinks.mockImplementation(() => {
executionOrder.push('deleteAllSharedLinks');
deleteAllSharedLinksWithCleanup.mockImplementation(() => {
executionOrder.push('deleteAllSharedLinksWithCleanup');
return Promise.resolve({ deletedCount: 3 });
});
await request(app).delete('/api/convos/all');
/** Verify all three functions were called */
expect(executionOrder).toEqual(['deleteConvos', 'deleteToolCalls', 'deleteAllSharedLinks']);
expect(executionOrder).toEqual([
'deleteConvos',
'deleteToolCalls',
'deleteAllSharedLinksWithCleanup',
]);
});
it('should maintain data integrity by cleaning up shared links when conversations are deleted', async () => {
@ -201,17 +203,17 @@ describe('Convos Routes', () => {
deleteConvos.mockResolvedValue(mockConvosDeleted);
deleteToolCalls.mockResolvedValue(mockToolCallsDeleted);
deleteAllSharedLinks.mockResolvedValue(mockSharedLinksDeleted);
deleteAllSharedLinksWithCleanup.mockResolvedValue(mockSharedLinksDeleted);
const response = await request(app).delete('/api/convos/all');
expect(response.status).toBe(201);
/** Verify that shared links cleanup was called for the same user */
expect(deleteAllSharedLinks).toHaveBeenCalledWith('test-user-123');
expect(deleteAllSharedLinksWithCleanup).toHaveBeenCalledWith('test-user-123');
/** Verify no shared links remain for deleted conversations */
expect(deleteAllSharedLinks).toHaveBeenCalledAfter(deleteConvos);
expect(deleteAllSharedLinksWithCleanup).toHaveBeenCalledAfter(deleteConvos);
});
});
@ -225,7 +227,7 @@ describe('Convos Routes', () => {
deleteConvos.mockResolvedValue(mockDbResponse);
deleteToolCalls.mockResolvedValue({ deletedCount: 3 });
deleteConvoSharedLink.mockResolvedValue({
deleteConvoSharedLinksWithCleanup.mockResolvedValue({
message: 'Shared links deleted successfully',
deletedCount: 1,
});
@ -249,11 +251,14 @@ describe('Convos Routes', () => {
/** Verify deleteToolCalls was called */
expect(deleteToolCalls).toHaveBeenCalledWith('test-user-123', mockConversationId);
/** Verify deleteConvoSharedLink was called */
expect(deleteConvoSharedLink).toHaveBeenCalledWith('test-user-123', mockConversationId);
/** Verify deleteConvoSharedLinksWithCleanup was called */
expect(deleteConvoSharedLinksWithCleanup).toHaveBeenCalledWith(
'test-user-123',
mockConversationId,
);
});
it('should not call deleteConvoSharedLink when no conversationId provided', async () => {
it('should not call deleteConvoSharedLinksWithCleanup when no conversationId provided', async () => {
deleteConvos.mockResolvedValue({ deletedCount: 0 });
deleteToolCalls.mockResolvedValue({ deletedCount: 0 });
@ -266,7 +271,7 @@ describe('Convos Routes', () => {
});
expect(response.status).toBe(200);
expect(deleteConvoSharedLink).not.toHaveBeenCalled();
expect(deleteConvoSharedLinksWithCleanup).not.toHaveBeenCalled();
});
it('should handle deletion of conversation without shared links', async () => {
@ -274,7 +279,7 @@ describe('Convos Routes', () => {
deleteConvos.mockResolvedValue({ deletedCount: 1 });
deleteToolCalls.mockResolvedValue({ deletedCount: 0 });
deleteConvoSharedLink.mockResolvedValue({
deleteConvoSharedLinksWithCleanup.mockResolvedValue({
message: 'Shared links deleted successfully',
deletedCount: 0,
});
@ -288,7 +293,10 @@ describe('Convos Routes', () => {
});
expect(response.status).toBe(201);
expect(deleteConvoSharedLink).toHaveBeenCalledWith('test-user-123', mockConversationId);
expect(deleteConvoSharedLinksWithCleanup).toHaveBeenCalledWith(
'test-user-123',
mockConversationId,
);
});
it('should return 400 when no parameters provided', async () => {
@ -299,7 +307,7 @@ describe('Convos Routes', () => {
expect(response.status).toBe(400);
expect(response.body).toEqual({ error: 'no parameters provided' });
expect(deleteConvos).not.toHaveBeenCalled();
expect(deleteConvoSharedLink).not.toHaveBeenCalled();
expect(deleteConvoSharedLinksWithCleanup).not.toHaveBeenCalled();
});
it('should return 400 when request body is empty (DoS prevention)', async () => {
@ -336,12 +344,14 @@ describe('Convos Routes', () => {
expect(deleteConvos).not.toHaveBeenCalled();
});
it('should return 500 if deleteConvoSharedLink fails', async () => {
it('should return 500 if deleteConvoSharedLinksWithCleanup fails', async () => {
const mockConversationId = 'conv-error';
deleteConvos.mockResolvedValue({ deletedCount: 1 });
deleteToolCalls.mockResolvedValue({ deletedCount: 2 });
deleteConvoSharedLink.mockRejectedValue(new Error('Failed to delete shared links'));
deleteConvoSharedLinksWithCleanup.mockRejectedValue(
new Error('Failed to delete shared links'),
);
const response = await request(app)
.delete('/api/convos')
@ -369,8 +379,8 @@ describe('Convos Routes', () => {
return Promise.resolve({ deletedCount: 2 });
});
deleteConvoSharedLink.mockImplementation(() => {
executionOrder.push('deleteConvoSharedLink');
deleteConvoSharedLinksWithCleanup.mockImplementation(() => {
executionOrder.push('deleteConvoSharedLinksWithCleanup');
return Promise.resolve({ deletedCount: 1 });
});
@ -382,7 +392,11 @@ describe('Convos Routes', () => {
},
});
expect(executionOrder).toEqual(['deleteConvos', 'deleteToolCalls', 'deleteConvoSharedLink']);
expect(executionOrder).toEqual([
'deleteConvos',
'deleteToolCalls',
'deleteConvoSharedLinksWithCleanup',
]);
});
it('should prevent orphaned shared links when deleting single conversation', async () => {
@ -390,7 +404,7 @@ describe('Convos Routes', () => {
deleteConvos.mockResolvedValue({ deletedCount: 1 });
deleteToolCalls.mockResolvedValue({ deletedCount: 4 });
deleteConvoSharedLink.mockResolvedValue({
deleteConvoSharedLinksWithCleanup.mockResolvedValue({
message: 'Shared links deleted successfully',
deletedCount: 2,
});
@ -406,10 +420,13 @@ describe('Convos Routes', () => {
expect(response.status).toBe(201);
/** Verify shared links were deleted for the specific conversation */
expect(deleteConvoSharedLink).toHaveBeenCalledWith('test-user-123', mockConversationId);
expect(deleteConvoSharedLinksWithCleanup).toHaveBeenCalledWith(
'test-user-123',
mockConversationId,
);
/** Verify it was called after the conversation was deleted */
expect(deleteConvoSharedLink).toHaveBeenCalledAfter(deleteConvos);
expect(deleteConvoSharedLinksWithCleanup).toHaveBeenCalledAfter(deleteConvos);
});
});

View file

@ -3,9 +3,18 @@ const request = require('supertest');
const mongoose = require('mongoose');
const mockGetSharedLinkExpiration = jest.fn();
const mockGrantCreationPermissions = jest.fn();
const mockUpdateSharedLinkPermissionsExpiration = jest.fn();
const mockSharedLinksAccess = jest.fn((_req, _res, next) => next());
jest.mock('@librechat/api', () => ({
isEnabled: jest.fn(() => true),
generateCheckAccess: jest.fn(() => mockSharedLinksAccess),
grantCreationPermissions: (...args) => mockGrantCreationPermissions(...args),
updateSharedLinkPermissionsExpiration: (...args) =>
mockUpdateSharedLinkPermissionsExpiration(...args),
ensureLinkPermissions: jest.fn(),
deleteSharedLinkWithCleanup: jest.fn(),
getSharedLinkExpiration: (...args) => mockGetSharedLinkExpiration(...args),
isActiveExpirationDate: jest.fn((expiredAt) => expiredAt > new Date()),
}));
@ -16,6 +25,13 @@ jest.mock('@librechat/data-schemas', () => ({
}));
jest.mock('librechat-data-provider', () => ({
PermissionTypes: {
SHARED_LINKS: 'SHARED_LINKS',
},
Permissions: {
CREATE: 'CREATE',
SHARE_PUBLIC: 'SHARE_PUBLIC',
},
RetentionMode: {
ALL: 'all',
TEMPORARY: 'temporary',
@ -40,13 +56,17 @@ jest.mock('~/models', () => ({
deleteSharedLink: jest.fn(),
getSharedLinks: jest.fn(),
getSharedLink: jest.fn(),
getRoleByName: jest.fn(),
}));
jest.mock('~/server/middleware/canAccessSharedLink', () => (_req, _res, next) => next());
jest.mock('~/server/middleware/optionalJwtAuth', () => (req, _res, next) => next());
jest.mock('~/server/middleware/requireJwtAuth', () => (req, res, next) => next());
const { RetentionMode } = require('librechat-data-provider');
const { createTempChatExpirationDate, logger } = require('@librechat/data-schemas');
const { createSharedLink, updateSharedLink } = require('~/models');
const { deleteSharedLinkWithCleanup } = require('@librechat/api');
const { createSharedLink, updateSharedLink, getRoleByName } = require('~/models');
const shareRouter = require('../share');
const activeExpiration = new Date('2030-01-01T00:00:00.000Z');
@ -71,11 +91,19 @@ const buildApp = ({ retentionMode = RetentionMode.TEMPORARY } = {}) => {
describe('share routes retention', () => {
beforeEach(() => {
jest.clearAllMocks();
getRoleByName.mockResolvedValue({
permissions: {
SHARED_LINKS: {
SHARE_PUBLIC: true,
},
},
});
mockGrantCreationPermissions.mockResolvedValue(undefined);
});
it('expires new shares for retained non-temporary conversations', async () => {
mockGetSharedLinkExpiration.mockResolvedValue(activeExpiration);
createSharedLink.mockResolvedValue({ shareId: 'share-123' });
createSharedLink.mockResolvedValue({ _id: 'link-123', shareId: 'share-123' });
const response = await request(buildApp())
.post('/api/share/convo-123')
@ -106,11 +134,18 @@ describe('share routes retention', () => {
'msg-123',
new Date('2030-01-01T00:00:00.000Z'),
);
expect(mockGrantCreationPermissions).toHaveBeenCalledWith(
'link-123',
'user-123',
true,
new Date('2030-01-01T00:00:00.000Z'),
);
expect(mockSharedLinksAccess).toHaveBeenCalled();
});
it('rejects new shares when the retained conversation expired', async () => {
mockGetSharedLinkExpiration.mockResolvedValue(expiredExpiration);
createSharedLink.mockResolvedValue({ shareId: 'share-123' });
createSharedLink.mockResolvedValue({ _id: 'link-123', shareId: 'share-123' });
const response = await request(buildApp())
.post('/api/share/convo-123')
@ -122,7 +157,7 @@ describe('share routes retention', () => {
it('rejects new shares for expired conversations in all retention mode', async () => {
mockGetSharedLinkExpiration.mockResolvedValue(expiredExpiration);
createSharedLink.mockResolvedValue({ shareId: 'share-123' });
createSharedLink.mockResolvedValue({ _id: 'link-123', shareId: 'share-123' });
const response = await request(buildApp({ retentionMode: RetentionMode.ALL }))
.post('/api/share/convo-123')
@ -135,7 +170,7 @@ describe('share routes retention', () => {
it('expires updated shares for retained non-temporary conversations', async () => {
mongoose.models.SharedLink.findOne.mockReturnValue(lean({ conversationId: 'convo-123' }));
mockGetSharedLinkExpiration.mockResolvedValue(activeExpiration);
updateSharedLink.mockResolvedValue({ shareId: 'share-456' });
updateSharedLink.mockResolvedValue({ _id: 'link-456', shareId: 'share-456' });
const response = await request(buildApp()).patch('/api/share/share-123');
@ -162,6 +197,10 @@ describe('share routes retention', () => {
undefined,
new Date('2030-01-01T00:00:00.000Z'),
);
expect(mockUpdateSharedLinkPermissionsExpiration).toHaveBeenCalledWith(
'link-456',
new Date('2030-01-01T00:00:00.000Z'),
);
});
it('rejects updated shares when the retained conversation expired', async () => {
@ -195,12 +234,14 @@ describe('share routes retention', () => {
it('clears updated share expiration when the conversation is no longer retained', async () => {
mongoose.models.SharedLink.findOne.mockReturnValue(lean({ conversationId: 'convo-123' }));
mockGetSharedLinkExpiration.mockResolvedValue(null);
updateSharedLink.mockResolvedValue({ shareId: 'share-456' });
updateSharedLink.mockResolvedValue({ _id: 'link-456', shareId: 'share-456' });
const response = await request(buildApp()).patch('/api/share/share-123');
expect(response.status).toBe(200);
expect(updateSharedLink).toHaveBeenCalledWith('user-123', 'share-123', undefined, null);
expect(mockUpdateSharedLinkPermissionsExpiration).toHaveBeenCalledWith('link-456', null);
expect(mockSharedLinksAccess).not.toHaveBeenCalled();
});
it('preserves updated share expiration when the conversation cannot be found', async () => {
@ -212,6 +253,7 @@ describe('share routes retention', () => {
expect(response.status).toBe(200);
expect(updateSharedLink).toHaveBeenCalledWith('user-123', 'share-123', undefined, undefined);
expect(mockUpdateSharedLinkPermissionsExpiration).not.toHaveBeenCalled();
});
it('clears updated share expiration when creating a new expiration throws', async () => {
@ -221,7 +263,7 @@ describe('share routes retention', () => {
dependencies.logger.error('[getSharedLinkExpiration] Error creating expiration date:', error);
return null;
});
updateSharedLink.mockResolvedValue({ shareId: 'share-456' });
updateSharedLink.mockResolvedValue({ _id: 'link-456', shareId: 'share-456' });
const response = await request(buildApp()).patch('/api/share/share-123');
@ -231,6 +273,7 @@ describe('share routes retention', () => {
error,
);
expect(updateSharedLink).toHaveBeenCalledWith('user-123', 'share-123', undefined, null);
expect(mockUpdateSharedLinkPermissionsExpiration).toHaveBeenCalledWith('link-456', null);
});
it('updates share target message while applying retention expiration', async () => {
@ -259,4 +302,14 @@ describe('share routes retention', () => {
expect(response.status).toBe(400);
expect(updateSharedLink).not.toHaveBeenCalled();
});
it('allows deleting existing shares without CREATE permission gate', async () => {
deleteSharedLinkWithCleanup.mockResolvedValue({ shareId: 'share-123' });
const response = await request(buildApp()).delete('/api/share/share-123');
expect(response.status).toBe(200);
expect(mockSharedLinksAccess).not.toHaveBeenCalled();
expect(deleteSharedLinkWithCleanup).toHaveBeenCalledWith('user-123', 'share-123');
});
});

View file

@ -1,5 +1,11 @@
const mongoose = require('mongoose');
const express = require('express');
const { ResourceType, PermissionBits } = require('librechat-data-provider');
const {
AccessRoleIds,
PrincipalType,
ResourceType,
PermissionBits,
} = require('librechat-data-provider');
const {
getUserEffectivePermissions,
getAllEffectivePermissions,
@ -82,6 +88,12 @@ const checkResourcePermissionAccess = (requiredPermission) => (req, res, next) =
resourceIdParam: 'resourceId',
idResolver: getSkillById,
});
} else if (resourceType === ResourceType.SHARED_LINK) {
middleware = canAccessResource({
resourceType: ResourceType.SHARED_LINK,
requiredPermission,
resourceIdParam: 'resourceId',
});
} else {
return res.status(400).json({
error: 'Bad Request',
@ -93,6 +105,57 @@ const checkResourcePermissionAccess = (requiredPermission) => (req, res, next) =
middleware(req, res, next);
};
const rejectSharedLinkOwnerPermissionChanges = async (req, res, next) => {
if (req.params.resourceType !== ResourceType.SHARED_LINK) {
return next();
}
const updated = Array.isArray(req.body?.updated) ? req.body.updated : [];
const removed = Array.isArray(req.body?.removed) ? req.body.removed : [];
const grantsOwner = updated.some(
(principal) => principal?.accessRoleId === AccessRoleIds.SHARED_LINK_OWNER,
);
const grantsPublicOwner = req.body?.publicAccessRoleId === AccessRoleIds.SHARED_LINK_OWNER;
if (grantsOwner || grantsPublicOwner) {
return res.status(400).json({
error: 'Bad Request',
message: 'Shared link owner permissions cannot be changed',
});
}
const userMutations = [...updated, ...removed].filter(
(principal) => principal?.type === PrincipalType.USER && principal?.id,
);
if (userMutations.length === 0) {
return next();
}
try {
const SharedLink = mongoose.models.SharedLink;
const link = await SharedLink.findById(req.params.resourceId, 'user').lean();
const ownerId = link?.user?.toString();
const touchesOwner = ownerId
? userMutations.some((principal) => principal.id?.toString() === ownerId)
: false;
if (touchesOwner) {
return res.status(400).json({
error: 'Bad Request',
message: 'Shared link owner permissions cannot be changed',
});
}
} catch (_error) {
return res.status(500).json({
error: 'Internal Server Error',
message: 'Failed to validate shared link owner permissions',
});
}
return next();
};
/**
* GET /api/permissions/{resourceType}/{resourceId}
* Get all permissions for a specific resource
@ -115,6 +178,7 @@ router.put(
checkResourcePermissionAccess(PermissionBits.SHARE),
checkShareAccess,
checkSharePublicAccess,
rejectSharedLinkOwnerPermissionChanges,
updateResourcePermissions,
);

View file

@ -30,6 +30,7 @@ jest.mock('~/server/controllers/PermissionsController', () => ({
const express = require('express');
const request = require('supertest');
const mongoose = require('mongoose');
const {
SystemRoles,
ResourceType,
@ -48,6 +49,8 @@ const { getRoleByName } = require('~/models');
describe('Access permissions share policy', () => {
let app;
const mockSharedLinkFindById = jest.fn();
const originalSharedLinkModel = mongoose.models.SharedLink;
const resourceId = '507f1f77bcf86cd799439011';
const sharePolicyCases = [
@ -116,8 +119,30 @@ describe('Access permissions share policy', () => {
accessRoleId,
});
const allowSharedLinkSharing = () => {
getRoleByName.mockResolvedValue({
permissions: {
[PermissionTypes.SHARED_LINKS]: {
[Permissions.SHARE]: true,
[Permissions.SHARE_PUBLIC]: true,
},
},
});
};
const mockSharedLinkOwner = (ownerId = 'owner-user') => {
mockSharedLinkFindById.mockReturnValue({
lean: jest.fn().mockResolvedValue({ user: ownerId }),
});
};
beforeEach(() => {
jest.clearAllMocks();
if (mongoose.models.SharedLink) {
mongoose.models.SharedLink.findById = mockSharedLinkFindById;
} else {
mongoose.models.SharedLink = { findById: mockSharedLinkFindById };
}
hasCapability.mockResolvedValue(false);
app = express();
@ -129,6 +154,14 @@ describe('Access permissions share policy', () => {
app.use('/api/permissions', accessPermissionsRouter);
});
afterAll(() => {
if (originalSharedLinkModel) {
mongoose.models.SharedLink = originalSharedLinkModel;
} else {
delete mongoose.models.SharedLink;
}
});
it.each(sharePolicyCases)(
'blocks non-public $label sharing when ACL SHARE passes but role SHARE is disabled',
async ({ resourceType, permissionType, accessRoleId, middlewareOptions }) => {
@ -208,4 +241,80 @@ describe('Access permissions share policy', () => {
});
expect(updateResourcePermissions).not.toHaveBeenCalled();
});
it('blocks granting shared-link owner through generic permission updates', async () => {
allowSharedLinkSharing();
mockSharedLinkOwner();
const response = await request(app)
.put(`/api/permissions/${ResourceType.SHARED_LINK}/${resourceId}`)
.send({
updated: [
{
type: PrincipalType.USER,
id: 'target-user',
accessRoleId: AccessRoleIds.SHARED_LINK_OWNER,
},
],
public: false,
});
expect(response.status).toBe(400);
expect(response.body.message).toBe('Shared link owner permissions cannot be changed');
expect(updateResourcePermissions).not.toHaveBeenCalled();
});
it('blocks granting shared-link owner to the public principal', async () => {
allowSharedLinkSharing();
const response = await request(app)
.put(`/api/permissions/${ResourceType.SHARED_LINK}/${resourceId}`)
.send({
public: true,
publicAccessRoleId: AccessRoleIds.SHARED_LINK_OWNER,
});
expect(response.status).toBe(400);
expect(response.body.message).toBe('Shared link owner permissions cannot be changed');
expect(updateResourcePermissions).not.toHaveBeenCalled();
expect(mockSharedLinkFindById).not.toHaveBeenCalled();
});
it('blocks removing the canonical shared-link owner', async () => {
allowSharedLinkSharing();
mockSharedLinkOwner('owner-user');
const response = await request(app)
.put(`/api/permissions/${ResourceType.SHARED_LINK}/${resourceId}`)
.send({
updated: [],
removed: [{ type: PrincipalType.USER, id: 'owner-user' }],
public: false,
});
expect(response.status).toBe(400);
expect(response.body.message).toBe('Shared link owner permissions cannot be changed');
expect(updateResourcePermissions).not.toHaveBeenCalled();
});
it('allows viewer grants for non-owner shared-link users', async () => {
allowSharedLinkSharing();
mockSharedLinkOwner('owner-user');
const response = await request(app)
.put(`/api/permissions/${ResourceType.SHARED_LINK}/${resourceId}`)
.send({
updated: [
{
type: PrincipalType.USER,
id: 'target-user',
accessRoleId: AccessRoleIds.SHARED_LINK_VIEWER,
},
],
public: false,
});
expect(response.status).toBe(200);
expect(updateResourcePermissions).toHaveBeenCalledTimes(1);
});
});

View file

@ -5,6 +5,8 @@ const {
isEnabled,
resolveImportMaxFileSize,
restoreTenantContextFromReq,
deleteAllSharedLinksWithCleanup,
deleteConvoSharedLinksWithCleanup,
} = require('@librechat/api');
const { logger } = require('@librechat/data-schemas');
const { CacheKeys, EModelEndpoint } = require('librechat-data-provider');
@ -133,7 +135,7 @@ router.delete('/', async (req, res) => {
const dbResponse = await db.deleteConvos(req.user.id, filter);
if (filter.conversationId) {
await db.deleteToolCalls(req.user.id, filter.conversationId);
await db.deleteConvoSharedLink(req.user.id, filter.conversationId);
await deleteConvoSharedLinksWithCleanup(req.user.id, filter.conversationId);
}
res.status(201).json(dbResponse);
} catch (error) {
@ -146,7 +148,7 @@ router.delete('/all', async (req, res) => {
try {
const dbResponse = await db.deleteConvos(req.user.id, {});
await db.deleteToolCalls(req.user.id);
await db.deleteAllSharedLinks(req.user.id);
await deleteAllSharedLinksWithCleanup(req.user.id);
res.status(201).json(dbResponse);
} catch (error) {
logger.error('Error clearing conversations', error);

View file

@ -1,18 +1,36 @@
const mongoose = require('mongoose');
const express = require('express');
const { isEnabled, isActiveExpirationDate, getSharedLinkExpiration } = require('@librechat/api');
const {
isEnabled,
generateCheckAccess,
grantCreationPermissions,
ensureLinkPermissions,
deleteSharedLinkWithCleanup,
updateSharedLinkPermissionsExpiration,
isActiveExpirationDate,
getSharedLinkExpiration,
} = require('@librechat/api');
const { logger, createTempChatExpirationDate } = require('@librechat/data-schemas');
const { PermissionTypes, Permissions } = require('librechat-data-provider');
const {
getSharedMessages,
createSharedLink,
updateSharedLink,
deleteSharedLink,
getSharedLinks,
getSharedLink,
getRoleByName,
} = require('~/models');
const canAccessSharedLink = require('~/server/middleware/canAccessSharedLink');
const optionalJwtAuth = require('~/server/middleware/optionalJwtAuth');
const requireJwtAuth = require('~/server/middleware/requireJwtAuth');
const router = express.Router();
const checkSharedLinksAccess = generateCheckAccess({
permissionType: PermissionTypes.SHARED_LINKS,
permissions: [Permissions.CREATE],
getRoleByName,
});
const resolveSharedLinkExpiration = (req, conversationId) =>
getSharedLinkExpiration(
{ req, conversationId },
@ -36,25 +54,19 @@ const allowSharedLinks =
process.env.ALLOW_SHARED_LINKS === undefined || isEnabled(process.env.ALLOW_SHARED_LINKS);
if (allowSharedLinks) {
const allowSharedLinksPublic = isEnabled(process.env.ALLOW_SHARED_LINKS_PUBLIC);
router.get(
'/:shareId',
allowSharedLinksPublic ? (req, res, next) => next() : requireJwtAuth,
async (req, res) => {
try {
const share = await getSharedMessages(req.params.shareId);
if (share) {
res.status(200).json(share);
} else {
res.status(404).end();
}
} catch (error) {
logger.error('Error getting shared messages:', error);
res.status(500).json({ message: 'Error getting shared messages' });
router.get('/:shareId', optionalJwtAuth, canAccessSharedLink, async (req, res) => {
try {
const share = await getSharedMessages(req.params.shareId, req.shareResourceId);
if (share) {
res.status(200).json(share);
} else {
res.status(404).end();
}
},
);
} catch (error) {
logger.error('Error getting shared messages:', error);
res.status(500).json({ message: 'Error getting shared messages' });
}
});
}
/**
@ -65,7 +77,6 @@ router.get('/', requireJwtAuth, async (req, res) => {
const params = {
pageParam: req.query.cursor,
pageSize: Math.max(1, parseInt(req.query.pageSize) || 10),
isPublic: isEnabled(req.query.isPublic),
sortBy: ['createdAt', 'title'].includes(req.query.sortBy) ? req.query.sortBy : 'createdAt',
sortDirection: ['asc', 'desc'].includes(req.query.sortDirection)
? req.query.sortDirection
@ -77,7 +88,6 @@ router.get('/', requireJwtAuth, async (req, res) => {
req.user.id,
params.pageParam,
params.pageSize,
params.isPublic,
params.sortBy,
params.sortDirection,
params.search,
@ -101,7 +111,12 @@ router.get('/link/:conversationId', requireJwtAuth, async (req, res) => {
try {
const share = await getSharedLink(req.user.id, req.params.conversationId);
if (share._id && share.success) {
await ensureLinkPermissions(share._id, req.user.id);
}
return res.status(200).json({
_id: share._id,
success: share.success,
shareId: share.shareId,
targetMessageId: share.targetMessageId,
@ -113,7 +128,7 @@ router.get('/link/:conversationId', requireJwtAuth, async (req, res) => {
}
});
router.post('/:conversationId', requireJwtAuth, async (req, res) => {
router.post('/:conversationId', requireJwtAuth, checkSharedLinksAccess, async (req, res) => {
try {
const { targetMessageId } = req.body;
const expiredAt = await resolveSharedLinkExpiration(req, req.params.conversationId);
@ -121,6 +136,10 @@ router.post('/:conversationId', requireJwtAuth, async (req, res) => {
return res.status(404).end();
}
const role = await getRoleByName(req.user.role);
const sharedLinksPerms = role?.permissions?.[PermissionTypes.SHARED_LINKS] || {};
const grantPublic = sharedLinksPerms[Permissions.SHARE_PUBLIC] === true;
const created = await createSharedLink(
req.user.id,
req.params.conversationId,
@ -128,6 +147,7 @@ router.post('/:conversationId', requireJwtAuth, async (req, res) => {
expiredAt,
);
if (created) {
await grantCreationPermissions(created._id, req.user.id, grantPublic, expiredAt);
res.status(200).json(created);
} else {
res.status(404).end();
@ -165,6 +185,9 @@ router.patch('/:shareId', requireJwtAuth, async (req, res) => {
expiredAt,
);
if (updatedShare) {
if (updatedShare._id && expiredAt !== undefined) {
await updateSharedLinkPermissionsExpiration(updatedShare._id, expiredAt);
}
res.status(200).json(updatedShare);
} else {
res.status(404).end();
@ -177,7 +200,7 @@ router.patch('/:shareId', requireJwtAuth, async (req, res) => {
router.delete('/:shareId', requireJwtAuth, async (req, res) => {
try {
const result = await deleteSharedLink(req.user.id, req.params.shareId);
const result = await deleteSharedLinkWithCleanup(req.user.id, req.params.shareId);
if (!result) {
return res.status(404).json({ message: 'Share not found' });

View file

@ -2,11 +2,12 @@ import { useState, useId, useRef } from 'react';
import { useRecoilValue } from 'recoil';
import * as Ariakit from '@ariakit/react';
import { Upload, Share2 } from 'lucide-react';
import { PermissionTypes, Permissions } from 'librechat-data-provider';
import { DropdownPopup, TooltipAnchor, useMediaQuery } from '@librechat/client';
import type * as t from '~/common';
import ExportModal from '~/components/Nav/ExportConversation/ExportModal';
import { ShareButton } from '~/components/Conversations/ConvoOptions';
import { useLocalize } from '~/hooks';
import { useHasAccess, useLocalize } from '~/hooks';
import store from '~/store';
export default function ExportAndShareMenu({
@ -22,6 +23,10 @@ export default function ExportAndShareMenu({
const menuId = useId();
const shareButtonRef = useRef<HTMLButtonElement>(null);
const exportButtonRef = useRef<HTMLButtonElement>(null);
const canCreateSharedLinks = useHasAccess({
permissionType: PermissionTypes.SHARED_LINKS,
permission: Permissions.CREATE,
});
const isSmallScreen = useMediaQuery('(max-width: 768px)');
const conversation = useRecoilValue(store.conversationByIndex(0));
@ -48,11 +53,11 @@ export default function ExportAndShareMenu({
label: localize('com_ui_share'),
onClick: shareHandler,
icon: <Share2 className="icon-md mr-2 text-text-secondary" />,
show: isSharedButtonEnabled,
show: isSharedButtonEnabled && canCreateSharedLinks,
/** NOTE: THE FOLLOWING PROPS ARE REQUIRED FOR MENU ITEMS THAT OPEN DIALOGS */
hideOnClick: false,
ref: shareButtonRef,
render: (props) => <button {...props} />,
render: (props) => <button {...props} data-testid="share-conversation-menu-item" />,
},
{
label: localize('com_endpoint_export'),

View file

@ -1,10 +1,10 @@
import { useState, useId, useRef, memo, useCallback, useMemo } from 'react';
import * as Ariakit from '@ariakit/react';
import { useParams, useNavigate } from 'react-router-dom';
import { QueryKeys } from 'librechat-data-provider';
import { useQueryClient } from '@tanstack/react-query';
import { useParams, useNavigate } from 'react-router-dom';
import { DropdownPopup, Spinner, useToastContext } from '@librechat/client';
import { Ellipsis, Share2, CopyPlus, Archive, Pen, Trash } from 'lucide-react';
import { QueryKeys, PermissionTypes, Permissions } from 'librechat-data-provider';
import type { MouseEvent } from 'react';
import type { TMessage } from 'librechat-data-provider';
import {
@ -13,7 +13,7 @@ import {
useGetStartupConfig,
useArchiveConvoMutation,
} from '~/data-provider';
import { useLocalize, useNavigateToConvo, useNewConvo } from '~/hooks';
import { useHasAccess, useLocalize, useNavigateToConvo, useNewConvo } from '~/hooks';
import { NotificationSeverity } from '~/common';
import { useChatContext } from '~/Providers';
import DeleteButton from './DeleteButton';
@ -57,6 +57,11 @@ function ConvoOptions({
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
const [announcement, setAnnouncement] = useState('');
const canCreateSharedLinks = useHasAccess({
permissionType: PermissionTypes.SHARED_LINKS,
permission: Permissions.CREATE,
});
const archiveConvoMutation = useArchiveConvoMutation();
const deleteMutation = useDeleteConversationMutation({
@ -189,7 +194,7 @@ function ConvoOptions({
label: localize('com_ui_share'),
onClick: shareHandler,
icon: <Share2 className="icon-sm mr-2 text-text-primary" aria-hidden="true" />,
show: startupConfig && startupConfig.sharedLinksEnabled,
show: startupConfig && startupConfig.sharedLinksEnabled && canCreateSharedLinks,
ariaHasPopup: 'dialog' as const,
ariaControls: 'share-conversation-dialog',
/** NOTE: THE FOLLOWING PROPS ARE REQUIRED FOR MENU ITEMS THAT OPEN DIALOGS */
@ -243,6 +248,7 @@ function ConvoOptions({
isArchiveLoading,
isDuplicateLoading,
handleArchiveClick,
canCreateSharedLinks,
handleDuplicateClick,
],
);

View file

@ -93,7 +93,12 @@ export default function ShareButton({
{shareId && (
<div className="flex items-center gap-2 rounded-md bg-surface-secondary p-2">
<div className="flex-1 break-all text-sm text-text-secondary">{sharedLink}</div>
<div
className="flex-1 break-all text-sm text-text-secondary"
data-testid="shared-link-url"
>
{sharedLink}
</div>
<span className="sr-only" aria-live="polite" aria-atomic="true">
{announcement}
</span>

View file

@ -13,15 +13,22 @@ import {
useToastContext,
OGDialogContent,
} from '@librechat/client';
import {
PermissionTypes,
Permissions,
PermissionBits,
ResourceType,
} from 'librechat-data-provider';
import type { TSharedLinkGetResponse } from 'librechat-data-provider';
import GenericGrantAccessDialog from '~/components/Sharing/GenericGrantAccessDialog';
import {
useCreateSharedLinkMutation,
useUpdateSharedLinkMutation,
useDeleteSharedLinkMutation,
} from '~/data-provider';
import { useHasAccess, useResourcePermissions, useLocalize } from '~/hooks';
import { NotificationSeverity } from '~/common';
import { buildShareLinkUrl } from '~/utils';
import { useLocalize } from '~/hooks';
export default function SharedLinkButton({
share,
@ -127,6 +134,22 @@ export default function SharedLinkButton({
}
};
const hasAccessToShareLinks = useHasAccess({
permissionType: PermissionTypes.SHARED_LINKS,
permission: Permissions.SHARE,
});
const { hasPermission, isLoading: permissionsLoading } = useResourcePermissions(
ResourceType.SHARED_LINK,
share?._id || '',
);
const canManageAccess =
hasAccessToShareLinks &&
!permissionsLoading &&
hasPermission(PermissionBits.SHARE) &&
!!share?._id;
const qrCodeLabel = showQR ? localize('com_ui_hide_qr') : localize('com_ui_show_qr');
return (
@ -192,6 +215,27 @@ export default function SharedLinkButton({
</Button>
)}
/>
{canManageAccess && (
<GenericGrantAccessDialog
resourceType={ResourceType.SHARED_LINK}
resourceDbId={share?._id}
resourceName={share?.shareId}
>
<TooltipAnchor
description={localize('com_ui_shared_link_manage_access')}
render={(props) => (
<Button
{...props}
variant="outline"
aria-label={localize('com_ui_shared_link_manage_access')}
>
{localize('com_ui_shared_link_manage_access')}
</Button>
)}
/>
</GenericGrantAccessDialog>
)}
</div>
)}
<OGDialog

View file

@ -38,7 +38,6 @@ const PAGE_SIZE = 25;
const DEFAULT_PARAMS: SharedLinksListParams = {
pageSize: PAGE_SIZE,
isPublic: true,
sortBy: 'createdAt',
sortDirection: 'desc',
search: '',

View file

@ -13,7 +13,7 @@ export default function MessagesView({
const localize = useLocalize();
const [currentEditId, setCurrentEditId] = useState<number | string | null>(-1);
return (
<div className="min-h-0 flex-1 overflow-hidden">
<div className="min-h-0 flex-1 overflow-hidden" data-testid="messages-view">
<div className="dark:gpt-dark-gray relative h-full">
<div
style={{

View file

@ -6,7 +6,7 @@ import { AccessRoleIds, ResourceType } from 'librechat-data-provider';
import { useGetAccessRolesQuery } from 'librechat-data-provider/react-query';
import type { AccessRole } from 'librechat-data-provider';
import type * as t from '~/common';
import { cn, getRoleLocalizationKeys } from '~/utils';
import { cn, getRoleLocalizationKeys, RESOURCE_CONFIGS } from '~/utils';
import { useLocalize } from '~/hooks';
interface AccessRolesPickerProps {
@ -37,6 +37,12 @@ export default function AccessRolesPicker({
};
};
const ownerRoleId = RESOURCE_CONFIGS[resourceType]?.defaultOwnerRoleId;
const filteredRoles =
resourceType === ResourceType.SHARED_LINK
? (accessRoles || []).filter((role) => role.accessRoleId !== ownerRoleId)
: accessRoles || [];
const selectedRole = accessRoles?.find((role) => role.accessRoleId === selectedRoleId);
const selectedRoleInfo = selectedRole ? getLocalizedRoleInfo(selectedRole.accessRoleId) : null;
@ -44,7 +50,7 @@ export default function AccessRolesPicker({
return <Skeleton className="h-10 w-24 rounded-lg" />;
}
const dropdownItems: t.MenuItemProps[] = accessRoles.map((role: AccessRole) => {
const dropdownItems: t.MenuItemProps[] = filteredRoles.map((role: AccessRole) => {
const localizedInfo = getLocalizedRoleInfo(role.accessRoleId);
return {
id: role.accessRoleId,

View file

@ -180,14 +180,18 @@ export default function GenericGrantAccessDialog({
return !allSharesMap.has(key);
});
const publicChanged = isPublic !== currentIsPublic;
const publicRoleChanged = isPublic && publicRole !== currentPublicRole;
const sendPublicUpdate = publicChanged || publicRoleChanged;
await updatePermissionsMutation.mutateAsync({
resourceType,
resourceId: resourceDbId,
data: {
updated,
removed,
public: isPublic,
publicAccessRoleId: isPublic ? publicRole : undefined,
...(sendPublicUpdate ? { public: isPublic } : {}),
...(sendPublicUpdate && isPublic ? { publicAccessRoleId: publicRole } : {}),
},
});

View file

@ -5,6 +5,7 @@ import { ResourceType } from 'librechat-data-provider';
import type { TPrincipal, AccessRoleIds } from 'librechat-data-provider';
import AccessRolesPicker from '~/components/Sharing/AccessRolesPicker';
import PrincipalAvatar from '~/components/Sharing/PrincipalAvatar';
import { RESOURCE_CONFIGS } from '~/utils/resources';
import { useLocalize } from '~/hooks';
interface SelectedPrincipalsListProps {
@ -50,6 +51,10 @@ export default function SelectedPrincipalsList({
<div className="space-y-2">
{principles.map((share) => {
const { displayName, subtitle } = getPrincipalDisplayInfo(share);
const ownerRoleId = RESOURCE_CONFIGS[resourceType]?.defaultOwnerRoleId;
const isOwner = share.accessRoleId === ownerRoleId;
const isSharedLink = resourceType === ResourceType.SHARED_LINK;
const lockOwner = isSharedLink && isOwner;
return (
<div
key={share.idOnTheSource + '-principalList'}
@ -73,24 +78,33 @@ export default function SelectedPrincipalsList({
</div>
<div className="flex flex-shrink-0 items-center gap-2">
{!!share.accessRoleId && !!onRoleChange && (
<AccessRolesPicker
resourceType={resourceType}
selectedRoleId={share.accessRoleId}
onRoleChange={(newRole) => {
onRoleChange?.(share.idOnTheSource!, newRole);
}}
className="min-w-0"
/>
{lockOwner ? (
<span className="px-3 py-2 text-sm font-medium text-text-secondary">
{localize('com_ui_role_owner')}
</span>
) : (
!!share.accessRoleId &&
!!onRoleChange && (
<AccessRolesPicker
resourceType={resourceType}
selectedRoleId={share.accessRoleId}
onRoleChange={(newRole) => {
onRoleChange?.(share.idOnTheSource!, newRole);
}}
className="min-w-0"
/>
)
)}
{!lockOwner && (
<Button
variant="outline"
onClick={() => onRemoveHandler(share.idOnTheSource!)}
className="h-9 w-9 p-0 hover:border-destructive/10 hover:bg-destructive/10 hover:text-destructive"
aria-label={localize('com_ui_remove_user', { 0: displayName })}
>
<X className="h-4 w-4" aria-hidden="true" />
</Button>
)}
<Button
variant="outline"
onClick={() => onRemoveHandler(share.idOnTheSource!)}
className="h-9 w-9 p-0 hover:border-destructive/10 hover:bg-destructive/10 hover:text-destructive"
aria-label={localize('com_ui_remove_user', { 0: displayName })}
>
<X className="h-4 w-4" aria-hidden="true" />
</Button>
</div>
</div>
);

View file

@ -140,7 +140,7 @@ const PeoplePickerAdminSettings = () => {
<OGDialogTrigger asChild>
<Button
variant={'outline'}
className="btn btn-neutral border-token-border-light relative gap-1 rounded-lg font-medium"
className="btn btn-neutral border-token-border-light gap-1 rounded-lg font-medium"
aria-label={localize('com_ui_admin_settings')}
>
<ShieldEllipsis className="cursor-pointer" aria-hidden="true" />

View file

@ -18,13 +18,14 @@ interface PublicSharingToggleProps {
const accessDescriptions: Record<
ResourceType,
'com_ui_agent' | 'com_ui_prompt' | 'com_ui_mcp_server' | 'com_ui_skill'
'com_ui_agent' | 'com_ui_prompt' | 'com_ui_mcp_server' | 'com_ui_skill' | 'com_ui_shared_link'
> = {
[ResourceType.AGENT]: 'com_ui_agent',
[ResourceType.PROMPTGROUP]: 'com_ui_prompt',
[ResourceType.MCPSERVER]: 'com_ui_mcp_server',
[ResourceType.REMOTE_AGENT]: 'com_ui_agent',
[ResourceType.SKILL]: 'com_ui_skill',
[ResourceType.SHARED_LINK]: 'com_ui_shared_link',
};
export default function PublicSharingToggle({

View file

@ -143,15 +143,14 @@ export const useSharedLinksQuery = (
params: SharedLinksListParams,
config?: UseInfiniteQueryOptions<SharedLinksResponse, unknown>,
) => {
const { pageSize, isPublic, search, sortBy, sortDirection } = params;
const { pageSize, search, sortBy, sortDirection } = params;
return useInfiniteQuery<SharedLinksResponse>({
queryKey: [QueryKeys.sharedLinks, { pageSize, isPublic, search, sortBy, sortDirection }],
queryKey: [QueryKeys.sharedLinks, { pageSize, search, sortBy, sortDirection }],
queryFn: ({ pageParam }) =>
dataService.listSharedLinks({
cursor: pageParam?.toString(),
pageSize,
isPublic,
search,
sortBy,
sortDirection,

View file

@ -7,6 +7,7 @@ const resourceToPermissionMap: Partial<Record<ResourceType, PermissionTypes>> =
[ResourceType.MCPSERVER]: PermissionTypes.MCP_SERVERS,
[ResourceType.REMOTE_AGENT]: PermissionTypes.REMOTE_AGENTS,
[ResourceType.SKILL]: PermissionTypes.SKILLS,
[ResourceType.SHARED_LINK]: PermissionTypes.SHARED_LINKS,
};
/**
@ -17,7 +18,7 @@ const resourceToPermissionMap: Partial<Record<ResourceType, PermissionTypes>> =
export const useCanSharePublic = (resourceType: ResourceType): boolean => {
const permissionType = resourceToPermissionMap[resourceType];
const hasAccess = useHasAccess({
permissionType,
permissionType: permissionType as PermissionTypes,
permission: Permissions.SHARE_PUBLIC,
});
return hasAccess;

View file

@ -1485,7 +1485,9 @@
"com_ui_share_qr_code_description": "QR code for sharing this conversation link",
"com_ui_share_update_message": "Your name, custom instructions, and any messages you add after sharing stay private.",
"com_ui_share_var": "Share {{0}}",
"com_ui_shared_link": "shared link",
"com_ui_shared_link_delete_success": "Successfully deleted shared link",
"com_ui_shared_link_manage_access": "Manage Access",
"com_ui_shared_link_not_found": "Shared link not found",
"com_ui_shared_prompts": "Shared Prompts",
"com_ui_shop": "Shopping",

View file

@ -71,6 +71,16 @@ export const RESOURCE_CONFIGS: Record<ResourceType, ResourceConfig> = {
`Manage permissions for ${name && name !== '' ? name : 'skill'}`,
getCopyUrlMessage: () => 'Skill URL copied',
},
[ResourceType.SHARED_LINK]: {
resourceType: ResourceType.SHARED_LINK,
defaultViewerRoleId: AccessRoleIds.SHARED_LINK_VIEWER,
defaultEditorRoleId: AccessRoleIds.SHARED_LINK_VIEWER,
defaultOwnerRoleId: AccessRoleIds.SHARED_LINK_OWNER,
getResourceName: (name?: string) => name || 'shared link',
getShareMessage: (name?: string) => name || 'shared link',
getManageMessage: (name?: string) => `Manage access for ${name || 'shared link'}`,
getCopyUrlMessage: () => 'Share link copied',
},
};
export const getResourceConfig = (resourceType: ResourceType): ResourceConfig | undefined => {

View file

@ -73,6 +73,15 @@ export const ROLE_LOCALIZATIONS = {
name: 'com_ui_role_owner' as const,
description: 'com_ui_skill_role_owner_desc' as const,
} as const,
// Shared link roles
sharedLink_viewer: {
name: 'com_ui_role_viewer' as const,
description: 'com_ui_role_viewer_desc' as const,
} as const,
sharedLink_owner: {
name: 'com_ui_role_owner' as const,
description: 'com_ui_role_owner_desc' as const,
} as const,
};
/**

View file

@ -0,0 +1,218 @@
jest.mock('../connect', () => jest.fn().mockResolvedValue(true));
jest.mock('@librechat/api', () => ({
ensureRequiredCollectionsExist: jest.fn().mockResolvedValue(undefined),
matchModelName: jest.fn(),
findMatchingPattern: jest.fn(),
}));
jest.mock('@librechat/data-schemas', () => ({
...jest.requireActual('@librechat/data-schemas'),
logger: { error: jest.fn(), info: jest.fn(), warn: jest.fn(), debug: jest.fn() },
}));
jest.mock('~/cache/getLogStores', () => jest.fn());
const mongoose = require('mongoose');
const { MongoMemoryServer } = require('mongodb-memory-server');
const { createMethods, SYSTEM_TENANT_ID, tenantStorage } = require('@librechat/data-schemas');
describe('migrate-shared-link-permissions', () => {
let mongoServer;
let SharedLink, AclEntry;
let migrateSharedLinkPermissions;
const testUserId = new mongoose.Types.ObjectId();
beforeAll(async () => {
mongoServer = await MongoMemoryServer.create();
await mongoose.connect(mongoServer.getUri());
const migration = require('../migrate-shared-link-permissions');
migrateSharedLinkPermissions = migration.migrateSharedLinkPermissions;
SharedLink = mongoose.models.SharedLink;
AclEntry = mongoose.models.AclEntry;
await createMethods(mongoose).seedDefaultRoles();
});
afterAll(async () => {
await mongoose.disconnect();
await mongoServer.stop();
});
beforeEach(async () => {
await AclEntry.deleteMany({});
await SharedLink.deleteMany({});
jest.restoreAllMocks();
});
async function createLegacyLink(isPublic = true, user = testUserId, overrides = {}) {
const link = await SharedLink.create({
shareId: `share-${Date.now()}-${Math.random()}`,
conversationId: 'convo1',
messages: [],
...(user != null ? { user } : {}),
...overrides,
});
await mongoose.connection.db
.collection('sharedlinks')
.updateOne({ _id: link._id }, { $set: { isPublic } });
return link;
}
test('removes isPublic from all links on success', async () => {
const link1 = await createLegacyLink(true);
const link2 = await createLegacyLink(true);
const result = await migrateSharedLinkPermissions({ dryRun: false, batchSize: 100 });
expect(result.errors).toBe(0);
expect(result.failedLinkCount).toBe(0);
const raw1 = await mongoose.connection.db.collection('sharedlinks').findOne({ _id: link1._id });
const raw2 = await mongoose.connection.db.collection('sharedlinks').findOne({ _id: link2._id });
expect(raw1).not.toHaveProperty('isPublic');
expect(raw2).not.toHaveProperty('isPublic');
});
test('preserves isPublic on links with partial write errors', async () => {
const link1 = await createLegacyLink(true);
const link2 = await createLegacyLink(true);
const link3 = await createLegacyLink(true);
const originalBulkWrite = AclEntry.bulkWrite.bind(AclEntry);
jest.spyOn(AclEntry, 'bulkWrite').mockImplementationOnce(async (ops, options) => {
const failedIndices = [];
const successOps = [];
for (let i = 0; i < ops.length; i++) {
if (ops[i].updateOne.filter.resourceId.equals(link2._id)) {
failedIndices.push(i);
} else {
successOps.push(ops[i]);
}
}
if (successOps.length > 0) {
await originalBulkWrite(successOps, options);
}
const error = new Error('Partial bulk write failure');
error.writeErrors = failedIndices.map((idx) => ({
index: idx,
errmsg: 'Simulated write error',
}));
throw error;
});
const result = await migrateSharedLinkPermissions({ dryRun: false, batchSize: 100 });
expect(result.failedLinkCount).toBe(1);
expect(result.errors).toBeGreaterThan(0);
const raw1 = await mongoose.connection.db.collection('sharedlinks').findOne({ _id: link1._id });
const raw2 = await mongoose.connection.db.collection('sharedlinks').findOne({ _id: link2._id });
const raw3 = await mongoose.connection.db.collection('sharedlinks').findOne({ _id: link3._id });
expect(raw2).toHaveProperty('isPublic', true);
expect(raw1).not.toHaveProperty('isPublic');
expect(raw3).not.toHaveProperty('isPublic');
});
test('preserves isPublic on all batch links when bulk write fails entirely', async () => {
const link1 = await createLegacyLink(true);
const link2 = await createLegacyLink(true);
jest.spyOn(AclEntry, 'bulkWrite').mockRejectedValueOnce(new Error('Connection lost'));
const result = await migrateSharedLinkPermissions({ dryRun: false, batchSize: 100 });
expect(result.failedLinkCount).toBe(2);
const raw1 = await mongoose.connection.db.collection('sharedlinks').findOne({ _id: link1._id });
const raw2 = await mongoose.connection.db.collection('sharedlinks').findOne({ _id: link2._id });
expect(raw1).toHaveProperty('isPublic', true);
expect(raw2).toHaveProperty('isPublic', true);
});
test('does not grant PUBLIC VIEWER to isPublic false links when forced', async () => {
const link = await createLegacyLink(false);
const result = await migrateSharedLinkPermissions({ dryRun: false, force: true });
expect(result.aborted).toBeUndefined();
expect(result.publicViewerSkipped).toBe(1);
const publicEntry = await AclEntry.findOne({
resourceId: link._id,
principalType: 'public',
}).lean();
expect(publicEntry).toBeNull();
const ownerEntry = await AclEntry.findOne({
resourceId: link._id,
principalType: 'user',
}).lean();
expect(ownerEntry).toBeDefined();
});
test('reports private legacy links during dry run without aborting', async () => {
await createLegacyLink(false);
const result = await migrateSharedLinkPermissions({ dryRun: true });
expect(result.aborted).toBeUndefined();
expect(result.dryRun).toBe(true);
expect(result.summary.withIsPublicFalse).toBe(1);
});
test('grants PUBLIC VIEWER to ownerless public legacy links', async () => {
const link = await createLegacyLink(true, null);
const result = await migrateSharedLinkPermissions({ dryRun: false });
expect(result.missingUserWarnings).toBe(1);
const publicEntry = await AclEntry.findOne({
resourceId: link._id,
principalType: 'public',
}).lean();
expect(publicEntry).toBeDefined();
expect(publicEntry).not.toHaveProperty('grantedBy');
const ownerEntry = await AclEntry.findOne({
resourceId: link._id,
principalType: 'user',
}).lean();
expect(ownerEntry).toBeNull();
});
test('copies shared link expiration to migrated ACL entries', async () => {
const expiredAt = new Date(Date.now() + 60 * 60 * 1000);
const link = await createLegacyLink(true, testUserId, { expiredAt });
await migrateSharedLinkPermissions({ dryRun: false });
const entries = await AclEntry.find({ resourceId: link._id }).lean();
expect(entries).toHaveLength(2);
for (const entry of entries) {
expect(entry.expiredAt?.toISOString()).toBe(expiredAt.toISOString());
}
});
test('runs the migration body inside a system tenant context', async () => {
await createLegacyLink(true);
const contextsObserved = [];
const originalCountDocuments = SharedLink.countDocuments.bind(SharedLink);
SharedLink.countDocuments = jest.fn((...args) => {
contextsObserved.push(tenantStorage.getStore()?.tenantId);
return originalCountDocuments(...args);
});
try {
await migrateSharedLinkPermissions({ dryRun: true });
expect(contextsObserved).toContain(SYSTEM_TENANT_ID);
} finally {
SharedLink.countDocuments = originalCountDocuments;
}
});
});

18
config/jest.config.js Normal file
View file

@ -0,0 +1,18 @@
const path = require('path');
const apiConfig = require('../api/jest.config');
const apiDir = path.resolve(__dirname, '..', 'api');
const resolvedMapper = Object.fromEntries(
Object.entries(apiConfig.moduleNameMapper).map(([key, value]) => [
key,
value.replace('<rootDir>', apiDir),
]),
);
module.exports = {
...apiConfig,
roots: ['<rootDir>'],
setupFiles: apiConfig.setupFiles.map((f) => path.resolve(apiDir, f)),
moduleNameMapper: resolvedMapper,
};

View file

@ -0,0 +1,357 @@
const path = require('path');
const { logger, runAsSystem } = require('@librechat/data-schemas');
const { ensureRequiredCollectionsExist } = require('@librechat/api');
require('module-alias')({ base: path.resolve(__dirname, '..', 'api') });
const connect = require('./connect');
const { findRoleByIdentifier } = require('~/models');
const { SharedLink, AclEntry } = require('~/db/models');
/**
* String literals matching `librechat-data-provider` enums so this script
* runs standalone without requiring a built data-provider package.
*/
const RESOURCE_TYPE_SHARED_LINK = 'sharedLink';
const ROLE_ID_OWNER = 'sharedLink_owner';
const ROLE_ID_VIEWER = 'sharedLink_viewer';
const PRINCIPAL_USER = 'user';
const PRINCIPAL_PUBLIC = 'public';
async function migrateSharedLinkPermissions({
dryRun = true,
batchSize = 100,
force = false,
} = {}) {
await connect();
return runAsSystem(async () => {
logger.info('Starting SharedLink Permissions Migration', { dryRun, batchSize, force });
const mongoose = require('mongoose');
/** @type {import('mongoose').mongo.Db | undefined} */
const db = mongoose.connection.db;
if (db) {
await ensureRequiredCollectionsExist(db);
}
const ownerRole = await findRoleByIdentifier(ROLE_ID_OWNER);
const viewerRole = await findRoleByIdentifier(ROLE_ID_VIEWER);
if (!ownerRole || !viewerRole) {
throw new Error(
'Required sharedLink roles not found (sharedLink_owner, sharedLink_viewer). Run role seeding first.',
);
}
logger.info('Roles resolved', {
owner: { id: ownerRole._id, permBits: ownerRole.permBits },
viewer: { id: viewerRole._id, permBits: viewerRole.permBits },
});
// --- Safety check: abort if isPublic: false documents exist (unless --force) ---
// Raw driver bypasses mongoose strictQuery: true, which silently strips query
// keys absent from the schema. isPublic was removed from the schema by this
// migration, so Mongoose queries like { isPublic: false } become {} (match all).
const rawCollection = mongoose.connection.db.collection('sharedlinks');
const isPublicFalseCount = await rawCollection.countDocuments({ isPublic: false });
if (!dryRun && isPublicFalseCount > 0 && !force) {
const sample = await rawCollection
.find({ isPublic: false })
.project({ _id: 1, shareId: 1, user: 1 })
.limit(20)
.toArray();
const sampleIds = sample.map((doc) => doc._id.toString());
logger.error(
`Found ${isPublicFalseCount} SharedLink documents with isPublic: false. ` +
'These may have been intentionally marked non-public. ' +
'Use --force to proceed anyway (they will NOT receive a PUBLIC VIEWER grant).',
{ sampleIds },
);
return {
aborted: true,
reason: 'isPublic: false documents found',
isPublicFalseCount,
sampleIds,
};
}
// --- Count totals for progress reporting ---
const totalLinks = await SharedLink.countDocuments({});
logger.info(`Found ${totalLinks} SharedLink documents total`);
if (totalLinks === 0) {
logger.info('No SharedLink documents to migrate');
return { migrated: 0, errors: 0, skipped: 0, dryRun };
}
// --- Dry run: scan and categorize ---
if (dryRun) {
const withUser = await SharedLink.countDocuments({ user: { $exists: true, $ne: null } });
const withoutUser = await SharedLink.countDocuments({
$or: [{ user: { $exists: false } }, { user: null }],
});
const withIsPublicTrue = await rawCollection.countDocuments({ isPublic: true });
const withIsPublicFalse = isPublicFalseCount;
const withIsPublicField = await rawCollection.countDocuments({ isPublic: { $exists: true } });
const alreadyMigratedOwner = await AclEntry.countDocuments({
resourceType: RESOURCE_TYPE_SHARED_LINK,
principalType: PRINCIPAL_USER,
});
const alreadyMigratedPublic = await AclEntry.countDocuments({
resourceType: RESOURCE_TYPE_SHARED_LINK,
principalType: PRINCIPAL_PUBLIC,
});
return {
migrated: 0,
errors: 0,
dryRun: true,
summary: {
totalLinks,
withUser,
withoutUser,
withIsPublicTrue,
withIsPublicFalse,
withIsPublicField,
alreadyMigratedOwner,
alreadyMigratedPublic,
},
};
}
// --- Live migration: cursor-based batch processing ---
const failedLinkIds = new Set();
const results = {
migrated: 0,
errors: 0,
skipped: 0,
ownerGrants: 0,
ownerSkipped: 0,
publicViewerGrants: 0,
publicViewerSkipped: 0,
missingUserWarnings: 0,
};
const cursor = SharedLink.find({})
.select('_id user isPublic tenantId expiredAt')
.lean()
.cursor();
let batch = [];
let batchIndex = 0;
/**
* Process a single batch of SharedLink documents.
* Collects upsert operations into a single bulkWrite for efficiency.
* Tracks which op indices map to which link IDs so write failures
* can be attributed to specific links.
*/
async function processBatch(links) {
const bulkOps = [];
const opIndexToLinkId = [];
for (const link of links) {
const linkId = link._id;
const userId = link.user;
const tenantId = link.tenantId;
const expiredAt = link.expiredAt;
const now = new Date();
if (userId) {
opIndexToLinkId.push(linkId);
bulkOps.push({
updateOne: {
filter: {
resourceType: RESOURCE_TYPE_SHARED_LINK,
resourceId: linkId,
principalType: PRINCIPAL_USER,
principalId: new mongoose.Types.ObjectId(userId),
},
update: {
$set: {
permBits: ownerRole.permBits,
roleId: ownerRole._id,
grantedBy: new mongoose.Types.ObjectId(userId),
grantedAt: now,
...(expiredAt && { expiredAt }),
},
$setOnInsert: {
principalModel: 'User',
...(tenantId && { tenantId }),
},
},
upsert: true,
},
});
} else {
results.missingUserWarnings++;
logger.warn('SharedLink has no user field, skipping OWNER grant', {
linkId: linkId.toString(),
});
}
const hasIsPublic = link.isPublic !== undefined;
if (hasIsPublic && link.isPublic === false) {
results.publicViewerSkipped++;
} else if (hasIsPublic) {
const publicUpdateSet = {
permBits: viewerRole.permBits,
roleId: viewerRole._id,
grantedAt: now,
...(expiredAt && { expiredAt }),
};
if (userId) {
publicUpdateSet.grantedBy = new mongoose.Types.ObjectId(userId);
}
opIndexToLinkId.push(linkId);
bulkOps.push({
updateOne: {
filter: {
resourceType: RESOURCE_TYPE_SHARED_LINK,
resourceId: linkId,
principalType: PRINCIPAL_PUBLIC,
},
update: {
$set: publicUpdateSet,
$setOnInsert: {
...(tenantId && { tenantId }),
},
},
upsert: true,
},
});
}
results.migrated++;
}
if (bulkOps.length > 0) {
try {
const bulkResult = await AclEntry.bulkWrite(bulkOps, { ordered: false });
results.ownerGrants += bulkResult.upsertedCount;
} catch (error) {
if (error.writeErrors) {
results.errors += error.writeErrors.length;
for (const writeError of error.writeErrors) {
const failedId = opIndexToLinkId[writeError.index];
if (failedId) {
failedLinkIds.add(failedId);
}
logger.error('Failed to migrate SharedLink in bulk', {
error: writeError.errmsg,
linkId: failedId?.toString(),
});
}
} else {
results.errors += links.length;
for (const link of links) {
failedLinkIds.add(link._id);
}
logger.error('Bulk write failed entirely', { error: error.message });
}
}
}
}
for await (const doc of cursor) {
batch.push(doc);
if (batch.length >= batchSize) {
batchIndex++;
const totalBatches = Math.ceil(totalLinks / batchSize);
if (batchIndex % 5 === 0 || batchIndex === 1) {
logger.info(`Processing batch ${batchIndex}/${totalBatches}`, {
migrated: results.migrated,
errors: results.errors,
});
}
await processBatch(batch);
batch = [];
await new Promise((resolve) => setTimeout(resolve, 100));
}
}
// Process remaining documents
if (batch.length > 0) {
batchIndex++;
const totalBatches = Math.ceil(totalLinks / batchSize);
logger.info(`Processing final batch ${batchIndex}/${totalBatches}`, {
remaining: batch.length,
});
await processBatch(batch);
}
// --- $unset isPublic only from successfully migrated documents ---
const unsetFilter = { isPublic: { $exists: true } };
if (failedLinkIds.size > 0) {
unsetFilter._id = { $nin: [...failedLinkIds] };
logger.warn(
`Skipping isPublic removal for ${failedLinkIds.size} links with failed ACL grants`,
{ failedLinkIds: [...failedLinkIds].map((id) => id.toString()) },
);
}
logger.info('Removing isPublic field from successfully migrated SharedLink documents...');
const unsetResult = await rawCollection.updateMany(unsetFilter, { $unset: { isPublic: 1 } });
logger.info(`Removed isPublic field from ${unsetResult.modifiedCount} documents`);
results.isPublicFieldsRemoved = unsetResult.modifiedCount;
results.failedLinkCount = failedLinkIds.size;
logger.info('SharedLink migration completed', results);
return results;
});
}
if (require.main === module) {
const dryRun = process.argv.includes('--dry-run');
const force = process.argv.includes('--force');
const batchSize =
parseInt(process.argv.find((arg) => arg.startsWith('--batch-size='))?.split('=')[1]) || 100;
migrateSharedLinkPermissions({ dryRun, batchSize, force })
.then((result) => {
if (result.aborted) {
console.log('\n=== MIGRATION ABORTED ===');
console.log(`Reason: ${result.reason}`);
console.log(`Documents with isPublic: false: ${result.isPublicFalseCount}`);
console.log(`Sample IDs: ${result.sampleIds.join(', ')}`);
console.log('\nUse --force to proceed anyway');
process.exit(1);
}
if (dryRun) {
console.log('\n=== DRY RUN RESULTS ===');
console.log(`Total SharedLink documents: ${result.summary.totalLinks}`);
console.log(`- With user field: ${result.summary.withUser}`);
console.log(`- Without user field: ${result.summary.withoutUser}`);
console.log(`- With isPublic: true: ${result.summary.withIsPublicTrue}`);
console.log(`- With isPublic: false: ${result.summary.withIsPublicFalse}`);
console.log(`- With isPublic field present: ${result.summary.withIsPublicField}`);
console.log(
`\nAlready migrated (OWNER AclEntries): ${result.summary.alreadyMigratedOwner}`,
);
console.log(
`Already migrated (PUBLIC AclEntries): ${result.summary.alreadyMigratedPublic}`,
);
console.log('\nTo run the actual migration, remove the --dry-run flag');
} else {
console.log('\n=== MIGRATION RESULTS ===');
console.log(JSON.stringify(result, null, 2));
}
process.exit(0);
})
.catch((error) => {
console.error('SharedLink migration failed:', error);
process.exit(1);
});
}
module.exports = { migrateSharedLinkPermissions };

View file

@ -0,0 +1,187 @@
import { expect, test } from '@playwright/test';
import { MongoClient } from 'mongodb';
import type { Collection, ObjectId } from 'mongodb';
import { applyRuntimeEnv } from '../../setup/runtimeEnv';
import {
MOCK_ENDPOINTS,
MOCK_REPLY_TEXT,
NEW_CHAT_PATH,
mockReply,
selectMockEndpoint,
sendMessage,
} from './helpers';
type SharedLinkDoc = {
_id?: ObjectId;
conversationId: string;
title?: string;
user?: string;
messages?: ObjectId[];
shareId: string;
isPublic?: boolean;
createdAt: Date;
updatedAt: Date;
};
type StoredSharedLinkDoc = SharedLinkDoc & {
_id: ObjectId;
messages: ObjectId[];
};
type AclEntryDoc = {
_id: ObjectId;
principalType: string;
resourceType: string;
resourceId: ObjectId;
};
const randomSuffix = () => `${Date.now()}-${Math.floor(Math.random() * 10000)}`;
async function connectToE2EDb() {
applyRuntimeEnv();
if (!process.env.MONGO_URI) {
throw new Error('MONGO_URI must be available for shared-links mock e2e tests');
}
const client = new MongoClient(process.env.MONGO_URI);
await client.connect();
return { client, db: client.db() };
}
async function waitForSharedLink(
sharedLinks: Collection<SharedLinkDoc>,
shareId: string,
): Promise<StoredSharedLinkDoc> {
const deadline = Date.now() + 15000;
while (Date.now() < deadline) {
const share = await sharedLinks.findOne({ shareId });
if (share?._id && Array.isArray(share.messages) && share.messages.length > 0) {
return share as StoredSharedLinkDoc;
}
await new Promise((resolve) => setTimeout(resolve, 250));
}
throw new Error(`Timed out waiting for persisted shared link ${shareId}`);
}
test.describe('shared links', () => {
test.setTimeout(120000);
test('creates a shared link and preserves legacy public links through runtime migration', async ({
page,
baseURL,
}) => {
if (typeof baseURL !== 'string') {
throw new Error('baseURL must be configured for shared-link mock e2e tests');
}
const suffix = randomSuffix();
const userMessage = `Shared link e2e ${suffix}`;
await page.goto(NEW_CHAT_PATH, { timeout: 10000 });
await selectMockEndpoint(page, MOCK_ENDPOINTS[0]);
const response = await sendMessage(page, userMessage);
expect(response.ok()).toBeTruthy();
await expect(page.getByText(userMessage)).toBeVisible();
await expect(mockReply(page)).toBeVisible();
await expect(page).toHaveURL(/\/c\/(?!new)[0-9a-fA-F-]{36}$/);
const conversationUrl = new URL(page.url());
const conversationId = conversationUrl.pathname.split('/').pop();
if (!conversationId) {
throw new Error(`Could not parse conversation id from ${conversationUrl.href}`);
}
await page.getByRole('button', { name: 'Export options' }).click();
await page.getByTestId('share-conversation-menu-item').click();
await expect(page.getByRole('dialog', { name: 'Share link to chat' })).toBeVisible();
const [shareResponse] = await Promise.all([
page.waitForResponse(
(res) =>
res.request().method() === 'POST' &&
res.url().includes(`/api/share/${conversationId}`) &&
res.status() === 200,
{ timeout: 30000 },
),
page.getByRole('button', { name: 'Create link' }).click(),
]);
expect(shareResponse.ok()).toBeTruthy();
const sharePayload = (await shareResponse.json()) as { shareId?: string };
if (!sharePayload.shareId) {
throw new Error('Expected create-share response to include a shareId');
}
await expect(page.getByTestId('shared-link-url')).toContainText('/share/');
await expect(page.getByRole('button', { name: 'Manage Access' })).toBeVisible();
const sharedLinkUrl = (await page.getByTestId('shared-link-url').textContent())?.trim();
if (!sharedLinkUrl) {
throw new Error('Expected shared-link URL to be rendered after creating a link');
}
await page.goto(new URL(sharedLinkUrl, baseURL).pathname, { timeout: 10000 });
await expect(page).toHaveURL(/\/share\/.+/);
await expect(page.getByTestId('messages-view').getByText(userMessage)).toBeVisible();
await expect(mockReply(page)).toBeVisible();
const { client, db } = await connectToE2EDb();
const aclEntries = db.collection<AclEntryDoc>('aclentries');
const sharedLinks = db.collection<SharedLinkDoc>('sharedlinks');
const legacyShareId = `legacy-${suffix}`;
let legacyResourceId: ObjectId | undefined;
try {
const createdShare = await waitForSharedLink(sharedLinks, sharePayload.shareId);
const legacyShare = {
shareId: legacyShareId,
conversationId: createdShare.conversationId,
title: createdShare.title ?? `Legacy shared link ${suffix}`,
...(createdShare.user ? { user: createdShare.user } : {}),
messages: createdShare.messages,
isPublic: true,
createdAt: new Date(),
updatedAt: new Date(),
};
const insertResult = await sharedLinks.insertOne(legacyShare);
const resourceId = insertResult.insertedId;
legacyResourceId = resourceId;
await page.goto(`/share/${legacyShareId}`, { timeout: 10000 });
await expect(page.getByTestId('messages-view').getByText(userMessage)).toBeVisible();
await expect(mockReply(page)).toBeVisible();
await expect
.poll(
async () =>
aclEntries.countDocuments({
resourceType: 'sharedLink',
resourceId,
principalType: 'public',
}),
{ timeout: 15000 },
)
.toBe(1);
await expect
.poll(
async () => {
const migrated = await sharedLinks.findOne({ _id: resourceId });
return migrated != null && !Object.prototype.hasOwnProperty.call(migrated, 'isPublic');
},
{ timeout: 15000 },
)
.toBe(true);
} finally {
if (legacyResourceId) {
await Promise.all([
aclEntries.deleteMany({ resourceId: legacyResourceId }),
sharedLinks.deleteOne({ _id: legacyResourceId }),
]);
}
await client.close();
}
});
});

View file

@ -146,6 +146,12 @@ interface:
# share: false
# public: false
# MCP Servers configuration example
# Shared Links configuration
# Controls user permissions for shared links (e.g. sharing conversations via link)
# sharedLinks:
# create: false
# share: true
# public: true # Allows users to toggle "share with everyone" for their links. Whether anonymous access is permitted is controlled by ALLOW_SHARED_LINKS_PUBLIC.
# mcpServers:
# Controls user permissions for MCP (Model Context Protocol) server management
# - use: Allow users to use configured MCP servers

View file

@ -71,6 +71,7 @@
"test:packages:api": "cd packages/api && npm run test:ci",
"test:packages:data-provider": "cd packages/data-provider && npm run test:ci",
"test:packages:data-schemas": "cd packages/data-schemas && npm run test:ci",
"test:config": "jest --config config/jest.config.js",
"test:all": "npm run test:client && npm run test:api && npm run test:packages:api && npm run test:packages:data-provider && npm run test:packages:data-schemas",
"e2e:update": "npm run e2e:prepare && playwright test --config=e2e/playwright.config.local.ts --update-snapshots",
"e2e:report": "npx playwright show-report e2e/playwright-report",
@ -99,6 +100,9 @@
"migrate:prompt-permissions:dry-run": "node config/migrate-prompt-permissions.js --dry-run",
"migrate:prompt-permissions": "node config/migrate-prompt-permissions.js",
"migrate:prompt-permissions:batch": "node config/migrate-prompt-permissions.js --batch-size=50",
"migrate:shared-link-permissions:dry-run": "node config/migrate-shared-link-permissions.js --dry-run",
"migrate:shared-link-permissions": "node config/migrate-shared-link-permissions.js",
"migrate:shared-link-permissions:batch": "node config/migrate-shared-link-permissions.js --batch-size=50",
"migrate:orphaned-agent-files:dry-run": "node config/migrate-orphaned-agent-files.js --dry-run",
"migrate:orphaned-agent-files": "node config/migrate-orphaned-agent-files.js",
"migrate:orphaned-agent-files:batch": "node config/migrate-orphaned-agent-files.js --batch-size=50"

View file

@ -810,6 +810,66 @@ describe('AccessControlService', () => {
});
});
describe('hasPublicAccess', () => {
const publicResource = new Types.ObjectId();
const privateResource = new Types.ObjectId();
beforeEach(async () => {
await service.grantPermission({
principalType: PrincipalType.PUBLIC,
principalId: null,
resourceType: ResourceType.AGENT,
resourceId: publicResource,
accessRoleId: AccessRoleIds.AGENT_VIEWER,
grantedBy: grantedById,
});
await service.grantPermission({
principalType: PrincipalType.USER,
principalId: userId,
resourceType: ResourceType.AGENT,
resourceId: privateResource,
accessRoleId: AccessRoleIds.AGENT_OWNER,
grantedBy: grantedById,
});
});
test('should return true for resource with PUBLIC AclEntry', async () => {
const findPublicResourceIdsSpy = jest.spyOn(service['_dbMethods'], 'findPublicResourceIds');
const result = await service.hasPublicAccess({
resourceType: ResourceType.AGENT,
resourceId: publicResource,
});
expect(result).toBe(true);
expect(findPublicResourceIdsSpy).not.toHaveBeenCalled();
findPublicResourceIdsSpy.mockRestore();
});
test('should return false for resource with only user AclEntry', async () => {
const result = await service.hasPublicAccess({
resourceType: ResourceType.AGENT,
resourceId: privateResource,
});
expect(result).toBe(false);
});
test('should return false for non-existent resource', async () => {
const result = await service.hasPublicAccess({
resourceType: ResourceType.AGENT,
resourceId: new Types.ObjectId(),
});
expect(result).toBe(false);
});
test('should return false for invalid resource type', async () => {
const result = await service.hasPublicAccess({
resourceType: 'invalid' as ResourceType,
resourceId: publicResource,
});
expect(result).toBe(false);
});
});
describe('checkPermission', () => {
const testResource = new Types.ObjectId();
const groupResource = new Types.ObjectId();

View file

@ -1,6 +1,11 @@
import { Types } from 'mongoose';
import { createMethods, logger } from '@librechat/data-schemas';
import { AccessRoleIds, PrincipalType, ResourceType } from 'librechat-data-provider';
import {
AccessRoleIds,
PermissionBits,
PrincipalType,
ResourceType,
} from 'librechat-data-provider';
import type { AllMethods, IAclEntry } from '@librechat/data-schemas';
import type { ClientSession, DeleteResult } from 'mongoose';
@ -23,6 +28,7 @@ export class AccessControlService {
* @param {string} params.accessRoleId - The ID of the role (e.g., AccessRoleIds.AGENT_VIEWER, AccessRoleIds.AGENT_EDITOR)
* @param {Types.ObjectId} params.grantedBy - User ID granting the permission
* @param {ClientSession} [params.session] - Optional MongoDB session for transactions
* @param {Date} [params.expiredAt] - Optional expiration for resource-tied permissions
* @returns {Promise<IAclEntry>} The created or updated ACL entry
*/
public async grantPermission(args: {
@ -32,9 +38,10 @@ export class AccessControlService {
resourceId: string | Types.ObjectId;
accessRoleId: AccessRoleIds;
grantedBy: string | Types.ObjectId;
grantedBy?: string | Types.ObjectId;
session?: ClientSession;
roleId?: string | Types.ObjectId;
expiredAt?: Date;
}): Promise<IAclEntry | null> {
const {
principalType,
@ -44,6 +51,7 @@ export class AccessControlService {
accessRoleId,
grantedBy,
session,
expiredAt,
} = args;
try {
if (!Object.values(PrincipalType).includes(principalType)) {
@ -96,6 +104,7 @@ export class AccessControlService {
grantedBy,
session,
role._id,
expiredAt,
);
} catch (error) {
logger.error(
@ -375,6 +384,33 @@ export class AccessControlService {
}
}
/**
* Check if a resource has a PUBLIC AclEntry (accessible to everyone).
* Unlike checkPermission, this does not require a user context.
*/
public async hasPublicAccess({
resourceType,
resourceId,
}: {
resourceType: ResourceType;
resourceId: string | Types.ObjectId;
}): Promise<boolean> {
try {
this.validateResourceType(resourceType);
return await this._dbMethods.hasPermission(
[{ principalType: PrincipalType.PUBLIC }],
resourceType,
resourceId,
PermissionBits.VIEW,
);
} catch (error) {
if (error instanceof Error) {
logger.error(`[PermissionService.hasPublicAccess] Error: ${error.message}`);
}
return false;
}
}
/**
* Validates that the resourceType is one of the supported enum values
* @param {string} resourceType - The resource type to validate

View file

@ -113,6 +113,11 @@ describe('updateInterfacePermissions - permissions', () => {
[Permissions.SHARE]: false,
[Permissions.SHARE_PUBLIC]: false,
},
[PermissionTypes.SHARED_LINKS]: {
[Permissions.CREATE]: true,
[Permissions.SHARE]: true,
[Permissions.SHARE_PUBLIC]: true,
},
};
const expectedPermissionsForAdmin = {
@ -167,6 +172,11 @@ describe('updateInterfacePermissions - permissions', () => {
[Permissions.SHARE]: true,
[Permissions.SHARE_PUBLIC]: true,
},
[PermissionTypes.SHARED_LINKS]: {
[Permissions.CREATE]: true,
[Permissions.SHARE]: true,
[Permissions.SHARE_PUBLIC]: true,
},
};
expect(mockUpdateAccessPermissions).toHaveBeenCalledTimes(2);
@ -285,6 +295,11 @@ describe('updateInterfacePermissions - permissions', () => {
[Permissions.SHARE]: false,
[Permissions.SHARE_PUBLIC]: false,
},
[PermissionTypes.SHARED_LINKS]: {
[Permissions.CREATE]: true,
[Permissions.SHARE]: true,
[Permissions.SHARE_PUBLIC]: true,
},
};
const expectedPermissionsForAdmin = {
@ -339,6 +354,11 @@ describe('updateInterfacePermissions - permissions', () => {
[Permissions.SHARE]: true,
[Permissions.SHARE_PUBLIC]: true,
},
[PermissionTypes.SHARED_LINKS]: {
[Permissions.CREATE]: true,
[Permissions.SHARE]: true,
[Permissions.SHARE_PUBLIC]: true,
},
};
expect(mockUpdateAccessPermissions).toHaveBeenCalledTimes(2);
@ -443,6 +463,11 @@ describe('updateInterfacePermissions - permissions', () => {
[Permissions.SHARE]: false,
[Permissions.SHARE_PUBLIC]: false,
},
[PermissionTypes.SHARED_LINKS]: {
[Permissions.CREATE]: true,
[Permissions.SHARE]: true,
[Permissions.SHARE_PUBLIC]: true,
},
};
const expectedPermissionsForAdmin = {
@ -497,6 +522,11 @@ describe('updateInterfacePermissions - permissions', () => {
[Permissions.SHARE]: true,
[Permissions.SHARE_PUBLIC]: true,
},
[PermissionTypes.SHARED_LINKS]: {
[Permissions.CREATE]: true,
[Permissions.SHARE]: true,
[Permissions.SHARE_PUBLIC]: true,
},
};
expect(mockUpdateAccessPermissions).toHaveBeenCalledTimes(2);
@ -614,6 +644,11 @@ describe('updateInterfacePermissions - permissions', () => {
[Permissions.SHARE]: false,
[Permissions.SHARE_PUBLIC]: false,
},
[PermissionTypes.SHARED_LINKS]: {
[Permissions.CREATE]: true,
[Permissions.SHARE]: true,
[Permissions.SHARE_PUBLIC]: true,
},
};
const expectedPermissionsForAdmin = {
@ -668,6 +703,11 @@ describe('updateInterfacePermissions - permissions', () => {
[Permissions.SHARE]: true,
[Permissions.SHARE_PUBLIC]: true,
},
[PermissionTypes.SHARED_LINKS]: {
[Permissions.CREATE]: true,
[Permissions.SHARE]: true,
[Permissions.SHARE_PUBLIC]: true,
},
};
expect(mockUpdateAccessPermissions).toHaveBeenCalledTimes(2);
@ -772,6 +812,11 @@ describe('updateInterfacePermissions - permissions', () => {
[Permissions.SHARE]: false,
[Permissions.SHARE_PUBLIC]: false,
},
[PermissionTypes.SHARED_LINKS]: {
[Permissions.CREATE]: true,
[Permissions.SHARE]: true,
[Permissions.SHARE_PUBLIC]: true,
},
};
const expectedPermissionsForAdmin = {
@ -826,6 +871,11 @@ describe('updateInterfacePermissions - permissions', () => {
[Permissions.SHARE]: true,
[Permissions.SHARE_PUBLIC]: true,
},
[PermissionTypes.SHARED_LINKS]: {
[Permissions.CREATE]: true,
[Permissions.SHARE]: true,
[Permissions.SHARE_PUBLIC]: true,
},
};
expect(mockUpdateAccessPermissions).toHaveBeenCalledTimes(2);
@ -935,6 +985,11 @@ describe('updateInterfacePermissions - permissions', () => {
[Permissions.SHARE]: false,
[Permissions.SHARE_PUBLIC]: false,
},
[PermissionTypes.SHARED_LINKS]: {
[Permissions.CREATE]: true,
[Permissions.SHARE]: true,
[Permissions.SHARE_PUBLIC]: true,
},
};
const expectedPermissionsForAdmin = {
@ -977,6 +1032,11 @@ describe('updateInterfacePermissions - permissions', () => {
[Permissions.SHARE]: true,
[Permissions.SHARE_PUBLIC]: true,
},
[PermissionTypes.SHARED_LINKS]: {
[Permissions.CREATE]: true,
[Permissions.SHARE]: true,
[Permissions.SHARE_PUBLIC]: true,
},
};
expect(mockUpdateAccessPermissions).toHaveBeenCalledTimes(2);
@ -1103,6 +1163,11 @@ describe('updateInterfacePermissions - permissions', () => {
[Permissions.SHARE]: false,
[Permissions.SHARE_PUBLIC]: false,
},
[PermissionTypes.SHARED_LINKS]: {
[Permissions.CREATE]: true,
[Permissions.SHARE]: true,
[Permissions.SHARE_PUBLIC]: true,
},
};
const expectedPermissionsForAdmin = {
@ -1149,6 +1214,11 @@ describe('updateInterfacePermissions - permissions', () => {
[Permissions.SHARE]: true,
[Permissions.SHARE_PUBLIC]: true,
},
[PermissionTypes.SHARED_LINKS]: {
[Permissions.CREATE]: true,
[Permissions.SHARE]: true,
[Permissions.SHARE_PUBLIC]: true,
},
};
expect(mockUpdateAccessPermissions).toHaveBeenCalledTimes(2);
@ -2698,4 +2768,78 @@ describe('updateInterfacePermissions - permissions', () => {
expect(userCall[1][PermissionTypes.MCP_SERVERS][Permissions.CREATE]).toBe(true);
});
it('should disable all SHARED_LINKS permissions when sharedLinks: false (boolean)', async () => {
mockGetRoleByName.mockResolvedValue({
permissions: {
[PermissionTypes.SHARED_LINKS]: {
[Permissions.CREATE]: true,
[Permissions.SHARE]: true,
[Permissions.SHARE_PUBLIC]: true,
},
},
});
const config = {
interface: {
sharedLinks: false,
},
};
const configDefaults = { interface: {} } as TConfigDefaults;
const interfaceConfig = await loadDefaultInterface({ config, configDefaults });
const appConfig = { config, interfaceConfig } as unknown as AppConfig;
await updateInterfacePermissions({
appConfig,
getRoleByName: mockGetRoleByName,
updateAccessPermissions: mockUpdateAccessPermissions,
});
const userCall = mockUpdateAccessPermissions.mock.calls.find(
(call) => call[0] === SystemRoles.USER,
);
expect(userCall[1][PermissionTypes.SHARED_LINKS]).toEqual({
[Permissions.CREATE]: false,
[Permissions.SHARE]: false,
[Permissions.SHARE_PUBLIC]: false,
});
});
it('should enable all SHARED_LINKS permissions when sharedLinks: true (boolean)', async () => {
mockGetRoleByName.mockResolvedValue({
permissions: {
[PermissionTypes.SHARED_LINKS]: {
[Permissions.CREATE]: false,
[Permissions.SHARE]: false,
[Permissions.SHARE_PUBLIC]: false,
},
},
});
const config = {
interface: {
sharedLinks: true,
},
};
const configDefaults = { interface: {} } as TConfigDefaults;
const interfaceConfig = await loadDefaultInterface({ config, configDefaults });
const appConfig = { config, interfaceConfig } as unknown as AppConfig;
await updateInterfacePermissions({
appConfig,
getRoleByName: mockGetRoleByName,
updateAccessPermissions: mockUpdateAccessPermissions,
});
const userCall = mockUpdateAccessPermissions.mock.calls.find(
(call) => call[0] === SystemRoles.USER,
);
expect(userCall[1][PermissionTypes.SHARED_LINKS]).toEqual({
[Permissions.CREATE]: true,
[Permissions.SHARE]: true,
[Permissions.SHARE_PUBLIC]: true,
});
});
});

View file

@ -47,6 +47,8 @@ function hasExplicitConfig(
return interfaceConfig?.remoteAgents !== undefined;
case PermissionTypes.SKILLS:
return interfaceConfig?.skills !== undefined;
case PermissionTypes.SHARED_LINKS:
return interfaceConfig?.sharedLinks !== undefined;
default:
return false;
}
@ -197,6 +199,12 @@ export async function updateInterfacePermissions({
typeof defaults.agents === 'object' ? defaults.agents?.public : undefined;
const skillsDefaultPublic =
typeof defaults.skills === 'object' ? defaults.skills?.public : undefined;
const sharedLinksDefaultCreate =
typeof defaults.sharedLinks === 'boolean' ? undefined : defaults.sharedLinks?.create;
const sharedLinksDefaultShare =
typeof defaults.sharedLinks === 'object' ? defaults.sharedLinks?.share : undefined;
const sharedLinksDefaultPublic =
typeof defaults.sharedLinks === 'object' ? defaults.sharedLinks?.public : undefined;
const allPermissions: Partial<Record<PermissionTypes, Record<string, boolean | undefined>>> = {
[PermissionTypes.PROMPTS]: {
@ -483,6 +491,43 @@ export async function updateInterfacePermissions({
}
: {}),
},
[PermissionTypes.SHARED_LINKS]: {
...(typeof interfaceConfig?.sharedLinks === 'boolean' ||
(typeof interfaceConfig?.sharedLinks === 'object' &&
'create' in interfaceConfig.sharedLinks) ||
!existingPermissions?.[PermissionTypes.SHARED_LINKS]
? {
[Permissions.CREATE]: getPermissionValue(
typeof loadedInterface.sharedLinks === 'boolean'
? loadedInterface.sharedLinks
: getConfigCreate(loadedInterface.sharedLinks),
defaultPerms[PermissionTypes.SHARED_LINKS]?.[Permissions.CREATE],
sharedLinksDefaultCreate ?? true,
),
}
: {}),
...(typeof interfaceConfig?.sharedLinks === 'boolean' ||
(typeof interfaceConfig?.sharedLinks === 'object' &&
('share' in interfaceConfig.sharedLinks || 'public' in interfaceConfig.sharedLinks)) ||
!existingPermissions?.[PermissionTypes.SHARED_LINKS]
? {
[Permissions.SHARE]: getPermissionValue(
typeof loadedInterface.sharedLinks === 'boolean'
? loadedInterface.sharedLinks
: getConfigShare(loadedInterface.sharedLinks),
defaultPerms[PermissionTypes.SHARED_LINKS]?.[Permissions.SHARE],
sharedLinksDefaultShare,
),
[Permissions.SHARE_PUBLIC]: getPermissionValue(
typeof loadedInterface.sharedLinks === 'boolean'
? loadedInterface.sharedLinks
: getConfigPublic(loadedInterface.sharedLinks),
defaultPerms[PermissionTypes.SHARED_LINKS]?.[Permissions.SHARE_PUBLIC],
sharedLinksDefaultPublic,
),
}
: {}),
},
};
// Check and add each permission type if needed
@ -577,6 +622,21 @@ export async function updateInterfacePermissions({
),
},
],
[
PermissionTypes.SHARED_LINKS,
{
[Permissions.SHARE]: getPermissionValue(
getConfigShare(loadedInterface.sharedLinks),
defaultPerms[PermissionTypes.SHARED_LINKS]?.[Permissions.SHARE],
sharedLinksDefaultShare,
),
[Permissions.SHARE_PUBLIC]: getPermissionValue(
getConfigPublic(loadedInterface.sharedLinks),
defaultPerms[PermissionTypes.SHARED_LINKS]?.[Permissions.SHARE_PUBLIC],
sharedLinksDefaultPublic,
),
},
],
];
for (const [permType, shareDefaults] of shareBackfill) {

View file

@ -55,6 +55,9 @@ export * from './tools';
export * from './web';
/* Cache */
export * from './cache';
/* Shared Links */
export * from './shared-links/access';
export * from './shared-links/service';
/* Stream */
export * from './stream';
/* Diagnostics */

View file

@ -6,7 +6,7 @@ jest.mock('@librechat/data-schemas', () => ({
},
}));
import { Permissions, PermissionTypes, ResourceType } from 'librechat-data-provider';
import { Permissions, PermissionTypes, PrincipalType, ResourceType } from 'librechat-data-provider';
import type { NextFunction, Response } from 'express';
import type { IRole } from '@librechat/data-schemas';
import type { ServerRequest } from '~/types/http';
@ -19,6 +19,9 @@ type ShareTestRequest = ServerRequest & {
};
body: ServerRequest['body'] & {
public?: boolean;
updated?: Array<{
type?: string;
}>;
};
};
@ -168,6 +171,41 @@ describe('createSharePolicyMiddleware', () => {
expect(next).not.toHaveBeenCalled();
});
it('requires SHARE_PUBLIC when PUBLIC is granted through updated principals', async () => {
const { checkSharePublicAccess } = createSharePolicyMiddleware({
getRoleByName,
hasCapability,
});
getRoleByName.mockResolvedValue(
createRole({
[PermissionTypes.SHARED_LINKS]: {
[Permissions.SHARE]: true,
[Permissions.SHARE_PUBLIC]: false,
},
}),
);
const req = createRequest({
params: { resourceType: ResourceType.SHARED_LINK },
body: {
updated: [
{
type: PrincipalType.PUBLIC,
},
],
},
});
const res = createResponse();
await checkSharePublicAccess(req, res, next);
expect(res.status).toHaveBeenCalledWith(403);
expect(res.json).toHaveBeenCalledWith({
error: 'Forbidden',
message: `You do not have permission to share ${ResourceType.SHARED_LINK} resources publicly`,
});
expect(next).not.toHaveBeenCalled();
});
it('reuses the role permission lookup for public sharing checks', async () => {
const { checkShareAccess, checkSharePublicAccess } = createSharePolicyMiddleware({
getRoleByName,

View file

@ -1,5 +1,5 @@
import { logger, ResourceCapabilityMap } from '@librechat/data-schemas';
import { Permissions, PermissionTypes, ResourceType } from 'librechat-data-provider';
import { Permissions, PermissionTypes, PrincipalType, ResourceType } from 'librechat-data-provider';
import type { NextFunction, Response } from 'express';
import type { IRole } from '@librechat/data-schemas';
import type { CapabilityUser, HasCapabilityFn } from './capabilities';
@ -18,6 +18,9 @@ type ShareRequest = ServerRequest & {
};
body: RequestBody & {
public?: boolean;
updated?: Array<{
type?: string;
}>;
};
sharePermissionContext?: SharePermissionCache;
};
@ -45,6 +48,7 @@ const resourceToPermissionType: Record<ResourceType, PermissionTypes> = {
[ResourceType.MCPSERVER]: PermissionTypes.MCP_SERVERS,
[ResourceType.REMOTE_AGENT]: PermissionTypes.REMOTE_AGENTS,
[ResourceType.SKILL]: PermissionTypes.SKILLS,
[ResourceType.SHARED_LINK]: PermissionTypes.SHARED_LINKS,
};
function formatError(error: unknown): string {
@ -204,9 +208,12 @@ export function createSharePolicyMiddleware({ getRoleByName, hasCapability }: Sh
next: NextFunction,
): Promise<Response | void> {
try {
const { public: isPublic } = req.body;
const { public: isPublic, updated } = req.body;
const updatesPublicPrincipal =
Array.isArray(updated) &&
updated.some((principal) => principal?.type === PrincipalType.PUBLIC);
if (!isPublic) {
if (!isPublic && !updatesPublicPrincipal) {
return next();
}

View file

@ -0,0 +1,325 @@
jest.mock('@librechat/data-schemas', () => ({
...jest.requireActual('@librechat/data-schemas'),
logger: { error: jest.fn(), info: jest.fn(), warn: jest.fn(), debug: jest.fn() },
}));
import mongoose, { Types, Model } from 'mongoose';
import { MongoMemoryServer } from 'mongodb-memory-server';
import { createModels, createMethods } from '@librechat/data-schemas';
import { ResourceType, PrincipalType, AccessRoleIds } from 'librechat-data-provider';
import type { Request, Response, NextFunction } from 'express';
import type { IAclEntry, ISharedLink } from '@librechat/data-schemas';
import { AccessControlService } from '~/acl/accessControlService';
import { createSharedLinkAccessMiddleware } from './access';
let mongoServer: MongoMemoryServer;
let AclEntry: Model<IAclEntry>;
let SharedLink: Model<ISharedLink>;
let aclService: AccessControlService;
let canAccessSharedLink: ReturnType<typeof createSharedLinkAccessMiddleware>;
const userId = new Types.ObjectId();
const mockGetUserPrincipals = jest.fn();
beforeAll(async () => {
mongoServer = await MongoMemoryServer.create();
await mongoose.connect(mongoServer.getUri());
createModels(mongoose);
const methods = createMethods(mongoose);
await methods.seedDefaultRoles();
AclEntry = mongoose.models.AclEntry as Model<IAclEntry>;
SharedLink = mongoose.models.SharedLink as Model<ISharedLink>;
aclService = new AccessControlService(mongoose);
const originalMethods = aclService['_dbMethods'];
aclService['_dbMethods'] = {
...originalMethods,
getUserPrincipals: mockGetUserPrincipals,
};
canAccessSharedLink = createSharedLinkAccessMiddleware({ mongoose, aclService });
});
afterAll(async () => {
await mongoose.disconnect();
await mongoServer.stop();
});
beforeEach(async () => {
await AclEntry.deleteMany({});
await SharedLink.deleteMany({});
mockGetUserPrincipals.mockReset();
delete process.env.ALLOW_SHARED_LINKS_PUBLIC;
delete process.env.SHARED_LINKS_AUTO_MIGRATE;
});
function createReq(overrides: Record<string, unknown> = {}): Request {
return { params: {}, user: undefined, ...overrides } as unknown as Request;
}
function createRes(): Response & { _status: number; _json: unknown } {
const res = {
_status: 0,
_json: null as unknown,
status(code: number) {
res._status = code;
return res;
},
json(body: unknown) {
res._json = body;
return res;
},
};
return res as unknown as Response & { _status: number; _json: unknown };
}
async function createTestLink(overrides: Partial<ISharedLink> = {}) {
return SharedLink.create({
shareId: `share-${Date.now()}-${Math.random()}`,
conversationId: 'convo1',
user: userId.toString(),
messages: [],
...overrides,
});
}
async function grantPublicViewer(resourceId: Types.ObjectId) {
await aclService.grantPermission({
principalType: PrincipalType.PUBLIC,
principalId: null,
resourceType: ResourceType.SHARED_LINK,
resourceId,
accessRoleId: AccessRoleIds.SHARED_LINK_VIEWER,
grantedBy: userId,
});
}
async function grantUserViewer(resourceId: Types.ObjectId, uid: Types.ObjectId) {
await aclService.grantPermission({
principalType: PrincipalType.USER,
principalId: uid,
resourceType: ResourceType.SHARED_LINK,
resourceId,
accessRoleId: AccessRoleIds.SHARED_LINK_VIEWER,
grantedBy: userId,
});
}
describe('canAccessSharedLink', () => {
describe('input validation', () => {
test('returns 400 when shareId is missing', async () => {
const req = createReq({ params: {} });
const res = createRes();
const next = jest.fn();
await canAccessSharedLink(req, res, next as unknown as NextFunction);
expect(res._status).toBe(400);
expect(next).not.toHaveBeenCalled();
});
test('returns 404 when share does not exist', async () => {
const req = createReq({ params: { shareId: 'nonexistent' } });
const res = createRes();
const next = jest.fn();
await canAccessSharedLink(req, res, next as unknown as NextFunction);
expect(res._status).toBe(404);
expect(next).not.toHaveBeenCalled();
});
});
describe('public links', () => {
test('calls next() for anonymous access when ALLOW_SHARED_LINKS_PUBLIC is true', async () => {
const link = await createTestLink();
await grantPublicViewer(link._id);
process.env.ALLOW_SHARED_LINKS_PUBLIC = 'true';
const req = createReq({ params: { shareId: link.shareId } });
const res = createRes();
const next = jest.fn();
await canAccessSharedLink(req, res, next as unknown as NextFunction);
expect(next).toHaveBeenCalled();
expect((req as unknown as Record<string, unknown>).shareResourceId).toBe(link._id.toString());
});
test('returns 401 for anonymous access when ALLOW_SHARED_LINKS_PUBLIC is not set', async () => {
const link = await createTestLink();
await grantPublicViewer(link._id);
const req = createReq({ params: { shareId: link.shareId } });
const res = createRes();
const next = jest.fn();
await canAccessSharedLink(req, res, next as unknown as NextFunction);
expect(res._status).toBe(401);
expect(next).not.toHaveBeenCalled();
});
test('calls next() for authenticated access to public link even without ALLOW_SHARED_LINKS_PUBLIC', async () => {
const link = await createTestLink();
await grantPublicViewer(link._id);
const req = createReq({
params: { shareId: link.shareId },
user: { id: userId.toString(), _id: userId },
});
const res = createRes();
const next = jest.fn();
await canAccessSharedLink(req, res, next as unknown as NextFunction);
expect(next).toHaveBeenCalled();
expect((req as unknown as Record<string, unknown>).shareResourceId).toBe(link._id.toString());
});
});
describe('private links', () => {
test('returns 401 for unauthenticated user', async () => {
const link = await createTestLink();
const req = createReq({ params: { shareId: link.shareId } });
const res = createRes();
const next = jest.fn();
await canAccessSharedLink(req, res, next as unknown as NextFunction);
expect(res._status).toBe(401);
expect(next).not.toHaveBeenCalled();
});
test('returns 403 for authenticated user without ACL entry', async () => {
const link = await createTestLink();
const otherUser = new Types.ObjectId();
mockGetUserPrincipals.mockResolvedValue([
{ principalType: PrincipalType.USER, principalId: otherUser },
]);
const req = createReq({
params: { shareId: link.shareId },
user: { id: otherUser.toString(), _id: otherUser, role: 'USER' },
});
const res = createRes();
const next = jest.fn();
await canAccessSharedLink(req, res, next as unknown as NextFunction);
expect(res._status).toBe(403);
expect(next).not.toHaveBeenCalled();
});
test('calls next() for authenticated user with ACL entry', async () => {
const link = await createTestLink();
const viewer = new Types.ObjectId();
await grantUserViewer(link._id, viewer);
mockGetUserPrincipals.mockResolvedValue([
{ principalType: PrincipalType.USER, principalId: viewer },
]);
const req = createReq({
params: { shareId: link.shareId },
user: { id: viewer.toString(), _id: viewer, role: 'USER' },
});
const res = createRes();
const next = jest.fn();
await canAccessSharedLink(req, res, next as unknown as NextFunction);
expect(next).toHaveBeenCalled();
expect((req as unknown as Record<string, unknown>).shareResourceId).toBe(link._id.toString());
});
});
describe('legacy link auto-migration', () => {
async function createLegacyLink(isPublic: boolean) {
const link = await createTestLink();
// Inject isPublic directly into MongoDB to simulate a legacy document
await mongoose.connection
.db!.collection('sharedlinks')
.updateOne({ _id: link._id }, { $set: { isPublic } });
return link;
}
test('auto-migrates public legacy link and calls next()', async () => {
const link = await createLegacyLink(true);
process.env.ALLOW_SHARED_LINKS_PUBLIC = 'true';
const req = createReq({ params: { shareId: link.shareId } });
const res = createRes();
const next = jest.fn();
await canAccessSharedLink(req, res, next as unknown as NextFunction);
expect(next).toHaveBeenCalled();
const entries = await AclEntry.find({ resourceId: link._id }).lean();
const hasOwner = entries.some((e) => e.principalType === PrincipalType.USER);
const hasPublic = entries.some((e) => e.principalType === PrincipalType.PUBLIC);
expect(hasOwner).toBe(true);
expect(hasPublic).toBe(true);
const rawDoc = await mongoose.connection
.db!.collection('sharedlinks')
.findOne({ _id: link._id });
expect(rawDoc).not.toHaveProperty('isPublic');
});
test('does not re-create PUBLIC after owner removes it', async () => {
const link = await createLegacyLink(true);
process.env.ALLOW_SHARED_LINKS_PUBLIC = 'true';
const next1 = jest.fn();
const req1 = createReq({ params: { shareId: link.shareId } });
await canAccessSharedLink(req1, createRes(), next1 as unknown as NextFunction);
await AclEntry.deleteMany({
resourceId: link._id,
principalType: PrincipalType.PUBLIC,
});
const next2 = jest.fn();
const req2 = createReq({ params: { shareId: link.shareId } });
const res2 = createRes();
await canAccessSharedLink(req2, res2, next2 as unknown as NextFunction);
const publicEntries = await AclEntry.find({
resourceId: link._id,
principalType: PrincipalType.PUBLIC,
}).lean();
expect(publicEntries).toHaveLength(0);
});
test('auto-migrates legacy link with isPublic: false — no PUBLIC grant', async () => {
const link = await createLegacyLink(false);
const viewer = new Types.ObjectId();
await grantUserViewer(link._id, viewer);
mockGetUserPrincipals.mockResolvedValue([
{ principalType: PrincipalType.USER, principalId: viewer },
]);
const req = createReq({
params: { shareId: link.shareId },
user: { id: viewer.toString(), _id: viewer, role: 'USER' },
});
const res = createRes();
const next = jest.fn();
await canAccessSharedLink(req, res, next as unknown as NextFunction);
const publicEntries = await AclEntry.find({
resourceId: link._id,
principalType: PrincipalType.PUBLIC,
}).lean();
expect(publicEntries).toHaveLength(0);
});
test('returns 403 when auto-migration is disabled', async () => {
const link = await createLegacyLink(true);
process.env.SHARED_LINKS_AUTO_MIGRATE = 'false';
const req = createReq({ params: { shareId: link.shareId } });
const res = createRes();
const next = jest.fn();
await canAccessSharedLink(req, res, next as unknown as NextFunction);
expect(res._status).toBe(403);
expect(next).not.toHaveBeenCalled();
expect((res._json as Record<string, string>).message).toContain('migration');
});
});
});

View file

@ -0,0 +1,137 @@
import { getTenantId, runAsSystem, tenantStorage } from '@librechat/data-schemas';
import { ResourceType, PermissionBits } from 'librechat-data-provider';
import type { Request, Response, NextFunction } from 'express';
import type { Types, Model } from 'mongoose';
import type { IUser } from '@librechat/data-schemas';
import { AccessControlService } from '~/acl/accessControlService';
import { autoMigrateLegacyLink } from './service';
import { isEnabled } from '~/utils';
interface RawSharedLink {
_id?: Types.ObjectId;
conversationId: string;
title?: string;
user?: string;
shareId?: string;
tenantId?: string;
isPublic?: boolean;
expiredAt?: Date;
}
export interface SharedLinkAccessDeps {
mongoose: typeof import('mongoose');
aclService?: AccessControlService;
}
function isAutoMigrateEnabled(): boolean {
// Fallback for legacy rows missed by the explicit shared-link permissions migration.
const val = process.env.SHARED_LINKS_AUTO_MIGRATE;
return val === undefined || isEnabled(val);
}
export function createSharedLinkAccessMiddleware(deps: SharedLinkAccessDeps) {
const { mongoose: mg } = deps;
const aclService = deps.aclService ?? new AccessControlService(mg);
async function hasPublicViewPermission(resourceId: string): Promise<boolean> {
return aclService.hasPublicAccess({
resourceType: ResourceType.SHARED_LINK,
resourceId,
});
}
return async function canAccessSharedLink(
req: Request,
res: Response,
next: NextFunction,
): Promise<void> {
const { shareId } = req.params;
if (!shareId) {
res.status(400).json({ message: 'Missing shareId' });
return;
}
const SharedLink = mg.models.SharedLink as Model<RawSharedLink>;
const findShare = async () =>
(await SharedLink.findOne({ shareId }).lean()) as RawSharedLink | null;
const rawShare = getTenantId() ? await findShare() : await runAsSystem(findShare);
if (!rawShare) {
res.status(404).json({ message: 'Shared link not found' });
return;
}
const resourceId = rawShare._id?.toString();
if (!resourceId) {
res.status(404).json({ message: 'Shared link not found' });
return;
}
const user = req.user as IUser | undefined;
const runWithTenant = async (fn: () => Promise<void>): Promise<void> => {
if (rawShare.tenantId) {
return tenantStorage.run({ tenantId: rawShare.tenantId }, fn);
}
return runAsSystem(fn);
};
await runWithTenant(async () => {
const isLegacy = 'isPublic' in rawShare;
if (isLegacy) {
if (!isAutoMigrateEnabled()) {
res.status(403).json({ message: 'Legacy shared link requires migration' });
return;
}
await autoMigrateLegacyLink(rawShare);
}
const publicGranted = await hasPublicViewPermission(resourceId);
if (publicGranted) {
if (isEnabled(process.env.ALLOW_SHARED_LINKS_PUBLIC)) {
(req as unknown as Record<string, unknown>).shareResourceId = resourceId;
next();
return;
}
if (!user) {
res.status(401).json({ message: 'Authentication required' });
return;
}
(req as unknown as Record<string, unknown>).shareResourceId = resourceId;
next();
return;
}
if (!user) {
res.status(401).json({ message: 'Authentication required' });
return;
}
const userId = user.id ?? user._id?.toString();
if (!userId) {
res.status(401).json({ message: 'Authentication required' });
return;
}
const hasAccess = await aclService.checkPermission({
userId,
role: user.role,
resourceType: ResourceType.SHARED_LINK,
resourceId,
requiredPermission: PermissionBits.VIEW,
});
if (!hasAccess) {
res.status(403).json({ message: 'You do not have permission to view this shared link' });
return;
}
(req as unknown as Record<string, unknown>).shareResourceId = resourceId;
next();
});
};
}

View file

@ -0,0 +1,369 @@
jest.mock('@librechat/data-schemas', () => ({
...jest.requireActual('@librechat/data-schemas'),
logger: { error: jest.fn(), info: jest.fn(), warn: jest.fn(), debug: jest.fn() },
}));
import mongoose, { Types, Model } from 'mongoose';
import { MongoMemoryServer } from 'mongodb-memory-server';
import { createModels, createMethods } from '@librechat/data-schemas';
import { PrincipalType, AccessRoleIds } from 'librechat-data-provider';
import type { IAclEntry, ISharedLink } from '@librechat/data-schemas';
import {
autoMigrateLegacyLink,
grantCreationPermissions,
ensureLinkPermissions,
updateSharedLinkPermissionsExpiration,
deleteSharedLinkWithCleanup,
deleteConvoSharedLinksWithCleanup,
deleteAllSharedLinksWithCleanup,
} from './service';
let mongoServer: MongoMemoryServer;
let AclEntry: Model<IAclEntry>;
let SharedLink: Model<ISharedLink>;
beforeAll(async () => {
mongoServer = await MongoMemoryServer.create();
await mongoose.connect(mongoServer.getUri());
createModels(mongoose);
const methods = createMethods(mongoose);
await methods.seedDefaultRoles();
AclEntry = mongoose.models.AclEntry as Model<IAclEntry>;
SharedLink = mongoose.models.SharedLink as Model<ISharedLink>;
});
afterAll(async () => {
await mongoose.disconnect();
await mongoServer.stop();
});
beforeEach(async () => {
await AclEntry.deleteMany({});
await SharedLink.deleteMany({});
});
const userId = new Types.ObjectId().toString();
async function createTestLink(overrides: Partial<ISharedLink> = {}) {
return SharedLink.create({
shareId: `share-${Date.now()}-${Math.random()}`,
conversationId: 'convo1',
user: userId,
messages: [],
...overrides,
});
}
describe('autoMigrateLegacyLink', () => {
async function createLegacyLink(isPublic: boolean) {
const link = await createTestLink();
await mongoose.connection
.db!.collection('sharedlinks')
.updateOne({ _id: link._id }, { $set: { isPublic } });
return link;
}
async function createOwnerlessLegacyLink(isPublic: boolean) {
const link = await SharedLink.create({
shareId: `share-${Date.now()}-${Math.random()}`,
conversationId: 'convo1',
messages: [],
});
await mongoose.connection
.db!.collection('sharedlinks')
.updateOne({ _id: link._id }, { $set: { isPublic } });
return link;
}
test('grants OWNER and PUBLIC VIEWER for public legacy link', async () => {
const link = await createLegacyLink(true);
await autoMigrateLegacyLink({
_id: link._id,
conversationId: link.conversationId,
user: userId,
shareId: link.shareId,
isPublic: true,
});
const entries = await AclEntry.find({ resourceId: link._id }).lean();
const hasOwner = entries.some((e) => e.principalType === PrincipalType.USER);
const hasPublic = entries.some((e) => e.principalType === PrincipalType.PUBLIC);
expect(hasOwner).toBe(true);
expect(hasPublic).toBe(true);
});
test('grants OWNER only for private legacy link (isPublic: false)', async () => {
const link = await createLegacyLink(false);
await autoMigrateLegacyLink({
_id: link._id,
conversationId: link.conversationId,
user: userId,
shareId: link.shareId,
isPublic: false,
});
const entries = await AclEntry.find({ resourceId: link._id }).lean();
const hasOwner = entries.some((e) => e.principalType === PrincipalType.USER);
const hasPublic = entries.some((e) => e.principalType === PrincipalType.PUBLIC);
expect(hasOwner).toBe(true);
expect(hasPublic).toBe(false);
});
test('grants PUBLIC VIEWER for ownerless public legacy link', async () => {
const link = await createOwnerlessLegacyLink(true);
await autoMigrateLegacyLink({
_id: link._id,
conversationId: link.conversationId,
shareId: link.shareId,
isPublic: true,
});
const entries = await AclEntry.find({ resourceId: link._id }).lean();
const hasOwner = entries.some((e) => e.principalType === PrincipalType.USER);
const hasPublic = entries.some((e) => e.principalType === PrincipalType.PUBLIC);
expect(hasOwner).toBe(false);
expect(hasPublic).toBe(true);
});
test('removes isPublic field from document', async () => {
const link = await createLegacyLink(true);
await autoMigrateLegacyLink({
_id: link._id,
conversationId: link.conversationId,
user: userId,
shareId: link.shareId,
isPublic: true,
});
const rawDoc = await mongoose.connection
.db!.collection('sharedlinks')
.findOne({ _id: link._id });
expect(rawDoc).not.toHaveProperty('isPublic');
});
test('preserves isPublic when grant fails, allowing retry on next access', async () => {
const link = await createLegacyLink(true);
const AccessRole = mongoose.models.AccessRole;
await AccessRole.deleteOne({ accessRoleId: AccessRoleIds.SHARED_LINK_OWNER });
try {
await autoMigrateLegacyLink({
_id: link._id,
conversationId: link.conversationId,
user: userId,
shareId: link.shareId,
isPublic: true,
});
const rawDoc = await mongoose.connection
.db!.collection('sharedlinks')
.findOne({ _id: link._id });
expect(rawDoc).toHaveProperty('isPublic');
} finally {
const methods = createMethods(mongoose);
await methods.seedDefaultRoles();
}
});
test('is idempotent — does not duplicate ACL entries on repeated calls', async () => {
const link = await createLegacyLink(true);
const args = {
_id: link._id,
conversationId: link.conversationId,
user: userId,
shareId: link.shareId,
isPublic: true,
};
await autoMigrateLegacyLink(args);
await autoMigrateLegacyLink(args);
const ownerEntries = await AclEntry.find({
resourceId: link._id,
principalType: PrincipalType.USER,
}).lean();
expect(ownerEntries).toHaveLength(1);
});
});
describe('grantCreationPermissions', () => {
test('creates OWNER and PUBLIC VIEWER AclEntries', async () => {
const link = await createTestLink();
await grantCreationPermissions(link._id, userId, true);
const entries = await AclEntry.find({ resourceId: link._id }).lean();
expect(entries).toHaveLength(2);
const owner = entries.find((e) => e.principalType === PrincipalType.USER);
expect(owner).toBeDefined();
expect(owner!.principalId!.toString()).toBe(userId);
const pub = entries.find((e) => e.principalType === PrincipalType.PUBLIC);
expect(pub).toBeDefined();
});
test('creates only OWNER when grantPublic is false', async () => {
const link = await createTestLink();
await grantCreationPermissions(link._id, userId, false);
const entries = await AclEntry.find({ resourceId: link._id }).lean();
expect(entries).toHaveLength(1);
expect(entries[0].principalType).toBe(PrincipalType.USER);
});
test('propagates shared link expiration to created AclEntries', async () => {
const expiredAt = new Date(Date.now() + 60 * 60 * 1000);
const link = await createTestLink({ expiredAt });
await grantCreationPermissions(link._id, userId, true, expiredAt);
const entries = await AclEntry.find({ resourceId: link._id }).lean();
expect(entries).toHaveLength(2);
for (const entry of entries) {
expect(entry.expiredAt?.toISOString()).toBe(expiredAt.toISOString());
}
});
test('deletes SharedLink when OWNER grant fails', async () => {
const link = await createTestLink();
try {
const AccessRole = mongoose.models.AccessRole;
await AccessRole.deleteOne({ accessRoleId: AccessRoleIds.SHARED_LINK_OWNER });
await expect(grantCreationPermissions(link._id, userId, true)).rejects.toThrow();
const linkAfter = await SharedLink.findById(link._id);
expect(linkAfter).toBeNull();
} finally {
const methods = createMethods(mongoose);
await methods.seedDefaultRoles();
}
});
});
describe('ensureLinkPermissions', () => {
test('creates OWNER AclEntry for legacy link with no entries', async () => {
const link = await createTestLink();
const beforeCount = await AclEntry.countDocuments({ resourceId: link._id });
expect(beforeCount).toBe(0);
await ensureLinkPermissions(link._id, userId);
const entries = await AclEntry.find({ resourceId: link._id }).lean();
expect(entries).toHaveLength(1);
expect(entries[0].principalType).toBe(PrincipalType.USER);
});
test('is idempotent — does not duplicate on repeated calls', async () => {
const link = await createTestLink();
await ensureLinkPermissions(link._id, userId);
await ensureLinkPermissions(link._id, userId);
const entries = await AclEntry.find({ resourceId: link._id }).lean();
expect(entries).toHaveLength(1);
});
test('does not delete the SharedLink on failure', async () => {
const link = await createTestLink();
const AccessRole = mongoose.models.AccessRole;
await AccessRole.deleteOne({ accessRoleId: AccessRoleIds.SHARED_LINK_OWNER });
try {
await ensureLinkPermissions(link._id, userId);
const linkAfter = await SharedLink.findById(link._id);
expect(linkAfter).not.toBeNull();
} finally {
const methods = createMethods(mongoose);
await methods.seedDefaultRoles();
}
});
});
describe('updateSharedLinkPermissionsExpiration', () => {
test('sets and clears expiration on shared-link AclEntries', async () => {
const link = await createTestLink();
await grantCreationPermissions(link._id, userId, true);
const expiredAt = new Date(Date.now() + 60 * 60 * 1000);
await updateSharedLinkPermissionsExpiration(link._id, expiredAt);
const expiringEntries = await AclEntry.find({ resourceId: link._id }).lean();
expect(expiringEntries).toHaveLength(2);
for (const entry of expiringEntries) {
expect(entry.expiredAt?.toISOString()).toBe(expiredAt.toISOString());
}
await updateSharedLinkPermissionsExpiration(link._id, null);
const retainedEntries = await AclEntry.find({ resourceId: link._id }).lean();
for (const entry of retainedEntries) {
expect(entry).not.toHaveProperty('expiredAt');
}
});
});
describe('deleteSharedLinkWithCleanup', () => {
test('deletes link and triggers ACL cleanup', async () => {
const link = await createTestLink();
await grantCreationPermissions(link._id, userId, true);
const result = await deleteSharedLinkWithCleanup(userId, link.shareId!);
expect(result).toMatchObject({ success: true, shareId: link.shareId! });
expect(result!._id).toBe(link._id.toString());
const linkAfter = await SharedLink.findById(link._id);
expect(linkAfter).toBeNull();
// ACL cleanup is async (fire-and-forget), wait briefly
await new Promise((r) => setTimeout(r, 100));
const aclAfter = await AclEntry.find({ resourceId: link._id }).lean();
expect(aclAfter).toHaveLength(0);
});
test('returns null when link not found', async () => {
const result = await deleteSharedLinkWithCleanup(userId, 'nonexistent');
expect(result).toBeNull();
});
});
describe('deleteConvoSharedLinksWithCleanup', () => {
test('deletes all links for conversation and cleans up ACLs', async () => {
const link1 = await createTestLink({ conversationId: 'convo-a' });
const link2 = await createTestLink({ conversationId: 'convo-a' });
await grantCreationPermissions(link1._id, userId, true);
await grantCreationPermissions(link2._id, userId, false);
const result = await deleteConvoSharedLinksWithCleanup(userId, 'convo-a');
expect(result.deletedCount).toBe(2);
await new Promise((r) => setTimeout(r, 100));
const aclAfter = await AclEntry.find({
resourceId: { $in: [link1._id, link2._id] },
}).lean();
expect(aclAfter).toHaveLength(0);
});
});
describe('deleteAllSharedLinksWithCleanup', () => {
test('deletes all user links and cleans up ACLs', async () => {
const link1 = await createTestLink({ conversationId: 'c1' });
const link2 = await createTestLink({ conversationId: 'c2' });
await grantCreationPermissions(link1._id, userId, true);
await grantCreationPermissions(link2._id, userId, true);
const result = await deleteAllSharedLinksWithCleanup(userId);
expect(result.deletedCount).toBe(2);
await new Promise((r) => setTimeout(r, 100));
const aclAfter = await AclEntry.find({
resourceId: { $in: [link1._id, link2._id] },
}).lean();
expect(aclAfter).toHaveLength(0);
});
});

View file

@ -0,0 +1,324 @@
import mongoose from 'mongoose';
import { logger } from '@librechat/data-schemas';
import {
PrincipalType,
ResourceType,
AccessRoleIds,
PermissionBits,
} from 'librechat-data-provider';
import type { Model, Types, DeleteResult, UpdateQuery } from 'mongoose';
import type { IAclEntry, ISharedLink } from '@librechat/data-schemas';
import { AccessControlService } from '~/acl/accessControlService';
let _aclService: AccessControlService | null = null;
function getAclService(): AccessControlService {
if (!_aclService) {
_aclService = new AccessControlService(mongoose);
}
return _aclService;
}
interface RawSharedLink {
_id?: Types.ObjectId;
conversationId: string;
title?: string;
user?: string;
shareId?: string;
tenantId?: string;
isPublic?: boolean;
expiredAt?: Date;
}
export async function autoMigrateLegacyLink(share: RawSharedLink): Promise<void> {
const shareId = share._id;
if (!shareId) {
return;
}
const resourceId = shareId.toString();
let ownerGranted = false;
let publicGranted = false;
let existingOwner = false;
if (share.user) {
existingOwner = await getAclService().checkPermission({
userId: share.user,
resourceType: ResourceType.SHARED_LINK,
resourceId,
requiredPermission: PermissionBits.DELETE,
});
if (!existingOwner) {
try {
await getAclService().grantPermission({
principalType: PrincipalType.USER,
principalId: share.user,
resourceType: ResourceType.SHARED_LINK,
resourceId,
accessRoleId: AccessRoleIds.SHARED_LINK_OWNER,
grantedBy: share.user,
expiredAt: share.expiredAt,
});
existingOwner = true;
ownerGranted = true;
} catch (err) {
logger.error('[autoMigrateLegacyLink] Failed to grant OWNER', {
shareId: share.shareId,
error: err instanceof Error ? err.message : String(err),
});
}
}
}
const needsPublicGrant = share.isPublic !== false;
if (needsPublicGrant) {
const hasPublic = await getAclService().hasPublicAccess({
resourceType: ResourceType.SHARED_LINK,
resourceId,
});
if (!hasPublic) {
try {
await getAclService().grantPermission({
principalType: PrincipalType.PUBLIC,
principalId: null,
resourceType: ResourceType.SHARED_LINK,
resourceId,
accessRoleId: AccessRoleIds.SHARED_LINK_VIEWER,
grantedBy: share.user,
expiredAt: share.expiredAt,
});
publicGranted = true;
} catch (err) {
logger.error('[autoMigrateLegacyLink] Failed to grant PUBLIC VIEWER', {
shareId: share.shareId,
error: err instanceof Error ? err.message : String(err),
});
}
} else {
publicGranted = true;
}
}
const ownerOk = existingOwner || !share.user;
const publicOk = publicGranted || !needsPublicGrant;
if (!ownerOk || !publicOk) {
logger.warn('[autoMigrateLegacyLink] Grants incomplete, keeping isPublic for retry', {
shareId: share.shareId,
ownerOk,
publicOk,
});
return;
}
await mongoose.connection
.db!.collection('sharedlinks')
.updateOne({ _id: shareId }, { $unset: { isPublic: 1 } });
logger.info('[autoMigrateLegacyLink] Migrated legacy shared link', {
shareId: share.shareId,
resourceId,
ownerGranted,
publicGranted,
});
}
export async function grantCreationPermissions(
sharedLinkId: string | Types.ObjectId,
userId: string,
grantPublic: boolean = true,
expiredAt?: Date | null,
): Promise<void> {
const resourceId = sharedLinkId.toString();
try {
await getAclService().grantPermission({
principalType: PrincipalType.USER,
principalId: userId,
resourceType: ResourceType.SHARED_LINK,
resourceId,
accessRoleId: AccessRoleIds.SHARED_LINK_OWNER,
grantedBy: userId,
expiredAt: expiredAt ?? undefined,
});
} catch (err) {
logger.error('[grantCreationPermissions] OWNER grant failed, deleting SharedLink', {
resourceId,
error: err instanceof Error ? err.message : String(err),
});
await mongoose.models.SharedLink.deleteOne({ _id: sharedLinkId });
throw err;
}
if (grantPublic) {
try {
await getAclService().grantPermission({
principalType: PrincipalType.PUBLIC,
principalId: null,
resourceType: ResourceType.SHARED_LINK,
resourceId,
accessRoleId: AccessRoleIds.SHARED_LINK_VIEWER,
grantedBy: userId,
expiredAt: expiredAt ?? undefined,
});
} catch (err) {
logger.error('[grantCreationPermissions] PUBLIC VIEWER grant failed, cleaning up', {
resourceId,
error: err instanceof Error ? err.message : String(err),
});
await Promise.all([
mongoose.models.SharedLink.deleteOne({ _id: sharedLinkId }),
getAclService().removeAllPermissions({
resourceType: ResourceType.SHARED_LINK,
resourceId,
}),
]);
throw err;
}
}
}
export async function ensureLinkPermissions(
sharedLinkId: string | Types.ObjectId,
userId: string,
): Promise<void> {
const SharedLink = mongoose.models.SharedLink as Model<ISharedLink>;
const rawDoc = await SharedLink.findById(sharedLinkId).lean();
if (!rawDoc) {
return;
}
if ('isPublic' in rawDoc) {
await autoMigrateLegacyLink(rawDoc as Parameters<typeof autoMigrateLegacyLink>[0]);
return;
}
try {
await getAclService().grantPermission({
principalType: PrincipalType.USER,
principalId: userId,
resourceType: ResourceType.SHARED_LINK,
resourceId: sharedLinkId.toString(),
accessRoleId: AccessRoleIds.SHARED_LINK_OWNER,
grantedBy: userId,
expiredAt: rawDoc.expiredAt,
});
} catch (err) {
logger.error('[ensureLinkPermissions] Failed to ensure OWNER AclEntry', {
resourceId: sharedLinkId.toString(),
error: err instanceof Error ? err.message : String(err),
});
}
}
export async function cleanupSharedLinkPermissions(
resourceId: string | Types.ObjectId,
): Promise<DeleteResult> {
return getAclService().removeAllPermissions({
resourceType: ResourceType.SHARED_LINK,
resourceId,
});
}
export async function cleanupBulkSharedLinkPermissions(
resourceIds: (string | Types.ObjectId)[],
): Promise<DeleteResult> {
const AclEntry = mongoose.models.AclEntry as Model<IAclEntry>;
return AclEntry.deleteMany({
resourceType: ResourceType.SHARED_LINK,
resourceId: { $in: resourceIds },
});
}
export async function updateSharedLinkPermissionsExpiration(
resourceId: string | Types.ObjectId,
expiredAt: Date | null,
): Promise<void> {
const AclEntry = mongoose.models.AclEntry as Model<IAclEntry>;
const update: UpdateQuery<IAclEntry> =
expiredAt instanceof Date ? { $set: { expiredAt } } : { $unset: { expiredAt: 1 } };
await AclEntry.updateMany(
{
resourceType: ResourceType.SHARED_LINK,
resourceId,
},
update,
);
}
export async function deleteSharedLinkWithCleanup(
user: string,
shareId: string,
): Promise<{ _id?: string; success: boolean; shareId: string; message: string } | null> {
const SharedLink = mongoose.models.SharedLink as Model<ISharedLink>;
const result = await SharedLink.findOneAndDelete({ shareId, user }).lean();
if (!result) {
return null;
}
const resourceId = result._id;
if (resourceId) {
cleanupSharedLinkPermissions(resourceId).catch((err) => {
logger.error('[deleteSharedLinkWithCleanup] ACL cleanup failed', {
shareId,
error: err instanceof Error ? err.message : String(err),
});
});
}
return {
_id: resourceId?.toString(),
success: true,
shareId,
message: 'Share deleted successfully',
};
}
export async function deleteConvoSharedLinksWithCleanup(
user: string,
conversationId: string,
): Promise<{ message: string; deletedCount: number }> {
const SharedLink = mongoose.models.SharedLink as Model<ISharedLink>;
const links = await SharedLink.find({ user, conversationId }).select('_id').lean();
const ids = links.map((l) => l._id);
const result = await SharedLink.deleteMany({ user, conversationId });
if (ids.length > 0) {
cleanupBulkSharedLinkPermissions(ids).catch((err) => {
logger.error('[deleteConvoSharedLinksWithCleanup] ACL cleanup failed', {
conversationId,
error: err instanceof Error ? err.message : String(err),
});
});
}
return {
message: 'Shared links deleted successfully',
deletedCount: result.deletedCount,
};
}
export async function deleteAllSharedLinksWithCleanup(
user: string,
): Promise<{ message: string; deletedCount: number }> {
const SharedLink = mongoose.models.SharedLink as Model<ISharedLink>;
const links = await SharedLink.find({ user }).select('_id').lean();
const ids = links.map((l) => l._id);
const result = await SharedLink.deleteMany({ user });
if (ids.length > 0) {
cleanupBulkSharedLinkPermissions(ids).catch((err) => {
logger.error('[deleteAllSharedLinksWithCleanup] ACL cleanup failed', {
user,
error: err instanceof Error ? err.message : String(err),
});
});
}
return {
message: 'All shared links deleted successfully',
deletedCount: result.deletedCount,
};
}

View file

@ -48,6 +48,7 @@ export enum ResourceType {
MCPSERVER = 'mcpServer',
REMOTE_AGENT = 'remoteAgent',
SKILL = 'skill',
SHARED_LINK = 'sharedLink',
}
/**
@ -83,6 +84,8 @@ export enum AccessRoleIds {
SKILL_VIEWER = 'skill_viewer',
SKILL_EDITOR = 'skill_editor',
SKILL_OWNER = 'skill_owner',
SHARED_LINK_VIEWER = 'sharedLink_viewer',
SHARED_LINK_OWNER = 'sharedLink_owner',
}
// ===== ZOD SCHEMAS =====
@ -145,7 +148,7 @@ export const resourcePermissionsResponseSchema = z.object({
export const updateResourcePermissionsRequestSchema = z.object({
updated: principalSchema.array(),
removed: principalSchema.array(),
public: z.boolean(),
public: z.boolean().optional(),
publicAccessRoleId: z.string().optional(),
});
@ -157,7 +160,7 @@ export const updateResourcePermissionsResponseSchema = z.object({
message: z.string(),
results: z.object({
principals: principalSchema.array(),
public: z.boolean(),
public: z.boolean().optional(),
publicAccessRoleId: z.string().optional(),
}),
});
@ -322,6 +325,7 @@ export function accessRoleToPermBits(accessRoleId: string): number {
case AccessRoleIds.MCPSERVER_VIEWER:
case AccessRoleIds.REMOTE_AGENT_VIEWER:
case AccessRoleIds.SKILL_VIEWER:
case AccessRoleIds.SHARED_LINK_VIEWER:
return PermissionBits.VIEW;
case AccessRoleIds.AGENT_EDITOR:
case AccessRoleIds.PROMPTGROUP_EDITOR:
@ -334,6 +338,7 @@ export function accessRoleToPermBits(accessRoleId: string): number {
case AccessRoleIds.MCPSERVER_OWNER:
case AccessRoleIds.REMOTE_AGENT_OWNER:
case AccessRoleIds.SKILL_OWNER:
case AccessRoleIds.SHARED_LINK_OWNER:
return (
PermissionBits.VIEW | PermissionBits.EDIT | PermissionBits.DELETE | PermissionBits.SHARE
);

View file

@ -74,13 +74,12 @@ export const shareMessages = (shareId: string) => `${shareRoot}/${shareId}`;
export const getSharedLink = (conversationId: string) => `${shareRoot}/link/${conversationId}`;
export const getSharedLinks = (
pageSize: number,
isPublic: boolean,
sortBy: 'title' | 'createdAt',
sortDirection: 'asc' | 'desc',
search?: string,
cursor?: string,
) =>
`${shareRoot}?pageSize=${pageSize}&isPublic=${isPublic}&sortBy=${sortBy}&sortDirection=${sortDirection}${
`${shareRoot}?pageSize=${pageSize}&sortBy=${sortBy}&sortDirection=${sortDirection}${
search ? `&search=${search}` : ''
}${cursor ? `&cursor=${cursor}` : ''}`;
export const createSharedLink = (conversationId: string) => `${shareRoot}/${conversationId}`;

View file

@ -1007,6 +1007,16 @@ export const interfaceSchema = z
}),
])
.optional(),
sharedLinks: z
.union([
z.boolean(),
z.object({
create: z.boolean().optional(),
share: z.boolean().optional(),
public: z.boolean().optional(),
}),
])
.optional(),
})
.default({
modelSelect: true,
@ -1061,6 +1071,11 @@ export const interfaceSchema = z
public: false,
defaultActiveOnShare: false,
},
sharedLinks: {
create: true,
share: true,
public: true,
},
});
export type TInterfaceConfig = z.infer<typeof interfaceSchema>;

View file

@ -65,11 +65,9 @@ export function getSharedMessages(shareId: string): Promise<t.TSharedMessagesRes
export const listSharedLinks = async (
params: q.SharedLinksListParams,
): Promise<q.SharedLinksResponse> => {
const { pageSize, isPublic, sortBy, sortDirection, search, cursor } = params;
const { pageSize, sortBy, sortDirection, search, cursor } = params;
return request.get(
endpoints.getSharedLinks(pageSize, isPublic, sortBy, sortDirection, search, cursor),
);
return request.get(endpoints.getSharedLinks(pageSize, sortBy, sortDirection, search, cursor));
};
export function getSharedLink(conversationId: string): Promise<t.TSharedLinkGetResponse> {

View file

@ -64,6 +64,10 @@ export enum PermissionTypes {
* Type for Skill Permissions
*/
SKILLS = 'SKILLS',
/**
* Type for Shared Link Permissions
*/
SHARED_LINKS = 'SHARED_LINKS',
}
/**
@ -87,6 +91,7 @@ export const PERMISSION_TYPE_INTERFACE_FIELDS: Record<PermissionTypes, string> =
[PermissionTypes.MCP_SERVERS]: 'mcpServers',
[PermissionTypes.REMOTE_AGENTS]: 'remoteAgents',
[PermissionTypes.SKILLS]: 'skills',
[PermissionTypes.SHARED_LINKS]: 'sharedLinks',
};
/** Set of interface config field names that correspond to role permissions. */
@ -240,6 +245,13 @@ export const skillPermissionsSchema = z.object({
});
export type TSkillPermissions = z.infer<typeof skillPermissionsSchema>;
export const sharedLinksPermissionsSchema = z.object({
[Permissions.CREATE]: z.boolean().default(true),
[Permissions.SHARE]: z.boolean().default(true),
[Permissions.SHARE_PUBLIC]: z.boolean().default(false),
});
export type TSharedLinksPermissions = z.infer<typeof sharedLinksPermissionsSchema>;
// Define a single permissions schema that holds all permission types.
export const permissionsSchema = z.object({
[PermissionTypes.PROMPTS]: promptPermissionsSchema,
@ -257,4 +269,5 @@ export const permissionsSchema = z.object({
[PermissionTypes.MCP_SERVERS]: mcpServersPermissionsSchema,
[PermissionTypes.REMOTE_AGENTS]: remoteAgentsPermissionsSchema,
[PermissionTypes.SKILLS]: skillPermissionsSchema,
[PermissionTypes.SHARED_LINKS]: sharedLinksPermissionsSchema,
});

View file

@ -89,7 +89,8 @@ describe('roleDefaults', () => {
permType === PermissionTypes.MEMORIES ||
permType === PermissionTypes.PROMPTS ||
permType === PermissionTypes.AGENTS ||
permType === PermissionTypes.SKILLS;
permType === PermissionTypes.SKILLS ||
permType === PermissionTypes.SHARED_LINKS;
expect({
permType,

View file

@ -13,6 +13,7 @@ import {
fileSearchPermissionsSchema,
multiConvoPermissionsSchema,
mcpServersPermissionsSchema,
sharedLinksPermissionsSchema,
peoplePickerPermissionsSchema,
remoteAgentsPermissionsSchema,
temporaryChatPermissionsSchema,
@ -111,6 +112,11 @@ const defaultRolesSchema = z.object({
[Permissions.SHARE]: z.boolean().default(true),
[Permissions.SHARE_PUBLIC]: z.boolean().default(true),
}),
[PermissionTypes.SHARED_LINKS]: sharedLinksPermissionsSchema.extend({
[Permissions.CREATE]: z.boolean().default(true),
[Permissions.SHARE]: z.boolean().default(true),
[Permissions.SHARE_PUBLIC]: z.boolean().default(true),
}),
}),
}),
[SystemRoles.USER]: roleSchema.extend({
@ -200,6 +206,11 @@ export const roleDefaults = defaultRolesSchema.parse({
[Permissions.SHARE]: true,
[Permissions.SHARE_PUBLIC]: true,
},
[PermissionTypes.SHARED_LINKS]: {
[Permissions.CREATE]: true,
[Permissions.SHARE]: true,
[Permissions.SHARE_PUBLIC]: true,
},
},
},
[SystemRoles.USER]: {
@ -252,6 +263,11 @@ export const roleDefaults = defaultRolesSchema.parse({
[Permissions.SHARE]: false,
[Permissions.SHARE_PUBLIC]: false,
},
[PermissionTypes.SHARED_LINKS]: {
[Permissions.CREATE]: true,
[Permissions.SHARE]: true,
[Permissions.SHARE_PUBLIC]: true,
},
},
},
});

View file

@ -1074,7 +1074,6 @@ export const tSharedLinkSchema = z.object({
shareId: z.string(),
targetMessageId: z.string().optional(),
messages: z.array(z.string()),
isPublic: z.boolean(),
title: z.string(),
createdAt: z.string(),
updatedAt: z.string(),

View file

@ -320,7 +320,9 @@ export type TUpdateShareLinkRequest = Pick<TSharedLink, 'shareId' | 'targetMessa
export type TSharedLinkResponse = Pick<TSharedLink, 'shareId'> &
Pick<TSharedLink, 'targetMessageId'> &
Pick<TConversation, 'conversationId'>;
Pick<TConversation, 'conversationId'> & {
_id?: string;
};
export type TSharedLinkGetResponse = Omit<TSharedLinkResponse, 'shareId'> & {
shareId: string | null;

View file

@ -60,7 +60,6 @@ export type SharedMessagesResponse = Omit<s.TSharedLink, 'messages'> & {
export interface SharedLinksListParams {
pageSize: number;
isPublic: boolean;
sortBy: 'title' | 'createdAt';
sortDirection: 'asc' | 'desc';
search?: string;
@ -70,7 +69,6 @@ export interface SharedLinksListParams {
export type SharedLinkItem = {
shareId: string;
title: string;
isPublic: boolean;
createdAt: Date;
conversationId: string;
};

View file

@ -37,6 +37,8 @@ export const SystemCapabilities = {
MANAGE_PROMPTS: 'manage:prompts',
READ_SKILLS: 'read:skills',
MANAGE_SKILLS: 'manage:skills',
READ_SHARED_LINKS: 'read:sharedlinks',
MANAGE_SHARED_LINKS: 'manage:sharedlinks',
/** Reserved — not yet enforced by any middleware. */
READ_ASSISTANTS: 'read:assistants',
MANAGE_ASSISTANTS: 'manage:assistants',
@ -55,6 +57,7 @@ export const CapabilityImplications: Partial<Record<BaseSystemCapability, BaseSy
[SystemCapabilities.MANAGE_AGENTS]: [SystemCapabilities.READ_AGENTS],
[SystemCapabilities.MANAGE_PROMPTS]: [SystemCapabilities.READ_PROMPTS],
[SystemCapabilities.MANAGE_SKILLS]: [SystemCapabilities.READ_SKILLS],
[SystemCapabilities.MANAGE_SHARED_LINKS]: [SystemCapabilities.READ_SHARED_LINKS],
[SystemCapabilities.MANAGE_ASSISTANTS]: [SystemCapabilities.READ_ASSISTANTS],
};
@ -143,6 +146,7 @@ export const ResourceCapabilityMap: Record<ResourceType, SystemCapability> = {
[ResourceType.MCPSERVER]: SystemCapabilities.MANAGE_MCP_SERVERS,
[ResourceType.REMOTE_AGENT]: SystemCapabilities.MANAGE_AGENTS,
[ResourceType.SKILL]: SystemCapabilities.MANAGE_SKILLS,
[ResourceType.SHARED_LINK]: SystemCapabilities.MANAGE_SHARED_LINKS,
};
/**
@ -213,6 +217,8 @@ export const CAPABILITY_CATEGORIES: CapabilityCategory[] = [
SystemCapabilities.MANAGE_ASSISTANTS,
SystemCapabilities.READ_ASSISTANTS,
SystemCapabilities.MANAGE_MCP_SERVERS,
SystemCapabilities.MANAGE_SHARED_LINKS,
SystemCapabilities.READ_SHARED_LINKS,
],
},
{

View file

@ -58,6 +58,7 @@ export async function loadDefaultInterface({
marketplace: interfaceConfig?.marketplace,
remoteAgents: interfaceConfig?.remoteAgents,
skills: interfaceConfig?.skills,
sharedLinks: interfaceConfig?.sharedLinks,
});
return loadedInterface;

View file

@ -206,6 +206,8 @@ describe('AccessRole Model Tests', () => {
AccessRoleIds.REMOTE_AGENT_EDITOR,
AccessRoleIds.REMOTE_AGENT_OWNER,
AccessRoleIds.REMOTE_AGENT_VIEWER,
AccessRoleIds.SHARED_LINK_OWNER,
AccessRoleIds.SHARED_LINK_VIEWER,
AccessRoleIds.SKILL_EDITOR,
AccessRoleIds.SKILL_OWNER,
AccessRoleIds.SKILL_VIEWER,

View file

@ -209,6 +209,20 @@ export function createAccessRoleMethods(mongoose: typeof import('mongoose')) {
resourceType: ResourceType.SKILL,
permBits: RoleBits.OWNER,
},
{
accessRoleId: AccessRoleIds.SHARED_LINK_VIEWER,
name: 'com_ui_role_viewer',
description: 'com_ui_role_viewer_desc',
resourceType: ResourceType.SHARED_LINK,
permBits: RoleBits.VIEWER,
},
{
accessRoleId: AccessRoleIds.SHARED_LINK_OWNER,
name: 'com_ui_role_owner',
description: 'com_ui_role_owner_desc',
resourceType: ResourceType.SHARED_LINK,
permBits: RoleBits.OWNER,
},
];
const result: Record<string, IAccessRole> = {};

View file

@ -264,9 +264,10 @@ export function createAclEntryMethods(mongoose: typeof import('mongoose')) {
resourceType: string,
resourceId: string | Types.ObjectId,
permBits: number,
grantedBy: string | Types.ObjectId,
grantedBy?: string | Types.ObjectId,
session?: ClientSession,
roleId?: string | Types.ObjectId,
expiredAt?: Date,
): Promise<IAclEntry | null> {
const AclEntry = mongoose.models.AclEntry as Model<IAclEntry>;
const query: Record<string, unknown> = {
@ -292,9 +293,10 @@ export function createAclEntryMethods(mongoose: typeof import('mongoose')) {
const update = {
$set: {
permBits,
grantedBy,
grantedAt: new Date(),
...(grantedBy && { grantedBy }),
...(roleId && { roleId }),
...(expiredAt && { expiredAt }),
},
};

View file

@ -27,7 +27,6 @@ describe('Share Methods', () => {
messages: [{ type: mongoose.Schema.Types.ObjectId, ref: 'Message' }],
shareId: { type: String, index: true },
targetMessageId: { type: String, required: false, index: true },
isPublic: { type: Boolean, default: true },
expiredAt: { type: Date },
},
{ timestamps: true },
@ -116,6 +115,7 @@ describe('Share Methods', () => {
const result = await shareMethods.createSharedLink(userId, conversationId);
expect(result).toBeDefined();
expect(result._id).toBeDefined();
expect(result.shareId).toBeDefined();
expect(result.conversationId).toBe(conversationId);
@ -155,7 +155,7 @@ describe('Share Methods', () => {
);
});
test('should ignore expired public shares when checking for duplicates', async () => {
test('should ignore expired shares when checking for duplicates', async () => {
const userId = new mongoose.Types.ObjectId().toString();
const conversationId = `conv_${nanoid()}`;
const expiredShareId = `share_${nanoid()}`;
@ -179,7 +179,6 @@ describe('Share Methods', () => {
conversationId,
user: userId,
messages: [message._id],
isPublic: true,
expiredAt: new Date(Date.now() - 60 * 60 * 1000),
});
@ -327,7 +326,6 @@ describe('Share Methods', () => {
user: userId,
title: 'Test Share',
messages: messages.map((m) => m._id),
isPublic: true,
});
const result = await shareMethods.getSharedMessages(shareId);
@ -346,20 +344,6 @@ describe('Share Methods', () => {
});
});
test('should return null for non-public share', async () => {
const shareId = `share_${nanoid()}`;
await SharedLink.create({
shareId,
conversationId: 'conv123',
user: 'user123',
isPublic: false,
});
const result = await shareMethods.getSharedMessages(shareId);
expect(result).toBeNull();
});
test('should return null for non-existent share', async () => {
const result = await shareMethods.getSharedMessages('non_existent_share');
expect(result).toBeNull();
@ -372,7 +356,6 @@ describe('Share Methods', () => {
shareId,
conversationId: 'conv123',
user: 'user123',
isPublic: true,
expiredAt: new Date(Date.now() - 60 * 60 * 1000),
});
@ -405,7 +388,6 @@ describe('Share Methods', () => {
conversationId,
user: userId,
messages: [message._id],
isPublic: true,
});
const result = await shareMethods.getSharedMessages(shareId);
@ -431,7 +413,6 @@ describe('Share Methods', () => {
conversationId: `conv_${i}`,
user: userId,
title: `Share ${i}`,
isPublic: true,
createdAt: new Date(Date.now() - i * 1000 * 60), // Different timestamps
}),
);
@ -449,36 +430,6 @@ describe('Share Methods', () => {
expect(result.links[9].title).toBe('Share 9');
});
test('should filter by isPublic parameter', async () => {
const userId = new mongoose.Types.ObjectId().toString();
await SharedLink.create([
{
shareId: 'public_share',
conversationId: 'conv1',
user: userId,
title: 'Public Share',
isPublic: true,
},
{
shareId: 'private_share',
conversationId: 'conv2',
user: userId,
title: 'Private Share',
isPublic: false,
},
]);
const publicResults = await shareMethods.getSharedLinks(userId, undefined, 10, true);
const privateResults = await shareMethods.getSharedLinks(userId, undefined, 10, false);
expect(publicResults.links).toHaveLength(1);
expect(publicResults.links[0].title).toBe('Public Share');
expect(privateResults.links).toHaveLength(1);
expect(privateResults.links[0].title).toBe('Private Share');
});
test('should exclude expired shares', async () => {
const userId = new mongoose.Types.ObjectId().toString();
@ -488,7 +439,6 @@ describe('Share Methods', () => {
conversationId: 'conv1',
user: userId,
title: 'Active Share',
isPublic: true,
expiredAt: new Date(Date.now() + 60 * 60 * 1000),
},
{
@ -496,12 +446,11 @@ describe('Share Methods', () => {
conversationId: 'conv2',
user: userId,
title: 'Expired Share',
isPublic: true,
expiredAt: new Date(Date.now() - 60 * 60 * 1000),
},
]);
const result = await shareMethods.getSharedLinks(userId, undefined, 10, true);
const result = await shareMethods.getSharedLinks(userId, undefined, 10);
expect(result.links).toHaveLength(1);
expect(result.links[0].shareId).toBe('active_share');
@ -522,14 +471,12 @@ describe('Share Methods', () => {
conversationId: 'conv1',
user: userId,
title: 'Matching Share',
isPublic: true,
},
{
shareId: 'share2',
conversationId: 'conv2',
user: userId,
title: 'Non-matching Share',
isPublic: true,
},
]);
@ -537,7 +484,6 @@ describe('Share Methods', () => {
userId,
undefined,
10,
true,
'createdAt',
'desc',
'search term',
@ -592,21 +538,18 @@ describe('Share Methods', () => {
conversationId: 'conv1',
user: userId1,
title: 'User 1 Share',
isPublic: true,
},
{
shareId: 'share2',
conversationId: 'conv2',
user: userId2,
title: 'User 2 Share',
isPublic: true,
},
{
shareId: 'share3',
conversationId: 'conv3',
user: userId1,
title: 'Another User 1 Share',
isPublic: true,
},
]);
@ -615,7 +558,6 @@ describe('Share Methods', () => {
userId1,
undefined,
10,
true,
'createdAt',
'desc',
'search term',
@ -635,7 +577,6 @@ describe('Share Methods', () => {
userId2,
undefined,
10,
true,
'createdAt',
'desc',
'search term',
@ -662,21 +603,18 @@ describe('Share Methods', () => {
conversationId: 'conv1',
user: userId1,
title: 'User 1 Share',
isPublic: true,
},
{
shareId: 'share2',
conversationId: 'conv2',
user: userId2,
title: 'User 2 Share',
isPublic: true,
},
{
shareId: 'share3',
conversationId: 'conv3',
user: userId1,
title: 'Another User 1 Share',
isPublic: true,
},
]);
@ -714,7 +652,6 @@ describe('Share Methods', () => {
conversationId,
user: userId,
messages: initialMessages.map((m) => m._id),
isPublic: true,
});
// Add new message
@ -728,6 +665,7 @@ describe('Share Methods', () => {
const result = await shareMethods.updateSharedLink(userId, oldShareId);
expect(result._id).toBeDefined();
expect(result.shareId).not.toBe(oldShareId); // Should generate new shareId
expect(result.conversationId).toBe(conversationId);
@ -749,7 +687,6 @@ describe('Share Methods', () => {
conversationId,
user: userId,
messages: [],
isPublic: true,
expiredAt: expiresAt,
});
await Message.create({
@ -777,7 +714,6 @@ describe('Share Methods', () => {
conversationId,
user: userId,
messages: [],
isPublic: true,
expiredAt: expiresAt,
});
await Message.create({
@ -818,7 +754,6 @@ describe('Share Methods', () => {
conversationId,
user: userId,
messages: [],
isPublic: true,
});
// Add messages from different users
@ -890,7 +825,6 @@ describe('Share Methods', () => {
user: userId,
messages: initialMessages.map((message) => message._id),
targetMessageId: oldAnswerId,
isPublic: true,
});
await Message.create([
@ -942,7 +876,6 @@ describe('Share Methods', () => {
user: userId,
messages: [],
targetMessageId,
isPublic: true,
});
const result = await shareMethods.updateSharedLink(userId, shareId);
@ -964,7 +897,6 @@ describe('Share Methods', () => {
conversationId,
user: ownerUserId,
messages: [],
isPublic: true,
});
// Try to update as a different user
@ -988,7 +920,6 @@ describe('Share Methods', () => {
shareId,
conversationId: 'conv123',
user: userId,
isPublic: true,
});
const result = await shareMethods.deleteSharedLink(userId, shareId);
@ -1016,7 +947,6 @@ describe('Share Methods', () => {
shareId,
conversationId: 'conv123',
user: userId1,
isPublic: true,
});
const result = await shareMethods.deleteSharedLink(userId2, shareId);
@ -1048,11 +978,11 @@ describe('Share Methods', () => {
shareId,
conversationId,
user: userId,
isPublic: true,
});
const result = await shareMethods.getSharedLink(userId, conversationId);
expect(result._id).toBeDefined();
expect(result.success).toBe(true);
expect(result.shareId).toBe(shareId);
});
@ -1072,7 +1002,6 @@ describe('Share Methods', () => {
shareId: 'share123',
conversationId,
user: userId,
isPublic: true,
expiredAt: new Date(Date.now() - 60 * 60 * 1000),
});
@ -1091,7 +1020,6 @@ describe('Share Methods', () => {
shareId: 'share123',
conversationId,
user: userId1,
isPublic: true,
});
const result = await shareMethods.getSharedLink(userId2, conversationId);
@ -1109,25 +1037,6 @@ describe('Share Methods', () => {
'Missing required parameters',
);
});
test('should only return public shares', async () => {
const userId = new mongoose.Types.ObjectId().toString();
const conversationId = `conv_${nanoid()}`;
const shareId = `share_${nanoid()}`;
// Create a non-public share
await SharedLink.create({
shareId,
conversationId,
user: userId,
isPublic: false,
});
const result = await shareMethods.getSharedLink(userId, conversationId);
expect(result.success).toBe(false);
expect(result.shareId).toBeNull();
});
});
describe('deleteAllSharedLinks', () => {
@ -1167,11 +1076,11 @@ describe('Share Methods', () => {
// Create multiple shares for different users
await SharedLink.create([
{ shareId: 'share1', conversationId: 'conv1', user: userId1, isPublic: true },
{ shareId: 'share2', conversationId: 'conv2', user: userId1, isPublic: false },
{ shareId: 'share3', conversationId: 'conv3', user: userId2, isPublic: true },
{ shareId: 'share4', conversationId: 'conv4', user: userId2, isPublic: true },
{ shareId: 'share5', conversationId: 'conv5', user: userId3, isPublic: true },
{ shareId: 'share1', conversationId: 'conv1', user: userId1 },
{ shareId: 'share2', conversationId: 'conv2', user: userId1 },
{ shareId: 'share3', conversationId: 'conv3', user: userId2 },
{ shareId: 'share4', conversationId: 'conv4', user: userId2 },
{ shareId: 'share5', conversationId: 'conv5', user: userId3 },
]);
// Delete all shares for userId1
@ -1199,9 +1108,9 @@ describe('Share Methods', () => {
const conversationId2 = 'conv-to-keep';
await SharedLink.create([
{ shareId: 'share1', conversationId: conversationId1, user: userId, isPublic: true },
{ shareId: 'share2', conversationId: conversationId1, user: userId, isPublic: false },
{ shareId: 'share3', conversationId: conversationId2, user: userId, isPublic: true },
{ shareId: 'share1', conversationId: conversationId1, user: userId },
{ shareId: 'share2', conversationId: conversationId1, user: userId },
{ shareId: 'share3', conversationId: conversationId2, user: userId },
]);
const result = await shareMethods.deleteConvoSharedLink(userId, conversationId1);
@ -1220,9 +1129,9 @@ describe('Share Methods', () => {
const conversationId = 'shared-conv';
await SharedLink.create([
{ shareId: 'share1', conversationId, user: userId1, isPublic: true },
{ shareId: 'share2', conversationId, user: userId2, isPublic: true },
{ shareId: 'share3', conversationId: 'other-conv', user: userId1, isPublic: true },
{ shareId: 'share1', conversationId, user: userId1 },
{ shareId: 'share2', conversationId, user: userId2 },
{ shareId: 'share3', conversationId: 'other-conv', user: userId1 },
]);
const result = await shareMethods.deleteConvoSharedLink(userId1, conversationId);
@ -1266,22 +1175,20 @@ describe('Share Methods', () => {
const conversationId = 'conv-with-many-shares';
await SharedLink.create([
{ shareId: 'share1', conversationId, user: userId, isPublic: true },
{ shareId: 'share1', conversationId, user: userId },
{
shareId: 'share2',
conversationId,
user: userId,
isPublic: true,
targetMessageId: 'msg1',
},
{
shareId: 'share3',
conversationId,
user: userId,
isPublic: true,
targetMessageId: 'msg2',
},
{ shareId: 'share4', conversationId, user: userId, isPublic: false },
{ shareId: 'share4', conversationId, user: userId },
]);
const result = await shareMethods.deleteConvoSharedLink(userId, conversationId);
@ -1338,7 +1245,6 @@ describe('Share Methods', () => {
conversationId,
user: userId,
messages: [message._id],
isPublic: true,
});
const result = await shareMethods.getSharedMessages(shareId);
@ -1449,7 +1355,6 @@ describe('Share Methods', () => {
conversationId,
user: userId,
messages: messages.map((m) => m._id),
isPublic: true,
});
const result = await shareMethods.getSharedMessages(shareId);
@ -1485,7 +1390,6 @@ describe('Share Methods', () => {
conversationId,
user: userId,
messages: [message._id],
isPublic: true,
});
const result = await shareMethods.getSharedMessages(shareId);

View file

@ -157,16 +157,19 @@ function getMessagesUpToTarget(messages: t.IMessage[], targetMessageId: string):
/** Factory function that takes mongoose instance and returns the methods */
export function createShareMethods(mongoose: typeof import('mongoose')) {
/**
* Get shared messages for a public share link
* Get shared messages for a share link
*/
async function getSharedMessages(shareId: string): Promise<t.SharedMessagesResult | null> {
async function getSharedMessages(
shareId: string,
shareObjectId?: string,
): Promise<t.SharedMessagesResult | null> {
try {
const SharedLink = mongoose.models.SharedLink as Model<t.ISharedLink>;
const share = (await SharedLink.findOne({
shareId,
isPublic: true,
...activeExpirationFilter<t.ISharedLink>(),
})
const query = shareObjectId
? SharedLink.findOne({ _id: shareObjectId, ...activeExpirationFilter<t.ISharedLink>() })
: SharedLink.findOne({ shareId, ...activeExpirationFilter<t.ISharedLink>() });
const share = (await query
.populate({
path: 'messages',
select: '-_id -__v -user',
@ -174,7 +177,7 @@ export function createShareMethods(mongoose: typeof import('mongoose')) {
.select('-_id -__v -user')
.lean()) as (t.ISharedLink & { messages: t.IMessage[] }) | null;
if (!share?.conversationId || !share.isPublic) {
if (!share?.conversationId) {
return null;
}
@ -188,7 +191,6 @@ export function createShareMethods(mongoose: typeof import('mongoose')) {
const result: t.SharedMessagesResult = {
shareId: share.shareId || shareId,
title: share.title,
isPublic: share.isPublic,
createdAt: share.createdAt,
updatedAt: share.updatedAt,
conversationId: newConvoId,
@ -212,7 +214,6 @@ export function createShareMethods(mongoose: typeof import('mongoose')) {
user: string,
pageParam?: Date,
pageSize: number = 10,
isPublic: boolean = true,
sortBy: string = 'createdAt',
sortDirection: string = 'desc',
search?: string,
@ -222,7 +223,6 @@ export function createShareMethods(mongoose: typeof import('mongoose')) {
const Conversation = mongoose.models.Conversation as SchemaWithMeiliMethods;
const query: FilterQuery<t.ISharedLink> = {
user,
isPublic,
...activeExpirationFilter<t.ISharedLink>(),
};
@ -283,7 +283,6 @@ export function createShareMethods(mongoose: typeof import('mongoose')) {
links: links.map((link) => ({
shareId: link.shareId || '',
title: link?.title || 'Untitled',
isPublic: link.isPublic,
createdAt: link.createdAt || new Date(),
conversationId: link.conversationId,
})),
@ -302,13 +301,18 @@ export function createShareMethods(mongoose: typeof import('mongoose')) {
/**
* Delete all shared links for a user
*/
async function deleteAllSharedLinks(user: string): Promise<t.DeleteAllSharesResult> {
async function deleteAllSharedLinks(
user: string,
): Promise<t.DeleteAllSharesResult & { deletedIds: string[] }> {
try {
const SharedLink = mongoose.models.SharedLink as Model<t.ISharedLink>;
const links = await SharedLink.find({ user }).select('_id').lean();
const ids = links.map((l) => l._id.toString());
const result = await SharedLink.deleteMany({ user });
return {
message: 'All shared links deleted successfully',
deletedCount: result.deletedCount,
deletedIds: ids,
};
} catch (error) {
logger.error('[deleteAllSharedLinks] Error deleting shared links', {
@ -325,17 +329,20 @@ export function createShareMethods(mongoose: typeof import('mongoose')) {
async function deleteConvoSharedLink(
user: string,
conversationId: string,
): Promise<t.DeleteAllSharesResult> {
): Promise<t.DeleteAllSharesResult & { deletedIds: string[] }> {
if (!user || !conversationId) {
throw new ShareServiceError('Missing required parameters', 'INVALID_PARAMS');
}
try {
const SharedLink = mongoose.models.SharedLink as Model<t.ISharedLink>;
const links = await SharedLink.find({ user, conversationId }).select('_id').lean();
const ids = links.map((l) => l._id.toString());
const result = await SharedLink.deleteMany({ user, conversationId });
return {
message: 'Shared links deleted successfully',
deletedCount: result.deletedCount,
deletedIds: ids,
};
} catch (error) {
logger.error('[deleteConvoSharedLink] Error deleting shared links', {
@ -368,7 +375,6 @@ export function createShareMethods(mongoose: typeof import('mongoose')) {
SharedLink.findOne({
conversationId,
user,
isPublic: true,
...activeExpirationFilter<t.ISharedLink>(),
...(targetMessageId && { targetMessageId }),
})
@ -377,19 +383,13 @@ export function createShareMethods(mongoose: typeof import('mongoose')) {
Message.find({ conversationId, user }).sort({ createdAt: 1 }).lean(),
]);
if (existingShare && existingShare.isPublic) {
if (existingShare) {
logger.error('[createSharedLink] Share already exists', {
user,
conversationId,
targetMessageId,
});
throw new ShareServiceError('Share already exists', 'SHARE_EXISTS');
} else if (existingShare) {
await SharedLink.deleteOne({
conversationId,
user,
...(targetMessageId && { targetMessageId }),
});
}
const conversation = (await Conversation.findOne({ conversationId, user }).lean()) as {
@ -412,7 +412,7 @@ export function createShareMethods(mongoose: typeof import('mongoose')) {
const title = conversation.title || 'Untitled';
const shareId = nanoid();
await SharedLink.create({
const created = await SharedLink.create({
shareId,
conversationId,
messages: conversationMessages,
@ -422,7 +422,7 @@ export function createShareMethods(mongoose: typeof import('mongoose')) {
...(expiredAt && { expiredAt }),
});
return { shareId, conversationId, targetMessageId };
return { _id: created._id.toString(), shareId, conversationId, targetMessageId };
} catch (error) {
if (error instanceof ShareServiceError) {
throw error;
@ -453,18 +453,22 @@ export function createShareMethods(mongoose: typeof import('mongoose')) {
const share = (await SharedLink.findOne({
conversationId,
user,
isPublic: true,
...activeExpirationFilter<t.ISharedLink>(),
})
.select('shareId targetMessageId -_id')
.select('shareId targetMessageId _id')
.sort({ updatedAt: -1 })
.lean()) as { shareId?: string; targetMessageId?: string } | null;
.lean()) as {
shareId?: string;
targetMessageId?: string;
_id?: import('mongoose').Types.ObjectId;
} | null;
if (!share) {
return { shareId: null, success: false };
}
return {
_id: share._id?.toString(),
shareId: share.shareId || null,
targetMessageId: share.targetMessageId,
success: true,
@ -534,6 +538,7 @@ export function createShareMethods(mongoose: typeof import('mongoose')) {
anonymizeConvo(updatedShare);
return {
_id: updatedShare._id?.toString(),
shareId: newShareId,
conversationId: updatedShare.conversationId,
targetMessageId: updatedShare.targetMessageId,
@ -571,6 +576,7 @@ export function createShareMethods(mongoose: typeof import('mongoose')) {
}
return {
_id: result._id?.toString(),
success: true,
shareId,
message: 'Share deleted successfully',

View file

@ -15,7 +15,16 @@ const accessRoleSchema = new Schema<IAccessRole>(
description: String,
resourceType: {
type: String,
enum: ['agent', 'project', 'file', 'promptGroup', 'mcpServer', 'remoteAgent', 'skill'],
enum: [
'agent',
'project',
'file',
'promptGroup',
'mcpServer',
'remoteAgent',
'skill',
'sharedLink',
],
required: true,
default: 'agent',
},

View file

@ -62,6 +62,9 @@ const aclEntrySchema = new Schema<IAclEntry>(
type: Date,
default: Date.now,
},
expiredAt: {
type: Date,
},
tenantId: {
type: String,
index: true,
@ -81,5 +84,6 @@ aclEntrySchema.index({ resourceId: 1, principalType: 1, principalId: 1, tenantId
aclEntrySchema.index({ principalId: 1, permBits: 1, resourceType: 1, tenantId: 1 });
/** Covers `findPublicResourceIds` and the public branch of `findAccessibleResources`. */
aclEntrySchema.index({ principalType: 1, resourceType: 1, permBits: 1, resourceId: 1 });
aclEntrySchema.index({ expiredAt: 1 }, { expireAfterSeconds: 0 });
export default aclEntrySchema;

View file

@ -74,6 +74,11 @@ const rolePermissionsSchema = new Schema(
[Permissions.SHARE]: { type: Boolean },
[Permissions.SHARE_PUBLIC]: { type: Boolean },
},
[PermissionTypes.SHARED_LINKS]: {
[Permissions.CREATE]: { type: Boolean },
[Permissions.SHARE]: { type: Boolean },
[Permissions.SHARE_PUBLIC]: { type: Boolean },
},
},
{ _id: false },
);

View file

@ -7,7 +7,6 @@ export interface ISharedLink extends Document {
messages?: Types.ObjectId[];
shareId?: string;
targetMessageId?: string;
isPublic: boolean;
expiredAt?: Date;
createdAt?: Date;
updatedAt?: Date;
@ -38,10 +37,6 @@ const shareSchema: Schema<ISharedLink> = new Schema(
required: false,
index: true,
},
isPublic: {
type: Boolean,
default: true,
},
tenantId: {
type: String,
index: true,

View file

@ -22,6 +22,8 @@ export type AclEntry = {
grantedBy?: Types.ObjectId;
/** When this permission was granted */
grantedAt?: Date;
/** Optional expiration date for permissions tied to expiring resources */
expiredAt?: Date;
tenantId?: string;
};

View file

@ -72,6 +72,11 @@ export interface IRole extends Document {
[Permissions.SHARE]?: boolean;
[Permissions.SHARE_PUBLIC]?: boolean;
};
[PermissionTypes.SHARED_LINKS]?: {
[Permissions.CREATE]?: boolean;
[Permissions.SHARE]?: boolean;
[Permissions.SHARE_PUBLIC]?: boolean;
};
};
tenantId?: string;
}

View file

@ -9,7 +9,6 @@ export interface ISharedLink {
messages?: Types.ObjectId[];
shareId?: string;
targetMessageId?: string;
isPublic: boolean;
expiredAt?: Date;
createdAt?: Date;
updatedAt?: Date;
@ -23,7 +22,6 @@ export interface SharedLinksResult {
links: Array<{
shareId: string;
title: string;
isPublic: boolean;
createdAt: Date;
conversationId: string;
}>;
@ -36,30 +34,33 @@ export interface SharedMessagesResult {
messages: Array<IMessage>;
shareId: string;
title?: string;
isPublic: boolean;
createdAt?: Date;
updatedAt?: Date;
}
export interface CreateShareResult {
_id?: string;
shareId: string;
conversationId: string;
targetMessageId?: string;
}
export interface UpdateShareResult {
_id?: string;
shareId: string;
conversationId: string;
targetMessageId?: string;
}
export interface DeleteShareResult {
_id?: string;
success: boolean;
shareId: string;
message: string;
}
export interface GetShareLinkResult {
_id?: string;
shareId: string | null;
targetMessageId?: string;
success: boolean;