From 963f12fe6a739fb868be94b2d11e1122a6bb3649 Mon Sep 17 00:00:00 2001 From: Romuald Wandji Date: Fri, 11 Sep 2026 15:58:06 +0200 Subject: [PATCH 01/14] feat(artifact-apps): add automatic artifact marketplace --- api/server/index.js | 1 + .../canAccessArtifactAppResource.js | 34 + .../canAccessArtifactAppResource.spec.js | 203 +++++ .../middleware/accessResources/index.js | 2 + api/server/routes/accessPermissions.js | 6 + .../accessPermissions.sharePolicy.test.js | 11 + api/server/routes/artifactApps.js | 129 +++ api/server/routes/index.js | 2 + api/server/routes/roles.js | 9 + api/server/services/start/migration.js | 7 +- .../components/ArtifactApps/AppRenderer.tsx | 42 + .../ArtifactAppsAdminSettings.spec.tsx | 106 +++ .../ArtifactAppsAdminSettings.tsx | 55 ++ .../ArtifactApps/ArtifactAppsList.spec.tsx | 230 +++++ .../ArtifactApps/ArtifactAppsList.tsx | 226 +++++ .../ArtifactApps/ArtifactAppsSearchBar.tsx | 51 ++ .../ArtifactCatalogRegistrar.spec.tsx | 280 ++++++ .../ArtifactApps/ArtifactCatalogRegistrar.tsx | 206 +++++ .../ArtifactApps/StandaloneAppView.tsx | 117 +++ client/src/components/ArtifactApps/index.ts | 3 + .../components/ArtifactApps/runtime.spec.ts | 17 + client/src/components/ArtifactApps/runtime.ts | 17 + client/src/components/Artifacts/Artifact.tsx | 28 +- .../components/Artifacts/ArtifactButton.tsx | 3 + client/src/components/Artifacts/Artifacts.tsx | 33 +- .../Content/Parts/ToolArtifactCard.tsx | 2 + .../src/components/Chat/Presentation.spec.tsx | 132 +++ client/src/components/Chat/Presentation.tsx | 96 ++- .../src/components/Nav/ArtifactAppsButton.tsx | 51 ++ .../Sharing/PublicSharingToggle.tsx | 8 +- .../UnifiedSidebar/ExpandedPanel.tsx | 2 + .../__tests__/ExpandedPanel.spec.tsx | 1 + .../UnifiedSidebar/mobile/Header.tsx | 2 + .../mobile/__tests__/Header.spec.tsx | 1 + .../src/data-provider/ArtifactApps/index.ts | 2 + .../data-provider/ArtifactApps/mutations.ts | 141 +++ .../src/data-provider/ArtifactApps/queries.ts | 115 +++ client/src/data-provider/index.ts | 1 + client/src/data-provider/roles.ts | 32 + .../Artifacts/__tests__/useArtifacts.test.ts | 80 +- .../hooks/Artifacts/useArtifactCatalogSync.ts | 29 + client/src/hooks/Artifacts/useArtifacts.ts | 76 +- .../useClearArtifactNavigationRequest.ts | 38 + .../Conversations/useNavigateToConvo.tsx | 28 +- client/src/hooks/Sharing/useCanSharePublic.ts | 1 + client/src/locales/en/translation.json | 32 +- client/src/routes/index.tsx | 14 + client/src/store/artifacts.ts | 15 +- client/src/utils/artifactCatalog.spec.ts | 82 ++ client/src/utils/artifactCatalog.ts | 114 +++ client/src/utils/resources.ts | 11 + docs/artifact-apps/WP0-exploration.md | 145 ++++ .../adr-0001-snapshot-storage.md | 67 ++ docs/artifact-apps/adr-0002-mcp-bridge.md | 61 ++ packages/api/src/app/permissions.spec.ts | 28 +- packages/api/src/app/permissions.ts | 44 + .../artifactApps/__tests__/handlers.spec.ts | 633 ++++++++++++++ packages/api/src/artifactApps/handlers.ts | 801 ++++++++++++++++++ packages/api/src/artifactApps/index.ts | 2 + packages/api/src/index.ts | 2 + packages/api/src/middleware/share.ts | 3 +- .../data-provider/src/accessPermissions.ts | 7 + packages/data-provider/src/api-endpoints.ts | 31 + packages/data-provider/src/artifactApps.ts | 234 +++++ packages/data-provider/src/config.ts | 17 + packages/data-provider/src/data-service.ts | 95 +++ packages/data-provider/src/index.ts | 2 + packages/data-provider/src/keys.ts | 3 + packages/data-provider/src/permissions.ts | 12 + packages/data-provider/src/roles.spec.ts | 18 +- packages/data-provider/src/roles.ts | 19 + packages/data-provider/src/types/mutations.ts | 8 + .../data-schemas/src/admin/capabilities.ts | 6 + packages/data-schemas/src/index.ts | 4 + .../src/methods/accessRole.spec.ts | 15 + .../data-schemas/src/methods/accessRole.ts | 21 + .../src/methods/artifactApp.spec.ts | 425 ++++++++++ .../data-schemas/src/methods/artifactApp.ts | 711 ++++++++++++++++ packages/data-schemas/src/methods/index.ts | 14 +- .../src/migrations/artifactAppIndexes.ts | 78 ++ packages/data-schemas/src/migrations/index.ts | 1 + .../data-schemas/src/models/artifactApp.ts | 11 + .../src/models/artifactVersion.ts | 14 + packages/data-schemas/src/models/index.ts | 6 + .../data-schemas/src/schema/artifactApp.ts | 175 ++++ .../src/schema/artifactVersion.ts | 101 +++ packages/data-schemas/src/schema/index.ts | 2 + packages/data-schemas/src/schema/role.ts | 6 + packages/data-schemas/src/types/admin.ts | 35 + .../data-schemas/src/types/artifactApp.ts | 189 +++++ packages/data-schemas/src/types/index.ts | 2 + packages/data-schemas/src/types/role.ts | 6 + 92 files changed, 6911 insertions(+), 67 deletions(-) create mode 100644 api/server/middleware/accessResources/canAccessArtifactAppResource.js create mode 100644 api/server/middleware/accessResources/canAccessArtifactAppResource.spec.js create mode 100644 api/server/routes/artifactApps.js create mode 100644 client/src/components/ArtifactApps/AppRenderer.tsx create mode 100644 client/src/components/ArtifactApps/ArtifactAppsAdminSettings.spec.tsx create mode 100644 client/src/components/ArtifactApps/ArtifactAppsAdminSettings.tsx create mode 100644 client/src/components/ArtifactApps/ArtifactAppsList.spec.tsx create mode 100644 client/src/components/ArtifactApps/ArtifactAppsList.tsx create mode 100644 client/src/components/ArtifactApps/ArtifactAppsSearchBar.tsx create mode 100644 client/src/components/ArtifactApps/ArtifactCatalogRegistrar.spec.tsx create mode 100644 client/src/components/ArtifactApps/ArtifactCatalogRegistrar.tsx create mode 100644 client/src/components/ArtifactApps/StandaloneAppView.tsx create mode 100644 client/src/components/ArtifactApps/index.ts create mode 100644 client/src/components/ArtifactApps/runtime.spec.ts create mode 100644 client/src/components/ArtifactApps/runtime.ts create mode 100644 client/src/components/Chat/Presentation.spec.tsx create mode 100644 client/src/components/Nav/ArtifactAppsButton.tsx create mode 100644 client/src/data-provider/ArtifactApps/index.ts create mode 100644 client/src/data-provider/ArtifactApps/mutations.ts create mode 100644 client/src/data-provider/ArtifactApps/queries.ts create mode 100644 client/src/hooks/Artifacts/useArtifactCatalogSync.ts create mode 100644 client/src/hooks/Artifacts/useClearArtifactNavigationRequest.ts create mode 100644 client/src/utils/artifactCatalog.spec.ts create mode 100644 client/src/utils/artifactCatalog.ts create mode 100644 docs/artifact-apps/WP0-exploration.md create mode 100644 docs/artifact-apps/adr-0001-snapshot-storage.md create mode 100644 docs/artifact-apps/adr-0002-mcp-bridge.md create mode 100644 packages/api/src/artifactApps/__tests__/handlers.spec.ts create mode 100644 packages/api/src/artifactApps/handlers.ts create mode 100644 packages/api/src/artifactApps/index.ts create mode 100644 packages/data-provider/src/artifactApps.ts create mode 100644 packages/data-schemas/src/methods/artifactApp.spec.ts create mode 100644 packages/data-schemas/src/methods/artifactApp.ts create mode 100644 packages/data-schemas/src/migrations/artifactAppIndexes.ts create mode 100644 packages/data-schemas/src/models/artifactApp.ts create mode 100644 packages/data-schemas/src/models/artifactVersion.ts create mode 100644 packages/data-schemas/src/schema/artifactApp.ts create mode 100644 packages/data-schemas/src/schema/artifactVersion.ts create mode 100644 packages/data-schemas/src/types/artifactApp.ts diff --git a/api/server/index.js b/api/server/index.js index b8c0ec5b517..011014b5781 100644 --- a/api/server/index.js +++ b/api/server/index.js @@ -419,6 +419,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/accessPermissions.js b/api/server/routes/accessPermissions.js index 96aa8c8a3e0..a468250124b 100644 --- a/api/server/routes/accessPermissions.js +++ b/api/server/routes/accessPermissions.js @@ -99,6 +99,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, diff --git a/api/server/routes/accessPermissions.sharePolicy.test.js b/api/server/routes/accessPermissions.sharePolicy.test.js index ed17a044529..98ab9c2fec8 100644 --- a/api/server/routes/accessPermissions.sharePolicy.test.js +++ b/api/server/routes/accessPermissions.sharePolicy.test.js @@ -65,6 +65,17 @@ describe('Access permissions share policy', () => { resourceIdParam: 'resourceId', }, }, + { + label: 'artifact', + resourceType: ResourceType.ARTIFACT_APP, + permissionType: PermissionTypes.ARTIFACTS, + accessRoleId: AccessRoleIds.ARTIFACT_APP_VIEWER, + middlewareOptions: { + resourceType: ResourceType.ARTIFACT_APP, + requiredPermission: PermissionBits.SHARE, + resourceIdParam: 'resourceId', + }, + }, { label: 'prompt group', resourceType: ResourceType.PROMPTGROUP, diff --git a/api/server/routes/artifactApps.js b/api/server/routes/artifactApps.js new file mode 100644 index 00000000000..5eb81967cbf --- /dev/null +++ b/api/server/routes/artifactApps.js @@ -0,0 +1,129 @@ +const express = require('express'); +const { createArtifactAppHandlers, generateCheckAccess } = require('@librechat/api'); +const { Permissions, PermissionBits, PermissionTypes } = require('librechat-data-provider'); +const { + createArtifactAppWithVersion, + syncArtifactAppWithVersion, + getArtifactAppByAppId, + getArtifactAppBySource, + resolveArtifactAppId, + listArtifactApps, + updateArtifactApp, + deleteArtifactApp, + getArtifactVersion, + listArtifactVersions, + createArtifactVersion, + releaseArtifactVersion, + activateArtifactVersion, + withdrawArtifactVersion, + recordAuditEntry, + getRoleByName, +} = require('~/models'); +const { requireJwtAuth, canAccessArtifactAppResource } = require('~/server/middleware'); +const { + getResourcePermissionsMap, + grantPermission, +} = require('~/server/services/PermissionService'); +const configMiddleware = require('~/server/middleware/config/app'); + +const router = express.Router(); + +router.use(requireJwtAuth); +router.use(configMiddleware); + +const checkArtifactAccess = generateCheckAccess({ + permissionType: PermissionTypes.ARTIFACTS, + permissions: [Permissions.USE], + getRoleByName, +}); +const checkArtifactCreate = generateCheckAccess({ + permissionType: PermissionTypes.ARTIFACTS, + permissions: [Permissions.USE, Permissions.CREATE], + getRoleByName, +}); + +const handlers = createArtifactAppHandlers({ + createArtifactAppWithVersion, + syncArtifactAppWithVersion, + getArtifactAppByAppId, + getArtifactAppBySource, + resolveArtifactAppId, + listArtifactApps, + updateArtifactApp, + deleteArtifactApp, + getArtifactVersion, + listArtifactVersions, + createArtifactVersion, + releaseArtifactVersion, + activateArtifactVersion, + withdrawArtifactVersion, + getResourcePermissionsMap, + grantPermission, + recordAuditEntry, +}); + +// Collection +router.get('/', checkArtifactAccess, handlers.list); +router.post('/', checkArtifactCreate, handlers.publish); +router.post('/sync', checkArtifactCreate, handlers.sync); +router.get('/source', checkArtifactAccess, handlers.getBySource); + +// Single app +router.get( + '/:id', + checkArtifactAccess, + canAccessArtifactAppResource({ requiredPermission: PermissionBits.VIEW }), + handlers.get, +); +router.patch( + '/:id', + checkArtifactAccess, + canAccessArtifactAppResource({ requiredPermission: PermissionBits.EDIT }), + handlers.update, +); +router.delete( + '/:id', + checkArtifactAccess, + canAccessArtifactAppResource({ requiredPermission: PermissionBits.DELETE }), + handlers.remove, +); + +// Versions +router.get( + '/:id/versions', + checkArtifactAccess, + canAccessArtifactAppResource({ requiredPermission: PermissionBits.VIEW }), + handlers.listVersions, +); +router.post( + '/:id/versions', + checkArtifactAccess, + canAccessArtifactAppResource({ requiredPermission: PermissionBits.EDIT }), + handlers.createVersion, +); +router.get( + '/:id/versions/:versionId', + checkArtifactAccess, + canAccessArtifactAppResource({ requiredPermission: PermissionBits.VIEW }), + handlers.getVersion, +); +router.post( + '/:id/versions/:versionId/release', + checkArtifactAccess, + canAccessArtifactAppResource({ requiredPermission: PermissionBits.EDIT }), + handlers.releaseVersion, +); +router.post( + '/:id/versions/:versionId/activate', + checkArtifactAccess, + canAccessArtifactAppResource({ requiredPermission: PermissionBits.EDIT }), + handlers.activateVersion, +); +router.post( + '/:id/versions/:versionId/withdraw', + checkArtifactAccess, + canAccessArtifactAppResource({ requiredPermission: PermissionBits.EDIT }), + handlers.withdrawVersion, +); + +module.exports = router; diff --git a/api/server/routes/index.js b/api/server/routes/index.js index 7827559f112..87ee8034b34 100644 --- a/api/server/routes/index.js +++ b/api/server/routes/index.js @@ -21,6 +21,7 @@ const projects = require('./projects'); const prompts = require('./prompts'); const schedules = require('./schedules'); const skills = require('./skills'); +const artifactApps = require('./artifactApps'); const balance = require('./balance'); const actions = require('./actions'); const apiKeys = require('./apiKeys'); @@ -78,6 +79,7 @@ module.exports = { projects, schedules, skills, + artifactApps, actions, presets, balance, diff --git a/api/server/routes/roles.js b/api/server/routes/roles.js index ec4c4fea116..73d8c97ecca 100644 --- a/api/server/routes/roles.js +++ b/api/server/routes/roles.js @@ -5,6 +5,7 @@ const { roleDefaults, PermissionTypes, agentPermissionsSchema, + artifactPermissionsSchema, promptPermissionsSchema, memoryPermissionsSchema, mcpServersPermissionsSchema, @@ -36,6 +37,11 @@ const permissionConfigs = { permissionType: PermissionTypes.AGENTS, errorMessage: 'Invalid agent permissions.', }, + artifacts: { + schema: artifactPermissionsSchema, + permissionType: PermissionTypes.ARTIFACTS, + errorMessage: 'Invalid artifact permissions.', + }, memories: { schema: memoryPermissionsSchema, permissionType: PermissionTypes.MEMORIES, @@ -165,6 +171,9 @@ router.put('/:roleName/prompts', manageRoles, createPermissionUpdateHandler('pro */ router.put('/:roleName/agents', manageRoles, createPermissionUpdateHandler('agents')); +/** Update Artifact Catalog permissions for a specific role. */ +router.put('/:roleName/artifacts', manageRoles, createPermissionUpdateHandler('artifacts')); + /** * PUT /api/roles/:roleName/memories * Update memory permissions for a specific role diff --git a/api/server/services/start/migration.js b/api/server/services/start/migration.js index 70f8300e08d..25cccb271b9 100644 --- a/api/server/services/start/migration.js +++ b/api/server/services/start/migration.js @@ -1,5 +1,5 @@ const mongoose = require('mongoose'); -const { logger } = require('@librechat/data-schemas'); +const { logger, ensureArtifactAppIndexes } = require('@librechat/data-schemas'); const { logAgentMigrationWarning, logPromptMigrationWarning, @@ -13,6 +13,11 @@ const { findRoleByIdentifier } = require('~/models'); * This runs at the end to ensure all systems are initialized */ async function checkMigrations() { + try { + await ensureArtifactAppIndexes(mongoose.connection); + } catch (error) { + logger.error('Failed to ensure artifact catalog indexes:', error); + } try { const agentMigrationResult = await checkAgentPermissionsMigration({ mongoose, diff --git a/client/src/components/ArtifactApps/AppRenderer.tsx b/client/src/components/ArtifactApps/AppRenderer.tsx new file mode 100644 index 00000000000..6f686bc8423 --- /dev/null +++ b/client/src/components/ArtifactApps/AppRenderer.tsx @@ -0,0 +1,42 @@ +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'; +import { getArtifactMimeType } from './runtime'; + +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: getArtifactMimeType(version.artifactType), + content: version.sourceSnapshot, + title, + }; + + const { files, fileKey, template, sharedProps } = useArtifactProps({ artifact }); + + return ( +
+ +
+ ); +} diff --git a/client/src/components/ArtifactApps/ArtifactAppsAdminSettings.spec.tsx b/client/src/components/ArtifactApps/ArtifactAppsAdminSettings.spec.tsx new file mode 100644 index 00000000000..ab7a36e3fec --- /dev/null +++ b/client/src/components/ArtifactApps/ArtifactAppsAdminSettings.spec.tsx @@ -0,0 +1,106 @@ +import React from 'react'; +import userEvent from '@testing-library/user-event'; +import { render, screen } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import ArtifactAppsAdminSettings from './ArtifactAppsAdminSettings'; + +let mockRole = 'ADMIN'; +const mockDefaultValues = {}; + +jest.mock('@librechat/client', () => ({ + ...jest.requireActual('@librechat/client'), + useToastContext: () => ({ showToast: jest.fn() }), +})); + +jest.mock('~/components/ui', () => { + const ReactModule = jest.requireActual('react') as typeof React; + const labels: Record = { + com_ui_artifacts_allow_use: 'Allow access to the artifact catalog', + com_ui_artifacts_allow_create: 'Automatically save created artifacts', + com_ui_artifacts_allow_share: 'Allow sharing artifacts with people and groups', + com_ui_artifacts_allow_share_public: 'Allow sharing artifacts with everyone', + }; + return { + AdminSettingsDialog: ({ + trigger, + permissions, + }: { + trigger: React.ReactElement; + permissions: Array<{ labelKey: string }>; + }) => { + const [open, setOpen] = ReactModule.useState(false); + if (mockRole !== 'ADMIN') { + return null; + } + const heading = ['Admin Settings', 'Artifacts'].join(' - '); + return ( + <> + {ReactModule.cloneElement(trigger, { onClick: () => setOpen(true) })} + {open && ( +
+

{heading}

+ {permissions.map(({ labelKey }) => ( + {labels[labelKey]} + ))} +
+ )} + + ); + }, + }; +}); + +jest.mock('~/hooks', () => ({ + useAuthContext: () => ({ user: { role: mockRole } }), + useLocalize: () => (key: string, values?: { section?: string }) => { + const translations: Record = { + com_ui_admin_settings: 'Admin Settings', + com_ui_artifact_apps: 'Artifacts', + com_ui_artifacts_allow_use: 'Allow access to the artifact catalog', + com_ui_artifacts_allow_create: 'Automatically save created artifacts', + com_ui_artifacts_allow_share: 'Allow sharing artifacts with people and groups', + com_ui_artifacts_allow_share_public: 'Allow sharing artifacts with everyone', + }; + if (key === 'com_ui_admin_settings_section') { + return `Admin Settings - ${values?.section}`; + } + return translations[key] ?? key; + }, + useRoleSelector: () => ({ + selectedRole: 'ADMIN', + isSelectedCustomRole: false, + isCustomRoleLoading: false, + isCustomRoleError: false, + defaultValues: mockDefaultValues, + roleDropdownItems: [], + }), +})); + +jest.mock('~/data-provider', () => ({ + useUpdateArtifactPermissionsMutation: () => ({ mutate: jest.fn(), isLoading: false }), +})); + +describe('ArtifactAppsAdminSettings', () => { + beforeEach(() => { + mockRole = 'ADMIN'; + }); + + it('opens artifact permission settings for an administrator', async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole('button', { name: 'Admin Settings' })); + + expect(screen.getByRole('dialog')).toBeInTheDocument(); + expect(screen.getByText('Admin Settings - Artifacts')).toBeInTheDocument(); + expect(screen.getByText('Allow access to the artifact catalog')).toBeInTheDocument(); + expect(screen.getByText('Allow sharing artifacts with everyone')).toBeInTheDocument(); + }); + + it('does not show the administration action to a non-administrator', () => { + mockRole = 'USER'; + render(); + + expect(screen.queryByRole('button', { name: 'Admin Settings' })).not.toBeInTheDocument(); + }); +}); diff --git a/client/src/components/ArtifactApps/ArtifactAppsAdminSettings.tsx b/client/src/components/ArtifactApps/ArtifactAppsAdminSettings.tsx new file mode 100644 index 00000000000..b6ba51a142f --- /dev/null +++ b/client/src/components/ArtifactApps/ArtifactAppsAdminSettings.tsx @@ -0,0 +1,55 @@ +import { ShieldEllipsis } from 'lucide-react'; +import { Button, useToastContext } from '@librechat/client'; +import { Permissions, PermissionTypes } from 'librechat-data-provider'; +import type { PermissionConfig } from '~/components/ui'; +import { useUpdateArtifactPermissionsMutation } from '~/data-provider'; +import { AdminSettingsDialog } from '~/components/ui'; +import { useLocalize } from '~/hooks'; + +const permissions: PermissionConfig[] = [ + { permission: Permissions.USE, labelKey: 'com_ui_artifacts_allow_use' }, + { permission: Permissions.CREATE, labelKey: 'com_ui_artifacts_allow_create' }, + { permission: Permissions.SHARE, labelKey: 'com_ui_artifacts_allow_share' }, + { permission: Permissions.SHARE_PUBLIC, labelKey: 'com_ui_artifacts_allow_share_public' }, +]; + +const ArtifactAppsAdminSettings = ({ compact = false }: { compact?: boolean }) => { + const localize = useLocalize(); + const { showToast } = useToastContext(); + const mutation = useUpdateArtifactPermissionsMutation({ + onSuccess: () => showToast({ status: 'success', message: localize('com_ui_saved') }), + onError: () => + showToast({ status: 'error', message: localize('com_ui_error_save_admin_settings') }), + }); + + const trigger = ( + + ); + + return ( + + ); +}; + +export default ArtifactAppsAdminSettings; diff --git a/client/src/components/ArtifactApps/ArtifactAppsList.spec.tsx b/client/src/components/ArtifactApps/ArtifactAppsList.spec.tsx new file mode 100644 index 00000000000..3ceedf1ccc5 --- /dev/null +++ b/client/src/components/ArtifactApps/ArtifactAppsList.spec.tsx @@ -0,0 +1,230 @@ +import React from 'react'; +import { BrowserRouter } from 'react-router-dom'; +import { fireEvent, render, screen } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import { useSetRecoilState } from 'recoil'; +import type { TArtifactApp } from 'librechat-data-provider'; +import { useListArtifactAppsQuery } from '~/data-provider'; +import ArtifactAppsList from './ArtifactAppsList'; + +jest.mock('~/data-provider', () => ({ + useListArtifactAppsQuery: jest.fn(), +})); + +jest.mock('recoil', () => ({ + ...jest.requireActual('recoil'), + useSetRecoilState: jest.fn(), +})); + +jest.mock('~/hooks', () => ({ + useAuthContext: () => ({ user: { id: 'user-1', role: 'USER' } }), + useLocalize: () => (key: string) => { + const translations: Record = { + com_ui_artifact_apps: 'Artifacts', + com_ui_artifact_apps_description: 'Artifacts published from your conversations.', + com_ui_artifact_apps_search_placeholder: 'Search artifacts', + com_ui_artifact_apps_search_aria: 'Search artifacts', + com_ui_artifact_apps_clear_search: 'Clear artifact search', + com_ui_artifact_apps_no_results: 'No artifacts found', + com_ui_artifact_apps_no_results_hint: 'Try a different name, description, category, or tag.', + com_ui_artifact_catalog_filters: 'Artifact catalog filters', + com_ui_artifact_scope_personal: 'Personal', + com_ui_artifact_scope_shared: 'Shared', + com_ui_artifact_scope_all: 'All', + com_ui_artifact_shared_with_you: 'Shared with you', + }; + return translations[key] ?? key; + }, +})); + +jest.mock('@librechat/client', () => ({ + ...jest.requireActual('@librechat/client'), + useMediaQuery: () => false, +})); + +jest.mock('~/components/Chat/Menus/OpenSidebar', () => () => null); +jest.mock('./ArtifactAppsAdminSettings', () => () => + + ))} + + {hasNextPage && ( +
+ +
+ )} + + ); + }; + + return ( +
+
+ {!isSmallScreen && ( +
+
+

+ {localize('com_ui_artifact_apps')} +

+

+ {localize('com_ui_artifact_apps_description')} +

+
+
+ )} + +
+
+
+ + +
+
+ + {!isSmallScreen && } +
+
+ {SCOPES.map((candidate) => ( + + ))} +
+
+
+ +
+ {renderContent()} +
+
+
+ ); +} diff --git a/client/src/components/ArtifactApps/ArtifactAppsSearchBar.tsx b/client/src/components/ArtifactApps/ArtifactAppsSearchBar.tsx new file mode 100644 index 00000000000..14ca3d88419 --- /dev/null +++ b/client/src/components/ArtifactApps/ArtifactAppsSearchBar.tsx @@ -0,0 +1,51 @@ +import { Search, X } from 'lucide-react'; +import { Input } from '@librechat/client'; +import { useLocalize } from '~/hooks'; + +interface ArtifactAppsSearchBarProps { + value: string; + onChange: (value: string) => void; +} + +const ArtifactAppsSearchBar = ({ value, onChange }: ArtifactAppsSearchBarProps) => { + const localize = useLocalize(); + + return ( +
+ + onChange(event.target.value)} + placeholder={localize('com_ui_artifact_apps_search_placeholder')} + className="h-12 rounded-xl border-border-medium bg-transparent pl-12 pr-12 text-lg text-text-primary shadow-md transition-[border-color,box-shadow] duration-200 placeholder:text-text-secondary focus:border-border-heavy focus:shadow-lg focus:ring-0" + aria-label={localize('com_ui_artifact_apps_search_aria')} + autoComplete="off" + spellCheck="false" + /> + + {value && ( + + )} +
+ ); +}; + +export default ArtifactAppsSearchBar; diff --git a/client/src/components/ArtifactApps/ArtifactCatalogRegistrar.spec.tsx b/client/src/components/ArtifactApps/ArtifactCatalogRegistrar.spec.tsx new file mode 100644 index 00000000000..a4b785ba122 --- /dev/null +++ b/client/src/components/ArtifactApps/ArtifactCatalogRegistrar.spec.tsx @@ -0,0 +1,280 @@ +import React from 'react'; +import { act, render } from '@testing-library/react'; +import { dataService } from 'librechat-data-provider'; +import type { Artifact } from '~/common'; +import ArtifactCatalogRegistrar from './ArtifactCatalogRegistrar'; + +const mockSetQueryData = jest.fn(); +const mockInvalidateQueries = jest.fn(); +const mockUseRecoilValue = jest.fn(); +const mockUseArtifactsContext = jest.fn(); + +jest.mock('librechat-data-provider', () => { + const actual = jest.requireActual('librechat-data-provider'); + return { + ...actual, + dataService: { + ...actual.dataService, + syncArtifactApp: jest.fn(), + }, + }; +}); + +jest.mock('@tanstack/react-query', () => ({ + useQueryClient: () => ({ + setQueryData: mockSetQueryData, + invalidateQueries: mockInvalidateQueries, + }), +})); + +jest.mock('recoil', () => ({ + useRecoilValue: () => mockUseRecoilValue(), +})); + +jest.mock('~/Providers', () => ({ + useArtifactsContext: () => mockUseArtifactsContext(), +})); + +jest.mock('~/hooks/Roles/useHasAccess', () => ({ + __esModule: true, + default: () => true, +})); + +jest.mock('~/store', () => ({ + __esModule: true, + default: { artifactsState: { key: 'artifactsState' } }, +})); + +jest.mock('~/utils', () => ({ + logger: { error: jest.fn() }, +})); + +const syncArtifactApp = jest.mocked(dataService.syncArtifactApp); + +function makeArtifact(overrides: Partial = {}): Artifact { + return { + id: 'artifact-1', + identifier: 'chart', + title: 'Revenue chart', + type: 'application/vnd.react', + content: 'export default () =>
chart
;', + messageId: 'message-1', + lastUpdateTime: 1, + ...overrides, + }; +} + +describe('ArtifactCatalogRegistrar', () => { + let artifacts: Record; + let context: { conversationId: string; isSubmitting: boolean; latestMessageId: string | null }; + + beforeEach(() => { + jest.useFakeTimers(); + jest.clearAllMocks(); + artifacts = { 'artifact-1': makeArtifact() }; + context = { + conversationId: 'conversation-1', + isSubmitting: false, + latestMessageId: 'message-1', + }; + mockUseRecoilValue.mockImplementation(() => artifacts); + mockUseArtifactsContext.mockImplementation(() => context); + syncArtifactApp.mockResolvedValue({ + app: { artifactAppId: 'app-1' }, + version: { artifactVersionId: 'version-1' }, + created: true, + versionCreated: true, + } as Awaited>); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('does not synchronize artifacts while reading idle conversation history', async () => { + const { rerender } = render(); + + artifacts = { 'artifact-old': makeArtifact({ id: 'artifact-old', content: 'old snapshot' }) }; + context = { + conversationId: 'conversation-old', + isSubmitting: false, + latestMessageId: 'message-old', + }; + rerender(); + await act(async () => { + jest.advanceTimersByTime(1000); + }); + + expect(syncArtifactApp).not.toHaveBeenCalled(); + }); + + it('synchronizes only artifacts changed by a completed generation', async () => { + const unchanged = makeArtifact({ id: 'artifact-old', identifier: 'unchanged' }); + artifacts = { 'artifact-old': unchanged }; + const { rerender } = render(); + + context = { ...context, isSubmitting: true }; + rerender(); + + artifacts = { + 'artifact-old': unchanged, + 'artifact-new': makeArtifact({ + id: 'artifact-new', + identifier: 'new-chart', + messageId: 'message-2', + lastUpdateTime: 2, + }), + }; + rerender(); + + context = { ...context, isSubmitting: false, latestMessageId: 'message-2' }; + rerender(); + await act(async () => { + jest.advanceTimersByTime(500); + await Promise.resolve(); + }); + + expect(syncArtifactApp).toHaveBeenCalledTimes(1); + expect(syncArtifactApp.mock.calls[0]?.[0].source.sourceKey).toContain('new-chart'); + expect(mockInvalidateQueries).toHaveBeenCalledWith({ + queryKey: expect.arrayContaining(['artifactApps']), + refetchType: 'all', + }); + }); + + it('continues observing artifacts that resolve after the initial settle delay', async () => { + artifacts = {}; + const { rerender } = render(); + + context = { ...context, isSubmitting: true }; + rerender(); + context = { ...context, isSubmitting: false }; + rerender(); + await act(async () => { + jest.advanceTimersByTime(61_000); + await Promise.resolve(); + }); + expect(syncArtifactApp).not.toHaveBeenCalled(); + + artifacts = { + 'artifact-delayed': makeArtifact({ + id: 'artifact-delayed', + identifier: 'delayed-presentation', + type: 'application/vnd.librechat.presentation-preview', + content: 'presentation', + lastUpdateTime: 2, + }), + }; + rerender(); + await act(async () => { + jest.advanceTimersByTime(500); + await Promise.resolve(); + }); + + expect(syncArtifactApp).toHaveBeenCalledTimes(1); + expect(syncArtifactApp.mock.calls[0]?.[0].artifact.type).toBe('presentation'); + }); + + it('ignores delayed artifacts from a message outside the completed generation', async () => { + artifacts = {}; + const { rerender } = render(); + + context = { ...context, isSubmitting: true }; + rerender(); + context = { ...context, isSubmitting: false, latestMessageId: 'message-new' }; + rerender(); + + artifacts = { + 'artifact-stale': makeArtifact({ + id: 'artifact-stale', + identifier: 'stale-presentation', + type: 'application/vnd.librechat.presentation-preview', + content: 'stale presentation', + messageId: 'message-old', + lastUpdateTime: 2, + }), + }; + rerender(); + await act(async () => { + jest.advanceTimersByTime(1000); + await Promise.resolve(); + }); + + expect(syncArtifactApp).not.toHaveBeenCalled(); + }); + + it('retries failed automatic registrations with backoff', async () => { + artifacts = {}; + syncArtifactApp.mockRejectedValueOnce(new Error('temporary failure')); + const { rerender } = render(); + + context = { ...context, isSubmitting: true }; + rerender(); + artifacts = { 'artifact-new': makeArtifact({ id: 'artifact-new' }) }; + rerender(); + context = { ...context, isSubmitting: false }; + rerender(); + + await act(async () => { + jest.advanceTimersByTime(500); + await Promise.resolve(); + await Promise.resolve(); + }); + expect(syncArtifactApp).toHaveBeenCalledTimes(1); + + await act(async () => { + jest.advanceTimersByTime(2000); + await Promise.resolve(); + await Promise.resolve(); + }); + expect(syncArtifactApp).toHaveBeenCalledTimes(2); + }); + + it('does not retry permanent registration failures', async () => { + artifacts = {}; + syncArtifactApp.mockRejectedValueOnce({ response: { status: 400 } }); + const { rerender } = render(); + + context = { ...context, isSubmitting: true }; + rerender(); + artifacts = { 'artifact-new': makeArtifact({ id: 'artifact-new' }) }; + rerender(); + context = { ...context, isSubmitting: false }; + rerender(); + + await act(async () => { + jest.advanceTimersByTime(30_000); + await Promise.resolve(); + await Promise.resolve(); + }); + expect(syncArtifactApp).toHaveBeenCalledTimes(1); + }); + + it('refreshes catalog scopes after metadata-only synchronization', async () => { + syncArtifactApp.mockResolvedValueOnce({ + app: { artifactAppId: 'app-1' }, + version: { artifactVersionId: 'version-1' }, + created: false, + versionCreated: false, + } as Awaited>); + const { rerender } = render(); + + context = { ...context, isSubmitting: true }; + rerender(); + artifacts = { + 'artifact-1': makeArtifact({ title: 'Renamed chart', lastUpdateTime: 2 }), + }; + rerender(); + context = { ...context, isSubmitting: false }; + rerender(); + await act(async () => { + jest.advanceTimersByTime(500); + await Promise.resolve(); + }); + + expect(mockInvalidateQueries).toHaveBeenCalledWith({ + queryKey: expect.arrayContaining(['artifactApps']), + refetchType: 'all', + }); + }); +}); diff --git a/client/src/components/ArtifactApps/ArtifactCatalogRegistrar.tsx b/client/src/components/ArtifactApps/ArtifactCatalogRegistrar.tsx new file mode 100644 index 00000000000..522124b11e2 --- /dev/null +++ b/client/src/components/ArtifactApps/ArtifactCatalogRegistrar.tsx @@ -0,0 +1,206 @@ +import { useCallback, useEffect, useRef } from 'react'; +import { useRecoilValue } from 'recoil'; +import { useQueryClient } from '@tanstack/react-query'; +import { + Constants, + Permissions, + PermissionTypes, + QueryKeys, + dataService, +} from 'librechat-data-provider'; +import type { TSyncArtifactAppRequest } from 'librechat-data-provider'; +import { toLatestArtifactSyncRequests } from '~/utils/artifactCatalog'; +import useHasAccess from '~/hooks/Roles/useHasAccess'; +import { useArtifactsContext } from '~/Providers'; +import { logger } from '~/utils'; +import store from '~/store'; + +const SYNC_SETTLE_DELAY_MS = 500; +const SYNC_RETRY_BASE_DELAY_MS = 1000; +const SYNC_RETRY_MAX_DELAY_MS = 30_000; + +interface PendingSync { + request: TSyncArtifactAppRequest; + signature: string; + failures: number; +} + +interface SyncHttpError { + response?: { status?: number }; +} + +function getSyncKey(request: TSyncArtifactAppRequest): string { + return `${request.source.conversationId}\u0000${request.source.sourceKey}`; +} + +function getRequestSignature(request: TSyncArtifactAppRequest): string { + return JSON.stringify(request); +} + +function isRetryableSyncError(error: unknown): boolean { + const status = (error as SyncHttpError | null)?.response?.status; + return status == null || status === 408 || status === 429 || status >= 500; +} + +/** Persists artifacts created or changed by a completed generation. */ +export default function ArtifactCatalogRegistrar() { + const artifacts = useRecoilValue(store.artifactsState); + const { conversationId, isSubmitting, latestMessageId } = useArtifactsContext(); + const queryClient = useQueryClient(); + const canCreate = useHasAccess({ + permissionType: PermissionTypes.ARTIFACTS, + permission: Permissions.CREATE, + }); + const previousIsSubmittingRef = useRef(false); + const generationConversationRef = useRef(null); + const generationMessageRef = useRef(null); + const successfulHashesRef = useRef(new Map()); + const pendingSyncsRef = useRef(new Map()); + const syncTimerRef = useRef(null); + const flushPendingRef = useRef<() => Promise>(async () => undefined); + const mountedRef = useRef(true); + const flushingRef = useRef(false); + + const scheduleSync = useCallback((delay: number) => { + if (syncTimerRef.current != null) { + window.clearTimeout(syncTimerRef.current); + } + syncTimerRef.current = window.setTimeout(() => { + syncTimerRef.current = null; + void flushPendingRef.current(); + }, delay); + }, []); + + const flushPending = useCallback(async () => { + if (flushingRef.current || !canCreate || pendingSyncsRef.current.size === 0) { + return; + } + flushingRef.current = true; + let completed = false; + + for (const [syncKey, pending] of Array.from(pendingSyncsRef.current.entries())) { + if (!mountedRef.current) { + break; + } + try { + const result = await dataService.syncArtifactApp(pending.request); + queryClient.setQueryData( + [ + QueryKeys.artifactApp, + 'source', + pending.request.source.conversationId, + pending.request.source.sourceKey, + ], + result, + ); + successfulHashesRef.current.set(syncKey, pending.signature); + if (pendingSyncsRef.current.get(syncKey)?.signature === pending.signature) { + pendingSyncsRef.current.delete(syncKey); + } + completed = true; + } catch (error) { + const current = pendingSyncsRef.current.get(syncKey); + if (current?.signature === pending.signature) { + if (isRetryableSyncError(error)) { + pendingSyncsRef.current.set(syncKey, { ...current, failures: current.failures + 1 }); + } else { + pendingSyncsRef.current.delete(syncKey); + } + } + logger.error('artifacts', 'Failed to sync artifact with catalog', error); + } + } + + flushingRef.current = false; + if (!mountedRef.current) { + return; + } + if (completed) { + await queryClient.invalidateQueries({ + queryKey: [QueryKeys.artifactApps], + refetchType: 'all', + }); + } + if (pendingSyncsRef.current.size > 0) { + const failures = Math.min( + ...Array.from(pendingSyncsRef.current.values(), ({ failures }) => failures), + ); + scheduleSync( + Math.min(SYNC_RETRY_BASE_DELAY_MS * 2 ** Math.min(failures, 5), SYNC_RETRY_MAX_DELAY_MS), + ); + } + }, [canCreate, queryClient, scheduleSync]); + + useEffect(() => { + flushPendingRef.current = flushPending; + }, [flushPending]); + + useEffect(() => { + mountedRef.current = true; + return () => { + mountedRef.current = false; + if (syncTimerRef.current != null) { + window.clearTimeout(syncTimerRef.current); + } + }; + }, []); + + useEffect(() => { + const wasSubmitting = previousIsSubmittingRef.current; + previousIsSubmittingRef.current = isSubmitting; + const validConversation = + conversationId != null && conversationId !== Constants.NEW_CONVO ? conversationId : null; + + if (isSubmitting) { + if (!wasSubmitting && validConversation) { + const baselineRequests = artifacts + ? toLatestArtifactSyncRequests(artifacts, validConversation) + : []; + for (const request of baselineRequests) { + successfulHashesRef.current.set(getSyncKey(request), getRequestSignature(request)); + } + } + generationConversationRef.current = validConversation; + generationMessageRef.current = latestMessageId; + return; + } + + const generationConversation = generationConversationRef.current; + if (wasSubmitting && generationConversation) { + generationMessageRef.current = latestMessageId; + } + const observingGeneration = + canCreate && generationConversation != null && generationConversation === validConversation; + if (!observingGeneration || !artifacts) { + return; + } + + let queued = false; + const requests = toLatestArtifactSyncRequests(artifacts, generationConversation); + for (const request of requests) { + if ( + generationMessageRef.current == null || + request.source.messageId !== generationMessageRef.current + ) { + continue; + } + const syncKey = getSyncKey(request); + const signature = getRequestSignature(request); + if (successfulHashesRef.current.get(syncKey) === signature) { + continue; + } + const current = pendingSyncsRef.current.get(syncKey); + pendingSyncsRef.current.set(syncKey, { + request, + signature, + failures: current?.signature === signature ? current.failures : 0, + }); + queued = true; + } + if (queued || pendingSyncsRef.current.size > 0) { + scheduleSync(SYNC_SETTLE_DELAY_MS); + } + }, [artifacts, canCreate, conversationId, isSubmitting, latestMessageId, scheduleSync]); + + return null; +} diff --git a/client/src/components/ArtifactApps/StandaloneAppView.tsx b/client/src/components/ArtifactApps/StandaloneAppView.tsx new file mode 100644 index 00000000000..8df4174a0bb --- /dev/null +++ b/client/src/components/ArtifactApps/StandaloneAppView.tsx @@ -0,0 +1,117 @@ +import { useParams, useNavigate } from 'react-router-dom'; +import type { TArtifactVersion } from 'librechat-data-provider'; +import { + useGetArtifactAppQuery, + useGetArtifactAppVersionQuery, + useListArtifactAppVersionsQuery, +} from '~/data-provider'; +import useLocalize from '~/hooks/useLocalize'; +import AppRenderer from './AppRenderer'; + +function errorStatus(error: unknown): number | undefined { + return (error as { response?: { status?: number } } | null)?.response?.status; +} + +function StateMessage({ message }: { message: string }) { + return ( +
+

{message}

+
+ ); +} + +export default function StandaloneAppView() { + const localize = useLocalize(); + const navigate = useNavigate(); + const { artifactAppId, versionId } = useParams<{ + artifactAppId: string; + versionId?: string; + }>(); + + const appQuery = useGetArtifactAppQuery(artifactAppId); + const versionQuery = useGetArtifactAppVersionQuery(artifactAppId, versionId, { + enabled: !!versionId, + }); + const versionsQuery = useListArtifactAppVersionsQuery(artifactAppId); + + if (appQuery.isLoading) { + return ; + } + + if (appQuery.isError) { + const status = errorStatus(appQuery.error); + if (status === 403) { + return ; + } + return ; + } + + if (versionId && versionQuery.isLoading) { + return ; + } + + if (versionId && versionQuery.isError) { + const status = errorStatus(versionQuery.error); + if (status === 403) { + return ; + } + return ; + } + + const app = appQuery.data?.app; + if (!app) { + return ; + } + + if (app.status === 'suspended') { + return ; + } + if (app.status === 'archived') { + return ; + } + + const version: TArtifactVersion | null | undefined = versionId + ? versionQuery.data + : appQuery.data?.version; + + const versions = versionsQuery.data?.versions ?? []; + + return ( +
+
+
+

{app.title}

+ {app.description != null && app.description.length > 0 && ( +

{app.description}

+ )} +
+ {versions.length > 0 && ( + + )} +
+
+ {version ? ( + + ) : ( + + )} +
+
+ ); +} diff --git a/client/src/components/ArtifactApps/index.ts b/client/src/components/ArtifactApps/index.ts new file mode 100644 index 00000000000..e24838ebac4 --- /dev/null +++ b/client/src/components/ArtifactApps/index.ts @@ -0,0 +1,3 @@ +export { default as StandaloneAppView } from './StandaloneAppView'; +export { default as AppRenderer } from './AppRenderer'; +export { default as ArtifactAppsList } from './ArtifactAppsList'; diff --git a/client/src/components/ArtifactApps/runtime.spec.ts b/client/src/components/ArtifactApps/runtime.spec.ts new file mode 100644 index 00000000000..5d3622feb4c --- /dev/null +++ b/client/src/components/ArtifactApps/runtime.spec.ts @@ -0,0 +1,17 @@ +import { getArtifactMimeType } from './runtime'; + +describe('getArtifactMimeType', () => { + it.each([ + ['react', 'application/vnd.react'], + ['html', 'text/html'], + ['mermaid', 'application/vnd.mermaid'], + ['markdown', 'text/markdown'], + ['text', 'text/plain'], + ['code', 'application/vnd.code'], + ['document', 'application/vnd.librechat.docx-preview'], + ['spreadsheet', 'application/vnd.librechat.spreadsheet-preview'], + ['presentation', 'application/vnd.librechat.presentation-preview'], + ] as const)('maps %s snapshots to %s artifacts', (runtimeType, expected) => { + expect(getArtifactMimeType(runtimeType)).toBe(expected); + }); +}); diff --git a/client/src/components/ArtifactApps/runtime.ts b/client/src/components/ArtifactApps/runtime.ts new file mode 100644 index 00000000000..e07403b2d2f --- /dev/null +++ b/client/src/components/ArtifactApps/runtime.ts @@ -0,0 +1,17 @@ +import type { ArtifactRuntimeType } from 'librechat-data-provider'; + +const ARTIFACT_MIME_TYPES: Record = { + react: 'application/vnd.react', + html: 'text/html', + mermaid: 'application/vnd.mermaid', + markdown: 'text/markdown', + text: 'text/plain', + code: 'application/vnd.code', + document: 'application/vnd.librechat.docx-preview', + spreadsheet: 'application/vnd.librechat.spreadsheet-preview', + presentation: 'application/vnd.librechat.presentation-preview', +}; + +export function getArtifactMimeType(runtimeType: ArtifactRuntimeType): string { + return ARTIFACT_MIME_TYPES[runtimeType]; +} diff --git a/client/src/components/Artifacts/Artifact.tsx b/client/src/components/Artifacts/Artifact.tsx index d2e98be5f1e..e7a9f6e6a23 100644 --- a/client/src/components/Artifacts/Artifact.tsx +++ b/client/src/components/Artifacts/Artifact.tsx @@ -1,13 +1,13 @@ import React, { useEffect, useCallback, useRef, useState } from 'react'; import throttle from 'lodash/throttle'; import { visit } from 'unist-util-visit'; -import { useSetRecoilState } from 'recoil'; import { useLocation } from 'react-router-dom'; +import { useRecoilValue, useSetRecoilState } from 'recoil'; import type { Pluggable } from 'unified'; import type { Artifact } from '~/common'; +import { artifactByIdSelector, artifactsState } from '~/store/artifacts'; import { useMessageContext, useArtifactContext } from '~/Providers'; import { logger, extractContent, isArtifactRoute } from '~/utils'; -import { artifactsState } from '~/store/artifacts'; import ArtifactButton from './ArtifactButton'; export const artifactPlugin: Pluggable = () => { @@ -53,6 +53,7 @@ export function Artifact({ const setArtifacts = useSetRecoilState(artifactsState); const [artifact, setArtifact] = useState(null); + const registeredArtifact = useRecoilValue(artifactByIdSelector(artifact?.id ?? '')); const throttledUpdateRef = useRef( throttle((updateFn: () => void) => { @@ -124,5 +125,28 @@ export function Artifact({ updateArtifact(); }, [updateArtifact, resetCounter]); + /** + * Conversation hydration can reset the shared artifact map after message content has already + * rendered. Re-register the local parsed artifact when its map entry is missing or stale so a + * catalog deep link can resolve without requiring the message component to remount. + */ + useEffect(() => { + if (!artifact || !isArtifactRoute(location.pathname)) { + return; + } + if ( + registeredArtifact != null && + registeredArtifact.content === artifact.content && + registeredArtifact.type === artifact.type && + registeredArtifact.title === artifact.title + ) { + return; + } + setArtifacts((prevArtifacts) => ({ + ...(prevArtifacts ?? {}), + [artifact.id]: artifact, + })); + }, [artifact, location.pathname, registeredArtifact, setArtifacts]); + return ; } diff --git a/client/src/components/Artifacts/ArtifactButton.tsx b/client/src/components/Artifacts/ArtifactButton.tsx index 58535031041..5e4c4d44127 100644 --- a/client/src/components/Artifacts/ArtifactButton.tsx +++ b/client/src/components/Artifacts/ArtifactButton.tsx @@ -3,6 +3,7 @@ import debounce from 'lodash/debounce'; import { useLocation } from 'react-router-dom'; import { useRecoilState, useSetRecoilState, useResetRecoilState } from 'recoil'; import type { Artifact } from '~/common'; +import useClearArtifactNavigationRequest from '~/hooks/Artifacts/useClearArtifactNavigationRequest'; import ArtifactRow from '~/components/Chat/Messages/Content/Parts/ArtifactRow'; import { artifactRowKind } from '~/utils/artifacts'; import { logger, isArtifactRoute } from '~/utils'; @@ -16,6 +17,7 @@ const ArtifactButton = ({ artifact }: { artifact: Artifact | null }) => { const resetCurrentArtifactId = useResetRecoilState(store.currentArtifactId); const isSelected = artifact?.id === currentArtifactId; const [visibleArtifacts, setVisibleArtifacts] = useRecoilState(store.visibleArtifacts); + const clearArtifactNavigationRequest = useClearArtifactNavigationRequest(); const debouncedSetVisibleRef = useRef( debounce((artifactToSet: Artifact) => { @@ -52,6 +54,7 @@ const ArtifactButton = ({ artifact }: { artifact: Artifact | null }) => { } const handleOpen = () => { + clearArtifactNavigationRequest(); if (isSelected) { resetCurrentArtifactId(); setVisible(false); diff --git a/client/src/components/Artifacts/Artifacts.tsx b/client/src/components/Artifacts/Artifacts.tsx index ccdf927af05..e30fb21a30c 100644 --- a/client/src/components/Artifacts/Artifacts.tsx +++ b/client/src/components/Artifacts/Artifacts.tsx @@ -4,14 +4,18 @@ import * as Tabs from '@radix-ui/react-tabs'; import { useSetRecoilState, useResetRecoilState } from 'recoil'; import { Button, Spinner, useMediaQuery, Radio } from '@librechat/client'; import { Code, Maximize2, Minimize2, Play, RefreshCw, X } from 'lucide-react'; +import { Permissions, PermissionTypes, ResourceType } from 'librechat-data-provider'; import type { SandpackPreviewRef } from '@codesandbox/sandpack-react'; import type { ProcessedMermaidSvg } from '~/utils/diagram/export'; +import useClearArtifactNavigationRequest from '~/hooks/Artifacts/useClearArtifactNavigationRequest'; import { TOOL_ARTIFACT_TYPES, isCodeOnlyArtifact, isPreviewOnlyArtifact } from '~/utils/artifacts'; import { displayFilename } from '~/components/Chat/Messages/Content/Parts/attachmentTypes'; +import useArtifactCatalogSync from '~/hooks/Artifacts/useArtifactCatalogSync'; import CopyButton from '~/components/Messages/Content/CopyButton'; +import { useFocusTrap, useHasAccess, useLocalize } from '~/hooks'; +import { GenericGrantAccessDialog } from '~/components/Sharing'; import { useShareContext, useMutationState } from '~/Providers'; import useArtifacts from '~/hooks/Artifacts/useArtifacts'; -import { useFocusTrap, useLocalize } from '~/hooks'; import DownloadArtifact from './DownloadArtifact'; import ArtifactVersion from './ArtifactVersion'; import MermaidExport from './Mermaid/Export'; @@ -50,6 +54,7 @@ export default function Artifacts() { const dragStartHeight = useRef(90); const setArtifactsVisible = useSetRecoilState(store.artifactsVisibility); const resetCurrentArtifactId = useResetRecoilState(store.currentArtifactId); + const clearArtifactNavigationRequest = useClearArtifactNavigationRequest(); const allTabOptions = useMemo( () => [ @@ -118,6 +123,13 @@ export default function Artifacts() { orderedArtifactIds, setCurrentArtifactId, } = useArtifacts(); + const { artifactEntry, isSyncing } = useArtifactCatalogSync( + isSharedConvo ? null : currentArtifact, + ); + const canShareArtifacts = useHasAccess({ + permissionType: PermissionTypes.ARTIFACTS, + permission: Permissions.SHARE, + }); const restoreArtifactTriggerFocus = useCallback(() => { const opener = openerRef.current; @@ -152,6 +164,7 @@ export default function Artifacts() { const isMermaidArtifact = currentArtifact?.type === TOOL_ARTIFACT_TYPES.MERMAID; const closeArtifacts = useCallback(() => { + clearArtifactNavigationRequest(); if (isMobile) { setIsClosing(true); setIsVisible(false); @@ -174,6 +187,7 @@ export default function Artifacts() { setArtifactsVisible(false); restoreArtifactTriggerFocus(); }, [ + clearArtifactNavigationRequest, isMobile, prefersReducedMotion, resetCurrentArtifactId, @@ -505,6 +519,23 @@ export default function Artifacts() { ) : ( )} + {isSyncing && ( + + + + )} + {!isSharedConvo && canShareArtifacts && artifactEntry?.app && ( + + )} + } + /> + ); +} diff --git a/client/src/components/Sharing/PublicSharingToggle.tsx b/client/src/components/Sharing/PublicSharingToggle.tsx index bdd8dd99ea7..de01096cb34 100644 --- a/client/src/components/Sharing/PublicSharingToggle.tsx +++ b/client/src/components/Sharing/PublicSharingToggle.tsx @@ -20,7 +20,12 @@ interface PublicSharingToggleProps { const accessDescriptions: Partial< Record< ResourceType, - 'com_ui_agent' | 'com_ui_prompt' | 'com_ui_mcp_server' | 'com_ui_skill' | 'com_ui_shared_link' + | 'com_ui_agent' + | 'com_ui_prompt' + | 'com_ui_mcp_server' + | 'com_ui_skill' + | 'com_ui_shared_link' + | 'com_ui_artifact_app' > > = { [ResourceType.AGENT]: 'com_ui_agent', @@ -29,6 +34,7 @@ const accessDescriptions: Partial< [ResourceType.REMOTE_AGENT]: 'com_ui_agent', [ResourceType.SKILL]: 'com_ui_skill', [ResourceType.SHARED_LINK]: 'com_ui_shared_link', + [ResourceType.ARTIFACT_APP]: 'com_ui_artifact_app', }; export default function PublicSharingToggle({ diff --git a/client/src/components/UnifiedSidebar/ExpandedPanel.tsx b/client/src/components/UnifiedSidebar/ExpandedPanel.tsx index 371cb383bdf..b68588a71d4 100644 --- a/client/src/components/UnifiedSidebar/ExpandedPanel.tsx +++ b/client/src/components/UnifiedSidebar/ExpandedPanel.tsx @@ -7,6 +7,7 @@ import type { NavLink } from '~/common'; import { useShortcutAriaKey, useShortcutHint } from '~/hooks/useKeyboardShortcuts'; import { useActivePanel, resolveActivePanel, DEFAULT_PANEL } from '~/Providers'; import AgentMarketplaceButton from '~/components/Nav/AgentMarketplaceButton'; +import ArtifactAppsButton from '~/components/Nav/ArtifactAppsButton'; import { CLOSE_SIDEBAR_ID } from '~/components/Chat/Menus/OpenSidebar'; import useNewChat from '~/hooks/Chat/useNewChat'; import { useLocalize } from '~/hooks'; @@ -171,6 +172,7 @@ function ExpandedPanel({ /> +
{links.map((link) => ( diff --git a/client/src/components/UnifiedSidebar/__tests__/ExpandedPanel.spec.tsx b/client/src/components/UnifiedSidebar/__tests__/ExpandedPanel.spec.tsx index feab690f2ad..dfa3ad2069e 100644 --- a/client/src/components/UnifiedSidebar/__tests__/ExpandedPanel.spec.tsx +++ b/client/src/components/UnifiedSidebar/__tests__/ExpandedPanel.spec.tsx @@ -45,6 +45,7 @@ jest.mock('~/hooks', () => ({ useLocalize: () => (key: string) => key, useNewConvo: () => ({ newConversation: mockNewConversation }), useShowMarketplace: () => false, + useHasAccess: () => true, })); /** diff --git a/client/src/components/UnifiedSidebar/mobile/Header.tsx b/client/src/components/UnifiedSidebar/mobile/Header.tsx index bbaebcb3614..523fe568963 100644 --- a/client/src/components/UnifiedSidebar/mobile/Header.tsx +++ b/client/src/components/UnifiedSidebar/mobile/Header.tsx @@ -2,6 +2,7 @@ import { memo, lazy, Suspense, useEffect, useRef } from 'react'; import { Button, Sidebar, Skeleton } from '@librechat/client'; import type { NavLink } from '~/common'; import AgentMarketplaceButton from '~/components/Nav/AgentMarketplaceButton'; +import ArtifactAppsButton from '~/components/Nav/ArtifactAppsButton'; import { CLOSE_SIDEBAR_ID } from '~/components/Chat/Menus/OpenSidebar'; import { useShortcutAriaKey } from '~/hooks/useKeyboardShortcuts'; import { useLocalize } from '~/hooks'; @@ -76,6 +77,7 @@ function Header({ routeActiveId={routeActiveId} /> + }> diff --git a/client/src/components/UnifiedSidebar/mobile/__tests__/Header.spec.tsx b/client/src/components/UnifiedSidebar/mobile/__tests__/Header.spec.tsx index 0a4cefb7e94..cbf795cbbb5 100644 --- a/client/src/components/UnifiedSidebar/mobile/__tests__/Header.spec.tsx +++ b/client/src/components/UnifiedSidebar/mobile/__tests__/Header.spec.tsx @@ -8,6 +8,7 @@ let mockShowMarketplace = true; jest.mock('~/hooks', () => ({ useLocalize: () => (key: string) => key, useShowMarketplace: () => mockShowMarketplace, + useHasAccess: () => true, })); jest.mock('~/hooks/useKeyboardShortcuts', () => ({ diff --git a/client/src/data-provider/ArtifactApps/index.ts b/client/src/data-provider/ArtifactApps/index.ts new file mode 100644 index 00000000000..d0720956a0e --- /dev/null +++ b/client/src/data-provider/ArtifactApps/index.ts @@ -0,0 +1,2 @@ +export * from './queries'; +export * from './mutations'; diff --git a/client/src/data-provider/ArtifactApps/mutations.ts b/client/src/data-provider/ArtifactApps/mutations.ts new file mode 100644 index 00000000000..928f7146a50 --- /dev/null +++ b/client/src/data-provider/ArtifactApps/mutations.ts @@ -0,0 +1,141 @@ +import { QueryKeys, dataService } from 'librechat-data-provider'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import type { + TArtifactApp, + TArtifactVersion, + TArtifactAppWithVersion, + TPublishArtifactAppRequest, + TSyncArtifactAppRequest, + TSyncArtifactAppResponse, + TUpdateArtifactAppRequest, + TCreateArtifactVersionRequest, +} from 'librechat-data-provider'; +import type { UseMutationResult } from '@tanstack/react-query'; + +export const usePublishArtifactAppMutation = (): UseMutationResult< + TArtifactAppWithVersion, + Error, + TPublishArtifactAppRequest +> => { + const queryClient = useQueryClient(); + return useMutation((payload) => dataService.publishArtifactApp(payload), { + onSuccess: () => { + queryClient.invalidateQueries([QueryKeys.artifactApps]); + }, + }); +}; + +export const useSyncArtifactAppMutation = (): UseMutationResult< + TSyncArtifactAppResponse, + Error, + TSyncArtifactAppRequest +> => { + const queryClient = useQueryClient(); + return useMutation((payload) => dataService.syncArtifactApp(payload), { + onSuccess: (data, payload) => { + queryClient.setQueryData( + [QueryKeys.artifactApp, 'source', payload.source.conversationId, payload.source.sourceKey], + data, + ); + queryClient.invalidateQueries([QueryKeys.artifactApps]); + }, + }); +}; + +export const useUpdateArtifactAppMutation = (): UseMutationResult< + TArtifactApp, + Error, + { artifactAppId: string; payload: TUpdateArtifactAppRequest } +> => { + const queryClient = useQueryClient(); + return useMutation( + ({ artifactAppId, payload }) => dataService.updateArtifactApp(artifactAppId, payload), + { + onSuccess: (_data, { artifactAppId }) => { + queryClient.invalidateQueries([QueryKeys.artifactApps]); + queryClient.invalidateQueries([QueryKeys.artifactApp, artifactAppId]); + }, + }, + ); +}; + +export const useDeleteArtifactAppMutation = (): UseMutationResult< + { success: boolean }, + Error, + string +> => { + const queryClient = useQueryClient(); + return useMutation((artifactAppId) => dataService.deleteArtifactApp(artifactAppId), { + onSuccess: () => { + queryClient.invalidateQueries([QueryKeys.artifactApps]); + }, + }); +}; + +export const useCreateArtifactVersionMutation = (): UseMutationResult< + TArtifactVersion, + Error, + { artifactAppId: string; payload: TCreateArtifactVersionRequest } +> => { + const queryClient = useQueryClient(); + return useMutation( + ({ artifactAppId, payload }) => dataService.createArtifactAppVersion(artifactAppId, payload), + { + onSuccess: (_data, { artifactAppId }) => { + queryClient.invalidateQueries([QueryKeys.artifactAppVersions, artifactAppId]); + }, + }, + ); +}; + +export const useReleaseArtifactVersionMutation = (): UseMutationResult< + TArtifactVersion, + Error, + { artifactAppId: string; versionId: string } +> => { + const queryClient = useQueryClient(); + return useMutation( + ({ artifactAppId, versionId }) => + dataService.releaseArtifactAppVersion(artifactAppId, versionId), + { + onSuccess: (_data, { artifactAppId }) => { + queryClient.invalidateQueries([QueryKeys.artifactAppVersions, artifactAppId]); + }, + }, + ); +}; + +export const useActivateArtifactVersionMutation = (): UseMutationResult< + TArtifactApp, + Error, + { artifactAppId: string; versionId: string } +> => { + const queryClient = useQueryClient(); + return useMutation( + ({ artifactAppId, versionId }) => + dataService.activateArtifactAppVersion(artifactAppId, versionId), + { + onSuccess: (_data, { artifactAppId }) => { + queryClient.invalidateQueries([QueryKeys.artifactApp, artifactAppId]); + queryClient.invalidateQueries([QueryKeys.artifactAppVersions, artifactAppId]); + }, + }, + ); +}; + +export const useWithdrawArtifactVersionMutation = (): UseMutationResult< + TArtifactVersion, + Error, + { artifactAppId: string; versionId: string } +> => { + const queryClient = useQueryClient(); + return useMutation( + ({ artifactAppId, versionId }) => + dataService.withdrawArtifactAppVersion(artifactAppId, versionId), + { + onSuccess: (_data, { artifactAppId }) => { + queryClient.invalidateQueries([QueryKeys.artifactAppVersions, artifactAppId]); + }, + }, + ); +}; diff --git a/client/src/data-provider/ArtifactApps/queries.ts b/client/src/data-provider/ArtifactApps/queries.ts new file mode 100644 index 00000000000..f70441e7f18 --- /dev/null +++ b/client/src/data-provider/ArtifactApps/queries.ts @@ -0,0 +1,115 @@ +import { QueryKeys, dataService } from 'librechat-data-provider'; +import { useQuery, useInfiniteQuery } from '@tanstack/react-query'; +import type { + TArtifactAppList, + TArtifactVersion, + TArtifactVersionList, + TArtifactAppWithVersion, + ArtifactAppListScope, +} from 'librechat-data-provider'; +import type { + QueryObserverResult, + UseQueryOptions, + UseInfiniteQueryOptions, +} from '@tanstack/react-query'; + +const ARTIFACT_APP_PAGE_SIZE = 20; + +export const useListArtifactAppsQuery = ( + scope: ArtifactAppListScope = 'personal', + config?: UseInfiniteQueryOptions, +) => { + return useInfiniteQuery( + [QueryKeys.artifactApps, scope], + ({ pageParam }) => + dataService.listArtifactApps({ + scope, + limit: ARTIFACT_APP_PAGE_SIZE, + cursor: typeof pageParam === 'string' ? pageParam : undefined, + }), + { + getNextPageParam: (lastPage) => + lastPage.has_more && lastPage.after ? lastPage.after : undefined, + refetchOnWindowFocus: false, + refetchOnReconnect: false, + refetchOnMount: 'always', + ...config, + }, + ); +}; + +export const useGetArtifactAppBySourceQuery = ( + conversationId: string | null | undefined, + sourceKey: string | null | undefined, + config?: UseQueryOptions, +): QueryObserverResult => { + const enabled = !!conversationId && !!sourceKey; + return useQuery( + [QueryKeys.artifactApp, 'source', conversationId, sourceKey], + () => dataService.getArtifactAppBySource(conversationId as string, sourceKey as string), + { + refetchOnWindowFocus: false, + refetchOnReconnect: false, + retry: false, + ...config, + enabled: enabled && (config?.enabled ?? true), + }, + ); +}; + +export const useGetArtifactAppQuery = ( + artifactAppId: string | null | undefined, + config?: UseQueryOptions, +): QueryObserverResult => { + const enabled = !!artifactAppId; + return useQuery( + [QueryKeys.artifactApp, artifactAppId], + () => dataService.getArtifactApp(artifactAppId as string), + { + refetchOnWindowFocus: false, + refetchOnReconnect: false, + refetchOnMount: false, + retry: false, + ...config, + enabled: enabled && (config?.enabled ?? true), + }, + ); +}; + +export const useListArtifactAppVersionsQuery = ( + artifactAppId: string | null | undefined, + config?: UseQueryOptions, +): QueryObserverResult => { + const enabled = !!artifactAppId; + return useQuery( + [QueryKeys.artifactAppVersions, artifactAppId], + () => dataService.listArtifactAppVersions(artifactAppId as string), + { + refetchOnWindowFocus: false, + refetchOnReconnect: false, + refetchOnMount: false, + ...config, + enabled: enabled && (config?.enabled ?? true), + }, + ); +}; + +export const useGetArtifactAppVersionQuery = ( + artifactAppId: string | null | undefined, + versionId: string | null | undefined, + config?: UseQueryOptions, +): QueryObserverResult => { + const enabled = !!artifactAppId && !!versionId; + return useQuery( + [QueryKeys.artifactAppVersions, artifactAppId, versionId], + () => dataService.getArtifactAppVersion(artifactAppId as string, versionId as string), + { + refetchOnWindowFocus: false, + refetchOnReconnect: false, + refetchOnMount: false, + retry: false, + ...config, + enabled: enabled && (config?.enabled ?? true), + }, + ); +}; diff --git a/client/src/data-provider/index.ts b/client/src/data-provider/index.ts index ea2696ae8d4..d725db9559c 100644 --- a/client/src/data-provider/index.ts +++ b/client/src/data-provider/index.ts @@ -1,5 +1,6 @@ export * from './Auth'; export * from './Agents'; +export * from './ArtifactApps'; export * from './Endpoints'; export * from './Skills'; export * from './Files'; diff --git a/client/src/data-provider/roles.ts b/client/src/data-provider/roles.ts index bc847fdfc6f..d4f3048f41e 100644 --- a/client/src/data-provider/roles.ts +++ b/client/src/data-provider/roles.ts @@ -3,6 +3,7 @@ import { QueryKeys, dataService, promptPermissionsSchema, + artifactPermissionsSchema, skillPermissionsSchema, memoryPermissionsSchema, mcpServersPermissionsSchema, @@ -114,6 +115,37 @@ export const useUpdateAgentPermissionsMutation = ( ); }; +export const useUpdateArtifactPermissionsMutation = ( + options?: t.UpdateArtifactPermOptions, +): UseMutationResult< + t.UpdatePermResponse, + t.TError | undefined, + t.UpdateArtifactPermVars, + unknown +> => { + const queryClient = useQueryClient(); + const { onMutate, onSuccess, onError } = options ?? {}; + return useMutation( + (variables) => { + artifactPermissionsSchema.partial().parse(variables.updates); + return dataService.updateArtifactPermissions(variables); + }, + { + onSuccess: (data, variables, context) => { + queryClient.invalidateQueries([QueryKeys.roles, variables.roleName]); + onSuccess?.(data, variables, context); + }, + onError: (...args) => { + if (args[0] != null) { + console.error('Failed to update artifact permissions:', args[0]); + } + onError?.(...args); + }, + onMutate, + }, + ); +}; + export const useUpdateSkillPermissionsMutation = ( options?: t.UpdateSkillPermOptions, ): UseMutationResult< diff --git a/client/src/hooks/Artifacts/__tests__/useArtifacts.test.ts b/client/src/hooks/Artifacts/__tests__/useArtifacts.test.ts index 02228a86957..f937204821b 100644 --- a/client/src/hooks/Artifacts/__tests__/useArtifacts.test.ts +++ b/client/src/hooks/Artifacts/__tests__/useArtifacts.test.ts @@ -20,6 +20,7 @@ jest.mock('~/store', () => ({ artifactsState: { key: 'artifactsState' }, currentArtifactId: { key: 'currentArtifactId' }, artifactsVisibility: { key: 'artifactsVisibility' }, + artifactNavigationRequest: { key: 'artifactNavigationRequest' }, }, })); @@ -30,12 +31,13 @@ jest.mock('recoil', () => { useRecoilValue: jest.fn(), useRecoilState: jest.fn(), useResetRecoilState: jest.fn(), + useSetRecoilState: jest.fn(), }; }); /** Import mocked functions after mocking */ import { useArtifactsContext } from '~/Providers'; -import { useRecoilValue, useRecoilState, useResetRecoilState } from 'recoil'; +import { useRecoilValue, useRecoilState, useResetRecoilState, useSetRecoilState } from 'recoil'; import { logger } from '~/utils'; import useArtifacts from '../useArtifacts'; @@ -43,6 +45,7 @@ describe('useArtifacts', () => { const mockResetArtifacts = jest.fn(); const mockResetCurrentArtifactId = jest.fn(); const mockSetCurrentArtifactId = jest.fn(); + const mockSetArtifactsVisible = jest.fn(); const createArtifact = (partial: Partial): Artifact => ({ id: 'artifact-1', @@ -64,10 +67,12 @@ describe('useArtifacts', () => { beforeEach(() => { jest.clearAllMocks(); jest.useFakeTimers(); + window.history.replaceState({}, '', '/'); (useArtifactsContext as jest.Mock).mockReturnValue(defaultContext); (useRecoilValue as jest.Mock).mockReturnValue({}); (useRecoilState as jest.Mock).mockReturnValue([null, mockSetCurrentArtifactId]); + (useSetRecoilState as jest.Mock).mockReturnValue(mockSetArtifactsVisible); (useResetRecoilState as jest.Mock).mockImplementation((atom) => { if (atom?.key === 'artifactsState') { return mockResetArtifacts; @@ -148,6 +153,79 @@ describe('useArtifacts', () => { expect(mockSetCurrentArtifactId).toHaveBeenCalledWith('artifact-1'); expect(mockSetCurrentArtifactId).not.toHaveBeenCalledWith('artifact-2'); }); + + it('selects the newest revision for a stable catalog source key', () => { + const artifacts = { + 'artifact-1': createArtifact({ + id: 'artifact-1', + identifier: 'revenue-chart', + lastUpdateTime: 1000, + }), + 'artifact-2': createArtifact({ + id: 'artifact-2', + identifier: 'revenue-chart', + messageId: 'msg-2', + lastUpdateTime: 2000, + }), + }; + window.history.replaceState( + {}, + '', + '/c/conv-1?artifact=identifier%3Arevenue-chart%3Aapplication%2Fvnd.react', + ); + (useRecoilValue as jest.Mock).mockReturnValue(artifacts); + + const { rerender } = renderHook(() => useArtifacts()); + + expect(mockSetCurrentArtifactId).toHaveBeenCalledWith('artifact-2'); + expect(mockSetArtifactsVisible).toHaveBeenCalledWith(true); + expect(window.location.search).not.toBe(''); + + (useRecoilState as jest.Mock).mockReturnValue(['artifact-2', mockSetCurrentArtifactId]); + rerender(); + expect(window.location.search).not.toBe(''); + }); + + it('prefers the stored original artifact id from a catalog link', () => { + const artifacts = { + 'artifact-1': createArtifact({ id: 'artifact-1', lastUpdateTime: 1000 }), + 'artifact-2': createArtifact({ id: 'artifact-2', lastUpdateTime: 2000 }), + }; + window.history.replaceState( + {}, + '', + '/c/conv-1?artifact=identifier%3Aother%3Aapplication%2Fvnd.react&artifactId=artifact-2', + ); + (useRecoilValue as jest.Mock).mockReturnValue(artifacts); + + renderHook(() => useArtifacts()); + + expect(mockSetCurrentArtifactId).toHaveBeenCalledWith('artifact-2'); + }); + + it('selects a pending catalog request after conversation navigation strips the query', () => { + const artifacts = { + 'artifact-1': createArtifact({ id: 'artifact-1', lastUpdateTime: 1000 }), + 'artifact-2': createArtifact({ + id: 'artifact-2', + identifier: 'revenue-chart', + lastUpdateTime: 2000, + }), + }; + (useRecoilValue as jest.Mock).mockImplementation(({ key }: { key: string }) => + key === 'artifactNavigationRequest' + ? { + conversationId: 'conv-1', + sourceKey: 'identifier:revenue-chart:application/vnd.react', + } + : artifacts, + ); + + renderHook(() => useArtifacts()); + + expect(mockSetCurrentArtifactId).toHaveBeenCalledWith('artifact-2'); + expect(mockSetArtifactsVisible).toHaveBeenCalledWith(true); + }); }); describe('tab switching - enclosed artifacts', () => { diff --git a/client/src/hooks/Artifacts/useArtifactCatalogSync.ts b/client/src/hooks/Artifacts/useArtifactCatalogSync.ts new file mode 100644 index 00000000000..849a74d4d6e --- /dev/null +++ b/client/src/hooks/Artifacts/useArtifactCatalogSync.ts @@ -0,0 +1,29 @@ +import { useMemo } from 'react'; +import { Permissions, PermissionTypes } from 'librechat-data-provider'; +import type { Artifact } from '~/common'; +import { getArtifactSourceKey, toArtifactSyncRequest } from '~/utils/artifactCatalog'; +import { useGetArtifactAppBySourceQuery } from '~/data-provider'; +import useHasAccess from '~/hooks/Roles/useHasAccess'; +import { useArtifactsContext } from '~/Providers'; + +export default function useArtifactCatalogSync(artifact: Artifact | null | undefined) { + const { conversationId, isSubmitting } = useArtifactsContext(); + const canUse = useHasAccess({ + permissionType: PermissionTypes.ARTIFACTS, + permission: Permissions.USE, + }); + const sourceKey = useMemo(() => getArtifactSourceKey(artifact), [artifact]); + const syncRequest = useMemo( + () => (artifact && conversationId ? toArtifactSyncRequest(artifact, conversationId) : null), + [artifact, conversationId], + ); + const entryQuery = useGetArtifactAppBySourceQuery(conversationId, sourceKey, { + enabled: canUse && !!syncRequest && !isSubmitting, + }); + + return { + artifactEntry: entryQuery.data, + isSyncing: entryQuery.isLoading, + sourceKey, + }; +} diff --git a/client/src/hooks/Artifacts/useArtifacts.ts b/client/src/hooks/Artifacts/useArtifacts.ts index 279883ecfcf..bf6ff7e737c 100644 --- a/client/src/hooks/Artifacts/useArtifacts.ts +++ b/client/src/hooks/Artifacts/useArtifacts.ts @@ -1,6 +1,7 @@ import { useMemo, useState, useEffect, useRef } from 'react'; import { Constants } from 'librechat-data-provider'; -import { useRecoilState, useRecoilValue, useResetRecoilState } from 'recoil'; +import { useRecoilState, useRecoilValue, useResetRecoilState, useSetRecoilState } from 'recoil'; +import { getArtifactSourceKey } from '~/utils/artifactCatalog'; import { isCodeOnlyArtifact } from '~/utils/artifacts'; import { useArtifactsContext } from '~/Providers'; import { logger } from '~/utils'; @@ -143,20 +144,63 @@ export default function useArtifacts() { const artifacts = useRecoilValue(store.artifactsState); const resetArtifacts = useResetRecoilState(store.artifactsState); const resetCurrentArtifactId = useResetRecoilState(store.currentArtifactId); + const setArtifactsVisible = useSetRecoilState(store.artifactsVisibility); const [currentArtifactId, setCurrentArtifactId] = useRecoilState(store.currentArtifactId); - - const { orderedArtifactIds, latestAutoOpenArtifactId } = useMemo(() => { + const artifactNavigationRequest = useRecoilValue(store.artifactNavigationRequest); + const pendingRequest = + artifactNavigationRequest?.conversationId === conversationId ? artifactNavigationRequest : null; + const requestedParams = new URLSearchParams(window.location.search); + const requestedSourceKey = requestedParams.get('artifact') ?? pendingRequest?.sourceKey ?? null; + const requestedOriginalId = + requestedParams.get('artifactId') ?? pendingRequest?.originalArtifactId ?? null; + const requestedMessageId = + requestedParams.get('artifactMessageId') ?? pendingRequest?.messageId ?? null; + + const { orderedArtifactIds, latestAutoOpenArtifactId, requestedArtifactId } = useMemo(() => { const ids = Object.keys(artifacts ?? {}).sort( (a, b) => (artifacts?.[a]?.lastUpdateTime ?? 0) - (artifacts?.[b]?.lastUpdateTime ?? 0), ); + let requestedId: string | undefined; + let messageMatch: string | undefined; + let sourceMatch: string | undefined; + if (requestedSourceKey) { + for (let i = ids.length - 1; i >= 0; i--) { + const id = ids[i]; + const artifact = artifacts?.[id]; + if (requestedOriginalId && artifact?.id === requestedOriginalId) { + requestedId = id; + break; + } + if (!messageMatch && requestedMessageId && artifact?.messageId === requestedMessageId) { + messageMatch = id; + } + if ( + !sourceMatch && + (artifact?.id === requestedSourceKey || + artifact?.messageId === requestedSourceKey || + getArtifactSourceKey(artifact) === requestedSourceKey) + ) { + sourceMatch = id; + } + } + requestedId = requestedId ?? messageMatch ?? sourceMatch; + } for (let i = ids.length - 1; i >= 0; i--) { const id = ids[i]; if (!isCodeOnlyArtifact(artifacts?.[id]?.type)) { - return { orderedArtifactIds: ids, latestAutoOpenArtifactId: id }; + return { + orderedArtifactIds: ids, + latestAutoOpenArtifactId: id, + requestedArtifactId: requestedId, + }; } } - return { orderedArtifactIds: ids, latestAutoOpenArtifactId: null }; - }, [artifacts]); + return { + orderedArtifactIds: ids, + latestAutoOpenArtifactId: null, + requestedArtifactId: requestedId, + }; + }, [artifacts, requestedMessageId, requestedOriginalId, requestedSourceKey]); const prevIsSubmittingRef = useRef(false); const lastContentRef = useRef(null); @@ -197,6 +241,16 @@ export default function useArtifacts() { useEffect(() => { if (orderedArtifactIds.length === 0) return; + if (requestedSourceKey) { + if (!requestedArtifactId) { + return; + } + if (currentArtifactIdRef.current !== requestedArtifactId) { + setCurrentArtifactId(requestedArtifactId); + } + setArtifactsVisible(true); + return; + } const currentId = currentArtifactIdRef.current; if (currentId != null && orderedArtifactIds.includes(currentId)) return; if (latestAutoOpenArtifactId == null) { @@ -206,7 +260,15 @@ export default function useArtifacts() { return; } setCurrentArtifactId(latestAutoOpenArtifactId); - }, [latestAutoOpenArtifactId, orderedArtifactIds, resetCurrentArtifactId, setCurrentArtifactId]); + }, [ + latestAutoOpenArtifactId, + orderedArtifactIds, + requestedArtifactId, + requestedSourceKey, + resetCurrentArtifactId, + setArtifactsVisible, + setCurrentArtifactId, + ]); /** * Manage artifact selection and code tab switching for non-enclosed artifacts diff --git a/client/src/hooks/Artifacts/useClearArtifactNavigationRequest.ts b/client/src/hooks/Artifacts/useClearArtifactNavigationRequest.ts new file mode 100644 index 00000000000..8fa11827096 --- /dev/null +++ b/client/src/hooks/Artifacts/useClearArtifactNavigationRequest.ts @@ -0,0 +1,38 @@ +import { useCallback } from 'react'; +import { useResetRecoilState } from 'recoil'; +import { useLocation, useNavigate } from 'react-router-dom'; +import store from '~/store'; + +export default function useClearArtifactNavigationRequest(): () => void { + const location = useLocation(); + const navigate = useNavigate(); + const resetArtifactNavigationRequest = useResetRecoilState(store.artifactNavigationRequest); + + return useCallback(() => { + resetArtifactNavigationRequest(); + const params = new URLSearchParams(location.search); + const hadArtifactRequest = params.has('artifact'); + params.delete('artifact'); + params.delete('artifactId'); + params.delete('artifactMessageId'); + if (!hadArtifactRequest) { + return; + } + const search = params.toString(); + navigate( + { + pathname: location.pathname, + search: search ? `?${search}` : '', + hash: location.hash, + }, + { replace: true, state: location.state }, + ); + }, [ + location.hash, + location.pathname, + location.search, + location.state, + navigate, + resetArtifactNavigationRequest, + ]); +} diff --git a/client/src/hooks/Conversations/useNavigateToConvo.tsx b/client/src/hooks/Conversations/useNavigateToConvo.tsx index 90168c94ae3..6de9d8a9651 100644 --- a/client/src/hooks/Conversations/useNavigateToConvo.tsx +++ b/client/src/hooks/Conversations/useNavigateToConvo.tsx @@ -90,6 +90,28 @@ export const supersedeNavigation = () => { navigationGeneration++; }; +function getConversationPath(conversationId: string): string { + const path = `/c/${conversationId}`; + if (!window.location.pathname.endsWith(path)) { + return path; + } + const currentParams = new URLSearchParams(window.location.search); + const artifact = currentParams.get('artifact'); + if (!artifact) { + return path; + } + const artifactParams = new URLSearchParams({ artifact }); + const artifactId = currentParams.get('artifactId'); + const artifactMessageId = currentParams.get('artifactMessageId'); + if (artifactId) { + artifactParams.set('artifactId', artifactId); + } + if (artifactMessageId) { + artifactParams.set('artifactMessageId', artifactMessageId); + } + return `${path}?${artifactParams.toString()}`; +} + const useNavigateToConvo = (index = 0) => { const navigate = useNavigate(); const queryClient = useQueryClient(); @@ -216,7 +238,7 @@ const useNavigateToConvo = (index = 0) => { return; } applyConversation(record); - navigate(`/c/${conversationId}`); + navigate(getConversationPath(conversationId)); }; const navigateToConvo = ( @@ -306,12 +328,12 @@ const useNavigateToConvo = (index = 0) => { * write this navigation makes — what the user sees now is what a send * will carry until they change it themselves. */ applyConversation({ ...cachedConvo, ...convo }); - navigate(`/c/${convo.conversationId}`); + navigate(getConversationPath(convo.conversationId)); refreshConversationRecord(convo.conversationId); } else { setConversation(convo); requestChatFocus(); - navigate(`/c/${convo.conversationId ?? Constants.NEW_CONVO}`); + navigate(getConversationPath(convo.conversationId ?? Constants.NEW_CONVO)); } }; diff --git a/client/src/hooks/Sharing/useCanSharePublic.ts b/client/src/hooks/Sharing/useCanSharePublic.ts index 521dd1ef4e7..a1415c2c5ac 100644 --- a/client/src/hooks/Sharing/useCanSharePublic.ts +++ b/client/src/hooks/Sharing/useCanSharePublic.ts @@ -3,6 +3,7 @@ import { useHasAccess } from '~/hooks'; const resourceToPermissionMap: Partial> = { [ResourceType.AGENT]: PermissionTypes.AGENTS, + [ResourceType.ARTIFACT_APP]: PermissionTypes.ARTIFACTS, [ResourceType.PROMPTGROUP]: PermissionTypes.PROMPTS, [ResourceType.MCPSERVER]: PermissionTypes.MCP_SERVERS, [ResourceType.REMOTE_AGENT]: PermissionTypes.REMOTE_AGENTS, diff --git a/client/src/locales/en/translation.json b/client/src/locales/en/translation.json index b3b9abb8061..05c0962219c 100644 --- a/client/src/locales/en/translation.json +++ b/client/src/locales/en/translation.json @@ -1047,6 +1047,15 @@ "com_ui_archive_all_success": "All chats archived", "com_ui_archive_delete_error": "Failed to delete archived conversation", "com_ui_archive_error": "Failed to archive conversation", + "com_ui_artifact_app": "App", + "com_ui_artifact_app_archived": "This artifact has been archived and is no longer available.", + "com_ui_artifact_app_forbidden": "You don't have permission to view this artifact.", + "com_ui_artifact_app_loading": "Loading artifact…", + "com_ui_artifact_app_no_version": "This artifact has no active version to display.", + "com_ui_artifact_app_not_found": "Artifact not found.", + "com_ui_artifact_app_suspended": "This app is currently suspended.", + "com_ui_artifact_app_version": "Version", + "com_ui_artifact_app_version_number": "v{{0}}", "com_ui_artifact_click": "Click to open", "com_ui_artifact_format_diagram": "Diagram", "com_ui_artifact_format_document": "Document", @@ -2905,5 +2914,26 @@ "com_insights_invalid_date_range": "Choose an end date after the start date, within {{days}} days.", "com_insights_sparkline_accessibility": "{{label}} over time", "com_nav_mobile_drawer_strip": "Show chat beside the sidebar on mobile", - "com_nav_mobile_drawer_strip_info": "The sidebar stops short of the edge so a strip of the conversation stays visible. Tapping it closes the sidebar." + "com_nav_mobile_drawer_strip_info": "The sidebar stops short of the edge so a strip of the conversation stays visible. Tapping it closes the sidebar.", + "com_ui_artifact_apps": "Artifacts", + "com_ui_artifact_apps_description": "Artifacts from your conversations, saved and versioned automatically.", + "com_ui_artifact_apps_empty": "No artifacts yet", + "com_ui_artifact_apps_empty_hint": "Create an artifact in a conversation and it will appear here automatically.", + "com_ui_artifact_apps_search_placeholder": "Search artifacts", + "com_ui_artifact_apps_search_aria": "Search artifacts", + "com_ui_artifact_apps_clear_search": "Clear artifact search", + "com_ui_artifact_apps_no_results": "No artifacts found", + "com_ui_artifact_apps_no_results_hint": "Try a different name, description, category, or tag.", + "com_ui_artifact_apps_admin_settings_placeholder": "Artifact administration options will be added here.", + "com_ui_artifact_syncing": "Saving artifact", + "com_ui_artifact_catalog_filters": "Artifact catalog filters", + "com_ui_artifact_scope_personal": "Personal", + "com_ui_artifact_scope_shared": "Shared", + "com_ui_artifact_scope_all": "All", + "com_ui_artifact_shared_with_you": "Shared with you", + "com_ui_artifacts_allow_use": "Allow access to the artifact catalog", + "com_ui_artifacts_allow_create": "Automatically save created artifacts", + "com_ui_artifacts_allow_share": "Allow sharing artifacts with people and groups", + "com_ui_artifacts_allow_share_public": "Allow sharing artifacts with everyone", + "com_nav_artifact_apps": "Artifacts" } diff --git a/client/src/routes/index.tsx b/client/src/routes/index.tsx index 78f2e982e1f..1c2fd087404 100644 --- a/client/src/routes/index.tsx +++ b/client/src/routes/index.tsx @@ -9,6 +9,8 @@ import { RequestPasswordReset, } from '~/components/Auth'; import { MarketplaceProvider } from '~/components/Agents/MarketplaceContext'; +import StandaloneAppView from '~/components/ArtifactApps/StandaloneAppView'; +import ArtifactAppsList from '~/components/ArtifactApps/ArtifactAppsList'; import AgentMarketplace from '~/components/Agents/Marketplace'; import { OAuthSuccess, OAuthError } from '~/components/OAuth'; import { AuthContextProvider } from '~/hooks/AuthContext'; @@ -188,6 +190,18 @@ export const router = createBrowserRouter( ), }, + { + path: 'apps', + element: , + }, + { + path: 'apps/:artifactAppId', + element: , + }, + { + path: 'apps/:artifactAppId/version/:versionId', + element: , + }, { path: 'agents/:category', element: ( diff --git a/client/src/store/artifacts.ts b/client/src/store/artifacts.ts index a92844293a2..786780bf5bc 100644 --- a/client/src/store/artifacts.ts +++ b/client/src/store/artifacts.ts @@ -1,6 +1,13 @@ import { atom, atomFamily, selectorFamily } from 'recoil'; -import { logger } from '~/utils'; import type { Artifact } from '~/common'; +import { logger } from '~/utils'; + +export interface ArtifactNavigationRequest { + conversationId: string; + sourceKey: string; + originalArtifactId?: string; + messageId?: string; +} export const artifactsState = atom | null>({ key: 'artifactsState', @@ -47,6 +54,12 @@ export const artifactsVisibility = atom({ ] as const, }); +/** Durable handoff from the artifact catalog to a hydrating conversation route. */ +export const artifactNavigationRequest = atom({ + key: 'artifactNavigationRequest', + default: null, +}); + /** * Per-artifact-id claim used by `ToolArtifactCard` to dedup the same file * across tool calls / messages. Holds the unique component-instance key diff --git a/client/src/utils/artifactCatalog.spec.ts b/client/src/utils/artifactCatalog.spec.ts new file mode 100644 index 00000000000..98045a93885 --- /dev/null +++ b/client/src/utils/artifactCatalog.spec.ts @@ -0,0 +1,82 @@ +import type { Artifact } from '~/common'; +import { + getArtifactRuntimeType, + getArtifactSourceKey, + toArtifactSyncRequest, + toLatestArtifactSyncRequests, +} from './artifactCatalog'; + +const artifact = (overrides: Partial = {}): Artifact => ({ + id: 'chart_application/vnd.react_Revenue_message-1', + identifier: 'revenue-chart', + messageId: 'message-1', + index: 0, + title: 'Revenue chart', + type: 'application/vnd.react', + content: 'export default function Chart() { return
; }', + lastUpdateTime: 1, + ...overrides, +}); + +describe('artifact catalog identity', () => { + it('keeps inline artifacts stable across rendering messages', () => { + const first = artifact(); + const revision = artifact({ id: 'different-ui-id', messageId: 'message-2' }); + + expect(getArtifactSourceKey(first)).toBe(getArtifactSourceKey(revision)); + }); + + it('keeps tool artifacts stable by file id', () => { + expect( + getArtifactSourceKey( + artifact({ id: 'tool-artifact-file-123', identifier: undefined, messageId: 'message-2' }), + ), + ).toBe('file:tool-artifact-file-123'); + }); + + it('maps every renderable office and text family to a stored runtime', () => { + expect(getArtifactRuntimeType('text/markdown')).toBe('markdown'); + expect(getArtifactRuntimeType('application/vnd.code')).toBe('code'); + expect(getArtifactRuntimeType('application/vnd.librechat.presentation-preview')).toBe( + 'presentation', + ); + }); + + it('builds an automatic sync request from the LLM-authored artifact title', () => { + expect(toArtifactSyncRequest(artifact(), 'conversation-1')).toMatchObject({ + title: 'Revenue chart', + source: { + conversationId: 'conversation-1', + sourceKey: 'identifier:revenue-chart:application/vnd.react', + }, + artifact: { type: 'react' }, + }); + }); + + it('syncs only the latest revision for each stable source', () => { + const requests = toLatestArtifactSyncRequests( + { + old: artifact({ content: 'old revision', lastUpdateTime: 1 }), + latest: artifact({ id: 'new-ui-id', content: 'latest revision', lastUpdateTime: 3 }), + separate: artifact({ + identifier: 'separate', + content: 'other artifact', + lastUpdateTime: 2, + }), + }, + 'conversation-1', + ); + + expect(requests).toHaveLength(2); + expect(requests).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + artifact: expect.objectContaining({ content: 'latest revision' }), + }), + expect.objectContaining({ + artifact: expect.objectContaining({ content: 'other artifact' }), + }), + ]), + ); + }); +}); diff --git a/client/src/utils/artifactCatalog.ts b/client/src/utils/artifactCatalog.ts new file mode 100644 index 00000000000..202c29dabf7 --- /dev/null +++ b/client/src/utils/artifactCatalog.ts @@ -0,0 +1,114 @@ +import type { ArtifactRuntimeType, TSyncArtifactAppRequest } from 'librechat-data-provider'; +import type { Artifact } from '~/common'; + +const INLINE_DEFAULT_IDENTIFIER = 'lc-no-identifier'; + +const runtimeByMime: Record = { + 'application/vnd.react': 'react', + 'application/vnd.ant.react': 'react', + 'text/html': 'html', + 'application/vnd.code-html': 'html', + 'application/vnd.mermaid': 'mermaid', + 'text/markdown': 'markdown', + 'text/md': 'markdown', + 'text/plain': 'text', + 'application/vnd.code': 'code', + 'application/vnd.librechat.docx-preview': 'document', + 'application/vnd.librechat.spreadsheet-preview': 'spreadsheet', + 'application/vnd.librechat.presentation-preview': 'presentation', +}; + +export function getArtifactRuntimeType( + type: string | null | undefined, +): ArtifactRuntimeType | null { + if (!type) { + return null; + } + return runtimeByMime[type] ?? null; +} + +/** + * Stable identity for an artifact inside a conversation. LLM-authored inline + * artifacts retain their identifier across revisions; tool artifacts retain + * their file id. The message/index fallback intentionally scopes an artifact + * without an identifier to the message that created it. + */ +export function getArtifactSourceKey(artifact: Artifact | null | undefined): string | null { + if (!artifact?.type) { + return null; + } + if (artifact.id.startsWith('tool-artifact-')) { + return `file:${artifact.id}`; + } + if (artifact.identifier && artifact.identifier !== INLINE_DEFAULT_IDENTIFIER) { + return `identifier:${artifact.identifier}:${artifact.type}`.slice(0, 500); + } + if (!artifact.messageId) { + return null; + } + return `message:${artifact.messageId}:${artifact.index ?? 0}:${artifact.type}`.slice(0, 500); +} + +export function toArtifactSyncRequest( + artifact: Artifact, + conversationId: string, +): TSyncArtifactAppRequest | null { + const sourceKey = getArtifactSourceKey(artifact); + const runtimeType = getArtifactRuntimeType(artifact.type); + const content = artifact.content?.trim(); + if (!sourceKey || !runtimeType || !content) { + return null; + } + + const generatedTitle = artifact.title?.trim(); + const fallbackTitle = `Artifact ${(artifact.index ?? 0) + 1}`; + return { + title: generatedTitle && generatedTitle !== 'untitled' ? generatedTitle : fallbackTitle, + artifact: { + type: runtimeType, + content: artifact.content as string, + title: generatedTitle || undefined, + language: artifact.language, + }, + source: { + conversationId, + messageId: artifact.messageId, + originalArtifactId: artifact.id, + sourceKey, + }, + }; +} + +export function toLatestArtifactSyncRequests( + artifacts: Record, + conversationId: string, +): TSyncArtifactAppRequest[] { + const latestBySource = new Map< + string, + { request: TSyncArtifactAppRequest; lastUpdateTime: number } + >(); + + for (const artifact of Object.values(artifacts)) { + if (!artifact) { + continue; + } + const request = toArtifactSyncRequest(artifact, conversationId); + if (!request) { + continue; + } + const existing = latestBySource.get(request.source.sourceKey); + if (existing && existing.lastUpdateTime > artifact.lastUpdateTime) { + continue; + } + latestBySource.set(request.source.sourceKey, { + request, + lastUpdateTime: artifact.lastUpdateTime, + }); + } + + return Array.from(latestBySource.values(), ({ request }) => request); +} + +export function isCatalogSupportedArtifact(artifact: Artifact | null | undefined): boolean { + return getArtifactRuntimeType(artifact?.type) != null; +} diff --git a/client/src/utils/resources.ts b/client/src/utils/resources.ts index e925d598ea3..39969f3751c 100644 --- a/client/src/utils/resources.ts +++ b/client/src/utils/resources.ts @@ -81,6 +81,17 @@ export const RESOURCE_CONFIGS: Partial> = { getManageMessage: (name?: string) => `Manage access for ${name || 'shared link'}`, getCopyUrlMessage: () => 'Share link copied', }, + [ResourceType.ARTIFACT_APP]: { + resourceType: ResourceType.ARTIFACT_APP, + defaultViewerRoleId: AccessRoleIds.ARTIFACT_APP_VIEWER, + defaultEditorRoleId: AccessRoleIds.ARTIFACT_APP_EDITOR, + defaultOwnerRoleId: AccessRoleIds.ARTIFACT_APP_OWNER, + getResourceUrl: (artifactId: string) => `${window.location.origin}/apps/${artifactId}`, + getResourceName: (name?: string) => name || 'artifact', + getShareMessage: (name?: string) => name || 'artifact', + getManageMessage: (name?: string) => `Manage access for ${name || 'artifact'}`, + getCopyUrlMessage: () => 'Artifact link copied', + }, }; export const getResourceConfig = (resourceType: ResourceType): ResourceConfig | undefined => { diff --git a/docs/artifact-apps/WP0-exploration.md b/docs/artifact-apps/WP0-exploration.md new file mode 100644 index 00000000000..425162f1b26 --- /dev/null +++ b/docs/artifact-apps/WP0-exploration.md @@ -0,0 +1,145 @@ +# WP0 — Repository Exploration for Artifact Apps + +This document records the concrete code paths that the Artifact Apps feature +builds on. It is the reference file list for the initial implementation. + +## 1. Artifact data structure + +- `client/src/common/artifacts.ts` — `Artifact` interface (`id`, `identifier`, + `type`, `content`, `title`, `language`, `messageId`, `lastUpdateTime`, + `download`). This is the canonical shape of an artifact in the client. +- `client/src/store/artifacts.ts` — Recoil atoms holding artifact state keyed by + a composite `artifactKey` (`${identifier}_${type}_${title}_${messageId}`). +- `client/src/components/Artifacts/Artifact.tsx` — `artifactPlugin` and the + construction of the composite key; associates an artifact with its `messageId`. +- Artifact MIME types: `application/vnd.react`, `text/html`, + `application/vnd.mermaid`, `text/markdown`, `application/vnd.code`. + +The Artifact App snapshot normalizes these to the three renderable runtime types +required by PLAN §6.2: `react | html | mermaid`. + +## 2. Artifact extraction / parsing + +- `client/src/components/Chat/Messages/Content/splitMarkdown.ts` — micromark / + mdast directive parsing of `:::artifact:::` blocks. +- `client/src/utils/artifacts.ts` — `TOOL_ARTIFACT_TYPES`, template selection + (`getArtifactType`, Sandpack template mapping), file-type detection. + +For catalog registration, a completed generation is passed from the client to +`POST /api/artifact-apps/sync` (already-extracted `content` + `type` + generated +title), together with its source metadata. The endpoint is idempotent: unchanged +content updates metadata without creating another version, while changed content +creates the next immutable version. The backend re-normalizes and snapshots the +content — it never trusts client-supplied identity/tenant fields. + +## 3. Renderer / Sandpack integration + +- `client/src/components/Artifacts/ArtifactPreview.tsx` — `SandpackProvider` + + `SandpackPreview` (`@codesandbox/sandpack-react/unstyled`). Props: `files`, + `fileKey`, `template`, `sharedProps`, `currentCode`, `startupConfig`. +- `client/src/components/Artifacts/ArtifactTabs.tsx` — code/preview tab switch. +- `client/src/components/Artifacts/Artifacts.tsx` — header actions (copy, + download, refresh, version navigation, and sharing). +- `client/src/utils/artifacts.ts` — `getArtifactFiles` / template helpers used to + build Sandpack `files` from an artifact. + +WP5 reuses these by feeding a snapshot (`sourceSnapshot`, `artifactType`) into a +standalone wrapper (`ArtifactAppViewer`) that constructs the same Sandpack files +without any chat context. + +## 4. Automatic catalog registration and sharing + +- `client/src/components/ArtifactApps/ArtifactCatalogRegistrar.tsx` observes the + artifacts produced by a completed generation and synchronizes them into the + owner's Personal catalog automatically. +- `client/src/components/Artifacts/Artifacts.tsx` exposes the standard resource + sharing dialog. Shared artifacts appear in the Shared and All catalog scopes. +- Opening an owned catalog entry returns to its source conversation and selects + the latest matching artifact revision; shared entries open in the standalone + snapshot viewer. + +## 5. Artifact ↔ conversation/message relationship + +- An artifact is addressed by `conversationId` + a stable source key, with + `messageId` and the original artifact ID retained to resolve the exact source. +- These are stored on the App only as **origin metadata** (`sourceMetadata`), + never as a runtime dependency (PLAN §4). Deleting the source conversation must + not change the App (enforced by full snapshotting in WP3). + +## 6. ACL / permissions system (reused, not reinvented) + +- `packages/data-provider/src/accessPermissions.ts` — `ResourceType`, + `PermissionBits` (VIEW=1, EDIT=2, DELETE=4, SHARE=8), `AccessRoleIds`, + `PrincipalType`, `accessRoleToPermBits`, zod DTOs for sharing. +- `packages/data-schemas/src/schema/aclEntry.ts` + `.../methods/aclEntry.ts` — + `grantPermission`, `revokePermission`, `hasPermission`, `findAccessibleResources`, + `getEffectivePermissions`, `findPublicResourceIds`. +- `packages/data-schemas/src/methods/accessRole.ts` — `seedDefaultRoles` + (viewer/editor/owner per resource type; `RoleBits`). +- `packages/data-schemas/src/common/permissions.ts` — `RoleBits` + (VIEWER=1, EDITOR=3, MANAGER=7, OWNER=15), `MAX_PERM_BITS`. +- `packages/api/src/acl/accessControlService.ts` — `AccessControlService` + (`grantPermission`, `checkPermission`, `findAccessibleResources`, + `hasPublicAccess`, `getUserPrincipals`). Validates against `ResourceType`. +- `packages/data-schemas/src/admin/capabilities.ts` — `SystemCapabilities`, + `ResourceCapabilityMap` (`Record` — adding a + new ResourceType requires a capability here) and `CapabilityImplications`. +- `api/server/middleware/accessResources/canAccessResource.js` — generic ACL + middleware with capability bypass + custom id resolver. +- `api/server/middleware/accessResources/canAccessAgentResource.js` — the + per-resource wrapper pattern mirrored by `canAccessArtifactAppResource`. +- `api/server/services/PermissionService` — `checkPermission` used by middleware. +- `api/server/controllers/PermissionsController.js` + + `api/server/routes/accessPermissions.js` — generic resource sharing endpoints + (People Picker principals). Artifact Apps register `ResourceType.ARTIFACT_APP` + and reuse these directly. + +## 7. Persistence patterns (mirrored for WP1) + +- Schema: `packages/data-schemas/src/schema/agent.ts` (+ `schema/index.ts`). +- Model factory: `packages/data-schemas/src/models/agent.ts` applies + `applyTenantIsolation`; registered in `models/index.ts` `createModels`. +- Types: `packages/data-schemas/src/types/agent.ts` (+ `types/index.ts`). +- Methods: `packages/data-schemas/src/methods/agent.ts` (factory returning + CRUD); aggregated in `methods/index.ts` `createMethods` and re-exported as + `AllMethods`. Consumed by `/api` via `api/models/index.js`. +- Tenant isolation: `packages/data-schemas/src/models/plugins/tenantIsolation.ts` + - `config/tenantContext.ts` (AsyncLocalStorage). All models get automatic + tenant filtering; DB methods additionally pass `tenantId` explicitly for + defense-in-depth. +- Migrations: `packages/data-schemas/src/migrations/*` (e.g. `tenantIndexes.ts`) + exported from `migrations/index.ts`. +- Transactions: `packages/data-schemas/src/utils/transactions.ts` + (`supportsTransactions`) — used for atomic App+Version creation with graceful + fallback when the deployment is a standalone (non-replica-set) MongoDB. +- Unit test pattern: `mongodb-memory-server` + `createModels`/`createMethods` + in `beforeAll` (see `methods/agent.spec.ts`). + +## 8. Audit + +- `packages/data-schemas/src/types/admin.ts` — `AUDIT_CATEGORIES`, + `AUDIT_ACTIONS`, `AUDIT_ACTION_CATEGORY`, outcomes/severities/actor types. +- `packages/data-schemas/src/methods/auditLog.ts` — `recordAuditEntry` + (append-only, hash-chained, tenant-scoped). + +WP3/WP4 extend the taxonomy with `artifact_app.*` / `artifact_version.*` / +`artifact_acl.*` actions and write via `recordAuditEntry`. Audit entries never +contain tokens/secrets (PLAN §13). + +## 9. MCP / HITL (out of scope for WP0–WP6, referenced for ADR-2) + +- `@librechat/agents` MCPManager (external dependency) is the only tool executor. +- Existing MCP server ACL: `ResourceType.MCPSERVER` with viewer/editor/owner. + +MCP bridge design is captured in ADR-2; no MCP code is written in WP0–WP6. + +## 10. Frontend routing / data-provider (WP5/WP6) + +- `packages/data-provider/src/api-endpoints.ts` — endpoint builders. +- `packages/data-provider/src/data-service.ts` — service functions. +- `packages/data-provider/src/keys.ts` — `QueryKeys` / `MutationKeys`. +- `client/src/routes` — SPA routes; `/apps` is the catalog and + `/apps/:artifactAppId[/version/:versionId]` is the standalone viewer. +- `client/src/locales/en/translation.json` — English localization keys + (`com_ui_*`, `com_artifact_apps_*`). + diff --git a/docs/artifact-apps/adr-0001-snapshot-storage.md b/docs/artifact-apps/adr-0001-snapshot-storage.md new file mode 100644 index 00000000000..4c0eac33c9f --- /dev/null +++ b/docs/artifact-apps/adr-0001-snapshot-storage.md @@ -0,0 +1,67 @@ +# ADR-0001: Artifact Version Snapshot Storage + +- Status: Accepted +- Scope: WP1–WP4 (persistence, publishing, versioning) +- Related: PLAN.md §4, §6.2, §7, §17 + +## Context + +An Artifact App must keep working unchanged after its source conversation or +message is deleted or edited (PLAN §2, §4). Conversation/message/artifact IDs are +"origin metadata only, never a runtime dependency" (PLAN §4). Published versions +must be immutable (PLAN §4, §7). We must decide how the artifact source is stored +per version. + +Options considered: + +1. **Reference the source message/artifact at render time.** Rejected — violates + the independence requirement; deleting the conversation breaks the app. +2. **Store a full, self-contained snapshot inline on each `ArtifactVersion`.** +3. **Store snapshots in external object storage (S3/GridFS), referenced by hash.** + +## Decision + +Store a **full inline snapshot** on each `ArtifactVersion` document: + +- `sourceSnapshot: string` — the complete, normalized artifact source (the code / + markup / mermaid text). No reference back to the message is required to render. +- `artifactType: 'react' | 'html' | 'mermaid'` — the normalized runtime type. +- `runtimeConfig` — `{ dependencies?, entryPoint?, renderMode? }` captured at + publish time so the renderer is reproducible from the version alone. +- `integrity.sourceHash` — **SHA-256** hex digest computed over a canonical + serialization of `{ artifactType, sourceSnapshot, runtimeConfig }`. This makes + the snapshot reproducible/verifiable (PLAN §17 "source hash is reproducible") + and lets us detect tampering. +- `integrity.schemaVersion` — snapshot format version for forward migration. + +Immutability is enforced at the schema level: once a version's +`publication.state` is `released`, the content fields (`sourceSnapshot`, +`artifactType`, `runtimeConfig`, `integrity`, `versionNumber`) can never be +mutated (pre-update/save guards + method-level checks). New content always +creates a **new version**; rollback only flips the App's `activeVersionId`. + +### Why inline (option 2) over external storage (option 3) + +- Artifact sources are small text blobs (react/html/mermaid), comfortably within + Mongo document limits; a dedicated object store adds operational complexity and + a second failure/consistency domain for no benefit at v1 sizes. +- Atomic App + Version-1 creation (PLAN §8.1 step 7) is a single transaction in + one database; splitting content into object storage would break atomicity or + require a saga. +- The integrity hash already provides tamper-evidence; external storage would + still need the same hash. + +If artifact sizes grow (e.g. bundled assets in a later WP), the `runtimeConfig` ++ `integrity` split leaves room to move large blobs behind a content-addressed +pointer without changing the App/Version contract. + +## Consequences + +- `ArtifactApp` and `ArtifactVersion` are separate collections; the App holds + lifecycle/ACL/marketplace metadata and points to an `activeVersionId`. +- Creation of App + Version 1 is atomic (transaction when the MongoDB deployment + supports it; sequential-with-compensation fallback otherwise). +- Deleting/editing the source conversation has zero effect on any version + (verified by WP3 "snapshot independence" tests). +- Tenant isolation applies to both collections; every query is tenant-scoped. + diff --git a/docs/artifact-apps/adr-0002-mcp-bridge.md b/docs/artifact-apps/adr-0002-mcp-bridge.md new file mode 100644 index 00000000000..d26354df54d --- /dev/null +++ b/docs/artifact-apps/adr-0002-mcp-bridge.md @@ -0,0 +1,61 @@ +# ADR-0002: Artifact Tool (MCP) Bridge Design + +- Status: Accepted (design only — implementation is WP9/WP10, **out of scope for + WP0–WP6**) +- Scope: forward-looking; recorded now per WP0 so later sessions do not re-plan +- Related: PLAN.md §11, §12, §15 + +## Context + +Artifact Apps may optionally call explicitly allow-listed MCP tools. The artifact +runs in a sandboxed iframe and must **never** receive OAuth/session tokens, API +keys, or MCP credentials (PLAN §11.3). Authentication, authorization, and tool +execution must stay fully server-side under LibreChat's existing `MCPManager` +(PLAN §3.2 — no new MCP/OAuth implementation). + +## Decision + +Design the bridge as a **thin, server-mediated proxy**, decided now so WP1's +`toolPolicy` schema and WP3's publish flow store the right fields: + +1. **Browser bridge:** the iframe exposes `window.libreChatApp.invokeTool({ server, + tool, arguments })`. The host page listens for `postMessage` and forwards to + the backend. The artifact receives **only** tool results, never credentials. +2. **Origin discipline:** `postMessage` is accepted only from the exact expected + origin; the host validates message shape before forwarding. Replay is + mitigated with a per-call `invocationId`. +3. **Backend endpoint:** `POST /api/artifact-apps/:id/tools/invoke` is the single + trust boundary. It **ignores all user/tenant/role/token fields supplied by the + artifact** (PLAN §8.7) and derives identity from the server session only. +4. **Authorization order** (PLAN §11.3): valid session → tenant match → app + status/visibility → viewer right → active/requested version → tool in the + App's `toolPolicy` allowlist → MCP server available to *this* user → + per-user OAuth/OBO present → tool risk class → rate/budget limits. +5. **Execution:** the existing `MCPManager` runs the tool in the authenticated + user's context with per-user OAuth. Output is size-limited and sanitized + before returning to the iframe. +6. **HITL:** tool risk class drives confirmation — `read` runs directly (if + allowed), `write` requires a parameter-preview confirmation, `destructive` + requires a highlighted confirmation. Reject never executes. Confirm/reject/ + complete/fail/deny are all audited. +7. **No anonymous execution:** `public-view` is separated from `public-execute`; + v1 allows optional static `public-view` but never anonymous tool execution + (PLAN §12). + +### Fields this ADR fixes into WP1 now + +`ArtifactApp.toolPolicy = { enabled, allowedServers[], allowedTools[], +requireConfirmationForWrites }` and `marketplace.riskClass` are persisted at +publish time so the (later) bridge has an authoritative, immutable-per-version +allowlist to enforce. WP0–WP6 only **store and display** these; no invoke +endpoint is implemented yet. + +## Consequences + +- The `toolPolicy` shape is stable from WP1, so WP9/WP10 add only the invoke + endpoint + bridge without a schema migration. +- Because identity is always server-derived, cross-tenant IDOR and forged + user/tenant IDs (PLAN §15) are structurally prevented at the bridge. +- Suspended/archived apps cannot execute tools (checked in step 3), consistent + with lifecycle rules (PLAN §7). + diff --git a/packages/api/src/app/permissions.spec.ts b/packages/api/src/app/permissions.spec.ts index d12fe0e3fec..ed242d2300b 100644 --- a/packages/api/src/app/permissions.spec.ts +++ b/packages/api/src/app/permissions.spec.ts @@ -192,14 +192,14 @@ describe('updateInterfacePermissions - permissions', () => { // Check USER role call expect(mockUpdateAccessPermissions).toHaveBeenCalledWith( SystemRoles.USER, - expectedPermissionsForUser, + expect.objectContaining(expectedPermissionsForUser), null, ); // Check ADMIN role call expect(mockUpdateAccessPermissions).toHaveBeenCalledWith( SystemRoles.ADMIN, - expectedPermissionsForAdmin, + expect.objectContaining(expectedPermissionsForAdmin), null, ); }); @@ -382,14 +382,14 @@ describe('updateInterfacePermissions - permissions', () => { // Check USER role call expect(mockUpdateAccessPermissions).toHaveBeenCalledWith( SystemRoles.USER, - expectedPermissionsForUser, + expect.objectContaining(expectedPermissionsForUser), null, ); // Check ADMIN role call expect(mockUpdateAccessPermissions).toHaveBeenCalledWith( SystemRoles.ADMIN, - expectedPermissionsForAdmin, + expect.objectContaining(expectedPermissionsForAdmin), null, ); }); @@ -558,14 +558,14 @@ describe('updateInterfacePermissions - permissions', () => { // Check USER role call expect(mockUpdateAccessPermissions).toHaveBeenCalledWith( SystemRoles.USER, - expectedPermissionsForUser, + expect.objectContaining(expectedPermissionsForUser), null, ); // Check ADMIN role call expect(mockUpdateAccessPermissions).toHaveBeenCalledWith( SystemRoles.ADMIN, - expectedPermissionsForAdmin, + expect.objectContaining(expectedPermissionsForAdmin), null, ); }); @@ -747,14 +747,14 @@ describe('updateInterfacePermissions - permissions', () => { // Check USER role call expect(mockUpdateAccessPermissions).toHaveBeenCalledWith( SystemRoles.USER, - expectedPermissionsForUser, + expect.objectContaining(expectedPermissionsForUser), null, ); // Check ADMIN role call expect(mockUpdateAccessPermissions).toHaveBeenCalledWith( SystemRoles.ADMIN, - expectedPermissionsForAdmin, + expect.objectContaining(expectedPermissionsForAdmin), null, ); }); @@ -923,14 +923,14 @@ describe('updateInterfacePermissions - permissions', () => { // Check USER role call expect(mockUpdateAccessPermissions).toHaveBeenCalledWith( SystemRoles.USER, - expectedPermissionsForUser, + expect.objectContaining(expectedPermissionsForUser), null, ); // Check ADMIN role call expect(mockUpdateAccessPermissions).toHaveBeenCalledWith( SystemRoles.ADMIN, - expectedPermissionsForAdmin, + expect.objectContaining(expectedPermissionsForAdmin), null, ); }); @@ -1090,7 +1090,7 @@ describe('updateInterfacePermissions - permissions', () => { expect(mockUpdateAccessPermissions).toHaveBeenCalledTimes(2); expect(mockUpdateAccessPermissions).toHaveBeenCalledWith( SystemRoles.USER, - expectedPermissionsForUser, + expect.objectContaining(expectedPermissionsForUser), expect.objectContaining({ permissions: expect.objectContaining({ [PermissionTypes.PROMPTS]: expect.objectContaining({ [Permissions.USE]: false }), @@ -1100,7 +1100,7 @@ describe('updateInterfacePermissions - permissions', () => { ); expect(mockUpdateAccessPermissions).toHaveBeenCalledWith( SystemRoles.ADMIN, - expectedPermissionsForAdmin, + expect.objectContaining(expectedPermissionsForAdmin), expect.objectContaining({ permissions: expect.objectContaining({ [PermissionTypes.PROMPTS]: expect.objectContaining({ [Permissions.USE]: false }), @@ -1280,14 +1280,14 @@ describe('updateInterfacePermissions - permissions', () => { expect(mockUpdateAccessPermissions).toHaveBeenCalledTimes(2); expect(mockUpdateAccessPermissions).toHaveBeenCalledWith( SystemRoles.USER, - expectedPermissionsForUser, + expect.objectContaining(expectedPermissionsForUser), expect.objectContaining({ permissions: expect.any(Object), }), ); expect(mockUpdateAccessPermissions).toHaveBeenCalledWith( SystemRoles.ADMIN, - expectedPermissionsForAdmin, + expect.objectContaining(expectedPermissionsForAdmin), expect.objectContaining({ permissions: expect.any(Object), }), diff --git a/packages/api/src/app/permissions.ts b/packages/api/src/app/permissions.ts index c4ea6ed6101..d6ce8cd4481 100644 --- a/packages/api/src/app/permissions.ts +++ b/packages/api/src/app/permissions.ts @@ -27,6 +27,8 @@ function hasExplicitConfig( return interfaceConfig?.multiConvo !== undefined; case PermissionTypes.AGENTS: return interfaceConfig?.agents !== undefined; + case PermissionTypes.ARTIFACTS: + return interfaceConfig?.artifacts !== undefined; case PermissionTypes.TEMPORARY_CHAT: return interfaceConfig?.temporaryChat !== undefined; case PermissionTypes.RUN_CODE: @@ -192,24 +194,32 @@ export async function updateInterfacePermissions({ typeof defaults.prompts === 'boolean' ? defaults.prompts : defaults.prompts?.use; const agentsDefaultUse = typeof defaults.agents === 'boolean' ? defaults.agents : defaults.agents?.use; + const artifactsDefaultUse = + typeof defaults.artifacts === 'boolean' ? defaults.artifacts : defaults.artifacts?.use; const skillsDefaultUse = typeof defaults.skills === 'boolean' ? defaults.skills : defaults.skills?.use; const promptsDefaultCreate = typeof defaults.prompts === 'object' ? defaults.prompts?.create : undefined; const agentsDefaultCreate = typeof defaults.agents === 'object' ? defaults.agents?.create : undefined; + const artifactsDefaultCreate = + typeof defaults.artifacts === 'object' ? defaults.artifacts?.create : undefined; const skillsDefaultCreate = typeof defaults.skills === 'object' ? defaults.skills?.create : undefined; const promptsDefaultShare = typeof defaults.prompts === 'object' ? defaults.prompts?.share : undefined; const agentsDefaultShare = typeof defaults.agents === 'object' ? defaults.agents?.share : undefined; + const artifactsDefaultShare = + typeof defaults.artifacts === 'object' ? defaults.artifacts?.share : undefined; const skillsDefaultShare = typeof defaults.skills === 'object' ? defaults.skills?.share : undefined; const promptsDefaultPublic = typeof defaults.prompts === 'object' ? defaults.prompts?.public : undefined; const agentsDefaultPublic = typeof defaults.agents === 'object' ? defaults.agents?.public : undefined; + const artifactsDefaultPublic = + typeof defaults.artifacts === 'object' ? defaults.artifacts?.public : undefined; const skillsDefaultPublic = typeof defaults.skills === 'object' ? defaults.skills?.public : undefined; // `schedules` is intentionally absent from the interface DEFAULTS (it is @@ -335,6 +345,40 @@ export async function updateInterfacePermissions({ } : {}), }, + [PermissionTypes.ARTIFACTS]: { + [Permissions.USE]: getPermissionValue( + getConfigUse(loadedInterface.artifacts), + defaultPerms[PermissionTypes.ARTIFACTS]?.[Permissions.USE], + artifactsDefaultUse, + ), + ...((typeof interfaceConfig?.artifacts === 'object' && + 'create' in interfaceConfig.artifacts) || + !existingPermissions?.[PermissionTypes.ARTIFACTS] + ? { + [Permissions.CREATE]: getPermissionValue( + getConfigCreate(loadedInterface.artifacts), + defaultPerms[PermissionTypes.ARTIFACTS]?.[Permissions.CREATE], + artifactsDefaultCreate ?? true, + ), + } + : {}), + ...((typeof interfaceConfig?.artifacts === 'object' && + ('share' in interfaceConfig.artifacts || 'public' in interfaceConfig.artifacts)) || + !existingPermissions?.[PermissionTypes.ARTIFACTS] + ? { + [Permissions.SHARE]: getPermissionValue( + getConfigShare(loadedInterface.artifacts), + defaultPerms[PermissionTypes.ARTIFACTS]?.[Permissions.SHARE], + artifactsDefaultShare, + ), + [Permissions.SHARE_PUBLIC]: getPermissionValue( + getConfigPublic(loadedInterface.artifacts), + defaultPerms[PermissionTypes.ARTIFACTS]?.[Permissions.SHARE_PUBLIC], + artifactsDefaultPublic, + ), + } + : {}), + }, [PermissionTypes.TEMPORARY_CHAT]: { [Permissions.USE]: getPermissionValue( loadedInterface.temporaryChat, diff --git a/packages/api/src/artifactApps/__tests__/handlers.spec.ts b/packages/api/src/artifactApps/__tests__/handlers.spec.ts new file mode 100644 index 00000000000..974844b7589 --- /dev/null +++ b/packages/api/src/artifactApps/__tests__/handlers.spec.ts @@ -0,0 +1,633 @@ +import mongoose from 'mongoose'; +import { MongoMemoryServer } from 'mongodb-memory-server'; +import { createModels, createArtifactAppMethods } from '@librechat/data-schemas'; +import { ResourceType, AccessRoleIds, PermissionBits } from 'librechat-data-provider'; +import type { IUser } from '@librechat/data-schemas'; +import type { Response } from 'express'; +import type { Types } from 'mongoose'; +import type { ServerRequest } from '~/types'; +import { createArtifactAppHandlers } from '../handlers'; + +let mongoServer: MongoMemoryServer; +let methods: ReturnType; +let handlers: ReturnType; + +interface GrantRecord { + principalId: string; + resourceType: string; + resourceId: string; + accessRoleId: string; +} + +let grants: GrantRecord[]; +let auditActions: string[]; +let accessibleIds: Types.ObjectId[]; +let permissionBatchSizes: number[]; + +function makeRes(): Response & { statusCode: number; body: unknown } { + const res = { + statusCode: 0, + body: undefined as unknown, + status(code: number) { + res.statusCode = code; + return res; + }, + json(payload: unknown) { + res.body = payload; + return res; + }, + }; + return res as unknown as Response & { statusCode: number; body: unknown }; +} + +/** Route bodies are validated by zod inside the handlers, so the mock body is + * deliberately unconstrained rather than the chat-shaped `ServerRequest['body']`. */ +type ReqOverrides = Partial> & { + body?: unknown; + user?: IUser; +}; + +function makeUser(overrides: Partial = {}): IUser { + return { + _id: new mongoose.Types.ObjectId(), + id: 'user-1', + name: 'User One', + email: 'user-one@example.com', + emailVerified: true, + provider: 'local', + tenantId: undefined, + ...overrides, + } as IUser; +} + +function makeReq(overrides: ReqOverrides = {}): ServerRequest { + return { + user: makeUser(), + params: {}, + body: {}, + query: {}, + ...overrides, + } as unknown as ServerRequest; +} + +const samplePublish = { + title: 'My Chart', + description: 'A bar chart', + visibility: 'private' as const, + artifact: { + type: 'react' as const, + content: 'export default () =>
hello
;', + title: 'Chart', + }, +}; + +beforeAll(async () => { + mongoServer = await MongoMemoryServer.create(); + await mongoose.connect(mongoServer.getUri()); + createModels(mongoose); + methods = createArtifactAppMethods(mongoose); + handlers = createArtifactAppHandlers({ + ...methods, + getResourcePermissionsMap: async ({ resourceIds }) => { + permissionBatchSizes.push(resourceIds.length); + const accessibleSet = new Set(accessibleIds.map((id) => id.toString())); + return new Map( + resourceIds + .filter((id) => accessibleSet.has(id.toString())) + .map((id) => [id.toString(), PermissionBits.VIEW]), + ); + }, + grantPermission: async (params) => { + grants.push({ + principalId: String(params.principalId), + resourceType: params.resourceType, + resourceId: String(params.resourceId), + accessRoleId: params.accessRoleId, + }); + return {}; + }, + recordAuditEntry: async (input) => { + auditActions.push(input.action); + return null; + }, + }); +}); + +afterAll(async () => { + await mongoose.disconnect(); + await mongoServer.stop(); +}); + +beforeEach(async () => { + await mongoose.connection.dropDatabase(); + grants = []; + auditActions = []; + accessibleIds = []; + permissionBatchSizes = []; +}); + +describe('publish', () => { + test('creates app + version 1, grants owner ACL, and audits', async () => { + const req = makeReq({ body: samplePublish }); + const res = makeRes(); + await handlers.publish(req, res); + + expect(res.statusCode).toBe(201); + const body = res.body as { + app: { artifactAppId: string; createdBy: string }; + version: { versionNumber: number; sourceSnapshot: string; integrity: { sourceHash: string } }; + }; + expect(body.app.artifactAppId).toMatch(/^app_/); + expect(body.app.createdBy).toBe('user-1'); + expect(body.version.versionNumber).toBe(1); + expect(body.version.sourceSnapshot).toBe(samplePublish.artifact.content); + expect(body.version.integrity.sourceHash).toHaveLength(64); + + expect(grants).toHaveLength(1); + expect(grants[0]).toMatchObject({ + principalId: 'user-1', + resourceType: ResourceType.ARTIFACT_APP, + accessRoleId: AccessRoleIds.ARTIFACT_APP_OWNER, + }); + expect(auditActions).toContain('artifact_app.created'); + }); + + test('ignores createdBy/tenantId supplied in the request body (§8.7)', async () => { + const req = makeReq({ + body: { ...samplePublish, createdBy: 'attacker', tenantId: 'other-tenant' } as never, + }); + const res = makeRes(); + await handlers.publish(req, res); + + const body = res.body as { app: { createdBy: string; tenantId?: string } }; + expect(body.app.createdBy).toBe('user-1'); + expect(body.app.tenantId).toBeUndefined(); + }); + + test('rejects invalid payloads with 400', async () => { + const req = makeReq({ body: { title: '' } }); + const res = makeRes(); + await handlers.publish(req, res); + expect(res.statusCode).toBe(400); + }); + + test('snapshot is independent of later publishes of a mutated artifact', async () => { + const first = makeRes(); + await handlers.publish(makeReq({ body: samplePublish }), first); + const firstBody = first.body as { app: { artifactAppId: string } }; + + const mutated = { + ...samplePublish, + artifact: { ...samplePublish.artifact, content: 'export default () =>
changed
;' }, + }; + const second = makeRes(); + await handlers.publish(makeReq({ body: mutated }), second); + + const stored = await methods.getArtifactVersion({ + artifactAppId: firstBody.app.artifactAppId, + versionNumber: 1, + }); + expect(stored?.sourceSnapshot).toBe(samplePublish.artifact.content); + }); +}); + +describe('automatic catalog sync', () => { + const syncBody = { + title: 'Revenue chart', + artifact: samplePublish.artifact, + source: { + conversationId: 'conversation-1', + messageId: 'message-1', + originalArtifactId: 'render-1', + sourceKey: 'identifier:revenue-chart:application/vnd.react', + }, + }; + + test('creates once, then reuses the catalog record for identical content', async () => { + const first = makeRes(); + await handlers.sync(makeReq({ body: syncBody }), first); + const second = makeRes(); + await handlers.sync( + makeReq({ + body: { + ...syncBody, + source: { ...syncBody.source, messageId: 'message-2', originalArtifactId: 'render-2' }, + }, + }), + second, + ); + + expect(first.statusCode).toBe(201); + expect(second.statusCode).toBe(200); + expect(second.body as { created: boolean; versionCreated: boolean }).toMatchObject({ + created: false, + versionCreated: false, + }); + expect(grants).toHaveLength(2); + }); + + test('returns an owner-only source lookup for the share button', async () => { + await handlers.sync(makeReq({ body: syncBody }), makeRes()); + const res = makeRes(); + await handlers.getBySource( + makeReq({ + query: { + conversationId: syncBody.source.conversationId, + sourceKey: syncBody.source.sourceKey, + }, + }), + res, + ); + + expect(res.statusCode).toBe(200); + expect((res.body as { app: { id: string } }).app.id).toMatch(/^[a-f0-9]{24}$/); + }); +}); + +describe('get / list', () => { + test('get returns app with its active version', async () => { + const created = makeRes(); + await handlers.publish(makeReq({ body: samplePublish }), created); + const appId = (created.body as { app: { artifactAppId: string } }).app.artifactAppId; + + const res = makeRes(); + await handlers.get(makeReq({ params: { id: appId } as never }), res); + expect(res.statusCode).toBe(200); + const body = res.body as { + app: { artifactAppId: string }; + version: { versionNumber: number } | null; + }; + expect(body.app.artifactAppId).toBe(appId); + expect(body.version?.versionNumber).toBe(1); + }); + + test('list returns only ACL-accessible apps', async () => { + const a = makeRes(); + await handlers.publish(makeReq({ body: samplePublish }), a); + const b = makeRes(); + await handlers.publish(makeReq({ body: { ...samplePublish, title: 'Second' } }), b); + + const resolvedA = await methods.resolveArtifactAppId({ + artifactAppId: (a.body as { app: { artifactAppId: string } }).app.artifactAppId, + }); + accessibleIds = [resolvedA!._id]; + + const res = makeRes(); + await handlers.list(makeReq({}), res); + const body = res.body as { apps: unknown[] }; + expect(body.apps).toHaveLength(1); + }); + + test('list separates personal and shared artifacts while all returns both', async () => { + const personal = makeRes(); + await handlers.publish(makeReq({ body: { ...samplePublish, title: 'Personal' } }), personal); + const shared = makeRes(); + await handlers.publish( + makeReq({ + user: makeUser({ id: 'user-2', name: 'User Two', email: 'user-two@example.com' }), + body: { ...samplePublish, title: 'Shared' }, + }), + shared, + ); + + const personalApp = await methods.resolveArtifactAppId({ + artifactAppId: (personal.body as { app: { artifactAppId: string } }).app.artifactAppId, + }); + const sharedApp = await methods.resolveArtifactAppId({ + artifactAppId: (shared.body as { app: { artifactAppId: string } }).app.artifactAppId, + }); + accessibleIds = [personalApp!._id, sharedApp!._id]; + + const personalResult = makeRes(); + await handlers.list(makeReq({ query: { scope: 'personal' } }), personalResult); + const sharedResult = makeRes(); + await handlers.list(makeReq({ query: { scope: 'shared' } }), sharedResult); + const allResult = makeRes(); + await handlers.list(makeReq({ query: { scope: 'all' } }), allResult); + + expect((personalResult.body as { apps: Array<{ title: string }> }).apps).toHaveLength(1); + expect((personalResult.body as { apps: Array<{ title: string }> }).apps[0]?.title).toBe( + 'Personal', + ); + expect((sharedResult.body as { apps: Array<{ title: string }> }).apps).toHaveLength(1); + expect((sharedResult.body as { apps: Array<{ title: string }> }).apps[0]?.title).toBe('Shared'); + expect((allResult.body as { apps: unknown[] }).apps).toHaveLength(2); + }); + + test('list returns stable cursor pages and bounds ACL permission batches', async () => { + const createdIds: Types.ObjectId[] = []; + for (let index = 0; index < 25; index++) { + const { app } = await methods.createArtifactAppWithVersion({ + createdBy: 'user-1', + title: `Artifact ${index}`, + visibility: 'private', + version: { + artifactType: 'react', + sourceSnapshot: `${samplePublish.artifact.content}-${index}`, + createdBy: 'user-1', + }, + }); + createdIds.push(app._id); + } + accessibleIds = createdIds; + + const first = makeRes(); + await handlers.list(makeReq({ query: { scope: 'personal', limit: '10' } }), first); + const firstPage = first.body as { + apps: Array<{ id: string }>; + has_more: boolean; + after: string | null; + }; + expect(firstPage.apps).toHaveLength(10); + expect(firstPage.has_more).toBe(true); + expect(firstPage.after).toEqual(expect.any(String)); + if (!firstPage.after) { + throw new Error('Expected a cursor for the next artifact page'); + } + + const second = makeRes(); + await handlers.list( + makeReq({ query: { scope: 'personal', limit: '10', cursor: firstPage.after } }), + second, + ); + const secondPage = second.body as { apps: Array<{ id: string }> }; + expect(secondPage.apps).toHaveLength(10); + expect(new Set([...firstPage.apps, ...secondPage.apps].map(({ id }) => id)).size).toBe(20); + expect(Math.max(...permissionBatchSizes)).toBeLessThanOrEqual(100); + }); + + test('list rejects malformed cursors', async () => { + const res = makeRes(); + await handlers.list(makeReq({ query: { cursor: 'not-a-cursor' } }), res); + expect(res.statusCode).toBe(400); + }); + + test('get returns 404 for unknown id', async () => { + const res = makeRes(); + await handlers.get(makeReq({ params: { id: 'app_missing' } as never }), res); + expect(res.statusCode).toBe(404); + }); +}); + +describe('version lifecycle', () => { + async function publishApp(): Promise { + const res = makeRes(); + await handlers.publish(makeReq({ body: samplePublish }), res); + return (res.body as { app: { artifactAppId: string } }).app.artifactAppId; + } + + test('create → release → activate updates the active version', async () => { + const appId = await publishApp(); + + const createRes = makeRes(); + await handlers.createVersion( + makeReq({ + params: { id: appId } as never, + body: { artifact: samplePublish.artifact, changelog: 'v2' }, + }), + createRes, + ); + expect(createRes.statusCode).toBe(201); + const versionId = (createRes.body as { artifactVersionId: string }).artifactVersionId; + + const releaseRes = makeRes(); + await handlers.releaseVersion( + makeReq({ params: { id: appId, versionId } as never }), + releaseRes, + ); + expect((releaseRes.body as { publication: { state: string } }).publication.state).toBe( + 'released', + ); + + const activateRes = makeRes(); + await handlers.activateVersion( + makeReq({ params: { id: appId, versionId } as never }), + activateRes, + ); + expect(activateRes.statusCode).toBe(200); + expect((activateRes.body as { activeVersionId: string }).activeVersionId).toBe(versionId); + }); + + test('activating an unreleased version returns 409', async () => { + const appId = await publishApp(); + const createRes = makeRes(); + await handlers.createVersion( + makeReq({ params: { id: appId } as never, body: { artifact: samplePublish.artifact } }), + createRes, + ); + const versionId = (createRes.body as { artifactVersionId: string }).artifactVersionId; + + const res = makeRes(); + await handlers.activateVersion(makeReq({ params: { id: appId, versionId } as never }), res); + expect(res.statusCode).toBe(409); + }); + + test('released version content is immutable across a re-release', async () => { + const appId = await publishApp(); + const createRes = makeRes(); + await handlers.createVersion( + makeReq({ params: { id: appId } as never, body: { artifact: samplePublish.artifact } }), + createRes, + ); + const versionId = (createRes.body as { artifactVersionId: string }).artifactVersionId; + + const firstRelease = makeRes(); + await handlers.releaseVersion( + makeReq({ params: { id: appId, versionId } as never }), + firstRelease, + ); + const hashAfterFirst = (firstRelease.body as { integrity: { sourceHash: string } }).integrity + .sourceHash; + + const secondRelease = makeRes(); + await handlers.releaseVersion( + makeReq({ params: { id: appId, versionId } as never }), + secondRelease, + ); + const hashAfterSecond = (secondRelease.body as { integrity: { sourceHash: string } }).integrity + .sourceHash; + + expect(hashAfterSecond).toBe(hashAfterFirst); + }); +}); + +async function publishAppId(): Promise { + const res = makeRes(); + await handlers.publish(makeReq({ body: samplePublish }), res); + return (res.body as { app: { artifactAppId: string } }).app.artifactAppId; +} + +/** Create a second (draft) version and return its id. */ +async function addVersion(appId: string): Promise { + const res = makeRes(); + await handlers.createVersion( + makeReq({ params: { id: appId } as never, body: { artifact: samplePublish.artifact } }), + res, + ); + return (res.body as { artifactVersionId: string }).artifactVersionId; +} + +describe('update', () => { + test('applies the patch and audits', async () => { + const appId = await publishAppId(); + auditActions = []; + + const res = makeRes(); + await handlers.update( + makeReq({ params: { id: appId } as never, body: { title: 'Renamed', category: 'charts' } }), + res, + ); + + expect(res.statusCode).toBe(200); + const body = res.body as { title: string; category: string }; + expect(body.title).toBe('Renamed'); + expect(body.category).toBe('charts'); + expect(auditActions).toContain('artifact_app.updated'); + }); + + test('rejects an empty patch with 400', async () => { + const appId = await publishAppId(); + const res = makeRes(); + await handlers.update(makeReq({ params: { id: appId } as never, body: {} }), res); + expect(res.statusCode).toBe(400); + }); + + test('rejects an invalid field value with 400', async () => { + const appId = await publishAppId(); + const res = makeRes(); + await handlers.update(makeReq({ params: { id: appId } as never, body: { title: '' } }), res); + expect(res.statusCode).toBe(400); + }); + + /* Same §8.7 guarantee publish has: server-owned fields supplied by the + * client must never be written through a patch. */ + test('ignores createdBy/tenantId supplied in the patch body', async () => { + const appId = await publishAppId(); + const res = makeRes(); + await handlers.update( + makeReq({ + params: { id: appId } as never, + body: { title: 'Renamed', createdBy: 'attacker', tenantId: 'other-tenant' } as never, + }), + res, + ); + + expect(res.statusCode).toBe(200); + const body = res.body as { createdBy: string; tenantId?: string }; + expect(body.createdBy).toBe('user-1'); + expect(body.tenantId).toBeUndefined(); + }); + + test('returns 404 for an unknown app', async () => { + const res = makeRes(); + await handlers.update( + makeReq({ params: { id: 'app_missing' } as never, body: { title: 'Renamed' } }), + res, + ); + expect(res.statusCode).toBe(404); + }); +}); + +describe('remove', () => { + test('deletes the app with its versions and audits', async () => { + const appId = await publishAppId(); + await addVersion(appId); + auditActions = []; + + const res = makeRes(); + await handlers.remove(makeReq({ params: { id: appId } as never }), res); + + expect(res.statusCode).toBe(200); + expect(auditActions).toContain('artifact_app.archived'); + expect(await methods.getArtifactAppByAppId({ artifactAppId: appId })).toBeNull(); + expect(await methods.listArtifactVersions({ artifactAppId: appId })).toHaveLength(0); + }); + + test('returns 404 for an unknown app', async () => { + const res = makeRes(); + await handlers.remove(makeReq({ params: { id: 'app_missing' } as never }), res); + expect(res.statusCode).toBe(404); + }); +}); + +describe('version reads', () => { + test('listVersions returns every version of the app', async () => { + const appId = await publishAppId(); + await addVersion(appId); + + const res = makeRes(); + await handlers.listVersions(makeReq({ params: { id: appId } as never }), res); + + expect(res.statusCode).toBe(200); + const body = res.body as { versions: { versionNumber: number }[] }; + expect(body.versions.map((v) => v.versionNumber).sort()).toEqual([1, 2]); + }); + + test('listVersions returns an empty list for an unknown app', async () => { + const res = makeRes(); + await handlers.listVersions(makeReq({ params: { id: 'app_missing' } as never }), res); + expect(res.statusCode).toBe(200); + expect((res.body as { versions: unknown[] }).versions).toHaveLength(0); + }); + + test('getVersion returns the requested version', async () => { + const appId = await publishAppId(); + const versionId = await addVersion(appId); + + const res = makeRes(); + await handlers.getVersion(makeReq({ params: { id: appId, versionId } as never }), res); + + expect(res.statusCode).toBe(200); + expect((res.body as { artifactVersionId: string }).artifactVersionId).toBe(versionId); + }); + + test('getVersion returns 404 for an unknown version', async () => { + const appId = await publishAppId(); + const res = makeRes(); + await handlers.getVersion( + makeReq({ params: { id: appId, versionId: 'ver_missing' } as never }), + res, + ); + expect(res.statusCode).toBe(404); + }); + + /* Version ids must not be readable across apps: passing app B's id with + * app A's version must miss, not leak A's snapshot. */ + test('getVersion does not resolve a version belonging to another app', async () => { + const appA = await publishAppId(); + const versionA = await addVersion(appA); + const appB = await publishAppId(); + + const res = makeRes(); + await handlers.getVersion(makeReq({ params: { id: appB, versionId: versionA } as never }), res); + expect(res.statusCode).toBe(404); + }); +}); + +describe('withdrawVersion', () => { + test('withdraws a released version and audits', async () => { + const appId = await publishAppId(); + const versionId = await addVersion(appId); + await handlers.releaseVersion( + makeReq({ params: { id: appId, versionId } as never }), + makeRes(), + ); + auditActions = []; + + const res = makeRes(); + await handlers.withdrawVersion(makeReq({ params: { id: appId, versionId } as never }), res); + + expect(res.statusCode).toBe(200); + expect(auditActions).toContain('artifact_version.withdrawn'); + }); + + test('returns 404 for an unknown version', async () => { + const appId = await publishAppId(); + const res = makeRes(); + await handlers.withdrawVersion( + makeReq({ params: { id: appId, versionId: 'ver_missing' } as never }), + res, + ); + expect(res.statusCode).toBe(404); + }); +}); diff --git a/packages/api/src/artifactApps/handlers.ts b/packages/api/src/artifactApps/handlers.ts new file mode 100644 index 00000000000..58aa3948f50 --- /dev/null +++ b/packages/api/src/artifactApps/handlers.ts @@ -0,0 +1,801 @@ +import { Types } from 'mongoose'; +import { logger } from '@librechat/data-schemas'; +import { + ResourceType, + AccessRoleIds, + PrincipalType, + PermissionBits, + publishArtifactAppSchema, + syncArtifactAppSchema, + artifactAppListRequestSchema, + updateArtifactAppSchema, + createArtifactVersionSchema, +} from 'librechat-data-provider'; +import type { + IArtifactApp, + IArtifactVersion, + ArtifactAppQuery, + ArtifactVersionQuery, + ArtifactAppWithVersion, + ArtifactAppIdResolution, + ArtifactAppSourceQuery, + SyncArtifactAppResult, + CreateArtifactAppInput, + CreateArtifactVersionInput, + RecordAuditEntryInput, + IAuditLog, + ArtifactAppListCursor, + ArtifactAppListOptions, +} from '@librechat/data-schemas'; +import type { TArtifactApp, TArtifactVersion, ArtifactRuntimeType } from 'librechat-data-provider'; +import type { FilterQuery } from 'mongoose'; +import type { Response } from 'express'; +import type { ServerRequest } from '~/types'; + +const ARTIFACT_APP_SCAN_BATCH_SIZE = 100; +const ARTIFACT_APP_MAX_SCAN_BATCHES = 10; + +/** + * All dependencies required to serve Artifact App HTTP requests. Every dep is + * resolved from the legacy api layer (`~/models`, `PermissionService`) so the + * handlers stay pure — no direct mongoose access, no direct filesystem I/O. + */ +export interface ArtifactAppHandlersDeps { + createArtifactAppWithVersion: (input: CreateArtifactAppInput) => Promise; + syncArtifactAppWithVersion: (input: CreateArtifactAppInput) => Promise; + getArtifactAppByAppId: (query: ArtifactAppQuery) => Promise; + getArtifactAppBySource: (query: ArtifactAppSourceQuery) => Promise; + resolveArtifactAppId: (query: ArtifactAppQuery) => Promise; + listArtifactApps: ( + filter: FilterQuery, + options?: ArtifactAppListOptions, + ) => Promise; + updateArtifactApp: ( + query: ArtifactAppQuery, + update: Partial, + ) => Promise; + deleteArtifactApp: ( + query: ArtifactAppQuery, + ) => Promise<{ deletedApp: boolean; deletedVersions: number }>; + getArtifactVersion: (query: ArtifactVersionQuery) => Promise; + listArtifactVersions: (query: ArtifactAppQuery) => Promise; + createArtifactVersion: ( + query: ArtifactAppQuery, + input: CreateArtifactVersionInput, + ) => Promise; + releaseArtifactVersion: ( + query: ArtifactVersionQuery, + releasedBy: string, + ) => Promise; + activateArtifactVersion: (query: ArtifactVersionQuery) => Promise; + withdrawArtifactVersion: (query: ArtifactVersionQuery) => Promise; + + getResourcePermissionsMap: (params: { + userId: string; + role?: string | null; + resourceType: string; + resourceIds: Types.ObjectId[]; + }) => Promise>; + grantPermission: (params: { + principalType: string; + principalId: string | Types.ObjectId; + resourceType: string; + resourceId: string | Types.ObjectId; + accessRoleId: string; + grantedBy: string | Types.ObjectId; + }) => Promise; + recordAuditEntry: (input: RecordAuditEntryInput) => Promise; +} + +function encodeListCursor(app: Pick): string { + return Buffer.from( + JSON.stringify({ + updatedAt: app.updatedAt.toISOString(), + _id: app._id.toString(), + }), + ).toString('base64'); +} + +function decodeListCursor(cursor: string | undefined): ArtifactAppListCursor | undefined { + if (!cursor) { + return undefined; + } + const decoded = JSON.parse(Buffer.from(cursor, 'base64').toString('utf8')) as { + updatedAt?: string; + _id?: string; + }; + const updatedAt = decoded.updatedAt ? new Date(decoded.updatedAt) : null; + if ( + !updatedAt || + Number.isNaN(updatedAt.getTime()) || + !decoded._id || + !Types.ObjectId.isValid(decoded._id) + ) { + throw new Error('Invalid artifact cursor'); + } + return { updatedAt, _id: new Types.ObjectId(decoded._id) }; +} + +function toIso(value: Date | undefined): string { + return (value ?? new Date()).toISOString(); +} + +function toIsoOptional(value: Date | undefined): string | undefined { + return value ? value.toISOString() : undefined; +} + +function serializeApp(app: IArtifactApp): TArtifactApp { + return { + id: app._id.toString(), + artifactAppId: app.artifactAppId, + tenantId: app.tenantId, + title: app.title, + description: app.description, + icon: app.icon, + category: app.category, + tags: app.tags, + createdBy: app.createdBy, + activeVersionId: app.activeVersionId, + latestVersionNumber: app.latestVersionNumber, + status: app.status, + visibility: app.visibility, + allowEmbed: app.allowEmbed, + allowFork: app.allowFork, + allowAnonymousView: app.allowAnonymousView, + toolPolicy: { + enabled: app.toolPolicy.enabled, + allowedServers: app.toolPolicy.allowedServers, + allowedTools: app.toolPolicy.allowedTools, + requireConfirmationForWrites: app.toolPolicy.requireConfirmationForWrites, + }, + marketplace: { + listed: app.marketplace.listed, + featured: app.marketplace.featured, + summary: app.marketplace.summary, + riskClass: app.marketplace.riskClass, + costClass: app.marketplace.costClass, + }, + sourceMetadata: app.sourceMetadata + ? { + conversationId: app.sourceMetadata.conversationId, + messageId: app.sourceMetadata.messageId, + originalArtifactId: app.sourceMetadata.originalArtifactId, + sourceKey: app.sourceMetadata.sourceKey, + } + : undefined, + review: app.review + ? { + submittedAt: toIsoOptional(app.review.submittedAt), + submittedBy: app.review.submittedBy, + reviewedAt: toIsoOptional(app.review.reviewedAt), + reviewedBy: app.review.reviewedBy, + result: app.review.result, + comment: app.review.comment, + } + : undefined, + createdAt: toIso(app.createdAt), + updatedAt: toIso(app.updatedAt), + archivedAt: toIsoOptional(app.archivedAt), + }; +} + +function serializeVersion(version: IArtifactVersion): TArtifactVersion { + const runtimeConfig = version.runtimeConfig ?? {}; + return { + artifactVersionId: version.artifactVersionId, + artifactAppId: version.artifactAppId, + tenantId: version.tenantId, + versionNumber: version.versionNumber, + versionLabel: version.versionLabel, + changelog: version.changelog, + artifactType: version.artifactType, + sourceSnapshot: version.sourceSnapshot, + runtimeConfig: { + dependencies: runtimeConfig.dependencies, + entryPoint: runtimeConfig.entryPoint, + renderMode: runtimeConfig.renderMode, + }, + integrity: { + sourceHash: version.integrity.sourceHash, + schemaVersion: version.integrity.schemaVersion, + }, + createdBy: version.createdBy, + createdAt: toIso(version.createdAt), + publication: { + state: version.publication.state, + releasedBy: version.publication.releasedBy, + releasedAt: toIsoOptional(version.publication.releasedAt), + }, + }; +} + +function requireUser(req: ServerRequest, res: Response): ServerRequest['user'] | null { + const user = req.user; + if (!user || !user.id) { + res.status(401).json({ error: 'Authentication required' }); + return null; + } + return user; +} + +/** + * Factory for the typed Express handlers served at `/api/artifact-apps`. + * The legacy route file passes in concrete deps from `~/models` and + * `PermissionService`; §8.7 mandates the server derives actor identity and + * tenant strictly from the authenticated session, never from the request body. + */ +export function createArtifactAppHandlers(deps: ArtifactAppHandlersDeps): { + publish: (req: ServerRequest, res: Response) => Promise; + sync: (req: ServerRequest, res: Response) => Promise; + list: (req: ServerRequest, res: Response) => Promise; + getBySource: (req: ServerRequest, res: Response) => Promise; + get: (req: ServerRequest, res: Response) => Promise; + update: (req: ServerRequest, res: Response) => Promise; + remove: (req: ServerRequest, res: Response) => Promise; + listVersions: (req: ServerRequest, res: Response) => Promise; + getVersion: (req: ServerRequest, res: Response) => Promise; + createVersion: (req: ServerRequest, res: Response) => Promise; + releaseVersion: (req: ServerRequest, res: Response) => Promise; + activateVersion: (req: ServerRequest, res: Response) => Promise; + withdrawVersion: (req: ServerRequest, res: Response) => Promise; +} { + const { + createArtifactAppWithVersion, + syncArtifactAppWithVersion, + getArtifactAppByAppId, + getArtifactAppBySource, + listArtifactApps, + updateArtifactApp, + deleteArtifactApp, + getArtifactVersion, + listArtifactVersions, + createArtifactVersion, + releaseArtifactVersion, + activateArtifactVersion, + withdrawArtifactVersion, + getResourcePermissionsMap, + grantPermission, + recordAuditEntry, + } = deps; + + function audit(input: RecordAuditEntryInput): void { + recordAuditEntry(input).catch((err) => + logger.error(`[artifactApps] audit write failed for ${input.action}`, err), + ); + } + + function toVersionInput( + artifact: { type: ArtifactRuntimeType; content: string; runtimeConfig?: unknown }, + label: string | undefined, + changelog: string | undefined, + createdBy: string, + ): CreateArtifactVersionInput { + return { + artifactType: artifact.type, + sourceSnapshot: artifact.content, + runtimeConfig: artifact.runtimeConfig as CreateArtifactVersionInput['runtimeConfig'], + versionLabel: label, + changelog, + createdBy, + }; + } + + async function publish(req: ServerRequest, res: Response) { + try { + const user = requireUser(req, res); + if (!user) { + return res as Response; + } + + const parsed = publishArtifactAppSchema.safeParse(req.body); + if (!parsed.success) { + return res.status(400).json({ error: 'Validation failed', issues: parsed.error.issues }); + } + const data = parsed.data; + + const userId = user.id as string; + const input: CreateArtifactAppInput = { + tenantId: user.tenantId, + createdBy: userId, + title: data.title, + description: data.description, + icon: data.icon, + category: data.category, + tags: data.tags, + visibility: data.visibility, + allowEmbed: data.allowEmbed, + allowFork: data.allowFork, + allowAnonymousView: data.allowAnonymousView, + toolPolicy: data.toolPolicy, + marketplace: data.marketplace, + sourceMetadata: data.source, + version: toVersionInput(data.artifact, data.versionLabel, data.changelog, userId), + }; + + const { app, version } = await createArtifactAppWithVersion(input); + + try { + await grantPermission({ + principalType: PrincipalType.USER, + principalId: userId, + resourceType: ResourceType.ARTIFACT_APP, + resourceId: app._id as Types.ObjectId, + accessRoleId: AccessRoleIds.ARTIFACT_APP_OWNER, + grantedBy: userId, + }); + } catch (permissionError) { + logger.error( + `[POST /artifact-apps] Failed to grant owner permission for ${app.artifactAppId}, rolling back:`, + permissionError, + ); + try { + await deleteArtifactApp({ artifactAppId: app.artifactAppId }); + } catch (rollbackError) { + logger.error( + `[POST /artifact-apps] Compensating delete failed for ${app.artifactAppId}:`, + rollbackError, + ); + } + return res.status(500).json({ error: 'Failed to initialize artifact app permissions' }); + } + + audit({ + tenantId: user.tenantId, + action: 'artifact_app.created', + actor: { type: 'user', id: userId, name: user.name ?? user.username ?? userId }, + target: { type: ResourceType.ARTIFACT_APP, id: app.artifactAppId, name: app.title }, + metadata: { versionNumber: version.versionNumber }, + }); + + return res.status(201).json({ app: serializeApp(app), version: serializeVersion(version) }); + } catch (error) { + logger.error('[POST /artifact-apps] Error publishing artifact app', error); + return res.status(500).json({ error: 'Error publishing artifact app' }); + } + } + + async function sync(req: ServerRequest, res: Response) { + try { + const user = requireUser(req, res); + if (!user) { + return res as Response; + } + const parsed = syncArtifactAppSchema.safeParse(req.body); + if (!parsed.success) { + return res.status(400).json({ error: 'Validation failed', issues: parsed.error.issues }); + } + + const userId = user.id as string; + const data = parsed.data; + const result = await syncArtifactAppWithVersion({ + tenantId: user.tenantId, + createdBy: userId, + title: data.title, + visibility: 'private', + marketplace: { listed: true }, + sourceMetadata: data.source, + version: toVersionInput(data.artifact, undefined, undefined, userId), + }); + + try { + await grantPermission({ + principalType: PrincipalType.USER, + principalId: userId, + resourceType: ResourceType.ARTIFACT_APP, + resourceId: result.app._id as Types.ObjectId, + accessRoleId: AccessRoleIds.ARTIFACT_APP_OWNER, + grantedBy: userId, + }); + } catch (permissionError) { + logger.error( + `[POST /artifact-apps/sync] Failed to ensure owner permission for ${result.app.artifactAppId}:`, + permissionError, + ); + return res.status(500).json({ error: 'Failed to initialize artifact permissions' }); + } + + if (result.created || result.versionCreated) { + audit({ + tenantId: user.tenantId, + action: result.created ? 'artifact_app.created' : 'artifact_version.created', + actor: { type: 'user', id: userId, name: user.name ?? user.username ?? userId }, + target: { + type: ResourceType.ARTIFACT_APP, + id: result.app.artifactAppId, + name: result.app.title, + }, + metadata: { versionNumber: result.version.versionNumber, automatic: true }, + }); + } + + return res.status(result.created ? 201 : 200).json({ + app: serializeApp(result.app), + version: serializeVersion(result.version), + created: result.created, + versionCreated: result.versionCreated, + }); + } catch (error) { + logger.error('[POST /artifact-apps/sync] Error syncing artifact', error); + return res.status(500).json({ error: 'Error syncing artifact' }); + } + } + + async function list(req: ServerRequest, res: Response) { + try { + const user = requireUser(req, res); + if (!user) { + return res as Response; + } + const parsed = artifactAppListRequestSchema.safeParse(req.query); + if (!parsed.success) { + return res.status(400).json({ error: 'Invalid artifact list request' }); + } + let scanCursor: ArtifactAppListCursor | undefined; + try { + scanCursor = decodeListCursor(parsed.data.cursor); + } catch { + return res.status(400).json({ error: 'Invalid artifact cursor' }); + } + const userId = user.id as string; + let ownershipFilter: FilterQuery = {}; + if (parsed.data.scope === 'personal') { + ownershipFilter = { createdBy: userId }; + } else if (parsed.data.scope === 'shared') { + ownershipFilter = { createdBy: { $ne: userId } }; + } + const accessibleApps: IArtifactApp[] = []; + let exhausted = false; + + for ( + let batch = 0; + batch < ARTIFACT_APP_MAX_SCAN_BATCHES && accessibleApps.length <= parsed.data.limit; + batch++ + ) { + const candidates = await listArtifactApps(ownershipFilter, { + cursor: scanCursor, + limit: ARTIFACT_APP_SCAN_BATCH_SIZE, + }); + if (candidates.length === 0) { + exhausted = true; + break; + } + + const permissions = await getResourcePermissionsMap({ + userId, + role: user.role, + resourceType: ResourceType.ARTIFACT_APP, + resourceIds: candidates.map((app) => app._id), + }); + for (const app of candidates) { + const permissionBits = permissions.get(app._id.toString()) ?? 0; + if ((permissionBits & PermissionBits.VIEW) === PermissionBits.VIEW) { + accessibleApps.push(app); + if (accessibleApps.length > parsed.data.limit) { + break; + } + } + } + + const lastCandidate = candidates[candidates.length - 1]; + if (lastCandidate) { + scanCursor = { updatedAt: lastCandidate.updatedAt, _id: lastCandidate._id }; + } + if (candidates.length < ARTIFACT_APP_SCAN_BATCH_SIZE) { + exhausted = true; + break; + } + } + + const apps = accessibleApps.slice(0, parsed.data.limit); + const hasMore = accessibleApps.length > parsed.data.limit || !exhausted; + const cursorApp = + accessibleApps.length > parsed.data.limit ? apps[apps.length - 1] : undefined; + const cursorSource = cursorApp ?? scanCursor; + const after = hasMore && cursorSource ? encodeListCursor(cursorSource) : null; + return res.status(200).json({ + apps: apps.map(serializeApp), + has_more: hasMore, + after, + }); + } catch (error) { + logger.error('[GET /artifact-apps] Error listing artifact apps', error); + return res.status(500).json({ error: 'Error listing artifact apps' }); + } + } + + async function getBySource(req: ServerRequest, res: Response) { + try { + const user = requireUser(req, res); + if (!user) { + return res as Response; + } + const conversationId = + typeof req.query.conversationId === 'string' ? req.query.conversationId : ''; + const sourceKey = typeof req.query.sourceKey === 'string' ? req.query.sourceKey : ''; + if (!conversationId || !sourceKey) { + return res.status(400).json({ error: 'conversationId and sourceKey are required' }); + } + const app = await getArtifactAppBySource({ + tenantId: user.tenantId, + createdBy: user.id as string, + conversationId, + sourceKey, + }); + if (!app) { + return res.status(404).json({ error: 'Artifact not found' }); + } + const version = app.activeVersionId + ? await getArtifactVersion({ + artifactAppId: app.artifactAppId, + artifactVersionId: app.activeVersionId, + }) + : null; + return res.status(200).json({ + app: serializeApp(app), + version: version ? serializeVersion(version) : null, + }); + } catch (error) { + logger.error('[GET /artifact-apps/source] Error fetching artifact', error); + return res.status(500).json({ error: 'Error fetching artifact' }); + } + } + + async function get(req: ServerRequest, res: Response) { + try { + const { id } = req.params as { id: string }; + const app = await getArtifactAppByAppId({ artifactAppId: id }); + if (!app) { + return res.status(404).json({ error: 'Artifact app not found' }); + } + const version = app.activeVersionId + ? await getArtifactVersion({ + artifactAppId: app.artifactAppId, + artifactVersionId: app.activeVersionId, + }) + : null; + return res.status(200).json({ + app: serializeApp(app), + version: version ? serializeVersion(version) : null, + }); + } catch (error) { + logger.error('[GET /artifact-apps/:id] Error fetching artifact app', error); + return res.status(500).json({ error: 'Error fetching artifact app' }); + } + } + + async function update(req: ServerRequest, res: Response) { + try { + const user = requireUser(req, res); + if (!user) { + return res as Response; + } + const { id } = req.params as { id: string }; + const parsed = updateArtifactAppSchema.safeParse(req.body); + if (!parsed.success) { + return res.status(400).json({ error: 'Validation failed', issues: parsed.error.issues }); + } + if (Object.keys(parsed.data).length === 0) { + return res.status(400).json({ error: 'At least one field must be provided for update' }); + } + + const updated = await updateArtifactApp( + { artifactAppId: id }, + parsed.data as Partial, + ); + if (!updated) { + return res.status(404).json({ error: 'Artifact app not found' }); + } + audit({ + tenantId: user.tenantId, + action: 'artifact_app.updated', + actor: { type: 'user', id: user.id as string, name: user.name ?? user.username ?? '' }, + target: { type: ResourceType.ARTIFACT_APP, id: updated.artifactAppId, name: updated.title }, + }); + return res.status(200).json(serializeApp(updated)); + } catch (error) { + logger.error('[PATCH /artifact-apps/:id] Error updating artifact app', error); + return res.status(500).json({ error: 'Error updating artifact app' }); + } + } + + async function remove(req: ServerRequest, res: Response) { + try { + const user = requireUser(req, res); + if (!user) { + return res as Response; + } + const { id } = req.params as { id: string }; + const result = await deleteArtifactApp({ artifactAppId: id }); + if (!result.deletedApp) { + return res.status(404).json({ error: 'Artifact app not found' }); + } + audit({ + tenantId: user.tenantId, + action: 'artifact_app.archived', + actor: { type: 'user', id: user.id as string, name: user.name ?? user.username ?? '' }, + target: { type: ResourceType.ARTIFACT_APP, id }, + metadata: { deletedVersions: result.deletedVersions }, + }); + return res.status(200).json({ success: true }); + } catch (error) { + logger.error('[DELETE /artifact-apps/:id] Error deleting artifact app', error); + return res.status(500).json({ error: 'Error deleting artifact app' }); + } + } + + async function listVersions(req: ServerRequest, res: Response) { + try { + const { id } = req.params as { id: string }; + const versions = await listArtifactVersions({ artifactAppId: id }); + return res.status(200).json({ versions: versions.map(serializeVersion) }); + } catch (error) { + logger.error('[GET /artifact-apps/:id/versions] Error listing versions', error); + return res.status(500).json({ error: 'Error listing artifact app versions' }); + } + } + + async function getVersion(req: ServerRequest, res: Response) { + try { + const { id, versionId } = req.params as { id: string; versionId: string }; + const version = await getArtifactVersion({ + artifactAppId: id, + artifactVersionId: versionId, + }); + if (!version) { + return res.status(404).json({ error: 'Artifact version not found' }); + } + return res.status(200).json(serializeVersion(version)); + } catch (error) { + logger.error('[GET /artifact-apps/:id/versions/:versionId] Error fetching version', error); + return res.status(500).json({ error: 'Error fetching artifact app version' }); + } + } + + async function createVersion(req: ServerRequest, res: Response) { + try { + const user = requireUser(req, res); + if (!user) { + return res as Response; + } + const { id } = req.params as { id: string }; + const parsed = createArtifactVersionSchema.safeParse(req.body); + if (!parsed.success) { + return res.status(400).json({ error: 'Validation failed', issues: parsed.error.issues }); + } + const data = parsed.data; + let version: IArtifactVersion; + try { + version = await createArtifactVersion( + { artifactAppId: id }, + toVersionInput(data.artifact, data.versionLabel, data.changelog, user.id as string), + ); + } catch (error) { + if ((error as Error).message?.includes('not found')) { + return res.status(404).json({ error: 'Artifact app not found' }); + } + throw error; + } + audit({ + tenantId: user.tenantId, + action: 'artifact_version.created', + actor: { type: 'user', id: user.id as string, name: user.name ?? user.username ?? '' }, + target: { type: ResourceType.ARTIFACT_APP, id, name: version.artifactVersionId }, + metadata: { versionNumber: version.versionNumber }, + }); + return res.status(201).json(serializeVersion(version)); + } catch (error) { + logger.error('[POST /artifact-apps/:id/versions] Error creating version', error); + return res.status(500).json({ error: 'Error creating artifact app version' }); + } + } + + async function releaseVersion(req: ServerRequest, res: Response) { + try { + const user = requireUser(req, res); + if (!user) { + return res as Response; + } + const { id, versionId } = req.params as { id: string; versionId: string }; + const version = await releaseArtifactVersion( + { artifactAppId: id, artifactVersionId: versionId }, + user.id as string, + ); + if (!version) { + return res.status(404).json({ error: 'Artifact version not found' }); + } + audit({ + tenantId: user.tenantId, + action: 'artifact_version.released', + actor: { type: 'user', id: user.id as string, name: user.name ?? user.username ?? '' }, + target: { type: ResourceType.ARTIFACT_APP, id, name: version.artifactVersionId }, + metadata: { versionNumber: version.versionNumber }, + }); + return res.status(200).json(serializeVersion(version)); + } catch (error) { + logger.error('[POST /artifact-apps/:id/versions/:versionId/release] Error', error); + return res.status(500).json({ error: 'Error releasing artifact app version' }); + } + } + + async function activateVersion(req: ServerRequest, res: Response) { + try { + const user = requireUser(req, res); + if (!user) { + return res as Response; + } + const { id, versionId } = req.params as { id: string; versionId: string }; + let result: ArtifactAppWithVersion | null; + try { + result = await activateArtifactVersion({ + artifactAppId: id, + artifactVersionId: versionId, + }); + } catch (error) { + if ((error as Error).message?.includes('released')) { + return res.status(409).json({ error: 'Only released versions can be activated' }); + } + throw error; + } + if (!result) { + return res.status(404).json({ error: 'Artifact version not found' }); + } + audit({ + tenantId: user.tenantId, + action: 'artifact_version.activated', + actor: { type: 'user', id: user.id as string, name: user.name ?? user.username ?? '' }, + target: { type: ResourceType.ARTIFACT_APP, id, name: result.version.artifactVersionId }, + metadata: { versionNumber: result.version.versionNumber }, + }); + return res.status(200).json(serializeApp(result.app)); + } catch (error) { + logger.error('[POST /artifact-apps/:id/versions/:versionId/activate] Error', error); + return res.status(500).json({ error: 'Error activating artifact app version' }); + } + } + + async function withdrawVersion(req: ServerRequest, res: Response) { + try { + const user = requireUser(req, res); + if (!user) { + return res as Response; + } + const { id, versionId } = req.params as { id: string; versionId: string }; + const version = await withdrawArtifactVersion({ + artifactAppId: id, + artifactVersionId: versionId, + }); + if (!version) { + return res.status(404).json({ error: 'Artifact version not found' }); + } + audit({ + tenantId: user.tenantId, + action: 'artifact_version.withdrawn', + actor: { type: 'user', id: user.id as string, name: user.name ?? user.username ?? '' }, + target: { type: ResourceType.ARTIFACT_APP, id, name: version.artifactVersionId }, + metadata: { versionNumber: version.versionNumber }, + }); + return res.status(200).json(serializeVersion(version)); + } catch (error) { + logger.error('[POST /artifact-apps/:id/versions/:versionId/withdraw] Error', error); + return res.status(500).json({ error: 'Error withdrawing artifact app version' }); + } + } + + return { + publish, + sync, + list, + getBySource, + get, + update, + remove, + listVersions, + getVersion, + createVersion, + releaseVersion, + activateVersion, + withdrawVersion, + }; +} + +export type ArtifactAppHandlers = ReturnType; diff --git a/packages/api/src/artifactApps/index.ts b/packages/api/src/artifactApps/index.ts new file mode 100644 index 00000000000..b7a0bddc22e --- /dev/null +++ b/packages/api/src/artifactApps/index.ts @@ -0,0 +1,2 @@ +export { createArtifactAppHandlers } from './handlers'; +export type { ArtifactAppHandlers, ArtifactAppHandlersDeps } from './handlers'; diff --git a/packages/api/src/index.ts b/packages/api/src/index.ts index a1bb4dd7890..88a8d9ae297 100644 --- a/packages/api/src/index.ts +++ b/packages/api/src/index.ts @@ -86,6 +86,8 @@ export * from './favorites'; export * from './user'; /* Agent Plugins */ export * from './plugins'; +/* Artifact Apps */ +export * from './artifactApps'; /* Endpoints */ export * from './endpoints'; /* Files */ diff --git a/packages/api/src/middleware/share.ts b/packages/api/src/middleware/share.ts index bd7e05530ad..52fe8b9b1cc 100644 --- a/packages/api/src/middleware/share.ts +++ b/packages/api/src/middleware/share.ts @@ -42,11 +42,12 @@ type ShareMiddleware = ( next: NextFunction, ) => Promise; -const resourceToPermissionType: Record = { +const resourceToPermissionType: Partial> = { [ResourceType.AGENT]: PermissionTypes.AGENTS, /** Environment sharing is capability-gated by MANAGE_CODE_ENVIRONMENTS. * RUN_CODE intentionally has no public-sharing bit, so public grants remain denied. */ [ResourceType.CODE_ENVIRONMENT]: PermissionTypes.RUN_CODE, + [ResourceType.ARTIFACT_APP]: PermissionTypes.ARTIFACTS, [ResourceType.PROMPTGROUP]: PermissionTypes.PROMPTS, [ResourceType.MCPSERVER]: PermissionTypes.MCP_SERVERS, [ResourceType.REMOTE_AGENT]: PermissionTypes.REMOTE_AGENTS, diff --git a/packages/data-provider/src/accessPermissions.ts b/packages/data-provider/src/accessPermissions.ts index b59bb7a8c5c..f5052c96090 100644 --- a/packages/data-provider/src/accessPermissions.ts +++ b/packages/data-provider/src/accessPermissions.ts @@ -50,6 +50,7 @@ export enum ResourceType { REMOTE_AGENT = 'remoteAgent', SKILL = 'skill', SHARED_LINK = 'sharedLink', + ARTIFACT_APP = 'artifactApp', } /** @@ -92,6 +93,9 @@ export enum AccessRoleIds { SKILL_OWNER = 'skill_owner', SHARED_LINK_VIEWER = 'sharedLink_viewer', SHARED_LINK_OWNER = 'sharedLink_owner', + ARTIFACT_APP_VIEWER = 'artifactApp_viewer', + ARTIFACT_APP_EDITOR = 'artifactApp_editor', + ARTIFACT_APP_OWNER = 'artifactApp_owner', } // ===== ZOD SCHEMAS ===== @@ -336,6 +340,7 @@ export function accessRoleToPermBits(accessRoleId: string): number { case AccessRoleIds.REMOTE_AGENT_VIEWER: case AccessRoleIds.SKILL_VIEWER: case AccessRoleIds.SHARED_LINK_VIEWER: + case AccessRoleIds.ARTIFACT_APP_VIEWER: return PermissionBits.VIEW; case AccessRoleIds.AGENT_EDITOR: case AccessRoleIds.CODE_ENVIRONMENT_EDITOR: @@ -343,6 +348,7 @@ export function accessRoleToPermBits(accessRoleId: string): number { case AccessRoleIds.MCPSERVER_EDITOR: case AccessRoleIds.REMOTE_AGENT_EDITOR: case AccessRoleIds.SKILL_EDITOR: + case AccessRoleIds.ARTIFACT_APP_EDITOR: return PermissionBits.VIEW | PermissionBits.EDIT; case AccessRoleIds.AGENT_OWNER: case AccessRoleIds.CODE_ENVIRONMENT_OWNER: @@ -351,6 +357,7 @@ export function accessRoleToPermBits(accessRoleId: string): number { case AccessRoleIds.REMOTE_AGENT_OWNER: case AccessRoleIds.SKILL_OWNER: case AccessRoleIds.SHARED_LINK_OWNER: + case AccessRoleIds.ARTIFACT_APP_OWNER: return ( PermissionBits.VIEW | PermissionBits.EDIT | PermissionBits.DELETE | PermissionBits.SHARE ); diff --git a/packages/data-provider/src/api-endpoints.ts b/packages/data-provider/src/api-endpoints.ts index da19a4476c4..00a34b5d715 100644 --- a/packages/data-provider/src/api-endpoints.ts +++ b/packages/data-provider/src/api-endpoints.ts @@ -531,6 +531,7 @@ export const getRole = (roleName: string) => `${roles()}/${encodeURIComponent(ro export const updatePromptPermissions = (roleName: string) => `${getRole(roleName)}/prompts`; export const updateMemoryPermissions = (roleName: string) => `${getRole(roleName)}/memories`; export const updateAgentPermissions = (roleName: string) => `${getRole(roleName)}/agents`; +export const updateArtifactPermissions = (roleName: string) => `${getRole(roleName)}/artifacts`; export const updatePeoplePickerPermissions = (roleName: string) => `${getRole(roleName)}/people-picker`; export const updateMCPServersPermissions = (roleName: string) => `${getRole(roleName)}/mcp-servers`; @@ -610,3 +611,33 @@ export const getAllEffectivePermissions = (resourceType: ResourceType) => // SharePoint Graph API Token export const graphToken = (scopes: string) => `${BASE_URL}/api/auth/graph-token?scopes=${encodeURIComponent(scopes)}`; + +// Artifact Apps +export const artifactAppsRoot = `${BASE_URL}/api/artifact-apps`; + +export const artifactApps = () => artifactAppsRoot; + +export const syncArtifactApp = () => `${artifactAppsRoot}/sync`; + +export const artifactAppBySource = (conversationId: string, sourceKey: string) => { + const params = new URLSearchParams({ conversationId, sourceKey }); + return `${artifactAppsRoot}/source?${params.toString()}`; +}; + +export const artifactAppById = (artifactAppId: string) => + `${artifactAppsRoot}/${encodeURIComponent(artifactAppId)}`; + +export const artifactAppVersions = (artifactAppId: string) => + `${artifactAppById(artifactAppId)}/versions`; + +export const artifactAppVersionById = (artifactAppId: string, versionId: string) => + `${artifactAppVersions(artifactAppId)}/${encodeURIComponent(versionId)}`; + +export const artifactAppVersionRelease = (artifactAppId: string, versionId: string) => + `${artifactAppVersionById(artifactAppId, versionId)}/release`; + +export const artifactAppVersionActivate = (artifactAppId: string, versionId: string) => + `${artifactAppVersionById(artifactAppId, versionId)}/activate`; + +export const artifactAppVersionWithdraw = (artifactAppId: string, versionId: string) => + `${artifactAppVersionById(artifactAppId, versionId)}/withdraw`; diff --git a/packages/data-provider/src/artifactApps.ts b/packages/data-provider/src/artifactApps.ts new file mode 100644 index 00000000000..5dde6c5c3a3 --- /dev/null +++ b/packages/data-provider/src/artifactApps.ts @@ -0,0 +1,234 @@ +import { z } from 'zod'; + +/** + * Shared types and Zod schemas for the Artifact Apps feature (PLAN.md §6, §8, §9). + * Consumed by both the backend (packages/api, data-schemas) and the client. + */ + +// ===== ENUMS ===== + +export const artifactAppStatusSchema = z.enum([ + 'draft', + 'pending_review', + 'published', + 'suspended', + 'archived', +]); +export type ArtifactAppStatus = z.infer; + +export const artifactAppVisibilitySchema = z.enum(['private', 'restricted', 'tenant', 'public']); +export type ArtifactAppVisibility = z.infer; + +export const artifactRuntimeTypeSchema = z.enum([ + 'react', + 'html', + 'mermaid', + 'markdown', + 'text', + 'code', + 'document', + 'spreadsheet', + 'presentation', +]); +export type ArtifactRuntimeType = z.infer; + +export const artifactAppListScopeSchema = z.enum(['personal', 'shared', 'all']); +export type ArtifactAppListScope = z.infer; + +export const artifactAppListRequestSchema = z.object({ + scope: artifactAppListScopeSchema.default('personal'), + limit: z.coerce.number().int().min(1).max(50).default(20), + cursor: z.string().min(1).optional(), +}); +export type TArtifactAppListRequest = z.infer; + +export const artifactVersionStateSchema = z.enum(['draft', 'released', 'withdrawn']); +export type ArtifactVersionState = z.infer; + +export const artifactRiskClassSchema = z.enum(['none', 'read', 'write', 'external']); +export type ArtifactRiskClass = z.infer; + +export const artifactCostClassSchema = z.enum(['free', 'low', 'medium', 'high']); +export type ArtifactCostClass = z.infer; + +// ===== NESTED SCHEMAS ===== + +export const artifactToolPolicySchema = z.object({ + enabled: z.boolean().default(false), + allowedServers: z.array(z.string()).default([]), + allowedTools: z.array(z.string()).default([]), + requireConfirmationForWrites: z.boolean().default(true), +}); +export type ArtifactToolPolicy = z.infer; + +export const artifactMarketplaceSchema = z.object({ + listed: z.boolean().default(false), + featured: z.boolean().default(false), + summary: z.string().optional(), + riskClass: artifactRiskClassSchema.default('none'), + costClass: artifactCostClassSchema.default('free'), +}); +export type ArtifactMarketplace = z.infer; + +export const artifactSourceMetadataSchema = z.object({ + conversationId: z.string().optional(), + messageId: z.string().optional(), + originalArtifactId: z.string().optional(), + sourceKey: z.string().optional(), +}); +export type ArtifactSourceMetadata = z.infer; + +export const artifactRuntimeConfigSchema = z.object({ + dependencies: z.record(z.string()).optional(), + entryPoint: z.string().optional(), + renderMode: z.string().optional(), +}); +export type ArtifactRuntimeConfig = z.infer; + +// ===== REQUEST SCHEMAS ===== + +/** The already-extracted artifact payload sent from the client at publish time. */ +export const artifactSnapshotInputSchema = z.object({ + type: artifactRuntimeTypeSchema, + content: z.string().min(1), + title: z.string().optional(), + language: z.string().optional(), + runtimeConfig: artifactRuntimeConfigSchema.optional(), +}); +export type ArtifactSnapshotInput = z.infer; + +/** POST /api/artifact-apps */ +export const publishArtifactAppSchema = z.object({ + title: z.string().min(1).max(200), + description: z.string().max(2000).optional(), + icon: z.string().optional(), + category: z.string().optional(), + tags: z.array(z.string()).optional(), + visibility: artifactAppVisibilitySchema.default('private'), + allowEmbed: z.boolean().optional(), + allowFork: z.boolean().optional(), + allowAnonymousView: z.boolean().optional(), + toolPolicy: artifactToolPolicySchema.partial().optional(), + marketplace: artifactMarketplaceSchema.partial().optional(), + artifact: artifactSnapshotInputSchema, + source: artifactSourceMetadataSchema.optional(), + changelog: z.string().optional(), + versionLabel: z.string().optional(), +}); +export type TPublishArtifactAppRequest = z.infer; + +/** POST /api/artifact-apps/sync — idempotently register an artifact in the catalog. */ +export const syncArtifactAppSchema = z.object({ + title: z.string().min(1).max(200), + artifact: artifactSnapshotInputSchema, + source: artifactSourceMetadataSchema.extend({ + conversationId: z.string().min(1), + sourceKey: z.string().min(1).max(500), + }), +}); +export type TSyncArtifactAppRequest = z.infer; + +/** PATCH /api/artifact-apps/:id */ +export const updateArtifactAppSchema = z + .object({ + title: z.string().min(1).max(200), + description: z.string().max(2000), + icon: z.string(), + category: z.string(), + tags: z.array(z.string()), + visibility: artifactAppVisibilitySchema, + allowEmbed: z.boolean(), + allowFork: z.boolean(), + allowAnonymousView: z.boolean(), + toolPolicy: artifactToolPolicySchema.partial(), + marketplace: artifactMarketplaceSchema.partial(), + }) + .partial(); +export type TUpdateArtifactAppRequest = z.infer; + +/** POST /api/artifact-apps/:id/versions */ +export const createArtifactVersionSchema = z.object({ + artifact: artifactSnapshotInputSchema, + changelog: z.string().optional(), + versionLabel: z.string().optional(), +}); +export type TCreateArtifactVersionRequest = z.infer; + +// ===== RESPONSE TYPES (client-facing; dates serialized as ISO strings) ===== + +export interface TArtifactApp { + /** MongoDB resource id used by the generic ACL endpoints. */ + id: string; + artifactAppId: string; + tenantId?: string; + title: string; + description?: string; + icon?: string; + category?: string; + tags?: string[]; + createdBy: string; + activeVersionId?: string; + latestVersionNumber: number; + status: ArtifactAppStatus; + visibility: ArtifactAppVisibility; + allowEmbed: boolean; + allowFork: boolean; + allowAnonymousView: boolean; + toolPolicy: ArtifactToolPolicy; + marketplace: ArtifactMarketplace; + sourceMetadata?: ArtifactSourceMetadata; + review?: { + submittedAt?: string; + submittedBy?: string; + reviewedAt?: string; + reviewedBy?: string; + result?: 'approved' | 'rejected'; + comment?: string; + }; + createdAt: string; + updatedAt: string; + archivedAt?: string; +} + +export interface TArtifactVersion { + artifactVersionId: string; + artifactAppId: string; + tenantId?: string; + versionNumber: number; + versionLabel?: string; + changelog?: string; + artifactType: ArtifactRuntimeType; + sourceSnapshot: string; + runtimeConfig: ArtifactRuntimeConfig; + integrity: { + sourceHash: string; + schemaVersion: number; + }; + createdBy: string; + createdAt: string; + publication: { + state: ArtifactVersionState; + releasedBy?: string; + releasedAt?: string; + }; +} + +export interface TArtifactAppWithVersion { + app: TArtifactApp; + version: TArtifactVersion | null; +} + +export interface TSyncArtifactAppResponse extends TArtifactAppWithVersion { + created: boolean; + versionCreated: boolean; +} + +export interface TArtifactAppList { + apps: TArtifactApp[]; + has_more: boolean; + after: string | null; +} + +export interface TArtifactVersionList { + versions: TArtifactVersion[]; +} diff --git a/packages/data-provider/src/config.ts b/packages/data-provider/src/config.ts index 151c66ba3bf..eee458bfedc 100644 --- a/packages/data-provider/src/config.ts +++ b/packages/data-provider/src/config.ts @@ -2044,6 +2044,17 @@ export const interfaceSchema = z }), ]) .optional(), + artifacts: z + .union([ + z.boolean(), + z.object({ + use: z.boolean().optional(), + create: z.boolean().optional(), + share: z.boolean().optional(), + public: z.boolean().optional(), + }), + ]) + .optional(), temporaryChat: z.boolean().optional(), temporaryChatRetention: z.number().min(1).max(8760).optional(), generalChatRetention: z.number().min(1).max(8760).optional(), @@ -2155,6 +2166,12 @@ export const interfaceSchema = z share: false, public: false, }, + artifacts: { + use: true, + create: true, + share: true, + public: false, + }, temporaryChat: true, autoSubmitFromUrl: true, runCode: true, diff --git a/packages/data-provider/src/data-service.ts b/packages/data-provider/src/data-service.ts index cf5ade19a1c..f10052ff1de 100644 --- a/packages/data-provider/src/data-service.ts +++ b/packages/data-provider/src/data-service.ts @@ -21,6 +21,7 @@ import * as m from './types/mutations'; import * as ag from './types/agents'; import * as q from './types/queries'; import * as sk from './types/skills'; +import * as aa from './artifactApps'; import * as f from './types/files'; import * as config from './config'; import request from './request'; @@ -1424,6 +1425,12 @@ export function updateAgentPermissions( return request.put(endpoints.updateAgentPermissions(variables.roleName), variables.updates); } +export function updateArtifactPermissions( + variables: m.UpdateArtifactPermVars, +): Promise { + return request.put(endpoints.updateArtifactPermissions(variables.roleName), variables.updates); +} + export function updateMemoryPermissions( variables: m.UpdateMemoryPermVars, ): Promise { @@ -1658,3 +1665,91 @@ export interface ActiveJobsResponse { export const getActiveJobs = (): Promise => { return request.get(endpoints.activeJobs()); }; + +/* Artifact Apps */ + +export function publishArtifactApp( + payload: aa.TPublishArtifactAppRequest, +): Promise { + return request.post(endpoints.artifactApps(), payload); +} + +export function syncArtifactApp( + payload: aa.TSyncArtifactAppRequest, +): Promise { + return request.post(endpoints.syncArtifactApp(), payload); +} + +export function listArtifactApps( + params: Partial = {}, +): Promise { + const query = new URLSearchParams({ + scope: params.scope ?? 'personal', + limit: String(params.limit ?? 20), + }); + if (params.cursor) { + query.set('cursor', params.cursor); + } + return request.get(`${endpoints.artifactApps()}?${query.toString()}`); +} + +export function getArtifactAppBySource( + conversationId: string, + sourceKey: string, +): Promise { + return request.get(endpoints.artifactAppBySource(conversationId, sourceKey)); +} + +export function getArtifactApp(artifactAppId: string): Promise { + return request.get(endpoints.artifactAppById(artifactAppId)); +} + +export function updateArtifactApp( + artifactAppId: string, + payload: aa.TUpdateArtifactAppRequest, +): Promise { + return request.patch(endpoints.artifactAppById(artifactAppId), payload); +} + +export function deleteArtifactApp(artifactAppId: string): Promise<{ success: boolean }> { + return request.delete(endpoints.artifactAppById(artifactAppId)); +} + +export function listArtifactAppVersions(artifactAppId: string): Promise { + return request.get(endpoints.artifactAppVersions(artifactAppId)); +} + +export function getArtifactAppVersion( + artifactAppId: string, + versionId: string, +): Promise { + return request.get(endpoints.artifactAppVersionById(artifactAppId, versionId)); +} + +export function createArtifactAppVersion( + artifactAppId: string, + payload: aa.TCreateArtifactVersionRequest, +): Promise { + return request.post(endpoints.artifactAppVersions(artifactAppId), payload); +} + +export function releaseArtifactAppVersion( + artifactAppId: string, + versionId: string, +): Promise { + return request.post(endpoints.artifactAppVersionRelease(artifactAppId, versionId), {}); +} + +export function activateArtifactAppVersion( + artifactAppId: string, + versionId: string, +): Promise { + return request.post(endpoints.artifactAppVersionActivate(artifactAppId, versionId), {}); +} + +export function withdrawArtifactAppVersion( + artifactAppId: string, + versionId: string, +): Promise { + return request.post(endpoints.artifactAppVersionWithdraw(artifactAppId, versionId), {}); +} diff --git a/packages/data-provider/src/index.ts b/packages/data-provider/src/index.ts index bcc234688b2..36ec79a9a82 100644 --- a/packages/data-provider/src/index.ts +++ b/packages/data-provider/src/index.ts @@ -47,6 +47,8 @@ export * from './types/subagents'; export * from './types/queuedTurns'; /* access permissions */ export * from './accessPermissions'; +/* artifact apps */ +export * from './artifactApps'; /* query/mutation keys */ export * from './keys'; /* api call helpers */ diff --git a/packages/data-provider/src/keys.ts b/packages/data-provider/src/keys.ts index d6e773d3312..bf8c7ddaf1e 100644 --- a/packages/data-provider/src/keys.ts +++ b/packages/data-provider/src/keys.ts @@ -31,6 +31,9 @@ export enum QueryKeys { assistant = 'assistant', agents = 'agents', agent = 'agent', + artifactApps = 'artifactApps', + artifactApp = 'artifactApp', + artifactAppVersions = 'artifactAppVersions', files = 'files', fileConfig = 'fileConfig', tools = 'tools', diff --git a/packages/data-provider/src/permissions.ts b/packages/data-provider/src/permissions.ts index deeedd57f4c..dafcfe85942 100644 --- a/packages/data-provider/src/permissions.ts +++ b/packages/data-provider/src/permissions.ts @@ -16,6 +16,8 @@ export enum PermissionTypes { * Type for Agent Permissions */ AGENTS = 'AGENTS', + /** Type for Artifact Catalog Permissions */ + ARTIFACTS = 'ARTIFACTS', /** * Type for Memory Permissions */ @@ -82,6 +84,7 @@ export enum PermissionTypes { export const PERMISSION_TYPE_INTERFACE_FIELDS: Record = { [PermissionTypes.PROMPTS]: 'prompts', [PermissionTypes.AGENTS]: 'agents', + [PermissionTypes.ARTIFACTS]: 'artifacts', [PermissionTypes.BOOKMARKS]: 'bookmarks', [PermissionTypes.MEMORIES]: 'memories', [PermissionTypes.MULTI_CONVO]: 'multiConvo', @@ -192,6 +195,14 @@ export const agentPermissionsSchema = z.object({ }); export type TAgentPermissions = z.infer; +export const artifactPermissionsSchema = z.object({ + [Permissions.USE]: z.boolean().default(true), + [Permissions.CREATE]: z.boolean().default(true), + [Permissions.SHARE]: z.boolean().default(true), + [Permissions.SHARE_PUBLIC]: z.boolean().default(false), +}); +export type TArtifactPermissions = z.infer; + export const multiConvoPermissionsSchema = z.object({ [Permissions.USE]: z.boolean().default(true), }); @@ -278,6 +289,7 @@ export const permissionsSchema = z.object({ [PermissionTypes.BOOKMARKS]: bookmarkPermissionsSchema, [PermissionTypes.MEMORIES]: memoryPermissionsSchema, [PermissionTypes.AGENTS]: agentPermissionsSchema, + [PermissionTypes.ARTIFACTS]: artifactPermissionsSchema, [PermissionTypes.MULTI_CONVO]: multiConvoPermissionsSchema, [PermissionTypes.TEMPORARY_CHAT]: temporaryChatPermissionsSchema, [PermissionTypes.RUN_CODE]: runCodePermissionsSchema, diff --git a/packages/data-provider/src/roles.spec.ts b/packages/data-provider/src/roles.spec.ts index f3b1068d0cf..d984ad394b7 100644 --- a/packages/data-provider/src/roles.spec.ts +++ b/packages/data-provider/src/roles.spec.ts @@ -10,7 +10,8 @@ const RESOURCE_MANAGEMENT_FIELDS: Permissions[] = [ /** * Permission types where CREATE/SHARE/SHARE_PUBLIC must default to false for USER. * MEMORIES is excluded: its CREATE/READ/UPDATE apply to the user's own private data. - * AGENTS/PROMPTS are excluded: CREATE=true is intentional (users own their agents/prompts). + * AGENTS/PROMPTS/ARTIFACTS are excluded: users own these resources and artifact + * sharing is a core catalog workflow. * Add new types here if they gate shared/multi-user resources. */ const RESOURCE_PERMISSION_TYPES: PermissionTypes[] = [ @@ -89,6 +90,7 @@ describe('roleDefaults', () => { permType === PermissionTypes.MEMORIES || permType === PermissionTypes.PROMPTS || permType === PermissionTypes.AGENTS || + permType === PermissionTypes.ARTIFACTS || permType === PermissionTypes.SKILLS || permType === PermissionTypes.SHARED_LINKS || permType === PermissionTypes.SCHEDULES; @@ -159,6 +161,20 @@ describe('roleDefaults', () => { }); }); + describe('ARTIFACTS permission defaults', () => { + it('lets users open the share dialog and share their artifacts with everyone', () => { + const userArtifacts = roleDefaults[SystemRoles.USER].permissions[ + PermissionTypes.ARTIFACTS + ] as Record; + expect(userArtifacts).toEqual({ + [Permissions.USE]: true, + [Permissions.CREATE]: true, + [Permissions.SHARE]: true, + [Permissions.SHARE_PUBLIC]: true, + }); + }); + }); + describe('MCP_SERVERS.CONFIGURE_OBO defaults', () => { it('grants ADMIN CONFIGURE_OBO by default', () => { const adminMcp = roleDefaults[SystemRoles.ADMIN].permissions[ diff --git a/packages/data-provider/src/roles.ts b/packages/data-provider/src/roles.ts index a4eda4eb7f4..0c42db0322d 100644 --- a/packages/data-provider/src/roles.ts +++ b/packages/data-provider/src/roles.ts @@ -4,6 +4,7 @@ import { PermissionTypes, permissionsSchema, agentPermissionsSchema, + artifactPermissionsSchema, promptPermissionsSchema, skillPermissionsSchema, memoryPermissionsSchema, @@ -68,6 +69,12 @@ const defaultRolesSchema = z.object({ [Permissions.SHARE]: z.boolean().default(true), [Permissions.SHARE_PUBLIC]: z.boolean().default(true), }), + [PermissionTypes.ARTIFACTS]: artifactPermissionsSchema.extend({ + [Permissions.USE]: z.boolean().default(true), + [Permissions.CREATE]: z.boolean().default(true), + [Permissions.SHARE]: z.boolean().default(true), + [Permissions.SHARE_PUBLIC]: z.boolean().default(true), + }), [PermissionTypes.MULTI_CONVO]: multiConvoPermissionsSchema.extend({ [Permissions.USE]: z.boolean().default(true), }), @@ -166,6 +173,12 @@ export const roleDefaults = defaultRolesSchema.parse({ [Permissions.SHARE]: true, [Permissions.SHARE_PUBLIC]: true, }, + [PermissionTypes.ARTIFACTS]: { + [Permissions.USE]: true, + [Permissions.CREATE]: true, + [Permissions.SHARE]: true, + [Permissions.SHARE_PUBLIC]: true, + }, [PermissionTypes.MULTI_CONVO]: { [Permissions.USE]: true, }, @@ -239,6 +252,12 @@ export const roleDefaults = defaultRolesSchema.parse({ [Permissions.SHARE]: false, [Permissions.SHARE_PUBLIC]: false, }, + [PermissionTypes.ARTIFACTS]: { + [Permissions.USE]: true, + [Permissions.CREATE]: true, + [Permissions.SHARE]: true, + [Permissions.SHARE_PUBLIC]: true, + }, [PermissionTypes.MULTI_CONVO]: {}, [PermissionTypes.TEMPORARY_CHAT]: {}, [PermissionTypes.RUN_CODE]: {}, diff --git a/packages/data-provider/src/types/mutations.ts b/packages/data-provider/src/types/mutations.ts index a99e5a45e79..ced16141ae3 100644 --- a/packages/data-provider/src/types/mutations.ts +++ b/packages/data-provider/src/types/mutations.ts @@ -297,6 +297,7 @@ export type UpdatePermVars = { export type UpdatePromptPermVars = UpdatePermVars; export type UpdateMemoryPermVars = UpdatePermVars; export type UpdateAgentPermVars = UpdatePermVars; +export type UpdateArtifactPermVars = UpdatePermVars; export type UpdatePeoplePickerPermVars = UpdatePermVars; export type UpdateMCPServersPermVars = UpdatePermVars; export type UpdateSkillPermVars = UpdatePermVars; @@ -360,6 +361,13 @@ export type UpdateAgentPermOptions = MutationOptions< types.TError | null | undefined >; +export type UpdateArtifactPermOptions = MutationOptions< + UpdatePermResponse, + UpdateArtifactPermVars, + unknown, + types.TError | null | undefined +>; + export type UpdatePeoplePickerPermOptions = MutationOptions< UpdatePermResponse, UpdatePeoplePickerPermVars, diff --git a/packages/data-schemas/src/admin/capabilities.ts b/packages/data-schemas/src/admin/capabilities.ts index c820255275c..03bf15e980e 100644 --- a/packages/data-schemas/src/admin/capabilities.ts +++ b/packages/data-schemas/src/admin/capabilities.ts @@ -37,6 +37,8 @@ export const SystemCapabilities = { MANAGE_SKILLS: 'manage:skills', READ_SHARED_LINKS: 'read:sharedlinks', MANAGE_SHARED_LINKS: 'manage:sharedlinks', + READ_ARTIFACT_APPS: 'read:artifactapps', + MANAGE_ARTIFACT_APPS: 'manage:artifactapps', /** Reserved — not yet enforced by any middleware. */ READ_ASSISTANTS: 'read:assistants', MANAGE_ASSISTANTS: 'manage:assistants', @@ -96,6 +98,7 @@ export const CapabilityImplications: Partial = { [ResourceType.REMOTE_AGENT]: SystemCapabilities.MANAGE_AGENTS, [ResourceType.SKILL]: SystemCapabilities.MANAGE_SKILLS, [ResourceType.SHARED_LINK]: SystemCapabilities.MANAGE_SHARED_LINKS, + [ResourceType.ARTIFACT_APP]: SystemCapabilities.MANAGE_ARTIFACT_APPS, }; /** @@ -258,6 +262,8 @@ export const CAPABILITY_CATEGORIES: CapabilityCategory[] = [ SystemCapabilities.MANAGE_CODE_ENVIRONMENTS, SystemCapabilities.MANAGE_SHARED_LINKS, SystemCapabilities.READ_SHARED_LINKS, + SystemCapabilities.MANAGE_ARTIFACT_APPS, + SystemCapabilities.READ_ARTIFACT_APPS, ], }, { diff --git a/packages/data-schemas/src/index.ts b/packages/data-schemas/src/index.ts index 0b463796ada..7cbdca762ee 100644 --- a/packages/data-schemas/src/index.ts +++ b/packages/data-schemas/src/index.ts @@ -29,6 +29,9 @@ export { validateSkillDescription, deriveStructuredFrontmatterFields, runAfterTransaction, + createArtifactAppMethods, + computeSourceHash, + ARTIFACT_SCHEMA_VERSION, AUDIT_SCHEMA_VERSION, MAX_AUDIT_EXPORT_ROWS, MAX_AUDIT_LOG_LIMIT, @@ -100,6 +103,7 @@ export type { TenantContext } from './config/tenantContext'; export { MCPServerNameMigrationError, createMCPAuthorityLookupIndexes, + ensureArtifactAppIndexes, dropSupersededTenantIndexes, migrateTenantIndexes, dropSupersededPromptGroupIndexes, diff --git a/packages/data-schemas/src/methods/accessRole.spec.ts b/packages/data-schemas/src/methods/accessRole.spec.ts index 41d6da4d820..7d79bce2952 100644 --- a/packages/data-schemas/src/methods/accessRole.spec.ts +++ b/packages/data-schemas/src/methods/accessRole.spec.ts @@ -225,6 +225,9 @@ describe('AccessRole Model Tests', () => { AccessRoleIds.SKILL_EDITOR, AccessRoleIds.SKILL_OWNER, AccessRoleIds.SKILL_VIEWER, + AccessRoleIds.ARTIFACT_APP_EDITOR, + AccessRoleIds.ARTIFACT_APP_OWNER, + AccessRoleIds.ARTIFACT_APP_VIEWER, ].sort(), ); @@ -240,6 +243,18 @@ describe('AccessRole Model Tests', () => { const agentOwnerRole = await methods.findRoleByIdentifier(AccessRoleIds.AGENT_OWNER); expect(agentOwnerRole).toBeDefined(); expect(agentOwnerRole?.permBits).toBe(RoleBits.OWNER); + + const artifactViewer = await methods.findRoleByIdentifier(AccessRoleIds.ARTIFACT_APP_VIEWER); + expect(artifactViewer?.resourceType).toBe(ResourceType.ARTIFACT_APP); + expect(artifactViewer?.permBits).toBe(RoleBits.VIEWER); + + const artifactEditor = await methods.findRoleByIdentifier(AccessRoleIds.ARTIFACT_APP_EDITOR); + expect(artifactEditor?.resourceType).toBe(ResourceType.ARTIFACT_APP); + expect(artifactEditor?.permBits).toBe(RoleBits.EDITOR); + + const artifactOwner = await methods.findRoleByIdentifier(AccessRoleIds.ARTIFACT_APP_OWNER); + expect(artifactOwner?.resourceType).toBe(ResourceType.ARTIFACT_APP); + expect(artifactOwner?.permBits).toBe(RoleBits.OWNER); }); test('should not modify existing roles when seeding', async () => { diff --git a/packages/data-schemas/src/methods/accessRole.ts b/packages/data-schemas/src/methods/accessRole.ts index 0238d6c6525..421d907ef30 100644 --- a/packages/data-schemas/src/methods/accessRole.ts +++ b/packages/data-schemas/src/methods/accessRole.ts @@ -264,6 +264,27 @@ export function createAccessRoleMethods(mongoose: typeof import('mongoose')): { resourceType: ResourceType.SHARED_LINK, permBits: RoleBits.OWNER, }, + { + accessRoleId: AccessRoleIds.ARTIFACT_APP_VIEWER, + name: 'com_ui_role_viewer', + description: 'com_ui_role_viewer_desc', + resourceType: ResourceType.ARTIFACT_APP, + permBits: RoleBits.VIEWER, + }, + { + accessRoleId: AccessRoleIds.ARTIFACT_APP_EDITOR, + name: 'com_ui_role_editor', + description: 'com_ui_role_editor_desc', + resourceType: ResourceType.ARTIFACT_APP, + permBits: RoleBits.EDITOR, + }, + { + accessRoleId: AccessRoleIds.ARTIFACT_APP_OWNER, + name: 'com_ui_role_owner', + description: 'com_ui_role_owner_desc', + resourceType: ResourceType.ARTIFACT_APP, + permBits: RoleBits.OWNER, + }, ]; const result: Record = {}; diff --git a/packages/data-schemas/src/methods/artifactApp.spec.ts b/packages/data-schemas/src/methods/artifactApp.spec.ts new file mode 100644 index 00000000000..df12229dcd3 --- /dev/null +++ b/packages/data-schemas/src/methods/artifactApp.spec.ts @@ -0,0 +1,425 @@ +import mongoose from 'mongoose'; +import { MongoMemoryServer } from 'mongodb-memory-server'; +import type { IArtifactApp, IArtifactVersion, CreateArtifactAppInput } from '~/types'; +import { + createArtifactAppMethods, + computeSourceHash, + type ArtifactAppMethods, +} from './artifactApp'; +import { tenantStorage } from '~/config/tenantContext'; +import { createModels } from '~/models'; + +jest.mock('~/config/winston', () => ({ + error: jest.fn(), + warn: jest.fn(), + info: jest.fn(), + debug: jest.fn(), +})); + +let mongoServer: InstanceType; +let ArtifactApp: mongoose.Model; +let ArtifactVersion: mongoose.Model; +let modelsToCleanup: string[] = []; +let methods: ArtifactAppMethods; + +function baseInput(overrides: Partial = {}): CreateArtifactAppInput { + return { + createdBy: 'user-1', + title: 'My Chart', + visibility: 'private', + version: { + artifactType: 'react', + sourceSnapshot: 'export default () =>
hi
;', + createdBy: 'user-1', + }, + ...overrides, + }; +} + +beforeAll(async () => { + mongoServer = await MongoMemoryServer.create(); + const models = createModels(mongoose); + modelsToCleanup = Object.keys(models); + ArtifactApp = mongoose.models.ArtifactApp as mongoose.Model; + ArtifactVersion = mongoose.models.ArtifactVersion as mongoose.Model; + methods = createArtifactAppMethods(mongoose); + await mongoose.connect(mongoServer.getUri()); + await Promise.all([ArtifactApp.syncIndexes(), ArtifactVersion.syncIndexes()]); +}, 30000); + +afterAll(async () => { + const collections = mongoose.connection.collections; + for (const key in collections) { + await collections[key].deleteMany({}); + } + for (const modelName of modelsToCleanup) { + if (mongoose.models[modelName]) { + delete (mongoose.models as Record)[modelName]; + } + } + await mongoose.disconnect(); + await mongoServer.stop(); +}); + +beforeEach(async () => { + await ArtifactApp.deleteMany({}); + await ArtifactVersion.deleteMany({}); +}); + +describe('createArtifactAppWithVersion', () => { + test('atomically creates app and version 1 with activeVersionId set', async () => { + const { app, version } = await methods.createArtifactAppWithVersion(baseInput()); + + expect(app.artifactAppId).toMatch(/^app_/); + expect(app.status).toBe('draft'); + expect(app.latestVersionNumber).toBe(1); + expect(version.versionNumber).toBe(1); + expect(version.artifactVersionId).toMatch(/^ver_/); + expect(app.activeVersionId).toBe(version.artifactVersionId); + + const persistedVersions = await ArtifactVersion.find({ artifactAppId: app.artifactAppId }); + expect(persistedVersions).toHaveLength(1); + }); + + test('computes a deterministic SHA-256 integrity hash over the snapshot', async () => { + const input = baseInput(); + const { version } = await methods.createArtifactAppWithVersion(input); + const expected = computeSourceHash( + input.version.artifactType, + input.version.sourceSnapshot, + input.version.runtimeConfig ?? {}, + ); + expect(version.integrity.sourceHash).toBe(expected); + expect(version.integrity.schemaVersion).toBe(1); + }); + + test('snapshot is independent — later app edits do not mutate the version source', async () => { + const { app, version } = await methods.createArtifactAppWithVersion(baseInput()); + const originalSource = version.sourceSnapshot; + + await methods.updateArtifactApp( + { artifactAppId: app.artifactAppId }, + { title: 'Renamed', description: 'new' }, + ); + + const reread = await methods.getArtifactVersion({ + artifactAppId: app.artifactAppId, + versionNumber: 1, + }); + expect(reread?.sourceSnapshot).toBe(originalSource); + }); +}); + +describe('syncArtifactAppWithVersion', () => { + const sourceMetadata = { + conversationId: 'conversation-1', + messageId: 'message-1', + originalArtifactId: 'render-id-1', + sourceKey: 'identifier:revenue-chart:application/vnd.react', + }; + + test('is idempotent when the source snapshot has not changed', async () => { + const input = baseInput({ sourceMetadata }); + const first = await methods.syncArtifactAppWithVersion(input); + const second = await methods.syncArtifactAppWithVersion({ + ...input, + sourceMetadata: { + ...sourceMetadata, + messageId: 'message-2', + originalArtifactId: 'render-id-2', + }, + }); + + expect(first.created).toBe(true); + expect(second.created).toBe(false); + expect(second.versionCreated).toBe(false); + expect(second.app.artifactAppId).toBe(first.app.artifactAppId); + expect(await ArtifactVersion.countDocuments({ artifactAppId: first.app.artifactAppId })).toBe( + 1, + ); + }); + + test('creates and activates the next version when content changes', async () => { + const input = baseInput({ sourceMetadata }); + const first = await methods.syncArtifactAppWithVersion(input); + const second = await methods.syncArtifactAppWithVersion({ + ...input, + version: { ...input.version, sourceSnapshot: 'export default () =>
v2
;' }, + }); + + expect(second.versionCreated).toBe(true); + expect(second.version.versionNumber).toBe(2); + expect(second.app.latestVersionNumber).toBe(2); + expect(second.app.activeVersionId).toBe(second.version.artifactVersionId); + expect(await ArtifactVersion.countDocuments({ artifactAppId: first.app.artifactAppId })).toBe( + 2, + ); + }); + + test('concurrent first syncs resolve to one fully initialized app and version', async () => { + const input = baseInput({ sourceMetadata }); + const results = await Promise.all( + Array.from({ length: 6 }, () => methods.syncArtifactAppWithVersion(input)), + ); + + expect(new Set(results.map(({ app }) => app.artifactAppId)).size).toBe(1); + expect(results.filter(({ created }) => created)).toHaveLength(1); + expect(await ArtifactApp.countDocuments({})).toBe(1); + expect(await ArtifactVersion.countDocuments({})).toBe(1); + expect( + results.every(({ app, version }) => app.activeVersionId === version.artifactVersionId), + ).toBe(true); + }); + + test('serializes concurrent standalone updates without duplicate versions', async () => { + const input = baseInput({ sourceMetadata }); + const first = await methods.syncArtifactAppWithVersion(input); + const updates = Array.from({ length: 4 }, (_, index) => + methods.syncArtifactAppWithVersion({ + ...input, + version: { + ...input.version, + sourceSnapshot: `export default () =>
update-${index}
;`, + }, + }), + ); + const results = await Promise.all(updates); + const versions = await ArtifactVersion.find({ artifactAppId: first.app.artifactAppId }) + .sort({ versionNumber: 1 }) + .lean(); + const app = await ArtifactApp.findOne({ artifactAppId: first.app.artifactAppId }).lean(); + + expect(results.every(({ versionCreated }) => versionCreated)).toBe(true); + expect(versions.map(({ versionNumber }) => versionNumber)).toEqual([1, 2, 3, 4, 5]); + expect(app?.latestVersionNumber).toBe(5); + expect( + versions.some(({ artifactVersionId }) => artifactVersionId === app?.activeVersionId), + ).toBe(true); + }); + + test('deduplicates concurrent updates with identical content', async () => { + const input = baseInput({ sourceMetadata }); + const first = await methods.syncArtifactAppWithVersion(input); + const update = { + ...input, + version: { ...input.version, sourceSnapshot: 'export default () =>
shared-v2
;' }, + }; + const results = await Promise.all( + Array.from({ length: 6 }, () => methods.syncArtifactAppWithVersion(update)), + ); + + expect(results.filter(({ versionCreated }) => versionCreated)).toHaveLength(1); + expect(await ArtifactVersion.countDocuments({ artifactAppId: first.app.artifactAppId })).toBe( + 2, + ); + }); + + test('does not advance the current version when version creation fails', async () => { + const input = baseInput({ sourceMetadata }); + const first = await methods.syncArtifactAppWithVersion(input); + + await expect( + methods.syncArtifactAppWithVersion({ + ...input, + version: { + ...input.version, + artifactType: 'invalid-runtime' as CreateArtifactAppInput['version']['artifactType'], + sourceSnapshot: 'invalid update', + }, + }), + ).rejects.toThrow(); + + const unchangedApp = await ArtifactApp.findOne({ + artifactAppId: first.app.artifactAppId, + }).lean(); + expect(unchangedApp?.latestVersionNumber).toBe(1); + expect(unchangedApp?.activeVersionId).toBe(first.version.artifactVersionId); + expect(await ArtifactVersion.countDocuments({ artifactAppId: first.app.artifactAppId })).toBe( + 1, + ); + + const retry = await methods.syncArtifactAppWithVersion({ + ...input, + version: { ...input.version, sourceSnapshot: 'valid update' }, + }); + expect(retry.version.versionNumber).toBe(2); + }); + + test('recovers a version written before its app pointer was committed', async () => { + const input = baseInput({ sourceMetadata }); + const first = await methods.syncArtifactAppWithVersion(input); + const orphanSnapshot = 'export default () =>
recover me
;'; + await ArtifactVersion.create({ + artifactVersionId: 'ver_recoverable', + artifactAppId: first.app.artifactAppId, + versionNumber: 2, + artifactType: 'react', + sourceSnapshot: orphanSnapshot, + runtimeConfig: {}, + integrity: { + sourceHash: computeSourceHash('react', orphanSnapshot), + schemaVersion: 1, + }, + createdBy: 'user-1', + publication: { state: 'draft' }, + }); + + const recovered = await methods.syncArtifactAppWithVersion({ + ...input, + version: { ...input.version, sourceSnapshot: orphanSnapshot }, + }); + const persistedApp = await ArtifactApp.findOne({ + artifactAppId: first.app.artifactAppId, + }).lean(); + + expect(recovered.versionCreated).toBe(true); + expect(recovered.version.artifactVersionId).toBe('ver_recoverable'); + expect(persistedApp?.latestVersionNumber).toBe(2); + expect(persistedApp?.activeVersionId).toBe('ver_recoverable'); + expect(await ArtifactVersion.countDocuments({ artifactAppId: first.app.artifactAppId })).toBe( + 2, + ); + }); +}); + +describe('CRUD', () => { + test('get / list / update / delete', async () => { + const { app } = await methods.createArtifactAppWithVersion(baseInput()); + + const fetched = await methods.getArtifactAppByAppId({ artifactAppId: app.artifactAppId }); + expect(fetched?.title).toBe('My Chart'); + + const listed = await methods.listArtifactApps({ createdBy: 'user-1' }); + expect(listed).toHaveLength(1); + + const updated = await methods.updateArtifactApp( + { artifactAppId: app.artifactAppId }, + { title: 'Updated' }, + ); + expect(updated?.title).toBe('Updated'); + + const del = await methods.deleteArtifactApp({ artifactAppId: app.artifactAppId }); + expect(del.deletedApp).toBe(true); + expect(del.deletedVersions).toBe(1); + expect(await methods.getArtifactAppByAppId({ artifactAppId: app.artifactAppId })).toBeNull(); + }); + + test('resolveArtifactAppId returns _id and artifactAppId for ACL checks', async () => { + const { app } = await methods.createArtifactAppWithVersion(baseInput()); + const resolved = await methods.resolveArtifactAppId({ artifactAppId: app.artifactAppId }); + expect(resolved?.artifactAppId).toBe(app.artifactAppId); + expect(resolved?._id).toBeDefined(); + }); +}); + +describe('version lifecycle', () => { + test('createArtifactVersion increments latestVersionNumber and starts as draft', async () => { + const { app } = await methods.createArtifactAppWithVersion(baseInput()); + const v2 = await methods.createArtifactVersion( + { artifactAppId: app.artifactAppId }, + { artifactType: 'react', sourceSnapshot: 'v2', createdBy: 'user-1' }, + ); + expect(v2.versionNumber).toBe(2); + expect(v2.publication.state).toBe('draft'); + + const reread = await methods.getArtifactAppByAppId({ artifactAppId: app.artifactAppId }); + expect(reread?.latestVersionNumber).toBe(2); + }); + + test('release then activate; activate rejects unreleased versions', async () => { + const { app } = await methods.createArtifactAppWithVersion(baseInput()); + const v2 = await methods.createArtifactVersion( + { artifactAppId: app.artifactAppId }, + { artifactType: 'react', sourceSnapshot: 'v2', createdBy: 'user-1' }, + ); + + await expect( + methods.activateArtifactVersion({ + artifactAppId: app.artifactAppId, + versionNumber: 2, + }), + ).rejects.toThrow(/released/); + + const released = await methods.releaseArtifactVersion( + { artifactAppId: app.artifactAppId, versionNumber: 2 }, + 'user-1', + ); + expect(released?.publication.state).toBe('released'); + expect(released?.publication.releasedBy).toBe('user-1'); + + const activated = await methods.activateArtifactVersion({ + artifactAppId: app.artifactAppId, + versionNumber: 2, + }); + expect(activated?.app.activeVersionId).toBe(v2.artifactVersionId); + }); + + test('rollback: activate an older released version', async () => { + const { app, version: v1 } = await methods.createArtifactAppWithVersion(baseInput()); + await methods.releaseArtifactVersion( + { artifactAppId: app.artifactAppId, versionNumber: 1 }, + 'user-1', + ); + const v2 = await methods.createArtifactVersion( + { artifactAppId: app.artifactAppId }, + { artifactType: 'react', sourceSnapshot: 'v2', createdBy: 'user-1' }, + ); + await methods.releaseArtifactVersion( + { artifactAppId: app.artifactAppId, versionNumber: 2 }, + 'user-1', + ); + await methods.activateArtifactVersion({ artifactAppId: app.artifactAppId, versionNumber: 2 }); + + const rolledBack = await methods.activateArtifactVersion({ + artifactAppId: app.artifactAppId, + versionNumber: 1, + }); + expect(rolledBack?.app.activeVersionId).toBe(v1.artifactVersionId); + expect(v2.versionNumber).toBe(2); + }); + + test('released version snapshot/hash are immutable across lifecycle transitions', async () => { + const { app } = await methods.createArtifactAppWithVersion(baseInput()); + const released = await methods.releaseArtifactVersion( + { artifactAppId: app.artifactAppId, versionNumber: 1 }, + 'user-1', + ); + const hashAtRelease = released?.integrity.sourceHash; + + await methods.withdrawArtifactVersion({ artifactAppId: app.artifactAppId, versionNumber: 1 }); + + const reread = await methods.getArtifactVersion({ + artifactAppId: app.artifactAppId, + versionNumber: 1, + }); + expect(reread?.integrity.sourceHash).toBe(hashAtRelease); + expect(reread?.publication.state).toBe('withdrawn'); + }); +}); + +describe('tenant isolation', () => { + test('apps are scoped by tenant; cross-tenant reads return nothing', async () => { + const appA = await tenantStorage.run({ tenantId: 'tenant-a' }, async () => + methods.createArtifactAppWithVersion(baseInput({ tenantId: 'tenant-a' })), + ); + await tenantStorage.run({ tenantId: 'tenant-b' }, async () => + methods.createArtifactAppWithVersion(baseInput({ tenantId: 'tenant-b' })), + ); + + const fromB = await tenantStorage.run({ tenantId: 'tenant-b' }, async () => + methods.getArtifactAppByAppId({ artifactAppId: appA.app.artifactAppId }), + ); + expect(fromB).toBeNull(); + + const fromA = await tenantStorage.run({ tenantId: 'tenant-a' }, async () => + methods.getArtifactAppByAppId({ artifactAppId: appA.app.artifactAppId }), + ); + expect(fromA?.artifactAppId).toBe(appA.app.artifactAppId); + + const listB = await tenantStorage.run({ tenantId: 'tenant-b' }, async () => + methods.listArtifactApps({}), + ); + expect(listB).toHaveLength(1); + expect(listB[0].tenantId).toBe('tenant-b'); + }); +}); diff --git a/packages/data-schemas/src/methods/artifactApp.ts b/packages/data-schemas/src/methods/artifactApp.ts new file mode 100644 index 00000000000..6d9c237cd13 --- /dev/null +++ b/packages/data-schemas/src/methods/artifactApp.ts @@ -0,0 +1,711 @@ +import { nanoid } from 'nanoid'; +import crypto from 'node:crypto'; +import type { ClientSession, FilterQuery, Model } from 'mongoose'; +import type { + IArtifactApp, + IArtifactVersion, + ArtifactAppQuery, + ArtifactVersionQuery, + CreateArtifactAppInput, + CreateArtifactVersionInput, + ArtifactAppWithVersion, + ArtifactAppIdResolution, + ArtifactAppSourceQuery, + SyncArtifactAppResult, + IArtifactVersionRuntimeConfig, + ArtifactAppListOptions, +} from '~/types'; +import { supportsTransactions } from '~/utils/transactions'; + +/** Snapshot schema version — bump when the canonical snapshot shape changes. */ +export const ARTIFACT_SCHEMA_VERSION = 1; +const SYNC_LOCK_LEASE_MS = 5000; +const SYNC_LOCK_RETRY_DELAY_MS = 50; +const SYNC_LOCK_RETRY_ATTEMPTS = 100; +const SYNC_WRITE_RETRY_ATTEMPTS = 3; + +interface MongoWriteError { + code?: number; + errorLabels?: string[]; +} + +class ArtifactSyncRetryError extends Error {} + +function isRetryableWriteError(error: unknown): boolean { + const writeError = error as MongoWriteError; + return ( + error instanceof ArtifactSyncRetryError || + writeError.code === 11000 || + writeError.code === 112 || + writeError.errorLabels?.includes('TransientTransactionError') === true + ); +} + +function waitForSyncLock(): Promise { + return new Promise((resolve) => setTimeout(resolve, SYNC_LOCK_RETRY_DELAY_MS)); +} + +/** + * Deterministic SHA-256 over the canonical version payload + * ({ artifactType, sourceSnapshot, runtimeConfig }). Keys are sorted so the + * hash is stable regardless of property insertion order. + */ +export function computeSourceHash( + artifactType: string, + sourceSnapshot: string, + runtimeConfig: IArtifactVersionRuntimeConfig = {}, +): string { + const canonical = JSON.stringify({ + artifactType, + sourceSnapshot, + runtimeConfig: canonicalize(runtimeConfig), + }); + return crypto.createHash('sha256').update(canonical).digest('hex'); +} + +function canonicalize(value: unknown): unknown { + if (Array.isArray(value)) { + return value.map(canonicalize); + } + if (value && typeof value === 'object') { + const source = value as Record; + return Object.keys(source) + .sort() + .reduce>((acc, key) => { + acc[key] = canonicalize(source[key]); + return acc; + }, {}); + } + return value; +} + +export interface ArtifactAppMethods { + createArtifactAppWithVersion: (input: CreateArtifactAppInput) => Promise; + syncArtifactAppWithVersion: (input: CreateArtifactAppInput) => Promise; + getArtifactAppByAppId: (query: ArtifactAppQuery) => Promise; + getArtifactAppBySource: (query: ArtifactAppSourceQuery) => Promise; + resolveArtifactAppId: (query: ArtifactAppQuery) => Promise; + listArtifactApps: ( + filter: FilterQuery, + options?: ArtifactAppListOptions, + ) => Promise; + updateArtifactApp: ( + query: ArtifactAppQuery, + update: Partial, + ) => Promise; + deleteArtifactApp: ( + query: ArtifactAppQuery, + ) => Promise<{ deletedApp: boolean; deletedVersions: number }>; + getArtifactVersion: (query: ArtifactVersionQuery) => Promise; + listArtifactVersions: (query: ArtifactAppQuery) => Promise; + createArtifactVersion: ( + query: ArtifactAppQuery, + input: CreateArtifactVersionInput, + ) => Promise; + releaseArtifactVersion: ( + query: ArtifactVersionQuery, + releasedBy: string, + ) => Promise; + activateArtifactVersion: (query: ArtifactVersionQuery) => Promise; + withdrawArtifactVersion: (query: ArtifactVersionQuery) => Promise; +} + +function buildVersionFilter(query: ArtifactVersionQuery): Record { + const filter: Record = { artifactAppId: query.artifactAppId }; + if (query.artifactVersionId) { + filter.artifactVersionId = query.artifactVersionId; + } + if (typeof query.versionNumber === 'number') { + filter.versionNumber = query.versionNumber; + } + return filter; +} + +export function createArtifactAppMethods(mongoose: typeof import('mongoose')): ArtifactAppMethods { + const getApp = () => mongoose.models.ArtifactApp as Model; + const getVersion = () => mongoose.models.ArtifactVersion as Model; + + function buildVersionDoc( + artifactAppId: string, + tenantId: string | undefined, + versionNumber: number, + input: CreateArtifactVersionInput, + state: 'draft' | 'released', + ): Partial { + const runtimeConfig = input.runtimeConfig ?? {}; + return { + artifactVersionId: `ver_${nanoid()}`, + artifactAppId, + tenantId, + versionNumber, + versionLabel: input.versionLabel, + changelog: input.changelog, + artifactType: input.artifactType, + sourceSnapshot: input.sourceSnapshot, + runtimeConfig, + integrity: { + sourceHash: computeSourceHash(input.artifactType, input.sourceSnapshot, runtimeConfig), + schemaVersion: ARTIFACT_SCHEMA_VERSION, + }, + createdBy: input.createdBy, + publication: { + state, + ...(state === 'released' ? { releasedBy: input.createdBy, releasedAt: new Date() } : {}), + }, + } as Partial; + } + + async function createArtifactAppWithVersion( + input: CreateArtifactAppInput, + ): Promise { + const ArtifactApp = getApp(); + const ArtifactVersion = getVersion(); + const artifactAppId = `app_${nanoid()}`; + + const appDoc: Partial = { + artifactAppId, + tenantId: input.tenantId, + title: input.title, + description: input.description, + icon: input.icon, + category: input.category, + tags: input.tags, + createdBy: input.createdBy, + latestVersionNumber: 1, + status: 'draft', + visibility: input.visibility, + allowEmbed: input.allowEmbed ?? false, + allowFork: input.allowFork ?? false, + allowAnonymousView: input.allowAnonymousView ?? false, + toolPolicy: input.toolPolicy as IArtifactApp['toolPolicy'], + marketplace: input.marketplace as IArtifactApp['marketplace'], + sourceMetadata: input.sourceMetadata, + }; + + const versionSeed = buildVersionDoc(artifactAppId, input.tenantId, 1, input.version, 'draft'); + const activeVersionId = versionSeed.artifactVersionId; + if (!activeVersionId) { + throw new Error('[createArtifactAppWithVersion] Version seed has no identifier'); + } + appDoc.activeVersionId = activeVersionId; + + const useTransaction = await supportsTransactions(mongoose); + + if (!useTransaction) { + const [version] = await ArtifactVersion.create([versionSeed]); + try { + const [app] = await ArtifactApp.create([appDoc]); + return { app, version }; + } catch (error) { + await ArtifactVersion.deleteOne({ artifactVersionId: version.artifactVersionId }).exec(); + throw error; + } + } + + const session: ClientSession = await mongoose.startSession(); + try { + let result: ArtifactAppWithVersion | undefined; + await session.withTransaction(async () => { + const [app] = await ArtifactApp.create([appDoc], { session }); + const [version] = await ArtifactVersion.create([versionSeed], { session }); + result = { app, version }; + }); + if (!result) { + throw new Error('[createArtifactAppWithVersion] Transaction produced no result'); + } + return result; + } finally { + await session.endSession(); + } + } + + async function getArtifactAppByAppId(query: ArtifactAppQuery): Promise { + return getApp().findOne({ artifactAppId: query.artifactAppId }).lean().exec(); + } + + function buildSourceFilter(query: ArtifactAppSourceQuery): FilterQuery { + const filter: FilterQuery = { + createdBy: query.createdBy, + 'sourceMetadata.conversationId': query.conversationId, + 'sourceMetadata.sourceKey': query.sourceKey, + }; + if (query.tenantId != null) { + filter.tenantId = query.tenantId; + } else { + filter.tenantId = { $exists: false }; + } + return filter; + } + + async function getArtifactAppBySource( + query: ArtifactAppSourceQuery, + ): Promise { + return getApp().findOne(buildSourceFilter(query)).lean().exec(); + } + + /** + * Creates the catalog record once, then appends and activates a new snapshot + * only when its canonical content hash changes. Version history remains in + * the separate ArtifactVersion collection so it can grow independently. + */ + async function syncArtifactAppWithVersion( + input: CreateArtifactAppInput, + ): Promise { + const source = input.sourceMetadata; + if (!source?.conversationId || !source.sourceKey) { + throw new Error('[syncArtifactAppWithVersion] Stable source metadata is required'); + } + + const sourceQuery: ArtifactAppSourceQuery = { + tenantId: input.tenantId, + createdBy: input.createdBy, + conversationId: source.conversationId, + sourceKey: source.sourceKey, + }; + let filter = buildSourceFilter(sourceQuery); + let existing = await getApp().findOne(filter).select({ _id: 1 }).lean().exec(); + + // Upgrade path for records created by the original manual Publish dialog, + // which stored an originalArtifactId but had no stable sourceKey. + if (!existing && source.originalArtifactId) { + const legacyFilter: FilterQuery = { + createdBy: input.createdBy, + 'sourceMetadata.conversationId': source.conversationId, + 'sourceMetadata.originalArtifactId': source.originalArtifactId, + 'sourceMetadata.sourceKey': { $exists: false }, + ...(input.tenantId != null + ? { tenantId: input.tenantId } + : { tenantId: { $exists: false } }), + }; + existing = await getApp().findOne(legacyFilter).select({ _id: 1 }).lean().exec(); + if (existing) { + filter = { _id: existing._id }; + } + } + + if (!existing) { + try { + const { app, version } = await createArtifactAppWithVersion({ + ...input, + visibility: 'private', + marketplace: { ...input.marketplace, listed: true }, + }); + return { app, version, created: true, versionCreated: true }; + } catch (error) { + // A concurrent first sync may win the unique source index. Re-enter the + // update path so both requests resolve to the same catalog record. + if (!isRetryableWriteError(error)) { + throw error; + } + } + } + + const ArtifactApp = getApp(); + const ArtifactVersion = getVersion(); + const sourceHash = computeSourceHash( + input.version.artifactType, + input.version.sourceSnapshot, + input.version.runtimeConfig, + ); + + const applyStandaloneSync = async (): Promise => { + const lockToken = `sync_${nanoid()}`; + let app: IArtifactApp | null = null; + let versionStaged = false; + + for (let attempt = 0; attempt < SYNC_LOCK_RETRY_ATTEMPTS; attempt++) { + const now = new Date(); + app = await ArtifactApp.findOneAndUpdate( + { + ...filter, + $or: [{ syncLock: { $exists: false } }, { 'syncLock.expiresAt': { $lte: now } }], + }, + { + $set: { + syncLock: { + token: lockToken, + expiresAt: new Date(now.getTime() + SYNC_LOCK_LEASE_MS), + }, + }, + }, + { new: true }, + ).exec(); + if (app) { + break; + } + if (!(await ArtifactApp.exists(filter))) { + throw new Error( + '[syncArtifactAppWithVersion] Artifact app not found after source lookup', + ); + } + await waitForSyncLock(); + } + + if (!app) { + throw new Error('[syncArtifactAppWithVersion] Timed out waiting for artifact sync lock'); + } + + try { + let recoveredVersion = false; + const recoverableVersion = await ArtifactVersion.findOne({ + artifactAppId: app.artifactAppId, + versionNumber: app.latestVersionNumber + 1, + }).exec(); + if (recoverableVersion) { + const recoveredApp = await ArtifactApp.findOneAndUpdate( + { + _id: app._id, + 'syncLock.token': lockToken, + latestVersionNumber: app.latestVersionNumber, + }, + { + $set: { + latestVersionNumber: recoverableVersion.versionNumber, + activeVersionId: recoverableVersion.artifactVersionId, + }, + }, + { new: true }, + ).exec(); + if (!recoveredApp) { + throw new Error('[syncArtifactAppWithVersion] Artifact sync lock was lost'); + } + app = recoveredApp; + recoveredVersion = true; + } + + const versionQuery = app.activeVersionId + ? ArtifactVersion.findOne({ + artifactAppId: app.artifactAppId, + artifactVersionId: app.activeVersionId, + }) + : ArtifactVersion.findOne({ artifactAppId: app.artifactAppId }).sort({ + versionNumber: -1, + }); + const activeVersion = await versionQuery.exec(); + const metadata = { + ...source, + conversationId: source.conversationId, + sourceKey: source.sourceKey, + }; + + if (activeVersion?.integrity.sourceHash === sourceHash) { + const updatedApp = await ArtifactApp.findOneAndUpdate( + { _id: app._id, 'syncLock.token': lockToken }, + { + $set: { + title: input.title, + sourceMetadata: metadata, + 'marketplace.listed': true, + }, + $unset: { syncLock: 1 }, + }, + { new: true }, + ).exec(); + if (!updatedApp) { + throw new Error('[syncArtifactAppWithVersion] Artifact sync lock was lost'); + } + return { + app: updatedApp, + version: activeVersion, + created: false, + versionCreated: recoveredVersion, + }; + } + + const nextVersionNumber = app.latestVersionNumber + 1; + const versionSeed = buildVersionDoc( + app.artifactAppId, + app.tenantId, + nextVersionNumber, + input.version, + 'draft', + ); + const stagedVersionId = versionSeed.artifactVersionId; + if (!stagedVersionId) { + throw new Error('[syncArtifactAppWithVersion] Version seed has no identifier'); + } + const [version] = await ArtifactVersion.create([versionSeed]); + versionStaged = true; + const updatedApp = await ArtifactApp.findOneAndUpdate( + { + _id: app._id, + 'syncLock.token': lockToken, + latestVersionNumber: app.latestVersionNumber, + }, + { + $set: { + title: input.title, + sourceMetadata: metadata, + 'marketplace.listed': true, + latestVersionNumber: nextVersionNumber, + activeVersionId: version.artifactVersionId, + }, + $unset: { syncLock: 1 }, + }, + { new: true }, + ).exec(); + if (!updatedApp) { + throw new Error('[syncArtifactAppWithVersion] Artifact sync lock was lost'); + } + return { + app: updatedApp, + version, + created: false, + versionCreated: true, + }; + } catch (error) { + await ArtifactApp.updateOne( + { _id: app._id, 'syncLock.token': lockToken }, + { $unset: { syncLock: 1 } }, + ).exec(); + if (versionStaged) { + throw new ArtifactSyncRetryError( + '[syncArtifactAppWithVersion] Recovering staged artifact version', + ); + } + throw error; + } + }; + + const applySync = async (session?: ClientSession): Promise => { + const appQuery = ArtifactApp.findOne(filter); + if (session) appQuery.session(session); + const app = await appQuery.exec(); + if (!app) { + throw new Error('[syncArtifactAppWithVersion] Artifact app not found after source lookup'); + } + + const versionQuery = app.activeVersionId + ? ArtifactVersion.findOne({ + artifactAppId: app.artifactAppId, + artifactVersionId: app.activeVersionId, + }) + : ArtifactVersion.findOne({ artifactAppId: app.artifactAppId }).sort({ versionNumber: -1 }); + if (session) versionQuery.session(session); + const activeVersion = await versionQuery.exec(); + + app.title = input.title; + app.sourceMetadata = { + ...source, + conversationId: source.conversationId, + sourceKey: source.sourceKey, + }; + app.set('marketplace.listed', true); + + if (activeVersion?.integrity.sourceHash === sourceHash) { + await app.save(session ? { session } : undefined); + return { + app: app.toObject() as IArtifactApp, + version: activeVersion.toObject() as IArtifactVersion, + created: false, + versionCreated: false, + }; + } + + const nextNumber = app.latestVersionNumber + 1; + const versionSeed = buildVersionDoc( + app.artifactAppId, + app.tenantId, + nextNumber, + input.version, + 'draft', + ); + const createOptions = session ? { session } : undefined; + const [version] = await ArtifactVersion.create([versionSeed], createOptions); + app.latestVersionNumber = nextNumber; + app.activeVersionId = version.artifactVersionId; + await app.save(session ? { session } : undefined); + return { + app: app.toObject() as IArtifactApp, + version: version.toObject() as IArtifactVersion, + created: false, + versionCreated: true, + }; + }; + + if (!(await supportsTransactions(mongoose))) { + for (let attempt = 0; attempt < SYNC_WRITE_RETRY_ATTEMPTS; attempt++) { + try { + return await applyStandaloneSync(); + } catch (error) { + if (!isRetryableWriteError(error) || attempt === SYNC_WRITE_RETRY_ATTEMPTS - 1) { + throw error; + } + } + } + throw new Error('[syncArtifactAppWithVersion] Standalone sync exhausted retries'); + } + + const session = await mongoose.startSession(); + try { + let result: SyncArtifactAppResult | undefined; + await session.withTransaction(async () => { + result = await applySync(session); + }); + if (!result) { + throw new Error('[syncArtifactAppWithVersion] Transaction produced no result'); + } + return result; + } finally { + await session.endSession(); + } + } + + async function resolveArtifactAppId( + query: ArtifactAppQuery, + ): Promise { + const doc = await getApp() + .findOne({ artifactAppId: query.artifactAppId }) + .select({ _id: 1, artifactAppId: 1 }) + .lean() + .exec(); + return doc; + } + + async function listArtifactApps( + filter: FilterQuery, + options: ArtifactAppListOptions = {}, + ): Promise { + const cursorFilter: FilterQuery | undefined = options.cursor + ? { + $or: [ + { updatedAt: { $lt: options.cursor.updatedAt } }, + { updatedAt: options.cursor.updatedAt, _id: { $lt: options.cursor._id } }, + ], + } + : undefined; + const query = getApp() + .find(cursorFilter ? { $and: [filter, cursorFilter] } : filter) + .sort({ updatedAt: -1, _id: -1 }); + if (options.limit) { + query.limit(options.limit); + } + return query.lean().exec(); + } + + async function updateArtifactApp( + query: ArtifactAppQuery, + update: Partial, + ): Promise { + return getApp() + .findOneAndUpdate({ artifactAppId: query.artifactAppId }, { $set: update }, { new: true }) + .lean() + .exec(); + } + + async function deleteArtifactApp( + query: ArtifactAppQuery, + ): Promise<{ deletedApp: boolean; deletedVersions: number }> { + const app = await getApp().findOneAndDelete({ artifactAppId: query.artifactAppId }).exec(); + if (!app) { + return { deletedApp: false, deletedVersions: 0 }; + } + const { deletedCount } = await getVersion() + .deleteMany({ artifactAppId: query.artifactAppId }) + .exec(); + return { deletedApp: true, deletedVersions: deletedCount ?? 0 }; + } + + async function getArtifactVersion(query: ArtifactVersionQuery): Promise { + return getVersion().findOne(buildVersionFilter(query)).lean().exec(); + } + + async function listArtifactVersions(query: ArtifactAppQuery): Promise { + return getVersion() + .find({ artifactAppId: query.artifactAppId }) + .sort({ versionNumber: -1 }) + .lean() + .exec(); + } + + async function createArtifactVersion( + query: ArtifactAppQuery, + input: CreateArtifactVersionInput, + ): Promise { + const ArtifactApp = getApp(); + const app = await ArtifactApp.findOne({ artifactAppId: query.artifactAppId }).exec(); + if (!app) { + throw new Error('[createArtifactVersion] Artifact app not found'); + } + const nextNumber = app.latestVersionNumber + 1; + const versionSeed = buildVersionDoc( + app.artifactAppId, + app.tenantId, + nextNumber, + input, + 'draft', + ); + const [version] = await getVersion().create([versionSeed]); + app.latestVersionNumber = nextNumber; + await app.save(); + return version.toObject() as IArtifactVersion; + } + + async function releaseArtifactVersion( + query: ArtifactVersionQuery, + releasedBy: string, + ): Promise { + const version = await getVersion().findOne(buildVersionFilter(query)).exec(); + if (!version) { + return null; + } + if (version.publication.state === 'released') { + return version.toObject() as IArtifactVersion; + } + version.publication = { + state: 'released', + releasedBy, + releasedAt: new Date(), + }; + await version.save(); + return version.toObject() as IArtifactVersion; + } + + async function activateArtifactVersion( + query: ArtifactVersionQuery, + ): Promise { + const version = await getVersion().findOne(buildVersionFilter(query)).exec(); + if (!version) { + return null; + } + if (version.publication.state !== 'released') { + throw new Error('[activateArtifactVersion] Only released versions can be activated'); + } + const app = await getApp().findOne({ artifactAppId: query.artifactAppId }).exec(); + if (!app) { + return null; + } + app.activeVersionId = version.artifactVersionId; + await app.save(); + return { app: app.toObject() as IArtifactApp, version: version.toObject() as IArtifactVersion }; + } + + async function withdrawArtifactVersion( + query: ArtifactVersionQuery, + ): Promise { + const version = await getVersion().findOne(buildVersionFilter(query)).exec(); + if (!version) { + return null; + } + version.publication = { ...version.publication, state: 'withdrawn' }; + await version.save(); + return version.toObject() as IArtifactVersion; + } + + return { + createArtifactAppWithVersion, + syncArtifactAppWithVersion, + getArtifactAppByAppId, + getArtifactAppBySource, + resolveArtifactAppId, + listArtifactApps, + updateArtifactApp, + deleteArtifactApp, + getArtifactVersion, + listArtifactVersions, + createArtifactVersion, + releaseArtifactVersion, + activateArtifactVersion, + withdrawArtifactVersion, + }; +} diff --git a/packages/data-schemas/src/methods/index.ts b/packages/data-schemas/src/methods/index.ts index 1c7f4563ef1..a9e8d653ba2 100644 --- a/packages/data-schemas/src/methods/index.ts +++ b/packages/data-schemas/src/methods/index.ts @@ -180,6 +180,13 @@ import { } from './mcpAuthority'; /* Insights */ import { createInsightsMethods, type InsightsMethods } from './insights'; +/* Artifact Apps */ +import { + createArtifactAppMethods, + computeSourceHash, + ARTIFACT_SCHEMA_VERSION, + type ArtifactAppMethods, +} from './artifactApp'; export { runAfterTransaction, @@ -217,6 +224,7 @@ export { AgentQueuedTurnConflictError, AgentQueuedTurnLaneRetiredError, }; +export { createArtifactAppMethods, computeSourceHash, ARTIFACT_SCHEMA_VERSION }; export type AllMethods = UserMethods & SessionMethods & @@ -262,7 +270,8 @@ export type AllMethods = UserMethods & AgentMethods & ConfigMethods & MCPAuthorityMethods & - InsightsMethods; + InsightsMethods & + ArtifactAppMethods; /** Dependencies injected from the api layer into createMethods */ export interface CreateMethodsDeps { @@ -493,6 +502,8 @@ export function createMethods( ...createMCPAuthorityMethods(mongoose), /* Insights */ ...createInsightsMethods(mongoose), + /* Artifact Apps */ + ...createArtifactAppMethods(mongoose), }; } @@ -573,6 +584,7 @@ export type { MCPAuthorityConfigSourceDocument, MCPAuthorityCredentialSourceDocument, InsightsMethods, + ArtifactAppMethods, }; export { recordAgentEventActorReceiptMetric, setAgentEventActorReceiptMetricObserver }; diff --git a/packages/data-schemas/src/migrations/artifactAppIndexes.ts b/packages/data-schemas/src/migrations/artifactAppIndexes.ts new file mode 100644 index 00000000000..fdfb12eb861 --- /dev/null +++ b/packages/data-schemas/src/migrations/artifactAppIndexes.ts @@ -0,0 +1,78 @@ +import type { Connection } from 'mongoose'; +import logger from '~/config/winston'; + +type IndexSpec = Record; +type IndexOptions = { + unique?: boolean; + partialFilterExpression?: Record; +}; + +interface IndexDefinition { + spec: IndexSpec; + options: IndexOptions; +} + +/** + * Compound tenant-scoped indexes for the artifact app collections (PLAN §6.4). + * Mongoose auto-creates these from the schema on model init, but a fresh + * deployment or a restore-from-dump may not have run model init yet. This + * migration ensures the indexes exist. It is idempotent — createIndex is a + * no-op when the index already exists with the same definition. + */ +const COLLECTION_INDEXES: Record = { + artifactapps: [ + { spec: { tenantId: 1, artifactAppId: 1 }, options: { unique: true } }, + { spec: { tenantId: 1, status: 1, visibility: 1 }, options: {} }, + { spec: { tenantId: 1, 'marketplace.listed': 1, 'marketplace.featured': 1 }, options: {} }, + { spec: { tenantId: 1, createdBy: 1 }, options: {} }, + { spec: { tenantId: 1, updatedAt: -1, _id: -1 }, options: {} }, + { spec: { tenantId: 1, createdBy: 1, updatedAt: -1, _id: -1 }, options: {} }, + { spec: { tenantId: 1, activeVersionId: 1 }, options: {} }, + { + spec: { + tenantId: 1, + createdBy: 1, + 'sourceMetadata.conversationId': 1, + 'sourceMetadata.sourceKey': 1, + }, + options: { + unique: true, + partialFilterExpression: { + 'sourceMetadata.conversationId': { $type: 'string' }, + 'sourceMetadata.sourceKey': { $type: 'string' }, + }, + }, + }, + ], + artifactversions: [ + { spec: { tenantId: 1, artifactAppId: 1, versionNumber: 1 }, options: { unique: true } }, + { spec: { tenantId: 1, artifactVersionId: 1 }, options: { unique: true } }, + ], +}; + +export async function ensureArtifactAppIndexes( + connection: Connection, +): Promise<{ created: string[]; errors: string[] }> { + const result = { created: [] as string[], errors: [] as string[] }; + + for (const [collectionName, indexes] of Object.entries(COLLECTION_INDEXES)) { + const collection = connection.db!.collection(collectionName); + for (const { spec, options } of indexes) { + try { + const name = await collection.createIndex(spec, options); + result.created.push(`${collectionName}.${name}`); + } catch (err) { + const msg = `${collectionName}.${JSON.stringify(spec)}: ${(err as Error).message}`; + result.errors.push(msg); + logger.error(`[ArtifactAppMigration] Failed to create index: ${msg}`); + } + } + } + + logger.info( + `[ArtifactAppMigration] Ensured artifact app indexes. Created/confirmed ${result.created.length}.`, + ); + return result; +} + +export { COLLECTION_INDEXES }; diff --git a/packages/data-schemas/src/migrations/index.ts b/packages/data-schemas/src/migrations/index.ts index c3ba6191819..842b38253bc 100644 --- a/packages/data-schemas/src/migrations/index.ts +++ b/packages/data-schemas/src/migrations/index.ts @@ -2,3 +2,4 @@ export { dropSupersededTenantIndexes, migrateTenantIndexes } from './tenantIndex export { dropSupersededPromptGroupIndexes } from './promptGroupIndexes'; export { createMCPAuthorityLookupIndexes } from './mcpAuthorityIndexes'; export { MCPServerNameMigrationError, backfillMCPServerNormalizedNames } from './mcpServerNames'; +export { ensureArtifactAppIndexes } from './artifactAppIndexes'; diff --git a/packages/data-schemas/src/models/artifactApp.ts b/packages/data-schemas/src/models/artifactApp.ts new file mode 100644 index 00000000000..a7061f222dd --- /dev/null +++ b/packages/data-schemas/src/models/artifactApp.ts @@ -0,0 +1,11 @@ +import { Model } from 'mongoose'; +import type { IArtifactApp } from '~/types'; +import { applyTenantIsolation } from '~/models/plugins/tenantIsolation'; +import artifactAppSchema from '~/schema/artifactApp'; + +export function createArtifactAppModel(mongoose: typeof import('mongoose')): Model { + applyTenantIsolation(artifactAppSchema); + return ( + mongoose.models.ArtifactApp || mongoose.model('ArtifactApp', artifactAppSchema) + ); +} diff --git a/packages/data-schemas/src/models/artifactVersion.ts b/packages/data-schemas/src/models/artifactVersion.ts new file mode 100644 index 00000000000..311517d05bf --- /dev/null +++ b/packages/data-schemas/src/models/artifactVersion.ts @@ -0,0 +1,14 @@ +import { Model } from 'mongoose'; +import type { IArtifactVersion } from '~/types'; +import { applyTenantIsolation } from '~/models/plugins/tenantIsolation'; +import artifactVersionSchema from '~/schema/artifactVersion'; + +export function createArtifactVersionModel( + mongoose: typeof import('mongoose'), +): Model { + applyTenantIsolation(artifactVersionSchema); + return ( + mongoose.models.ArtifactVersion || + mongoose.model('ArtifactVersion', artifactVersionSchema) + ); +} diff --git a/packages/data-schemas/src/models/index.ts b/packages/data-schemas/src/models/index.ts index d309b041010..30c7fb7221d 100644 --- a/packages/data-schemas/src/models/index.ts +++ b/packages/data-schemas/src/models/index.ts @@ -9,12 +9,14 @@ import { createAgentTriggerDeliveryModel } from './triggerDelivery'; import { createSkillSyncStatusModel } from './skillSyncStatus'; import { createConversationTagModel } from './conversationTag'; import { createCodeEnvironmentModel } from './codeEnvironment'; +import { createArtifactVersionModel } from './artifactVersion'; import { createAgentCategoryModel } from './agentCategory'; import { createChatProjectModel } from './chatProject'; import { createAgentApiKeyModel } from './agentApiKey'; import { createTransactionModel } from './transaction'; import { createPromptGroupModel } from './promptGroup'; import { createSystemGrantModel } from './systemGrant'; +import { createArtifactAppModel } from './artifactApp'; import { createPluginAuthModel } from './pluginAuth'; import { createSharedLinkModel } from './sharedLink'; import { createAccessRoleModel } from './accessRole'; @@ -96,6 +98,8 @@ export function createModels(mongoose: typeof import('mongoose')): { ScheduleRun: ReturnType; RefreshTokenBridge: ReturnType; OpenIDRefreshFlight: ReturnType; + ArtifactApp: ReturnType; + ArtifactVersion: ReturnType; } { const models = { User: createUserModel(mongoose), @@ -145,6 +149,8 @@ export function createModels(mongoose: typeof import('mongoose')): { ScheduleRun: createScheduleRunModel(mongoose), RefreshTokenBridge: createRefreshTokenBridgeModel(mongoose), OpenIDRefreshFlight: createOpenIDRefreshFlightModel(mongoose), + ArtifactApp: createArtifactAppModel(mongoose), + ArtifactVersion: createArtifactVersionModel(mongoose), }; /** * Background index builds fail silently unless an 'index' listener is diff --git a/packages/data-schemas/src/schema/artifactApp.ts b/packages/data-schemas/src/schema/artifactApp.ts new file mode 100644 index 00000000000..404fca047ef --- /dev/null +++ b/packages/data-schemas/src/schema/artifactApp.ts @@ -0,0 +1,175 @@ +import { Schema } from 'mongoose'; +import type { IArtifactApp } from '~/types'; + +const toolPolicySchema = new Schema( + { + enabled: { type: Boolean, default: false }, + allowedServers: { type: [String], default: [] }, + allowedTools: { type: [String], default: [] }, + requireConfirmationForWrites: { type: Boolean, default: true }, + }, + { _id: false }, +); + +const marketplaceSchema = new Schema( + { + listed: { type: Boolean, default: false }, + featured: { type: Boolean, default: false }, + summary: { type: String }, + riskClass: { + type: String, + enum: ['none', 'read', 'write', 'external'], + default: 'none', + }, + costClass: { + type: String, + enum: ['free', 'low', 'medium', 'high'], + default: 'free', + }, + }, + { _id: false }, +); + +const sourceMetadataSchema = new Schema( + { + conversationId: { type: String }, + messageId: { type: String }, + originalArtifactId: { type: String }, + sourceKey: { type: String }, + }, + { _id: false }, +); + +const syncLockSchema = new Schema( + { + token: { type: String, required: true }, + expiresAt: { type: Date, required: true }, + }, + { _id: false }, +); + +const reviewSchema = new Schema( + { + submittedAt: { type: Date }, + submittedBy: { type: String }, + reviewedAt: { type: Date }, + reviewedBy: { type: String }, + result: { type: String, enum: ['approved', 'rejected'] }, + comment: { type: String }, + }, + { _id: false }, +); + +const artifactAppSchema: Schema = new Schema( + { + artifactAppId: { + type: String, + required: true, + }, + tenantId: { + type: String, + }, + title: { + type: String, + required: true, + }, + description: { + type: String, + }, + icon: { + type: String, + }, + category: { + type: String, + }, + tags: { + type: [String], + default: undefined, + }, + createdBy: { + type: String, + required: true, + }, + activeVersionId: { + type: String, + }, + latestVersionNumber: { + type: Number, + default: 0, + }, + status: { + type: String, + enum: ['draft', 'pending_review', 'published', 'suspended', 'archived'], + default: 'draft', + }, + visibility: { + type: String, + enum: ['private', 'restricted', 'tenant', 'public'], + default: 'private', + }, + allowEmbed: { + type: Boolean, + default: false, + }, + allowFork: { + type: Boolean, + default: false, + }, + allowAnonymousView: { + type: Boolean, + default: false, + }, + toolPolicy: { + type: toolPolicySchema, + default: () => ({}), + }, + marketplace: { + type: marketplaceSchema, + default: () => ({}), + }, + sourceMetadata: { + type: sourceMetadataSchema, + default: undefined, + }, + syncLock: { + type: syncLockSchema, + default: undefined, + select: false, + }, + review: { + type: reviewSchema, + default: undefined, + }, + archivedAt: { + type: Date, + }, + }, + { + timestamps: true, + }, +); + +artifactAppSchema.index({ tenantId: 1, artifactAppId: 1 }, { unique: true }); +artifactAppSchema.index({ tenantId: 1, status: 1, visibility: 1 }); +artifactAppSchema.index({ tenantId: 1, 'marketplace.listed': 1, 'marketplace.featured': 1 }); +artifactAppSchema.index({ tenantId: 1, createdBy: 1 }); +artifactAppSchema.index({ tenantId: 1, updatedAt: -1, _id: -1 }); +artifactAppSchema.index({ tenantId: 1, createdBy: 1, updatedAt: -1, _id: -1 }); +artifactAppSchema.index({ tenantId: 1, activeVersionId: 1 }); +artifactAppSchema.index( + { + tenantId: 1, + createdBy: 1, + 'sourceMetadata.conversationId': 1, + 'sourceMetadata.sourceKey': 1, + }, + { + unique: true, + partialFilterExpression: { + 'sourceMetadata.conversationId': { $type: 'string' }, + 'sourceMetadata.sourceKey': { $type: 'string' }, + }, + }, +); + +export default artifactAppSchema; diff --git a/packages/data-schemas/src/schema/artifactVersion.ts b/packages/data-schemas/src/schema/artifactVersion.ts new file mode 100644 index 00000000000..12baadf76ce --- /dev/null +++ b/packages/data-schemas/src/schema/artifactVersion.ts @@ -0,0 +1,101 @@ +import { Schema } from 'mongoose'; +import type { IArtifactVersion } from '~/types'; + +const runtimeConfigSchema = new Schema( + { + dependencies: { type: Schema.Types.Mixed, default: undefined }, + entryPoint: { type: String }, + renderMode: { type: String }, + }, + { _id: false }, +); + +const integritySchema = new Schema( + { + sourceHash: { type: String, required: true }, + schemaVersion: { type: Number, required: true }, + }, + { _id: false }, +); + +const publicationSchema = new Schema( + { + state: { + type: String, + enum: ['draft', 'released', 'withdrawn'], + default: 'draft', + }, + releasedBy: { type: String }, + releasedAt: { type: Date }, + }, + { _id: false }, +); + +const artifactVersionSchema: Schema = new Schema( + { + artifactVersionId: { + type: String, + required: true, + }, + artifactAppId: { + type: String, + required: true, + }, + tenantId: { + type: String, + }, + versionNumber: { + type: Number, + required: true, + }, + versionLabel: { + type: String, + }, + changelog: { + type: String, + }, + artifactType: { + type: String, + enum: [ + 'react', + 'html', + 'mermaid', + 'markdown', + 'text', + 'code', + 'document', + 'spreadsheet', + 'presentation', + ], + required: true, + }, + sourceSnapshot: { + type: String, + required: true, + }, + runtimeConfig: { + type: runtimeConfigSchema, + default: () => ({}), + }, + integrity: { + type: integritySchema, + required: true, + }, + createdBy: { + type: String, + required: true, + }, + publication: { + type: publicationSchema, + default: () => ({}), + }, + }, + { + timestamps: { createdAt: true, updatedAt: false }, + }, +); + +artifactVersionSchema.index({ tenantId: 1, artifactAppId: 1, versionNumber: 1 }, { unique: true }); +artifactVersionSchema.index({ tenantId: 1, artifactVersionId: 1 }, { unique: true }); + +export default artifactVersionSchema; diff --git a/packages/data-schemas/src/schema/index.ts b/packages/data-schemas/src/schema/index.ts index 21f77ee052b..9df3a322f16 100644 --- a/packages/data-schemas/src/schema/index.ts +++ b/packages/data-schemas/src/schema/index.ts @@ -42,3 +42,5 @@ export { default as queuedTurnSchema } from './queuedTurn'; export { default as queuedTurnSequenceSchema } from './queuedTurnSequence'; export { default as scheduleSchema } from './schedule'; export { default as scheduleRunSchema } from './scheduleRun'; +export { default as artifactAppSchema } from './artifactApp'; +export { default as artifactVersionSchema } from './artifactVersion'; diff --git a/packages/data-schemas/src/schema/role.ts b/packages/data-schemas/src/schema/role.ts index 9b55d7112c6..c6c1f22f723 100644 --- a/packages/data-schemas/src/schema/role.ts +++ b/packages/data-schemas/src/schema/role.ts @@ -29,6 +29,12 @@ const rolePermissionsSchema = new Schema( [Permissions.SHARE]: { type: Boolean }, [Permissions.SHARE_PUBLIC]: { type: Boolean }, }, + [PermissionTypes.ARTIFACTS]: { + [Permissions.USE]: { type: Boolean }, + [Permissions.CREATE]: { type: Boolean }, + [Permissions.SHARE]: { type: Boolean }, + [Permissions.SHARE_PUBLIC]: { type: Boolean }, + }, [PermissionTypes.MULTI_CONVO]: { [Permissions.USE]: { type: Boolean }, }, diff --git a/packages/data-schemas/src/types/admin.ts b/packages/data-schemas/src/types/admin.ts index 665a1623e1b..f3cfe024cca 100644 --- a/packages/data-schemas/src/types/admin.ts +++ b/packages/data-schemas/src/types/admin.ts @@ -56,6 +56,7 @@ export const AUDIT_CATEGORIES = [ 'permission', 'auth', 'approval', + 'artifact', ] as const; export type AuditCategory = (typeof AUDIT_CATEGORIES)[number]; @@ -70,6 +71,23 @@ export const AUDIT_ACTIONS = [ 'grant.removed', 'permission.insights_assigned', 'permission.insights_removed', + 'artifact_app.created', + 'artifact_app.updated', + 'artifact_app.submitted', + 'artifact_app.approved', + 'artifact_app.rejected', + 'artifact_app.published', + 'artifact_app.suspended', + 'artifact_app.archived', + 'artifact_app.forked', + 'artifact_app.embedded', + 'artifact_version.created', + 'artifact_version.released', + 'artifact_version.activated', + 'artifact_version.withdrawn', + 'artifact_acl.granted', + 'artifact_acl.updated', + 'artifact_acl.revoked', ] as const; export type AuditAction = (typeof AUDIT_ACTIONS)[number]; @@ -79,6 +97,23 @@ export const AUDIT_ACTION_CATEGORY: Record = { 'grant.removed': 'grant', 'permission.insights_assigned': 'permission', 'permission.insights_removed': 'permission', + 'artifact_app.created': 'artifact', + 'artifact_app.updated': 'artifact', + 'artifact_app.submitted': 'artifact', + 'artifact_app.approved': 'artifact', + 'artifact_app.rejected': 'artifact', + 'artifact_app.published': 'artifact', + 'artifact_app.suspended': 'artifact', + 'artifact_app.archived': 'artifact', + 'artifact_app.forked': 'artifact', + 'artifact_app.embedded': 'artifact', + 'artifact_version.created': 'artifact', + 'artifact_version.released': 'artifact', + 'artifact_version.activated': 'artifact', + 'artifact_version.withdrawn': 'artifact', + 'artifact_acl.granted': 'artifact', + 'artifact_acl.updated': 'artifact', + 'artifact_acl.revoked': 'artifact', }; /** Result of the audited operation. Kept first-class instead of being encoded diff --git a/packages/data-schemas/src/types/artifactApp.ts b/packages/data-schemas/src/types/artifactApp.ts new file mode 100644 index 00000000000..843811b759f --- /dev/null +++ b/packages/data-schemas/src/types/artifactApp.ts @@ -0,0 +1,189 @@ +import type { + ArtifactAppStatus, + ArtifactAppVisibility, + ArtifactRuntimeType, + ArtifactVersionState, + ArtifactRiskClass, + ArtifactCostClass, +} from 'librechat-data-provider'; +import type { Document, Types } from 'mongoose'; + +export interface IArtifactAppToolPolicy { + enabled: boolean; + allowedServers: string[]; + allowedTools: string[]; + requireConfirmationForWrites: boolean; +} + +export interface IArtifactAppMarketplace { + listed: boolean; + featured: boolean; + summary?: string; + riskClass: ArtifactRiskClass; + costClass: ArtifactCostClass; +} + +export interface IArtifactAppSourceMetadata { + conversationId?: string; + messageId?: string; + originalArtifactId?: string; + /** Stable identity within a conversation, independent of the rendering message. */ + sourceKey?: string; +} + +export interface IArtifactAppSyncLock { + token: string; + expiresAt: Date; +} + +export interface ArtifactAppListCursor { + updatedAt: Date; + _id: Types.ObjectId; +} + +export interface ArtifactAppListOptions { + cursor?: ArtifactAppListCursor; + limit?: number; +} + +export interface IArtifactAppReview { + submittedAt?: Date; + submittedBy?: string; + reviewedAt?: Date; + reviewedBy?: string; + result?: 'approved' | 'rejected'; + comment?: string; +} + +export interface IArtifactApp extends Document { + artifactAppId: string; + tenantId?: string; + + title: string; + description?: string; + icon?: string; + category?: string; + tags?: string[]; + + createdBy: string; + activeVersionId?: string; + latestVersionNumber: number; + + status: ArtifactAppStatus; + visibility: ArtifactAppVisibility; + + allowEmbed: boolean; + allowFork: boolean; + allowAnonymousView: boolean; + + toolPolicy: IArtifactAppToolPolicy; + marketplace: IArtifactAppMarketplace; + sourceMetadata?: IArtifactAppSourceMetadata; + syncLock?: IArtifactAppSyncLock; + review?: IArtifactAppReview; + + createdAt: Date; + updatedAt: Date; + archivedAt?: Date; +} + +export interface IArtifactVersionRuntimeConfig { + dependencies?: Record; + entryPoint?: string; + renderMode?: string; +} + +export interface IArtifactVersionIntegrity { + sourceHash: string; + schemaVersion: number; +} + +export interface IArtifactVersionPublication { + state: ArtifactVersionState; + releasedBy?: string; + releasedAt?: Date; +} + +export interface IArtifactVersion extends Document { + artifactVersionId: string; + artifactAppId: string; + tenantId?: string; + + versionNumber: number; + versionLabel?: string; + changelog?: string; + + artifactType: ArtifactRuntimeType; + sourceSnapshot: string; + + runtimeConfig: IArtifactVersionRuntimeConfig; + integrity: IArtifactVersionIntegrity; + + createdBy: string; + createdAt: Date; + + publication: IArtifactVersionPublication; +} + +/** Version-1 create input threaded through the atomic publish transaction. */ +export interface CreateArtifactAppInput { + tenantId?: string; + createdBy: string; + title: string; + description?: string; + icon?: string; + category?: string; + tags?: string[]; + visibility: ArtifactAppVisibility; + allowEmbed?: boolean; + allowFork?: boolean; + allowAnonymousView?: boolean; + toolPolicy?: Partial; + marketplace?: Partial; + sourceMetadata?: IArtifactAppSourceMetadata; + version: CreateArtifactVersionInput; +} + +export interface CreateArtifactVersionInput { + artifactType: ArtifactRuntimeType; + sourceSnapshot: string; + runtimeConfig?: IArtifactVersionRuntimeConfig; + versionLabel?: string; + changelog?: string; + createdBy: string; +} + +export interface ArtifactAppWithVersion { + app: IArtifactApp; + version: IArtifactVersion; +} + +export interface SyncArtifactAppResult extends ArtifactAppWithVersion { + created: boolean; + versionCreated: boolean; +} + +export interface ArtifactAppSourceQuery { + tenantId?: string; + createdBy: string; + conversationId: string; + sourceKey: string; +} + +export type ArtifactAppQuery = { + artifactAppId: string; + tenantId?: string; +}; + +export type ArtifactVersionQuery = { + artifactAppId: string; + artifactVersionId?: string; + versionNumber?: number; + tenantId?: string; +}; + +/** Sole-argument object used to resolve a resource `_id` for ACL checks. */ +export type ArtifactAppIdResolution = { + _id: Types.ObjectId; + artifactAppId: string; +}; diff --git a/packages/data-schemas/src/types/index.ts b/packages/data-schemas/src/types/index.ts index 92ace38060f..ab75b8ca678 100644 --- a/packages/data-schemas/src/types/index.ts +++ b/packages/data-schemas/src/types/index.ts @@ -52,3 +52,5 @@ export * from './web'; /* MCP Servers */ export * from './mcp'; export * from './mcpAuthority'; +/* Artifact Apps */ +export * from './artifactApp'; diff --git a/packages/data-schemas/src/types/role.ts b/packages/data-schemas/src/types/role.ts index 9a5807013d7..c23de59c30c 100644 --- a/packages/data-schemas/src/types/role.ts +++ b/packages/data-schemas/src/types/role.ts @@ -28,6 +28,12 @@ export interface IRole extends Document { [Permissions.SHARE]?: boolean; [Permissions.SHARE_PUBLIC]?: boolean; }; + [PermissionTypes.ARTIFACTS]?: { + [Permissions.USE]?: boolean; + [Permissions.CREATE]?: boolean; + [Permissions.SHARE]?: boolean; + [Permissions.SHARE_PUBLIC]?: boolean; + }; [PermissionTypes.MULTI_CONVO]?: { [Permissions.USE]?: boolean; }; From d52e170b688a1ca7d9875feb087abaf03dd16566 Mon Sep 17 00:00:00 2001 From: Romuald Wandji Date: Fri, 11 Sep 2026 16:38:31 +0200 Subject: [PATCH 02/14] fix(artifact-apps): harden marketplace persistence --- .../canAccessArtifactAppResource.spec.js | 12 +- api/server/routes/artifactApps.js | 11 +- api/server/routes/config.js | 1 + api/server/services/start/migration.js | 6 +- .../ArtifactApps/ArtifactAppsList.spec.tsx | 6 +- .../ArtifactCatalogRegistrar.spec.tsx | 40 ++ .../ArtifactApps/ArtifactCatalogRegistrar.tsx | 34 +- .../ArtifactApps/StandaloneAppView.spec.tsx | 115 ++++++ .../ArtifactApps/StandaloneAppView.tsx | 44 ++- .../components/ArtifactApps/runtime.spec.ts | 1 + client/src/components/ArtifactApps/runtime.ts | 1 + client/src/components/Artifacts/Artifacts.tsx | 8 +- .../src/components/Chat/Presentation.spec.tsx | 2 +- .../data-provider/ArtifactApps/mutations.ts | 19 +- .../src/data-provider/ArtifactApps/queries.ts | 45 ++- .../Artifacts/__tests__/useArtifacts.test.ts | 10 +- client/src/utils/artifactCatalog.spec.ts | 11 +- client/src/utils/artifactCatalog.ts | 3 +- librechat.example.yaml | 14 + .../artifactApps/__tests__/handlers.spec.ts | 241 ++++++++----- packages/api/src/artifactApps/handlers.ts | 328 ++++++++--------- packages/data-provider/src/artifactApps.ts | 83 ++++- packages/data-provider/src/config.spec.ts | 43 +++ packages/data-provider/src/config.ts | 4 + packages/data-provider/src/data-service.ts | 24 +- .../src/methods/artifactApp.spec.ts | 125 +++++-- .../data-schemas/src/methods/artifactApp.ts | 341 ++++++++++++------ .../src/migrations/artifactAppIndexes.spec.ts | 80 ++++ .../src/migrations/artifactAppIndexes.ts | 5 + .../src/schema/artifactVersion.ts | 1 + .../data-schemas/src/types/artifactApp.ts | 70 +++- 31 files changed, 1200 insertions(+), 528 deletions(-) create mode 100644 client/src/components/ArtifactApps/StandaloneAppView.spec.tsx create mode 100644 packages/data-schemas/src/migrations/artifactAppIndexes.spec.ts diff --git a/api/server/middleware/accessResources/canAccessArtifactAppResource.spec.js b/api/server/middleware/accessResources/canAccessArtifactAppResource.spec.js index b2fb9c71139..756bb35586a 100644 --- a/api/server/middleware/accessResources/canAccessArtifactAppResource.spec.js +++ b/api/server/middleware/accessResources/canAccessArtifactAppResource.spec.js @@ -95,7 +95,7 @@ describe('canAccessArtifactAppResource middleware', () => { 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 }); + await grant({ principalId: testUser._id, resourceId: app.id, permBits: OWNER_BITS }); req.params.id = app.artifactAppId; await canAccessArtifactAppResource({ requiredPermission: VIEW })(req, res, next); @@ -112,7 +112,7 @@ describe('canAccessArtifactAppResource middleware', () => { role: 'test-role', }); const { app } = await publishApp(otherUser._id); - await grant({ principalId: otherUser._id, resourceId: app._id, permBits: OWNER_BITS }); + await grant({ principalId: otherUser._id, resourceId: app.id, permBits: OWNER_BITS }); req.params.id = app.artifactAppId; await canAccessArtifactAppResource({ requiredPermission: VIEW })(req, res, next); @@ -132,7 +132,7 @@ describe('canAccessArtifactAppResource middleware', () => { ['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 }); + await grant({ principalId: testUser._id, resourceId: app.id, permBits: VIEW }); req.params.id = app.artifactAppId; await canAccessArtifactAppResource({ requiredPermission })(req, res, next); @@ -143,7 +143,7 @@ describe('canAccessArtifactAppResource middleware', () => { 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 }); + await grant({ principalId: testUser._id, resourceId: app.id, permBits: VIEW | EDIT }); req.params.id = app.artifactAppId; await canAccessArtifactAppResource({ requiredPermission: EDIT })(req, res, next); @@ -174,7 +174,7 @@ describe('canAccessArtifactAppResource middleware', () => { principalId: testUser._id, principalModel: PrincipalModel.USER, resourceType: ResourceType.AGENT, - resourceId: app._id, + resourceId: app.id, permBits: OWNER_BITS, grantedBy: testUser._id, }); @@ -188,7 +188,7 @@ describe('canAccessArtifactAppResource middleware', () => { 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 }); + await grant({ principalId: testUser._id, resourceId: app.id, permBits: OWNER_BITS }); req.params.appId = app.artifactAppId; await canAccessArtifactAppResource({ diff --git a/api/server/routes/artifactApps.js b/api/server/routes/artifactApps.js index 5eb81967cbf..92e80d3757a 100644 --- a/api/server/routes/artifactApps.js +++ b/api/server/routes/artifactApps.js @@ -6,13 +6,11 @@ const { syncArtifactAppWithVersion, getArtifactAppByAppId, getArtifactAppBySource, - resolveArtifactAppId, listArtifactApps, updateArtifactApp, deleteArtifactApp, getArtifactVersion, listArtifactVersions, - createArtifactVersion, releaseArtifactVersion, activateArtifactVersion, withdrawArtifactVersion, @@ -47,19 +45,18 @@ const handlers = createArtifactAppHandlers({ syncArtifactAppWithVersion, getArtifactAppByAppId, getArtifactAppBySource, - resolveArtifactAppId, listArtifactApps, updateArtifactApp, deleteArtifactApp, getArtifactVersion, listArtifactVersions, - createArtifactVersion, releaseArtifactVersion, activateArtifactVersion, withdrawArtifactVersion, getResourcePermissionsMap, grantPermission, recordAuditEntry, + getConfig: (req) => req.config?.artifactApps, }); // Collection @@ -95,12 +92,6 @@ router.get( canAccessArtifactAppResource({ requiredPermission: PermissionBits.VIEW }), handlers.listVersions, ); -router.post( - '/:id/versions', - checkArtifactAccess, - canAccessArtifactAppResource({ requiredPermission: PermissionBits.EDIT }), - handlers.createVersion, -); router.get( '/:id/versions/:versionId', checkArtifactAccess, diff --git a/api/server/routes/config.js b/api/server/routes/config.js index b5ebdb9d612..506b9189b1c 100644 --- a/api/server/routes/config.js +++ b/api/server/routes/config.js @@ -292,6 +292,7 @@ router.get('/', async function (req, res) { ...preLoginPayload, ...publicSharePayload, ...buildPostLoginPayload(), + artifactApps: appConfig?.artifactApps, sharedLinksSnapshotFilesEnabled: sharedLinksEnabled && isFileSnapshotEnabled(appConfig), socialLogins: appConfig?.registration?.socialLogins ?? defaultSocialLogins, interface: appConfig?.interfaceConfig, diff --git a/api/server/services/start/migration.js b/api/server/services/start/migration.js index 25cccb271b9..607fd41dff4 100644 --- a/api/server/services/start/migration.js +++ b/api/server/services/start/migration.js @@ -13,11 +13,7 @@ const { findRoleByIdentifier } = require('~/models'); * This runs at the end to ensure all systems are initialized */ async function checkMigrations() { - try { - await ensureArtifactAppIndexes(mongoose.connection); - } catch (error) { - logger.error('Failed to ensure artifact catalog indexes:', error); - } + await ensureArtifactAppIndexes(mongoose.connection); try { const agentMigrationResult = await checkAgentPermissionsMigration({ mongoose, diff --git a/client/src/components/ArtifactApps/ArtifactAppsList.spec.tsx b/client/src/components/ArtifactApps/ArtifactAppsList.spec.tsx index 3ceedf1ccc5..2f055b8caf5 100644 --- a/client/src/components/ArtifactApps/ArtifactAppsList.spec.tsx +++ b/client/src/components/ArtifactApps/ArtifactAppsList.spec.tsx @@ -100,7 +100,7 @@ const apps = [ conversationId: 'conversation-1', messageId: 'message-2', originalArtifactId: 'artifact-revision-2', - sourceKey: 'identifier:quarterly-report:application/vnd.react', + sourceKey: 'identifier:quarterly-report', }, }, { @@ -215,14 +215,14 @@ describe('ArtifactAppsList', () => { expect(window.location.pathname).toBe('/c/conversation-1'); expect(new URLSearchParams(window.location.search).get('artifact')).toBe( - 'identifier:quarterly-report:application/vnd.react', + 'identifier:quarterly-report', ); expect(new URLSearchParams(window.location.search).get('artifactId')).toBe( 'artifact-revision-2', ); expect(mockSetArtifactNavigationRequest).toHaveBeenCalledWith({ conversationId: 'conversation-1', - sourceKey: 'identifier:quarterly-report:application/vnd.react', + sourceKey: 'identifier:quarterly-report', originalArtifactId: 'artifact-revision-2', messageId: 'message-2', }); diff --git a/client/src/components/ArtifactApps/ArtifactCatalogRegistrar.spec.tsx b/client/src/components/ArtifactApps/ArtifactCatalogRegistrar.spec.tsx index a4b785ba122..bf161f3b8c1 100644 --- a/client/src/components/ArtifactApps/ArtifactCatalogRegistrar.spec.tsx +++ b/client/src/components/ArtifactApps/ArtifactCatalogRegistrar.spec.tsx @@ -8,6 +8,7 @@ const mockSetQueryData = jest.fn(); const mockInvalidateQueries = jest.fn(); const mockUseRecoilValue = jest.fn(); const mockUseArtifactsContext = jest.fn(); +let mockStartupConfig: { artifactApps?: Record } | undefined; jest.mock('librechat-data-provider', () => { const actual = jest.requireActual('librechat-data-provider'); @@ -35,6 +36,10 @@ jest.mock('~/Providers', () => ({ useArtifactsContext: () => mockUseArtifactsContext(), })); +jest.mock('~/data-provider', () => ({ + useGetStartupConfig: () => ({ data: mockStartupConfig }), +})); + jest.mock('~/hooks/Roles/useHasAccess', () => ({ __esModule: true, default: () => true, @@ -71,6 +76,7 @@ describe('ArtifactCatalogRegistrar', () => { beforeEach(() => { jest.useFakeTimers(); jest.clearAllMocks(); + mockStartupConfig = undefined; artifacts = { 'artifact-1': makeArtifact() }; context = { conversationId: 'conversation-1', @@ -230,6 +236,40 @@ describe('ArtifactCatalogRegistrar', () => { expect(syncArtifactApp).toHaveBeenCalledTimes(2); }); + it('uses retry and settle timings supplied by startup configuration', async () => { + mockStartupConfig = { + artifactApps: { + clientSyncSettleDelayMs: 25, + clientSyncRetryBaseDelayMs: 100, + clientSyncRetryMaxDelayMs: 100, + }, + }; + artifacts = {}; + syncArtifactApp.mockRejectedValueOnce(new Error('temporary failure')); + const { rerender } = render(); + + context = { ...context, isSubmitting: true }; + rerender(); + artifacts = { 'artifact-new': makeArtifact({ id: 'artifact-new' }) }; + rerender(); + context = { ...context, isSubmitting: false }; + rerender(); + + await act(async () => { + jest.advanceTimersByTime(25); + await Promise.resolve(); + await Promise.resolve(); + }); + expect(syncArtifactApp).toHaveBeenCalledTimes(1); + + await act(async () => { + jest.advanceTimersByTime(100); + await Promise.resolve(); + await Promise.resolve(); + }); + expect(syncArtifactApp).toHaveBeenCalledTimes(2); + }); + it('does not retry permanent registration failures', async () => { artifacts = {}; syncArtifactApp.mockRejectedValueOnce({ response: { status: 400 } }); diff --git a/client/src/components/ArtifactApps/ArtifactCatalogRegistrar.tsx b/client/src/components/ArtifactApps/ArtifactCatalogRegistrar.tsx index 522124b11e2..c75cb4170b3 100644 --- a/client/src/components/ArtifactApps/ArtifactCatalogRegistrar.tsx +++ b/client/src/components/ArtifactApps/ArtifactCatalogRegistrar.tsx @@ -7,18 +7,16 @@ import { PermissionTypes, QueryKeys, dataService, + DEFAULT_ARTIFACT_APPS_CONFIG, } from 'librechat-data-provider'; import type { TSyncArtifactAppRequest } from 'librechat-data-provider'; import { toLatestArtifactSyncRequests } from '~/utils/artifactCatalog'; import useHasAccess from '~/hooks/Roles/useHasAccess'; +import { useGetStartupConfig } from '~/data-provider'; import { useArtifactsContext } from '~/Providers'; import { logger } from '~/utils'; import store from '~/store'; -const SYNC_SETTLE_DELAY_MS = 500; -const SYNC_RETRY_BASE_DELAY_MS = 1000; -const SYNC_RETRY_MAX_DELAY_MS = 30_000; - interface PendingSync { request: TSyncArtifactAppRequest; signature: string; @@ -47,6 +45,16 @@ export default function ArtifactCatalogRegistrar() { const artifacts = useRecoilValue(store.artifactsState); const { conversationId, isSubmitting, latestMessageId } = useArtifactsContext(); const queryClient = useQueryClient(); + const { data: startupConfig } = useGetStartupConfig(); + const syncSettleDelayMs = + startupConfig?.artifactApps?.clientSyncSettleDelayMs ?? + DEFAULT_ARTIFACT_APPS_CONFIG.clientSyncSettleDelayMs; + const syncRetryBaseDelayMs = + startupConfig?.artifactApps?.clientSyncRetryBaseDelayMs ?? + DEFAULT_ARTIFACT_APPS_CONFIG.clientSyncRetryBaseDelayMs; + const syncRetryMaxDelayMs = + startupConfig?.artifactApps?.clientSyncRetryMaxDelayMs ?? + DEFAULT_ARTIFACT_APPS_CONFIG.clientSyncRetryMaxDelayMs; const canCreate = useHasAccess({ permissionType: PermissionTypes.ARTIFACTS, permission: Permissions.CREATE, @@ -91,7 +99,7 @@ export default function ArtifactCatalogRegistrar() { pending.request.source.conversationId, pending.request.source.sourceKey, ], - result, + result.app, ); successfulHashesRef.current.set(syncKey, pending.signature); if (pendingSyncsRef.current.get(syncKey)?.signature === pending.signature) { @@ -126,10 +134,10 @@ export default function ArtifactCatalogRegistrar() { ...Array.from(pendingSyncsRef.current.values(), ({ failures }) => failures), ); scheduleSync( - Math.min(SYNC_RETRY_BASE_DELAY_MS * 2 ** Math.min(failures, 5), SYNC_RETRY_MAX_DELAY_MS), + Math.min(syncRetryBaseDelayMs * 2 ** Math.min(failures, 5), syncRetryMaxDelayMs), ); } - }, [canCreate, queryClient, scheduleSync]); + }, [canCreate, queryClient, scheduleSync, syncRetryBaseDelayMs, syncRetryMaxDelayMs]); useEffect(() => { flushPendingRef.current = flushPending; @@ -198,9 +206,17 @@ export default function ArtifactCatalogRegistrar() { queued = true; } if (queued || pendingSyncsRef.current.size > 0) { - scheduleSync(SYNC_SETTLE_DELAY_MS); + scheduleSync(syncSettleDelayMs); } - }, [artifacts, canCreate, conversationId, isSubmitting, latestMessageId, scheduleSync]); + }, [ + artifacts, + canCreate, + conversationId, + isSubmitting, + latestMessageId, + scheduleSync, + syncSettleDelayMs, + ]); return null; } diff --git a/client/src/components/ArtifactApps/StandaloneAppView.spec.tsx b/client/src/components/ArtifactApps/StandaloneAppView.spec.tsx new file mode 100644 index 00000000000..2cadd8834d5 --- /dev/null +++ b/client/src/components/ArtifactApps/StandaloneAppView.spec.tsx @@ -0,0 +1,115 @@ +import React from 'react'; +import { fireEvent, render, screen } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import type { TArtifactApp, TArtifactVersion } from 'librechat-data-provider'; +import { + useGetArtifactAppQuery, + useGetArtifactAppVersionQuery, + useListArtifactAppVersionsQuery, +} from '~/data-provider'; +import StandaloneAppView from './StandaloneAppView'; + +const mockNavigate = jest.fn(); +let mockVersionId: string | undefined; + +jest.mock('react-router-dom', () => ({ + useParams: () => ({ artifactAppId: 'app-1', versionId: mockVersionId }), + useNavigate: () => mockNavigate, +})); + +jest.mock('~/data-provider', () => ({ + useGetArtifactAppQuery: jest.fn(), + useGetArtifactAppVersionQuery: jest.fn(), + useListArtifactAppVersionsQuery: jest.fn(), +})); + +jest.mock('~/hooks/useLocalize', () => ({ + __esModule: true, + default: () => (key: string) => key, +})); + +jest.mock('./AppRenderer', () => ({ + __esModule: true, + default: ({ version }: { version: TArtifactVersion }) => ( +
{version.sourceSnapshot}
+ ), +})); + +const app = { + artifactAppId: 'app-1', + activeVersionId: 'version-2', + title: 'Revenue chart', + status: 'draft', +} as TArtifactApp; + +const version = { + artifactAppId: 'app-1', + artifactVersionId: 'version-2', + versionNumber: 2, + sourceSnapshot: 'selected snapshot', +} as TArtifactVersion; + +describe('StandaloneAppView', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockVersionId = undefined; + jest.mocked(useGetArtifactAppQuery).mockReturnValue({ + data: app, + isLoading: false, + isError: false, + } as ReturnType); + jest.mocked(useGetArtifactAppVersionQuery).mockReturnValue({ + data: version, + isLoading: false, + isError: false, + } as ReturnType); + jest.mocked(useListArtifactAppVersionsQuery).mockReturnValue({ + data: { + pages: [ + { + versions: [ + { + artifactAppId: 'app-1', + artifactVersionId: 'version-2', + versionNumber: 2, + }, + ], + has_more: true, + after: 'next-page', + }, + ], + pageParams: [undefined], + }, + hasNextPage: true, + isFetchingNextPage: false, + fetchNextPage: jest.fn(), + } as unknown as ReturnType); + }); + + it('fetches only the active full snapshot and renders it', () => { + render(); + + expect(useGetArtifactAppVersionQuery).toHaveBeenCalledWith('app-1', 'version-2', { + enabled: true, + }); + expect(screen.getByTestId('artifact-renderer')).toHaveTextContent('selected snapshot'); + }); + + it('loads additional metadata pages on demand', () => { + const fetchNextPage = jest.fn(); + jest.mocked(useListArtifactAppVersionsQuery).mockReturnValue({ + data: { + pages: [{ versions: [], has_more: true, after: 'next-page' }], + pageParams: [undefined], + }, + hasNextPage: true, + isFetchingNextPage: false, + fetchNextPage, + } as unknown as ReturnType); + + render(); + fireEvent.click(screen.getByRole('button', { name: 'com_ui_load_more' })); + + expect(fetchNextPage).toHaveBeenCalledTimes(1); + }); +}); diff --git a/client/src/components/ArtifactApps/StandaloneAppView.tsx b/client/src/components/ArtifactApps/StandaloneAppView.tsx index 8df4174a0bb..97df30d4915 100644 --- a/client/src/components/ArtifactApps/StandaloneAppView.tsx +++ b/client/src/components/ArtifactApps/StandaloneAppView.tsx @@ -32,8 +32,9 @@ export default function StandaloneAppView() { }>(); const appQuery = useGetArtifactAppQuery(artifactAppId); - const versionQuery = useGetArtifactAppVersionQuery(artifactAppId, versionId, { - enabled: !!versionId, + const selectedVersionId = versionId ?? appQuery.data?.activeVersionId; + const versionQuery = useGetArtifactAppVersionQuery(artifactAppId, selectedVersionId, { + enabled: !!selectedVersionId, }); const versionsQuery = useListArtifactAppVersionsQuery(artifactAppId); @@ -49,11 +50,11 @@ export default function StandaloneAppView() { return ; } - if (versionId && versionQuery.isLoading) { + if (selectedVersionId && versionQuery.isLoading) { return ; } - if (versionId && versionQuery.isError) { + if (selectedVersionId && versionQuery.isError) { const status = errorStatus(versionQuery.error); if (status === 403) { return ; @@ -61,7 +62,7 @@ export default function StandaloneAppView() { return ; } - const app = appQuery.data?.app; + const app = appQuery.data; if (!app) { return ; } @@ -73,11 +74,13 @@ export default function StandaloneAppView() { return ; } - const version: TArtifactVersion | null | undefined = versionId - ? versionQuery.data - : appQuery.data?.version; - - const versions = versionsQuery.data?.versions ?? []; + const version: TArtifactVersion | null | undefined = versionQuery.data; + const versions = versionsQuery.data?.pages.flatMap((page) => page.versions) ?? []; + const versionOptions = + !version || + versions.some((candidate) => candidate.artifactVersionId === version.artifactVersionId) + ? versions + : [version, ...versions]; return (
@@ -88,20 +91,35 @@ export default function StandaloneAppView() {

{app.description}

)}
- {versions.length > 0 && ( -
+ ); +} + +function ArtifactAppMenu({ + app, + isPinned, + onTogglePin, +}: { + app: TArtifactApp; + isPinned: boolean; + onTogglePin: (artifactAppId: string) => void; +}) { + const localize = useLocalize(); + const { showToast } = useToastContext(); + const canShare = useCanShareArtifactApp(app); + const [menuOpen, setMenuOpen] = useState(false); + const [shareOpen, setShareOpen] = useState(false); + + const copyLink = async () => { + try { + await navigator.clipboard.writeText(getArtifactAppUrl(app.artifactAppId)); + showToast({ status: 'success', message: localize('com_ui_artifact_link_copied') }); + } catch { + showToast({ status: 'error', message: localize('com_ui_copy_failed') }); + } + }; + + const items: MenuItemProps[] = [ + { + id: `artifact-pin-${app.artifactAppId}`, + label: localize(isPinned ? 'com_ui_unpin' : 'com_ui_pin'), + icon: isPinned ? : , + ariaChecked: isPinned, + onClick: () => onTogglePin(app.artifactAppId), + }, + { + id: `artifact-copy-link-${app.artifactAppId}`, + label: localize('com_ui_copy_link'), + icon: , + onClick: () => void copyLink(), + }, + ]; + + if (canShare) { + items.push({ + id: `artifact-share-${app.artifactAppId}`, + label: localize('com_ui_share'), + icon: , + onClick: () => setShareOpen(true), + }); + } + + return ( + <> + {shareOpen && ( + { + if (!open) { + setShareOpen(false); + } + }} + /> + )} + +
diff --git a/client/src/components/ArtifactApps/ArtifactCatalogRegistrar.spec.tsx b/client/src/components/ArtifactApps/ArtifactCatalogRegistrar.spec.tsx index 217304b8d0a..1645338d1e7 100644 --- a/client/src/components/ArtifactApps/ArtifactCatalogRegistrar.spec.tsx +++ b/client/src/components/ArtifactApps/ArtifactCatalogRegistrar.spec.tsx @@ -114,7 +114,7 @@ describe('ArtifactCatalogRegistrar', () => { expect(mockEnqueueArtifactSync).toHaveBeenCalledTimes(1); expect(mockEnqueueArtifactSync.mock.calls[0]?.[1].source.sourceKey).toContain('new-chart'); - expect(mockEnqueueArtifactSync.mock.calls[0]?.[3]).toBe(500); + expect(mockEnqueueArtifactSync.mock.calls[0]?.[3]).toBe(5_500); }); it('continues observing previews that resolve after generation completion', async () => { @@ -240,7 +240,52 @@ describe('ArtifactCatalogRegistrar', () => { }); it('uses the configured settle delay when persisting registration work', async () => { - mockStartupConfig = { artifactApps: { clientSyncSettleDelayMs: 25 } }; + mockStartupConfig = { + artifactApps: { clientSyncSettleDelayMs: 25, clientPreviewCaptureTimeoutMs: 750 }, + }; + artifacts = {}; + const { rerender } = render(); + + context = { ...context, isSubmitting: true }; + rerender(); + artifacts = { 'artifact-new': makeArtifact() }; + rerender(); + context = { ...context, isSubmitting: false }; + rerender(); + await act(async () => Promise.resolve()); + + expect(mockEnqueueArtifactSync.mock.calls[0]?.[3]).toBe(775); + }); + + it('queues a captured preview promptly and includes it in the sync request', async () => { + const preview = { + type: 'image' as const, + imageUrl: + 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=', + alt: 'Revenue chart', + }; + artifacts = {}; + const { rerender } = render(); + + context = { ...context, isSubmitting: true }; + rerender(); + artifacts = { 'artifact-new': makeArtifact({ preview }) }; + rerender(); + context = { ...context, isSubmitting: false }; + rerender(); + await act(async () => Promise.resolve()); + + expect(mockEnqueueArtifactSync).toHaveBeenCalledTimes(1); + expect(mockEnqueueArtifactSync.mock.calls[0]?.[1].artifact.preview).toEqual(preview); + expect(mockEnqueueArtifactSync.mock.calls[0]?.[3]).toBe(500); + }); + + it('supersedes the delayed fallback when a preview arrives after generation', async () => { + const preview = { + type: 'image' as const, + imageUrl: + 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=', + }; artifacts = {}; const { rerender } = render(); @@ -252,6 +297,13 @@ describe('ArtifactCatalogRegistrar', () => { rerender(); await act(async () => Promise.resolve()); - expect(mockEnqueueArtifactSync.mock.calls[0]?.[3]).toBe(25); + artifacts = { 'artifact-new': makeArtifact({ preview }) }; + rerender(); + await act(async () => Promise.resolve()); + + expect(mockEnqueueArtifactSync).toHaveBeenCalledTimes(2); + expect(mockEnqueueArtifactSync.mock.calls[0]?.[3]).toBe(5_500); + expect(mockEnqueueArtifactSync.mock.calls[1]?.[1].artifact.preview).toEqual(preview); + expect(mockEnqueueArtifactSync.mock.calls[1]?.[3]).toBe(500); }); }); diff --git a/client/src/components/ArtifactApps/ArtifactCatalogRegistrar.tsx b/client/src/components/ArtifactApps/ArtifactCatalogRegistrar.tsx index 455d5f26894..3d224122809 100644 --- a/client/src/components/ArtifactApps/ArtifactCatalogRegistrar.tsx +++ b/client/src/components/ArtifactApps/ArtifactCatalogRegistrar.tsx @@ -33,6 +33,9 @@ export default function ArtifactCatalogRegistrar() { const syncSettleDelayMs = startupConfig?.artifactApps?.clientSyncSettleDelayMs ?? DEFAULT_ARTIFACT_APPS_CONFIG.clientSyncSettleDelayMs; + const previewCaptureTimeoutMs = + startupConfig?.artifactApps?.clientPreviewCaptureTimeoutMs ?? + DEFAULT_ARTIFACT_APPS_CONFIG.clientPreviewCaptureTimeoutMs; const canCreate = useHasAccess({ permissionType: PermissionTypes.ARTIFACTS, permission: Permissions.CREATE, @@ -111,7 +114,11 @@ export default function ArtifactCatalogRegistrar() { continue; } observedSignaturesRef.current.set(syncKey, signature); - void enqueueArtifactSync(user.id, request, signature, syncSettleDelayMs).catch((error) => { + const delayMs = + !request.artifact.preview && request.artifact.type !== 'code' + ? syncSettleDelayMs + previewCaptureTimeoutMs + : syncSettleDelayMs; + void enqueueArtifactSync(user.id, request, signature, delayMs).catch((error) => { if (observedSignaturesRef.current.get(syncKey) === signature) { observedSignaturesRef.current.delete(syncKey); } @@ -124,6 +131,7 @@ export default function ArtifactCatalogRegistrar() { conversationId, isSubmitting, latestMessageId, + previewCaptureTimeoutMs, syncSettleDelayMs, user?.id, ]); diff --git a/client/src/components/ArtifactApps/Share.spec.tsx b/client/src/components/ArtifactApps/Share.spec.tsx new file mode 100644 index 00000000000..b9717be9d12 --- /dev/null +++ b/client/src/components/ArtifactApps/Share.spec.tsx @@ -0,0 +1,171 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import { PermissionBits, PermissionTypes, Permissions, SystemRoles } from 'librechat-data-provider'; +import type { TArtifactApp } from 'librechat-data-provider'; +import ArtifactAppShareDialog from './Share'; + +const mockUseAuthContext = jest.fn(); +const mockUseHasAccess = jest.fn(); + +jest.mock('~/hooks', () => ({ + useAuthContext: () => mockUseAuthContext(), + useHasAccess: (args: { permissionType: string; permission: string }) => mockUseHasAccess(args), +})); + +jest.mock('~/components/Sharing', () => ({ + GenericGrantAccessDialog: ({ + resourceDbId, + resourceId, + resourceName, + defaultOpen, + children, + }: { + resourceDbId?: string; + resourceId?: string; + resourceName?: string; + defaultOpen?: boolean; + children?: React.ReactNode; + }) => ( +
+ {resourceName} + {children} +
+ ), +})); + +const app = { + id: 'db-revenue-chart', + artifactAppId: 'revenue-chart', + createdBy: 'user-1', + title: 'Revenue chart', +} as TArtifactApp; + +function mockAccess({ + share = true, + viewUsers = false, + viewGroups = false, + viewRoles = false, + sharePublic = false, +}: { + share?: boolean; + viewUsers?: boolean; + viewGroups?: boolean; + viewRoles?: boolean; + sharePublic?: boolean; +} = {}) { + mockUseHasAccess.mockImplementation( + ({ permissionType, permission }: { permissionType: string; permission: string }) => { + if (permissionType === PermissionTypes.ARTIFACTS && permission === Permissions.SHARE) { + return share; + } + if (permissionType === PermissionTypes.ARTIFACTS && permission === Permissions.SHARE_PUBLIC) { + return sharePublic; + } + if ( + permissionType === PermissionTypes.PEOPLE_PICKER && + permission === Permissions.VIEW_USERS + ) { + return viewUsers; + } + if ( + permissionType === PermissionTypes.PEOPLE_PICKER && + permission === Permissions.VIEW_GROUPS + ) { + return viewGroups; + } + if ( + permissionType === PermissionTypes.PEOPLE_PICKER && + permission === Permissions.VIEW_ROLES + ) { + return viewRoles; + } + return false; + }, + ); +} + +describe('ArtifactAppShareDialog', () => { + beforeEach(() => { + mockUseAuthContext.mockReturnValue({ user: { id: 'user-1', role: SystemRoles.USER } }); + mockAccess({ share: true, viewUsers: true }); + }); + + it('exposes the share dialog for the artifact owner without a per-resource permission fetch', () => { + render(); + + const dialog = screen.getByTestId('artifact-share-dialog'); + expect(dialog).toHaveAttribute('data-resource-db-id', 'db-revenue-chart'); + expect(dialog).toHaveAttribute('data-resource-id', 'revenue-chart'); + expect(dialog).toHaveTextContent('Revenue chart'); + }); + + it('exposes the share dialog for an admin who does not own the artifact', () => { + mockUseAuthContext.mockReturnValue({ user: { id: 'admin-1', role: SystemRoles.ADMIN } }); + + render(); + + expect(screen.getByTestId('artifact-share-dialog')).toBeInTheDocument(); + }); + + it('exposes the share dialog when the catalog entry already includes SHARE', () => { + mockUseAuthContext.mockReturnValue({ user: { id: 'user-2', role: SystemRoles.USER } }); + + render(); + + expect(screen.getByTestId('artifact-share-dialog')).toBeInTheDocument(); + }); + + it('hides the share dialog from a viewer whose catalog entry only includes VIEW', () => { + mockUseAuthContext.mockReturnValue({ user: { id: 'user-2', role: SystemRoles.USER } }); + + render(); + + expect(screen.queryByTestId('artifact-share-dialog')).not.toBeInTheDocument(); + }); + + it('hides the share dialog when the ARTIFACTS SHARE permission is denied', () => { + mockAccess({ share: false, viewUsers: true }); + + render(); + + expect(screen.queryByTestId('artifact-share-dialog')).not.toBeInTheDocument(); + }); + + it('hides the share dialog when no user, group, or public destination is permitted', () => { + mockAccess({ share: true, viewRoles: true }); + + render(); + + expect(screen.queryByTestId('artifact-share-dialog')).not.toBeInTheDocument(); + }); + + it('exposes the share dialog when only people-picker users are permitted', () => { + mockAccess({ share: true, viewUsers: true }); + + render(); + + expect(screen.getByTestId('artifact-share-dialog')).toBeInTheDocument(); + }); + + it('exposes the share dialog when only people-picker groups are permitted', () => { + mockAccess({ share: true, viewGroups: true }); + + render(); + + expect(screen.getByTestId('artifact-share-dialog')).toBeInTheDocument(); + }); + + it('exposes the share dialog when only public sharing is permitted', () => { + mockAccess({ share: true, sharePublic: true }); + + render(); + + expect(screen.getByTestId('artifact-share-dialog')).toBeInTheDocument(); + }); +}); diff --git a/client/src/components/ArtifactApps/Share.tsx b/client/src/components/ArtifactApps/Share.tsx new file mode 100644 index 00000000000..c922fa027e5 --- /dev/null +++ b/client/src/components/ArtifactApps/Share.tsx @@ -0,0 +1,103 @@ +import { + SystemRoles, + Permissions, + ResourceType, + PermissionBits, + PermissionTypes, + hasPermissions, +} from 'librechat-data-provider'; +import type { TArtifactApp } from 'librechat-data-provider'; +import type { ReactNode } from 'react'; +import { GenericGrantAccessDialog } from '~/components/Sharing'; +import { useAuthContext, useHasAccess } from '~/hooks'; + +export function canShareArtifactApp({ + app, + userId, + userRole, + hasAccessToShare, + canViewUsers, + canViewGroups, + canSharePublic, +}: { + app: TArtifactApp; + userId?: string; + userRole?: string; + hasAccessToShare: boolean; + canViewUsers: boolean; + canViewGroups: boolean; + canSharePublic: boolean; +}): boolean { + if (!hasAccessToShare || !app.id) { + return false; + } + if (!canViewUsers && !canViewGroups && !canSharePublic) { + return false; + } + if (app.createdBy === userId || userRole === SystemRoles.ADMIN) { + return true; + } + return hasPermissions(app.permissionBits ?? 0, PermissionBits.SHARE); +} + +export function useCanShareArtifactApp(app: TArtifactApp): boolean { + const { user } = useAuthContext(); + const hasAccessToShare = useHasAccess({ + permissionType: PermissionTypes.ARTIFACTS, + permission: Permissions.SHARE, + }); + const canViewUsers = useHasAccess({ + permissionType: PermissionTypes.PEOPLE_PICKER, + permission: Permissions.VIEW_USERS, + }); + const canViewGroups = useHasAccess({ + permissionType: PermissionTypes.PEOPLE_PICKER, + permission: Permissions.VIEW_GROUPS, + }); + const canSharePublic = useHasAccess({ + permissionType: PermissionTypes.ARTIFACTS, + permission: Permissions.SHARE_PUBLIC, + }); + return canShareArtifactApp({ + app, + userId: user?.id, + userRole: user?.role, + hasAccessToShare, + canViewUsers, + canViewGroups, + canSharePublic, + }); +} + +export default function ArtifactAppShareDialog({ + app, + children, + buttonClassName, + defaultOpen = false, + onOpenChange, +}: { + app: TArtifactApp; + children?: ReactNode; + buttonClassName?: string; + defaultOpen?: boolean; + onOpenChange?: (open: boolean) => void; +}) { + const canShare = useCanShareArtifactApp(app); + if (!canShare) { + return null; + } + + return ( + + {children} + + ); +} diff --git a/client/src/components/ArtifactApps/StandaloneAppView.spec.tsx b/client/src/components/ArtifactApps/StandaloneAppView.spec.tsx index f02fa55c389..ec37ba1fa73 100644 --- a/client/src/components/ArtifactApps/StandaloneAppView.spec.tsx +++ b/client/src/components/ArtifactApps/StandaloneAppView.spec.tsx @@ -1,7 +1,12 @@ import React from 'react'; import { fireEvent, render, screen } from '@testing-library/react'; import '@testing-library/jest-dom'; -import type { TArtifactApp, TArtifactVersion } from 'librechat-data-provider'; +import type { + TArtifactApp, + TArtifactVersion, + TArtifactAppWithVersion, +} from 'librechat-data-provider'; +import type { QueryObserverLoadingErrorResult } from '@tanstack/react-query'; import { useGetArtifactAppQuery, useGetArtifactAppVersionQuery, @@ -28,6 +33,17 @@ jest.mock('~/hooks/useLocalize', () => ({ default: () => (key: string) => key, })); +jest.mock('~/hooks', () => ({ + useAuthContext: () => ({ user: { id: 'user-1', role: 'USER' } }), + useHasAccess: () => true, +})); + +jest.mock('~/components/Sharing', () => ({ + GenericGrantAccessDialog: ({ resourceName }: { resourceName?: string }) => ( + + )}
); } @@ -50,7 +67,16 @@ export default function StandaloneAppView() { if (status === 403) { return ; } - return ; + if (status === 404) { + return ; + } + return ( + void appQuery.refetch()} + isRetrying={appQuery.isFetching} + /> + ); } if (selectedVersionId && !includedVersionSelected && versionQuery.isLoading) { @@ -62,7 +88,16 @@ export default function StandaloneAppView() { if (status === 403) { return ; } - return ; + if (status === 404) { + return ; + } + return ( + void versionQuery.refetch()} + isRetrying={versionQuery.isFetching} + /> + ); } if (!app) { @@ -95,38 +130,62 @@ export default function StandaloneAppView() {

{app.description}

)} - {versionOptions.length > 0 && ( - + )} + + {versionsQuery.isError && ( +
+ {localize('com_ui_artifact_app_history_load_error')} + +
+ )}
{version ? ( diff --git a/client/src/components/ArtifactApps/Thumbnail.spec.tsx b/client/src/components/ArtifactApps/Thumbnail.spec.tsx new file mode 100644 index 00000000000..cab900019f7 --- /dev/null +++ b/client/src/components/ArtifactApps/Thumbnail.spec.tsx @@ -0,0 +1,72 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import type { TArtifactApp } from 'librechat-data-provider'; +import Thumbnail from './Thumbnail'; + +jest.mock('~/hooks', () => ({ useLocalize: () => (key: string) => key })); + +const app = { + artifactAppId: 'app-test', + title: 'Revenue dashboard', + activeVersionId: 'version-2', + preview: { + type: 'image', + imageUrl: + 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=', + alt: 'Preview of Revenue dashboard', + }, +} as TArtifactApp; + +it('renders the stored image without executing the artifact runtime', () => { + const { container } = render(); + const image = container.querySelector('img'); + + expect(image).toHaveAttribute('src', app.preview?.imageUrl); + expect(image).toHaveAttribute('alt', ''); + expect(image).toHaveAttribute('loading', 'lazy'); + expect(image).toHaveAttribute('decoding', 'async'); + expect(image).toHaveAttribute('referrerpolicy', 'no-referrer'); + expect(image).toHaveClass('object-cover', 'object-top'); + expect(container.firstChild).toHaveClass('aspect-[3/2]'); + expect(container.querySelector('iframe')).toBeNull(); +}); + +it('switches to a newly stored preview when the catalog changes', () => { + const { container, rerender } = render(); + rerender( + , + ); + + expect(container.querySelector('img')).toHaveAttribute( + 'src', + 'data:image/webp;base64,UklGRgQAAABXRUJQ', + ); +}); + +it.each([ + 'https://attacker.example/pixel.png', + '//attacker.example/pixel.png', + 'data:image/svg+xml;base64,PHN2Zy8+', +])('uses the fallback instead of loading an unsafe preview: %s', (imageUrl) => { + const { container } = render( + , + ); + + expect(screen.getByText('com_ui_artifact_app_preview_unavailable')).toBeInTheDocument(); + expect(container.querySelector('iframe, img')).toBeNull(); +}); + +it('shows a quiet fallback when no stored preview exists', () => { + const { container } = render(); + + expect(screen.getByText('com_ui_artifact_app_preview_unavailable')).toBeInTheDocument(); + expect(container.querySelector('iframe, img')).toBeNull(); +}); diff --git a/client/src/components/ArtifactApps/Thumbnail.tsx b/client/src/components/ArtifactApps/Thumbnail.tsx new file mode 100644 index 00000000000..34cfa96af5f --- /dev/null +++ b/client/src/components/ArtifactApps/Thumbnail.tsx @@ -0,0 +1,34 @@ +import { Shapes } from 'lucide-react'; +import { isAllowedArtifactPreviewUrl, type TArtifactApp } from 'librechat-data-provider'; +import { useLocalize } from '~/hooks'; + +export default function Thumbnail({ app }: { app: TArtifactApp }) { + const localize = useLocalize(); + const preview = + app.preview?.type === 'image' && isAllowedArtifactPreviewUrl(app.preview.imageUrl) + ? app.preview + : undefined; + + return ( + + ); +} diff --git a/client/src/components/ArtifactApps/navigation.ts b/client/src/components/ArtifactApps/navigation.ts new file mode 100644 index 00000000000..215c727947a --- /dev/null +++ b/client/src/components/ArtifactApps/navigation.ts @@ -0,0 +1,11 @@ +import { atom } from 'jotai'; + +export type ArtifactNavigationRequest = { + conversationId: string; + sourceKey: string; + originalArtifactId?: string; + messageId?: string; +}; + +/** Durable handoff from the artifact catalog to a hydrating conversation route. */ +export const artifactNavigationRequestAtom = atom(null); diff --git a/client/src/components/Artifacts/ArtifactPreview.test.tsx b/client/src/components/Artifacts/ArtifactPreview.test.tsx new file mode 100644 index 00000000000..deb7acefb0a --- /dev/null +++ b/client/src/components/Artifacts/ArtifactPreview.test.tsx @@ -0,0 +1,73 @@ +import React from 'react'; +import { act, render, screen } from '@testing-library/react'; +import type { SandpackPreviewRef } from '@codesandbox/sandpack-react/unstyled'; +import { ArtifactPreview } from './ArtifactPreview'; + +const mockSandpackPreview = jest.fn(() =>
); +const mockLocalize = jest.fn((key: string) => + key === 'com_ui_artifact_app_preview' ? 'Aperçu de l’artéfact' : key, +); + +jest.mock('@codesandbox/sandpack-react/unstyled', () => ({ + SandpackPreview: () => mockSandpackPreview(), + SandpackProvider: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), +})); +jest.mock('~/hooks', () => ({ useLocalize: () => mockLocalize })); + +describe('ArtifactPreview', () => { + beforeEach(() => { + mockSandpackPreview.mockClear(); + mockLocalize.mockClear(); + }); + + it('renders static HTML artifacts in a local iframe without Sandpack', () => { + const previewRef = { current: undefined } as React.MutableRefObject< + SandpackPreviewRef | undefined + >; + + render( +

Hello World!

' }} + fileKey="index.html" + template="static" + sharedProps={{}} + previewRef={previewRef as React.MutableRefObject} + />, + ); + + const iframe = screen.getByTitle('Aperçu de l’artéfact'); + expect(iframe).toHaveAttribute('srcdoc', expect.stringContaining('Hello World!')); + expect(iframe).toHaveAttribute( + 'srcdoc', + expect.stringContaining('librechat:artifact-preview:request'), + ); + expect(mockLocalize).toHaveBeenCalledWith('com_ui_artifact_app_preview'); + expect(mockSandpackPreview).not.toHaveBeenCalled(); + }); + + it('keeps the toolbar refresh action working for local static previews', () => { + const previewRef = { current: undefined } as React.MutableRefObject< + SandpackPreviewRef | undefined + >; + + render( +

Hello World!

' }} + fileKey="index.html" + template="static" + sharedProps={{}} + previewRef={previewRef as React.MutableRefObject} + />, + ); + + const firstIframe = screen.getByTitle('Aperçu de l’artéfact'); + + act(() => { + previewRef.current?.getClient()?.dispatch({ type: 'refresh' }); + }); + + expect(screen.getByTitle('Aperçu de l’artéfact')).not.toBe(firstIframe); + }); +}); diff --git a/client/src/components/Artifacts/ArtifactPreview.tsx b/client/src/components/Artifacts/ArtifactPreview.tsx index c49c22772e3..95f20ca3895 100644 --- a/client/src/components/Artifacts/ArtifactPreview.tsx +++ b/client/src/components/Artifacts/ArtifactPreview.tsx @@ -1,4 +1,4 @@ -import React, { memo, useMemo, type MutableRefObject } from 'react'; +import React, { memo, useEffect, useMemo, useState, type MutableRefObject } from 'react'; import { SandpackPreview, SandpackProvider } from '@codesandbox/sandpack-react/unstyled'; import type { SandpackProviderProps, @@ -6,7 +6,43 @@ import type { } from '@codesandbox/sandpack-react/unstyled'; import type { SandpackStartupConfig } from '~/utils/artifacts'; import type { ArtifactFiles } from '~/common'; -import { sharedFiles, buildSandpackOptions } from '~/utils/artifacts'; +import { sharedFiles, sharedOptions, buildSandpackOptions } from '~/utils/artifacts'; +import { ARTIFACT_PREVIEW_BRIDGE_SCRIPT } from '~/utils/artifactPreviewCapture'; +import { useLocalize } from '~/hooks'; + +type StaticPreviewRef = SandpackPreviewRef & { + __staticPreview?: true; +}; + +function getFileCode(file: unknown): string | undefined { + if (typeof file === 'string') { + return file; + } + + if (file && typeof file === 'object' && 'code' in file && typeof file.code === 'string') { + return file.code; + } + + return undefined; +} + +function injectExternalResources(html: string): string { + const externalScripts = sharedOptions?.externalResources + ?.filter((resource): resource is string => typeof resource === 'string' && resource.length > 0) + .map((resource) => resource.split('#')[0]) + .filter((resource) => !html.includes(resource)) + .map((resource) => ``) + .join('\n'); + const scripts = [``, externalScripts] + .filter(Boolean) + .join('\n'); + + if (/<\/head>/i.test(html)) { + return html.replace(/<\/head>/i, `${scripts}\n`); + } + + return `${scripts}\n${html}`; +} export const ArtifactPreview = memo(function ({ files, @@ -25,6 +61,7 @@ export const ArtifactPreview = memo(function ({ currentCode?: string; startupConfig?: SandpackStartupConfig; }) { + const localize = useLocalize(); const artifactFiles = useMemo(() => { if (Object.keys(files).length === 0) { return files; @@ -43,11 +80,63 @@ export const ArtifactPreview = memo(function ({ () => buildSandpackOptions(template, startupConfig), [startupConfig, template], ); + const [refreshKey, setRefreshKey] = useState(0); + const staticHtml = useMemo(() => { + if (template !== 'static') { + return undefined; + } + + const html = + getFileCode(artifactFiles['index.html']) ?? + (fileKey.endsWith('.html') ? getFileCode(artifactFiles[fileKey]) : undefined); + + return html ? injectExternalResources(html) : undefined; + }, [artifactFiles, fileKey, template]); + + useEffect(() => { + if (!staticHtml) { + return; + } + + const staticPreviewRef: StaticPreviewRef = { + __staticPreview: true, + clientId: 'static-artifact-preview', + getClient: () => + ({ + dispatch: ({ type }: { type?: string }) => { + if (type === 'refresh') { + setRefreshKey((key) => key + 1); + } + }, + }) as ReturnType, + }; + + previewRef.current = staticPreviewRef; + + return () => { + if ((previewRef.current as StaticPreviewRef | undefined)?.__staticPreview) { + (previewRef as MutableRefObject).current = undefined; + } + }; + }, [previewRef, staticHtml]); if (Object.keys(artifactFiles).length === 0) { return null; } + if (staticHtml) { + return ( +