diff --git a/api/server/index.js b/api/server/index.js index a27cedbb501..3cbac5df624 100644 --- a/api/server/index.js +++ b/api/server/index.js @@ -286,6 +286,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); diff --git a/api/server/middleware/accessResources/canAccessArtifactAppResource.js b/api/server/middleware/accessResources/canAccessArtifactAppResource.js new file mode 100644 index 00000000000..0df48493863 --- /dev/null +++ b/api/server/middleware/accessResources/canAccessArtifactAppResource.js @@ -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, +}; diff --git a/api/server/middleware/accessResources/canAccessArtifactAppResource.spec.js b/api/server/middleware/accessResources/canAccessArtifactAppResource.spec.js new file mode 100644 index 00000000000..b2fb9c71139 --- /dev/null +++ b/api/server/middleware/accessResources/canAccessArtifactAppResource.spec.js @@ -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 () =>
hi
;', + 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(); + }); + }); +}); diff --git a/api/server/middleware/accessResources/index.js b/api/server/middleware/accessResources/index.js index 31548411bac..6af901fade1 100644 --- a/api/server/middleware/accessResources/index.js +++ b/api/server/middleware/accessResources/index.js @@ -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, @@ -14,4 +15,5 @@ module.exports = { canAccessPromptGroupResource, canAccessMCPServerResource, canAccessSkillResource, + canAccessArtifactAppResource, }; diff --git a/api/server/routes/artifactApps.js b/api/server/routes/artifactApps.js new file mode 100644 index 00000000000..1ce190e9e02 --- /dev/null +++ b/api/server/routes/artifactApps.js @@ -0,0 +1,102 @@ +const express = require('express'); +const { createArtifactAppHandlers } = require('@librechat/api'); +const { PermissionBits } = require('librechat-data-provider'); +const { + createArtifactAppWithVersion, + getArtifactAppByAppId, + resolveArtifactAppId, + listArtifactApps, + updateArtifactApp, + deleteArtifactApp, + getArtifactVersion, + listArtifactVersions, + createArtifactVersion, + releaseArtifactVersion, + activateArtifactVersion, + withdrawArtifactVersion, + recordAuditEntry, +} = require('~/models'); +const { requireJwtAuth, canAccessArtifactAppResource } = require('~/server/middleware'); +const { + findAccessibleResources, + grantPermission, +} = require('~/server/services/PermissionService'); +const configMiddleware = require('~/server/middleware/config/app'); + +const router = express.Router(); + +router.use(requireJwtAuth); +router.use(configMiddleware); + +const handlers = createArtifactAppHandlers({ + createArtifactAppWithVersion, + getArtifactAppByAppId, + resolveArtifactAppId, + listArtifactApps, + updateArtifactApp, + deleteArtifactApp, + getArtifactVersion, + listArtifactVersions, + createArtifactVersion, + releaseArtifactVersion, + activateArtifactVersion, + withdrawArtifactVersion, + findAccessibleResources, + grantPermission, + recordAuditEntry, +}); + +// Collection +router.get('/', handlers.list); +router.post('/', handlers.publish); + +// Single app +router.get( + '/:id', + canAccessArtifactAppResource({ requiredPermission: PermissionBits.VIEW }), + handlers.get, +); +router.patch( + '/:id', + canAccessArtifactAppResource({ requiredPermission: PermissionBits.EDIT }), + handlers.update, +); +router.delete( + '/:id', + canAccessArtifactAppResource({ requiredPermission: PermissionBits.DELETE }), + handlers.remove, +); + +// Versions +router.get( + '/:id/versions', + canAccessArtifactAppResource({ requiredPermission: PermissionBits.VIEW }), + handlers.listVersions, +); +router.post( + '/:id/versions', + canAccessArtifactAppResource({ requiredPermission: PermissionBits.EDIT }), + handlers.createVersion, +); +router.get( + '/:id/versions/:versionId', + canAccessArtifactAppResource({ requiredPermission: PermissionBits.VIEW }), + handlers.getVersion, +); +router.post( + '/:id/versions/:versionId/release', + canAccessArtifactAppResource({ requiredPermission: PermissionBits.EDIT }), + handlers.releaseVersion, +); +router.post( + '/:id/versions/:versionId/activate', + canAccessArtifactAppResource({ requiredPermission: PermissionBits.EDIT }), + handlers.activateVersion, +); +router.post( + '/:id/versions/:versionId/withdraw', + canAccessArtifactAppResource({ requiredPermission: PermissionBits.EDIT }), + handlers.withdrawVersion, +); + +module.exports = router; diff --git a/api/server/routes/index.js b/api/server/routes/index.js index c2578e43532..ff8f53894a7 100644 --- a/api/server/routes/index.js +++ b/api/server/routes/index.js @@ -18,6 +18,7 @@ const presets = require('./presets'); const projects = require('./projects'); const prompts = require('./prompts'); const skills = require('./skills'); +const artifactApps = require('./artifactApps'); const balance = require('./balance'); const actions = require('./actions'); const apiKeys = require('./apiKeys'); @@ -68,6 +69,7 @@ module.exports = { prompts, projects, skills, + artifactApps, actions, presets, balance, diff --git a/client/src/components/ArtifactApps/AppRenderer.tsx b/client/src/components/ArtifactApps/AppRenderer.tsx new file mode 100644 index 00000000000..a64cb204020 --- /dev/null +++ b/client/src/components/ArtifactApps/AppRenderer.tsx @@ -0,0 +1,41 @@ +import { useRef } from 'react'; +import type { SandpackPreviewRef } from '@codesandbox/sandpack-react/unstyled'; +import type { TArtifactVersion } from 'librechat-data-provider'; +import type { Artifact } from '~/common'; +import { ArtifactPreview } from '~/components/Artifacts/ArtifactPreview'; +import useArtifactProps from '~/hooks/Artifacts/useArtifactProps'; +import { useGetStartupConfig } from '~/data-provider'; + +export default function AppRenderer({ + title, + version, +}: { + title: string; + version: TArtifactVersion; +}) { + const previewRef = useRef(null as unknown as SandpackPreviewRef); + const { data: startupConfig } = useGetStartupConfig(); + + const artifact: Artifact = { + id: version.artifactVersionId, + lastUpdateTime: Date.now(), + type: version.artifactType, + content: version.sourceSnapshot, + title, + }; + + const { files, fileKey, template, sharedProps } = useArtifactProps({ artifact }); + + return ( +
+ +
+ ); +} diff --git a/client/src/components/ArtifactApps/ArtifactAppsList.tsx b/client/src/components/ArtifactApps/ArtifactAppsList.tsx new file mode 100644 index 00000000000..1df227beaf1 --- /dev/null +++ b/client/src/components/ArtifactApps/ArtifactAppsList.tsx @@ -0,0 +1,105 @@ +import { useNavigate } from 'react-router-dom'; +import { Rocket, Lock, Globe, Users } from 'lucide-react'; +import { useListArtifactAppsQuery } from '~/data-provider'; +import { useLocalize } from '~/hooks'; +import type { TArtifactApp } from 'librechat-data-provider'; + +function visibilityIcon(v: TArtifactApp['visibility']) { + if (v === 'public') return