Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
963f12f
feat(artifact-apps): add automatic artifact marketplace
Sep 11, 2026
d52e170
fix(artifact-apps): harden marketplace persistence
Sep 11, 2026
cdde930
fix(artifact-apps): resolve rollout blockers
Sep 11, 2026
abd3296
fix(artifact-apps): close remaining release blockers
Sep 11, 2026
735034d
fix(artifact-apps): preserve rollout state
Sep 11, 2026
f2cb125
fix(artifact-apps): preserve sync ordering and released snapshots
Sep 14, 2026
7925ade
feat(artifact-apps): complete marketplace workflow
Sep 16, 2026
6145461
fix(artifact-apps): stabilize previews and add deletion
Sep 16, 2026
ca6a97d
fix(artifact-apps): finish deletion before restore and guard source r…
Sep 16, 2026
8ef4671
fix(artifacts): hide sharing in shared conversations
Sep 16, 2026
3c862f3
fix(artifacts): suppress sync state in shared views
Sep 16, 2026
b152f9d
fix(artifact-apps): adapt marketplace to current dev
Sep 16, 2026
6b2731b
fix(artifact-apps): observe sync baseline once, lock it in across ret…
Sep 18, 2026
659f397
fix(artifact-apps): address review findings
Sep 18, 2026
fd78d82
Merge branch 'dev' into feat/artifact-apps-release
romy651 Sep 18, 2026
d5736c8
Merge branch 'dev' into feat/artifact-apps-release
romy651 Sep 18, 2026
e18533b
Merge branch 'dev' into feat/artifact-apps-release
romy651 Sep 19, 2026
8cdda04
Merge branch 'dev' into feat/artifact-apps-release
romy651 Sep 19, 2026
b32cd29
Merge upstream dev into feat/artifact-apps-release
Oct 5, 2026
b5deeb1
Merge branch 'dev' into feat/artifact-apps-release
romy651 Oct 5, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions api/server/controllers/UserController.js
Original file line number Diff line number Diff line change
Expand Up @@ -548,6 +548,7 @@ const deleteUserController = async (req, res) => {
await db.deleteAllUserMemories(user.id);
await db.deleteUserPrompts(user.id);
await db.deleteUserSkills(user.id);
await db.deleteUserArtifactApps(user.id);
await deleteUserMcpServers(user.id);
await db.deleteActions({ user: user.id });
await db.deleteTokens({ userId: user.id });
Expand Down
1 change: 1 addition & 0 deletions api/server/controllers/UserController.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ jest.mock('~/models', () => {
cancelAgentTriggerUserDeletion: jest.fn().mockResolvedValue(true),
deleteUserPrompts: jest.fn().mockResolvedValue(undefined),
deleteUserSkills: jest.fn().mockResolvedValue(undefined),
deleteUserArtifactApps: jest.fn().mockResolvedValue(undefined),
deleteUserCodeEnvironments: jest.fn().mockResolvedValue(undefined),
deleteMessages: jest.fn().mockResolvedValue(undefined),
deleteBalances: jest.fn().mockResolvedValue(undefined),
Expand Down
2 changes: 2 additions & 0 deletions api/server/controllers/__tests__/deleteUser.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ const mockDeleteToolCalls = jest.fn();
const mockDeleteUserAgents = jest.fn();
const mockDeleteUserPrompts = jest.fn();
const mockDeleteUserSkills = jest.fn();
const mockDeleteUserArtifactApps = jest.fn();
const mockDeleteUserCodeEnvironments = jest.fn();
const mockInvalidateCodeEnvironmentConfigCache = jest.fn();
const mockGetCleanupBlockingJobIdsForUser = jest.fn();
Expand Down Expand Up @@ -106,6 +107,7 @@ jest.mock('~/models', () => ({
deleteUserAgents: (...args) => mockDeleteUserAgents(...args),
deleteUserPrompts: (...args) => mockDeleteUserPrompts(...args),
deleteUserSkills: (...args) => mockDeleteUserSkills(...args),
deleteUserArtifactApps: (...args) => mockDeleteUserArtifactApps(...args),
deleteUserCodeEnvironments: (...args) => mockDeleteUserCodeEnvironments(...args),
deleteTransactions: jest.fn(),
deleteBalances: jest.fn(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ const HANDLED_RESOURCE_TYPES = {
[ResourceType.MCPSERVER]: 'deleteUserMcpServers',
[ResourceType.SKILL]: 'deleteUserSkills',
[ResourceType.SHARED_LINK]: 'deleteAllSharedLinksWithCleanup',
[ResourceType.ARTIFACT_APP]: 'deleteUserArtifactApps',
};

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,8 @@ jest.mock('~/models', () => ({
deleteToolCalls: jest.fn(),
deleteUserAgents: jest.fn(),
deleteUserPrompts: jest.fn(),
deleteUserSkills: jest.fn(),
deleteUserArtifactApps: jest.fn(),
deleteTransactions: jest.fn(),
deleteBalances: jest.fn(),
deleteAllAgentApiKeys: jest.fn(),
Expand Down
1 change: 1 addition & 0 deletions api/server/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -426,6 +426,7 @@ const startServer = async () => {
app.use('/api/projects', routes.projects);
app.use('/api/prompts', routes.prompts);
app.use('/api/skills', routes.skills);
app.use('/api/artifact-apps', routes.artifactApps);
app.use('/api/categories', routes.categories);
app.use('/api/endpoints', routes.endpoints);
app.use('/api/balance', routes.balance);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
const { ResourceType } = require('librechat-data-provider');
const { canAccessResource } = require('./canAccessResource');
const { resolveArtifactAppId } = require('~/models');

/**
* Artifact-app-specific middleware factory that checks access permissions.
* Wraps the generic `canAccessResource` with the ARTIFACT_APP resource type and
* `resolveArtifactAppId` (custom `app_*` id → ObjectId) as the ID resolver.
*
* @param {Object} options
* @param {number} options.requiredPermission - Permission bit required (1=view, 2=edit, 4=delete, 8=share)
* @param {string} [options.resourceIdParam='id'] - Route parameter holding the artifact app id
* @returns {Function} Express middleware
*/
const canAccessArtifactAppResource = (options) => {
const { requiredPermission, resourceIdParam = 'id' } = options || {};

if (!requiredPermission || typeof requiredPermission !== 'number') {
throw new Error(
'canAccessArtifactAppResource: requiredPermission is required and must be a number',
);
}

return canAccessResource({
resourceType: ResourceType.ARTIFACT_APP,
requiredPermission,
resourceIdParam,
idResolver: (artifactAppId) => resolveArtifactAppId({ artifactAppId }),
});
};

module.exports = {
canAccessArtifactAppResource,
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,203 @@
const mongoose = require('mongoose');
const { MongoMemoryServer } = require('mongodb-memory-server');
const { ResourceType, PrincipalType, PrincipalModel } = require('librechat-data-provider');
const { canAccessArtifactAppResource } = require('./canAccessArtifactAppResource');
const { User, Role, AclEntry } = require('~/db/models');
const { createArtifactAppWithVersion } = require('~/models');

const VIEW = 1;
const EDIT = 2;
const DELETE = 4;

/** Owner ACL bits (view+edit+delete+share), matching what publish grants. */
const OWNER_BITS = 15;

describe('canAccessArtifactAppResource middleware', () => {
let mongoServer;
let req, res, next;
let testUser;

const publishApp = async (createdBy) =>
createArtifactAppWithVersion({
title: 'Test App',
visibility: 'private',
createdBy: String(createdBy),
version: {
artifactType: 'react',
sourceSnapshot: 'export default () => <div>hi</div>;',
createdBy: String(createdBy),
},
});

const grant = async ({ principalId, resourceId, permBits, grantedBy }) =>
AclEntry.create({
principalType: PrincipalType.USER,
principalId,
principalModel: PrincipalModel.USER,
resourceType: ResourceType.ARTIFACT_APP,
resourceId,
permBits,
grantedBy: grantedBy ?? principalId,
});

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

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

beforeEach(async () => {
await mongoose.connection.dropDatabase();
await Role.create({ name: 'test-role', permissions: {} });
testUser = await User.create({
email: 'test@example.com',
name: 'Test User',
username: 'testuser',
role: 'test-role',
});

req = { user: { id: testUser._id, role: testUser.role }, params: {} };
res = { status: jest.fn().mockReturnThis(), json: jest.fn() };
next = jest.fn();
jest.clearAllMocks();
});

describe('middleware factory', () => {
test('throws when requiredPermission is missing', () => {
expect(() => canAccessArtifactAppResource({})).toThrow(
'canAccessArtifactAppResource: requiredPermission is required and must be a number',
);
});

test('throws when requiredPermission is not a number', () => {
expect(() => canAccessArtifactAppResource({ requiredPermission: '1' })).toThrow(
'canAccessArtifactAppResource: requiredPermission is required and must be a number',
);
});

test('throws when called with no options at all', () => {
expect(() => canAccessArtifactAppResource()).toThrow(
'canAccessArtifactAppResource: requiredPermission is required and must be a number',
);
});

test('returns an express middleware with the default resourceIdParam', () => {
const middleware = canAccessArtifactAppResource({ requiredPermission: VIEW });
expect(typeof middleware).toBe('function');
expect(middleware.length).toBe(3);
});
});

describe('permission checking against real ACL entries', () => {
test('allows the owner to view their own app', async () => {
const { app } = await publishApp(testUser._id);
await grant({ principalId: testUser._id, resourceId: app.id, permBits: OWNER_BITS });
req.params.id = app.artifactAppId;

await canAccessArtifactAppResource({ requiredPermission: VIEW })(req, res, next);

expect(next).toHaveBeenCalled();
expect(res.status).not.toHaveBeenCalled();
});

test('denies a user with no ACL entry for the app', async () => {
const otherUser = await User.create({
email: 'other@example.com',
name: 'Other User',
username: 'otheruser',
role: 'test-role',
});
const { app } = await publishApp(otherUser._id);
await grant({ principalId: otherUser._id, resourceId: app.id, permBits: OWNER_BITS });
req.params.id = app.artifactAppId;

await canAccessArtifactAppResource({ requiredPermission: VIEW })(req, res, next);

expect(next).not.toHaveBeenCalled();
expect(res.status).toHaveBeenCalledWith(403);
expect(res.json).toHaveBeenCalledWith({
error: 'Forbidden',
message: `Insufficient permissions to access this ${ResourceType.ARTIFACT_APP}`,
});
});

/* The route table gates PATCH on EDIT and DELETE on DELETE, so a
* view-only grant must not satisfy either. */
test.each([
['edit', EDIT],
['delete', DELETE],
])('denies %s when the grant is view-only', async (_label, requiredPermission) => {
const { app } = await publishApp(testUser._id);
await grant({ principalId: testUser._id, resourceId: app.id, permBits: VIEW });
req.params.id = app.artifactAppId;

await canAccessArtifactAppResource({ requiredPermission })(req, res, next);

expect(next).not.toHaveBeenCalled();
expect(res.status).toHaveBeenCalledWith(403);
});

test('allows edit when the grant carries edit bits', async () => {
const { app } = await publishApp(testUser._id);
await grant({ principalId: testUser._id, resourceId: app.id, permBits: VIEW | EDIT });
req.params.id = app.artifactAppId;

await canAccessArtifactAppResource({ requiredPermission: EDIT })(req, res, next);

expect(next).toHaveBeenCalled();
expect(res.status).not.toHaveBeenCalled();
});

test('returns 404 for an unknown artifact app id', async () => {
req.params.id = 'app_does_not_exist';

await canAccessArtifactAppResource({ requiredPermission: VIEW })(req, res, next);

expect(next).not.toHaveBeenCalled();
expect(res.status).toHaveBeenCalledWith(404);
expect(res.json).toHaveBeenCalledWith({
error: 'Not Found',
message: `${ResourceType.ARTIFACT_APP} not found`,
});
});

/* A grant on the same ObjectId under a different resourceType must not
* leak across: ACL rows are keyed by (resourceType, resourceId). */
test('ignores a grant recorded under a different resourceType', async () => {
const { app } = await publishApp(testUser._id);
await AclEntry.create({
principalType: PrincipalType.USER,
principalId: testUser._id,
principalModel: PrincipalModel.USER,
resourceType: ResourceType.AGENT,
resourceId: app.id,
permBits: OWNER_BITS,
grantedBy: testUser._id,
});
req.params.id = app.artifactAppId;

await canAccessArtifactAppResource({ requiredPermission: VIEW })(req, res, next);

expect(next).not.toHaveBeenCalled();
expect(res.status).toHaveBeenCalledWith(403);
});

test('reads the app id from a custom resourceIdParam', async () => {
const { app } = await publishApp(testUser._id);
await grant({ principalId: testUser._id, resourceId: app.id, permBits: OWNER_BITS });
req.params.appId = app.artifactAppId;

await canAccessArtifactAppResource({
requiredPermission: VIEW,
resourceIdParam: 'appId',
})(req, res, next);

expect(next).toHaveBeenCalled();
expect(res.status).not.toHaveBeenCalled();
});
});
});
2 changes: 2 additions & 0 deletions api/server/middleware/accessResources/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ const { canAccessPromptViaGroup } = require('./canAccessPromptViaGroup');
const { canAccessPromptGroupResource } = require('./canAccessPromptGroupResource');
const { canAccessMCPServerResource } = require('./canAccessMCPServerResource');
const { canAccessSkillResource } = require('./canAccessSkillResource');
const { canAccessArtifactAppResource } = require('./canAccessArtifactAppResource');

module.exports = {
canAccessResource,
Expand All @@ -14,4 +15,5 @@ module.exports = {
canAccessPromptGroupResource,
canAccessMCPServerResource,
canAccessSkillResource,
canAccessArtifactAppResource,
};
16 changes: 15 additions & 1 deletion api/server/routes/accessPermissions.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,11 @@ const {
ResourceType,
PermissionBits,
} = require('librechat-data-provider');
const { createAgentAdminPermissionAccess, isAgentPermissionsAdmin } = require('@librechat/api');
const {
createAgentAdminPermissionAccess,
createArtifactAppSharingPolicy,
isAgentPermissionsAdmin,
} = require('@librechat/api');
const {
getUserEffectivePermissions,
getAllEffectivePermissions,
Expand All @@ -25,6 +29,9 @@ const db = require('~/models');
const { findMCPServerByObjectId, getSkillById } = db;

const router = express.Router();
const enforceArtifactAppSharingPolicy = createArtifactAppSharingPolicy({
getArtifactAppsByIds: db.getArtifactAppsByIds,
});

// Apply common middleware
router.use(requireJwtAuth);
Expand Down Expand Up @@ -99,6 +106,12 @@ const checkResourcePermissionAccess = (requiredPermission) =>
requiredPermission,
resourceIdParam: 'resourceId',
});
} else if (resourceType === ResourceType.ARTIFACT_APP) {
middleware = canAccessResource({
resourceType: ResourceType.ARTIFACT_APP,
requiredPermission,
resourceIdParam: 'resourceId',
});
} else if (resourceType === ResourceType.SHARED_LINK) {
middleware = canAccessResource({
resourceType: ResourceType.SHARED_LINK,
Expand Down Expand Up @@ -197,6 +210,7 @@ router.put(
checkResourcePermissionAccess(PermissionBits.SHARE),
checkShareAccessUnlessAgentAdmin,
checkSharePublicAccess,
enforceArtifactAppSharingPolicy,
rejectSharedLinkOwnerPermissionChanges,
updateResourcePermissions,
);
Expand Down
Loading