diff --git a/.changeset/shaggy-foxes-remain.md b/.changeset/shaggy-foxes-remain.md new file mode 100644 index 0000000000..5af3636916 --- /dev/null +++ b/.changeset/shaggy-foxes-remain.md @@ -0,0 +1,7 @@ +--- +'@xpert-ai/plugin-sdk': patch +'@xpert-ai/contracts': patch +'@xpert-ai/xpert-ui': patch +--- + +collaboration & artifacts diff --git a/.deploy/webapp/package.json b/.deploy/webapp/package.json index 8a39af0377..b7d1f52e68 100644 --- a/.deploy/webapp/package.json +++ b/.deploy/webapp/package.json @@ -73,7 +73,7 @@ "@sentry/tracing": "^7.38.0", "@swc/helpers": "0.5.3", "@tinymce/tinymce-angular": "^6.0.1", - "@xpert-ai/chatkit-ui": "~0.4.5", + "@xpert-ai/chatkit-ui": "~0.4.7", "angular-gridster2": "^14.0.1", "apache-arrow": "^9.0.0", "axios": "1.6.8", diff --git a/apps/cloud/package.json b/apps/cloud/package.json index 6e97b1d36e..397c78fb30 100644 --- a/apps/cloud/package.json +++ b/apps/cloud/package.json @@ -23,7 +23,7 @@ "@xterm/addon-fit": "^0.10.0", "@xterm/xterm": "^5.5.0", "@xpert-ai/chatkit-angular": "~0.4.0", - "@xpert-ai/chatkit-ui": "~0.4.6", + "@xpert-ai/chatkit-ui": "~0.4.7", "@xpert-ai/chatkit-types": "~0.4.5", "@xpert-ai/chatkit-web-component": "~0.4.0", "@xpert-ai/chatkit-web-shared": "~0.4.0", diff --git a/apps/cloud/proxy.conf.json b/apps/cloud/proxy.conf.json index 63dd627501..275fda06fb 100644 --- a/apps/cloud/proxy.conf.json +++ b/apps/cloud/proxy.conf.json @@ -1,6 +1,12 @@ { "/api": { - "target": "http://localhost:3000", - "secure": false + "target": "http://127.0.0.1:3000", + "secure": false, + "changeOrigin": true + }, + "/artifacts/share": { + "target": "http://127.0.0.1:3000", + "secure": false, + "changeOrigin": true } } diff --git a/apps/cloud/src/app/features/explore/agent-square/agent-square.component.html b/apps/cloud/src/app/features/explore/agent-square/agent-square.component.html index 0ffaa7f4cc..0467936b5f 100644 --- a/apps/cloud/src/app/features/explore/agent-square/agent-square.component.html +++ b/apps/cloud/src/app/features/explore/agent-square/agent-square.component.html @@ -1,26 +1,28 @@
-
-
-
- {{ 'PAC.Explore.AgentSquare.ReviewTitle' | translate: { Default: 'Requests to review' } }} -
-
- {{ - 'PAC.Explore.AgentSquare.ReviewSubtitle' - | translate: { Default: 'Approve or reject pending agent access requests.' } - }} + @if (reviewableCount() > 0) { +
+
+
+ {{ 'PAC.Explore.AgentSquare.ReviewTitle' | translate: { Default: 'Requests to review' } }} +
+
+ {{ + 'PAC.Explore.AgentSquare.ReviewSubtitle' + | translate: { Default: 'Approve or reject pending agent access requests.' } + }} +
+
- -
+ }
({ + AiFeatureEnum: { + FEATURE_XPERT: 'FEATURE_XPERT', + FEATURE_XPERT_MARKETPLACE: 'FEATURE_XPERT_MARKETPLACE' + }, AnalyticsPermissionsEnum: { STORIES_VIEW: 'STORIES_VIEW', STORIES_EDIT: 'STORIES_EDIT', @@ -9,11 +13,20 @@ jest.mock('../@core', () => ({ XPERT_EDIT: 'XPERT_EDIT' }, RolesEnum: { + ADMIN: 'ADMIN', + AI_BUILDER: 'AI_BUILDER', SUPER_ADMIN: 'SUPER_ADMIN' }, authGuard: jest.fn() })) +jest.mock('./feature-gate', () => ({ + featureGate: jest.fn((featureKeys: string[], redirectCommands: string[]) => ({ + featureKeys, + redirectCommands + })) +})) + jest.mock('./features.component', () => ({ FeaturesComponent: class FeaturesComponent {} })) @@ -31,7 +44,7 @@ jest.mock('../app.service', () => ({ })) import { NgxPermissionsGuard } from 'ngx-permissions' -import { routes } from './features-routing.module' +import { routes, xpertMarketplaceRouteGate } from './features-routing.module' describe('features routing', () => { const children = routes[0].children ?? [] @@ -73,6 +86,16 @@ describe('features routing', () => { expect(route?.data?.permissions?.only).toEqual(['SUPER_ADMIN']) }) + it('mounts xpert access requests at the top level behind marketplace and reviewer role gates', () => { + const route = children.find((item) => item.path === 'xpert-access-requests') + + expect(route?.loadComponent).toEqual(expect.any(Function)) + expect(route?.canActivate).toContain(NgxPermissionsGuard) + expect(route?.canActivate).toContain(xpertMarketplaceRouteGate) + expect(route?.data?.scopeContext).toBe('organization-only') + expect(route?.data?.permissions?.only).toEqual(['AI_BUILDER', 'ADMIN', 'SUPER_ADMIN']) + }) + it('mounts model providers at the top-level /copilot route', () => { const route = children.find((item) => item.path === 'copilot') diff --git a/apps/cloud/src/app/features/features-routing.module.ts b/apps/cloud/src/app/features/features-routing.module.ts index e9256c8063..3ee34c9a42 100644 --- a/apps/cloud/src/app/features/features-routing.module.ts +++ b/apps/cloud/src/app/features/features-routing.module.ts @@ -1,15 +1,21 @@ import { inject, NgModule } from '@angular/core' import { RouterModule, Routes } from '@angular/router' import { NgxPermissionsGuard } from 'ngx-permissions' -import { AIPermissionsEnum, AnalyticsPermissionsEnum, RolesEnum, authGuard } from '../@core' +import { AiFeatureEnum, AIPermissionsEnum, AnalyticsPermissionsEnum, RolesEnum, authGuard } from '../@core' import { FeaturesComponent } from './features.component' import { NotFoundComponent } from '../@shared/not-found' import { AppService } from '../app.service' +import { featureGate } from './feature-gate' export function redirectTo() { return '/chat' } +export const xpertMarketplaceRouteGate = featureGate( + [AiFeatureEnum.FEATURE_XPERT, AiFeatureEnum.FEATURE_XPERT_MARKETPLACE], + ['/explore'] +) + export const routes: Routes = [ { path: '', @@ -136,6 +142,20 @@ export const routes: Routes = [ } } }, + { + path: 'xpert-access-requests', + loadComponent: () => + import('./xpert-access-requests/xpert-access-requests.component').then((m) => m.XpertAccessRequestsComponent), + canActivate: [authGuard, NgxPermissionsGuard, xpertMarketplaceRouteGate], + data: { + title: 'Xpert Access Requests', + scopeContext: 'organization-only', + permissions: { + only: [RolesEnum.AI_BUILDER, RolesEnum.ADMIN, RolesEnum.SUPER_ADMIN], + redirectTo + } + } + }, { path: 'copilot', loadChildren: () => import('./setting/copilot/routing').then((m) => m.default), diff --git a/apps/cloud/src/app/features/menus.spec.ts b/apps/cloud/src/app/features/menus.spec.ts index 54fe02229b..eff6d11763 100644 --- a/apps/cloud/src/app/features/menus.spec.ts +++ b/apps/cloud/src/app/features/menus.spec.ts @@ -137,10 +137,8 @@ describe('getFeatureMenus', () => { it('promotes xpert access requests to the management menu with approval gates', () => { const menus = getFeatureMenus(RequestScopeLevel.ORGANIZATION, null) - const settings = menus.find((item) => item.link === '/settings') - const requests = menus.find((item) => item.link === '/settings/xpert-access-requests') + const requests = menus.find((item) => item.link === '/xpert-access-requests') - expect(settings?.data?.inactivePathPrefixes).toEqual(['/settings/xpert-access-requests']) expect(requests).toMatchObject({ title: 'Xpert Access Requests', icon: 'approval', @@ -153,7 +151,7 @@ describe('getFeatureMenus', () => { AiFeatureEnum.FEATURE_XPERT_MARKETPLACE, FeatureEnum.FEATURE_USER_GROUPS ]) - expect(requests?.data?.permissionKeys).toEqual([PermissionsEnum.ORG_USERS_VIEW, PermissionsEnum.ORG_USERS_EDIT]) + expect(requests?.data?.permissionKeys).toEqual([RolesEnum.AI_BUILDER, RolesEnum.ADMIN, RolesEnum.SUPER_ADMIN]) }) it('marks the workspace menu as an onboarding target', () => { diff --git a/apps/cloud/src/app/features/menus.ts b/apps/cloud/src/app/features/menus.ts index 7a59596b76..4c4ee5d8a2 100644 --- a/apps/cloud/src/app/features/menus.ts +++ b/apps/cloud/src/app/features/menus.ts @@ -379,14 +379,13 @@ export function getFeatureMenus(scopeLevel: RequestScopeLevel, _org: IOrganizati admin: true, scopeContext: 'dual-scope', data: { - translationKey: 'Settings', - inactivePathPrefixes: ['/settings/xpert-access-requests'] + translationKey: 'Settings' } }, { title: 'Xpert Access Requests', icon: 'approval', - link: '/settings/xpert-access-requests', + link: '/xpert-access-requests', admin: true, scopeContext: 'organization-only', data: { @@ -396,7 +395,7 @@ export function getFeatureMenus(scopeLevel: RequestScopeLevel, _org: IOrganizati AiFeatureEnum.FEATURE_XPERT_MARKETPLACE, FeatureEnum.FEATURE_USER_GROUPS ], - permissionKeys: [PermissionsEnum.ORG_USERS_VIEW, PermissionsEnum.ORG_USERS_EDIT] + permissionKeys: [RolesEnum.AI_BUILDER, RolesEnum.ADMIN, RolesEnum.SUPER_ADMIN] } }, { diff --git a/apps/cloud/src/app/features/setting/setting-routing.module.spec.ts b/apps/cloud/src/app/features/setting/setting-routing.module.spec.ts index e98ecdcbf6..5ddb9dfd79 100644 --- a/apps/cloud/src/app/features/setting/setting-routing.module.spec.ts +++ b/apps/cloud/src/app/features/setting/setting-routing.module.spec.ts @@ -48,12 +48,7 @@ jest.mock('./settings.component', () => ({ })) import { NgxPermissionsGuard } from 'ngx-permissions' -import { - membershipPlanAccountGate, - membershipPlanSettingsGate, - routes, - xpertMarketplaceSettingsGate -} from './setting-routing.module' +import { membershipPlanAccountGate, membershipPlanSettingsGate, routes } from './setting-routing.module' describe('setting routes', () => { const settingChildren = routes[0].children ?? [] @@ -72,10 +67,4 @@ describe('setting routes', () => { expect(usageRoute?.canActivate).toEqual([membershipPlanAccountGate]) expect(billingRoute?.canActivate).toEqual([membershipPlanAccountGate]) }) - - it('guards xpert access request approvals with the marketplace feature gate', () => { - const accessRequestsRoute = settingChildren.find((route) => route.path === 'xpert-access-requests') - - expect(accessRequestsRoute?.canActivate).toEqual([NgxPermissionsGuard, xpertMarketplaceSettingsGate]) - }) }) diff --git a/apps/cloud/src/app/features/setting/setting-routing.module.ts b/apps/cloud/src/app/features/setting/setting-routing.module.ts index c6faf2f632..a6777ad544 100644 --- a/apps/cloud/src/app/features/setting/setting-routing.module.ts +++ b/apps/cloud/src/app/features/setting/setting-routing.module.ts @@ -14,11 +14,6 @@ export const membershipPlanAccountGate = featureGate( [AiFeatureEnum.FEATURE_MEMBERSHIP_PLAN], ['/settings/account/profile'] ) -export const xpertMarketplaceSettingsGate = featureGate( - [AiFeatureEnum.FEATURE_XPERT, AiFeatureEnum.FEATURE_XPERT_MARKETPLACE], - ['/settings'] -) - export const routes: Routes = [ { path: '', @@ -141,22 +136,6 @@ export const routes: Routes = [ } } }, - { - path: 'xpert-access-requests', - loadComponent: () => - import('./xpert-access-requests/xpert-access-requests.component').then( - (m) => m.XpertAccessRequestsSettingsComponent - ), - canActivate: [NgxPermissionsGuard, xpertMarketplaceSettingsGate], - data: { - title: 'settings/xpert-access-requests', - scopeContext: 'organization-only', - permissions: { - only: [PermissionsEnum.ORG_USERS_VIEW], - redirectTo - } - } - }, { path: 'business-area', loadChildren: () => import('./business-area/').then((m) => m.routes), diff --git a/apps/cloud/src/app/features/sidebar/cloud-sidebar-assistants.component.html b/apps/cloud/src/app/features/sidebar/cloud-sidebar-assistants.component.html index 0bae2b661d..7913857ae9 100644 --- a/apps/cloud/src/app/features/sidebar/cloud-sidebar-assistants.component.html +++ b/apps/cloud/src/app/features/sidebar/cloud-sidebar-assistants.component.html @@ -155,7 +155,7 @@ [cdkDragDisabled]="collapsed()" >
@if (!collapsed()) { diff --git a/apps/cloud/src/app/features/sidebar/cloud-sidebar-assistants.component.spec.ts b/apps/cloud/src/app/features/sidebar/cloud-sidebar-assistants.component.spec.ts index 4cfe17112a..ab4f91f0b7 100644 --- a/apps/cloud/src/app/features/sidebar/cloud-sidebar-assistants.component.spec.ts +++ b/apps/cloud/src/app/features/sidebar/cloud-sidebar-assistants.component.spec.ts @@ -12,7 +12,8 @@ import { getAssistantLabel, getAssistantRouteId, isAssistantRouteActive, - normalizeAssistantXperts + normalizeAssistantXperts, + orderAssistantXperts } from './cloud-sidebar-assistants.utils' jest.mock('@xpert-ai/headless-ui', () => { @@ -139,6 +140,32 @@ describe('cloud sidebar assistants helpers', () => { expect(filterAssistantXperts(items, 'tool').map((item) => item.id)).toEqual(['tools']) }) + it('places assistants missing from the saved order first by newest creation time', () => { + const items = [ + xpert({ id: 'ordered-first', createdAt: new Date('2026-01-03T00:00:00Z') }), + xpert({ id: 'newer', createdAt: new Date('2026-01-05T00:00:00Z') }), + xpert({ id: 'ordered-second', createdAt: new Date('2026-01-04T00:00:00Z') }), + xpert({ id: 'newest', createdAt: new Date('2026-01-06T00:00:00Z') }) + ] + + expect(orderAssistantXperts(items, ['ordered-first', 'ordered-second']).map((item) => item.id)).toEqual([ + 'newest', + 'newer', + 'ordered-first', + 'ordered-second' + ]) + }) + + it('orders all assistants by newest creation time when no saved order exists', () => { + const items = [ + xpert({ id: 'oldest', createdAt: new Date('2026-01-01T00:00:00Z') }), + xpert({ id: 'newest', createdAt: new Date('2026-01-03T00:00:00Z') }), + xpert({ id: 'middle', createdAt: new Date('2026-01-02T00:00:00Z') }) + ] + + expect(orderAssistantXperts(items, []).map((item) => item.id)).toEqual(['newest', 'middle', 'oldest']) + }) + it('matches assistant categories from tag names instead of label or description keywords', () => { const items = [ xpert({ id: 'finance', title: 'General Assistant', tags: [{ name: 'Finance' }] }), diff --git a/apps/cloud/src/app/features/sidebar/cloud-sidebar-assistants.utils.ts b/apps/cloud/src/app/features/sidebar/cloud-sidebar-assistants.utils.ts index 5332f872e0..c12048f736 100644 --- a/apps/cloud/src/app/features/sidebar/cloud-sidebar-assistants.utils.ts +++ b/apps/cloud/src/app/features/sidebar/cloud-sidebar-assistants.utils.ts @@ -1,5 +1,6 @@ export interface AssistantXpertLike { id?: string | null + createdAt?: Date | string | null slug?: string | null name?: string | null title?: string | null @@ -37,23 +38,36 @@ export function filterAssistantXperts(items: T[], } export function orderAssistantXperts(items: T[], orderedIds: string[]) { - if (!orderedIds.length) { - return items - } - const itemById = new Map( items .filter((item): item is T & { id: string } => typeof item.id === 'string' && !!item.id.trim()) .map((item) => [item.id, item] as const) ) const orderedIdSet = new Set(orderedIds) + const unorderedItems = items + .map((item, index) => ({ item, index })) + .filter(({ item }) => !item.id || !orderedIdSet.has(item.id)) + .sort((left, right) => { + const leftCreatedAt = getAssistantCreatedAtTimestamp(left.item) + const rightCreatedAt = getAssistantCreatedAtTimestamp(right.item) + + return leftCreatedAt === rightCreatedAt ? left.index - right.index : rightCreatedAt > leftCreatedAt ? 1 : -1 + }) + .map(({ item }) => item) return [ - ...orderedIds.map((id) => itemById.get(id)).filter((item): item is T & { id: string } => !!item), - ...items.filter((item) => !item.id || !orderedIdSet.has(item.id)) + ...unorderedItems, + ...orderedIds.map((id) => itemById.get(id)).filter((item): item is T & { id: string } => !!item) ] } +function getAssistantCreatedAtTimestamp(xpert: AssistantXpertLike) { + const value = xpert.createdAt + const timestamp = value instanceof Date ? value.getTime() : typeof value === 'string' ? Date.parse(value) : Number.NaN + + return Number.isNaN(timestamp) ? Number.NEGATIVE_INFINITY : timestamp +} + export function getAssistantRouteId(xpert: AssistantXpertLike) { return xpert.slug || xpert.id || '' } diff --git a/apps/cloud/src/app/features/sidebar/cloud-sidebar-menu.component.spec.ts b/apps/cloud/src/app/features/sidebar/cloud-sidebar-menu.component.spec.ts index db1ed26953..654e67c5ef 100644 --- a/apps/cloud/src/app/features/sidebar/cloud-sidebar-menu.component.spec.ts +++ b/apps/cloud/src/app/features/sidebar/cloud-sidebar-menu.component.spec.ts @@ -29,7 +29,7 @@ describe('buildCloudSidebarMenuGroups', () => { menu({ title: 'MCP Monitor', link: '/operations' }), menu({ title: 'Plugins', link: '/plugins' }), menu({ title: 'Model Providers', link: '/copilot/basic', admin: true }), - menu({ title: 'Xpert Access Requests', link: '/settings/xpert-access-requests', admin: true }), + menu({ title: 'Xpert Access Requests', link: '/xpert-access-requests', admin: true }), menu({ title: 'Explore', link: '/explore' }) ]) @@ -51,7 +51,7 @@ describe('buildCloudSidebarMenuGroups', () => { '/plugins', '/operations', '/copilot/basic', - '/settings/xpert-access-requests', + '/xpert-access-requests', '/settings' ]) }) diff --git a/apps/cloud/src/app/features/sidebar/cloud-sidebar-menu.utils.ts b/apps/cloud/src/app/features/sidebar/cloud-sidebar-menu.utils.ts index 99ca87d5aa..016f78b0ea 100644 --- a/apps/cloud/src/app/features/sidebar/cloud-sidebar-menu.utils.ts +++ b/apps/cloud/src/app/features/sidebar/cloud-sidebar-menu.utils.ts @@ -20,7 +20,7 @@ const MANAGEMENT_MENU_ORDER = [ '/plugins', '/operations', '/copilot/basic', - '/settings/xpert-access-requests', + '/xpert-access-requests', '/settings' ] as const diff --git a/apps/cloud/src/app/features/setting/xpert-access-requests/xpert-access-requests.component.ts b/apps/cloud/src/app/features/xpert-access-requests/xpert-access-requests.component.ts similarity index 95% rename from apps/cloud/src/app/features/setting/xpert-access-requests/xpert-access-requests.component.ts rename to apps/cloud/src/app/features/xpert-access-requests/xpert-access-requests.component.ts index 235df810ec..5b807ea860 100644 --- a/apps/cloud/src/app/features/setting/xpert-access-requests/xpert-access-requests.component.ts +++ b/apps/cloud/src/app/features/xpert-access-requests/xpert-access-requests.component.ts @@ -3,11 +3,11 @@ import { ChangeDetectionStrategy, Component, signal } from '@angular/core' import { injectOrganization } from '@xpert-ai/cloud/state' import { TranslateModule } from '@ngx-translate/core' import { ZardBadgeComponent, ZardIconComponent } from '@xpert-ai/headless-ui' -import { XpertAccessRequestReviewListComponent } from '../../xpert-access-requests/review-requests-list.component' +import { XpertAccessRequestReviewListComponent } from './review-requests-list.component' @Component({ standalone: true, - selector: 'pac-xpert-access-requests-settings', + selector: 'xp-xpert-access-requests', imports: [ CommonModule, TranslateModule, @@ -95,7 +95,7 @@ import { XpertAccessRequestReviewListComponent } from '../../xpert-access-reques `, changeDetection: ChangeDetectionStrategy.OnPush }) -export class XpertAccessRequestsSettingsComponent { +export class XpertAccessRequestsComponent { readonly organization = injectOrganization() readonly reviewableCount = signal(0) readonly filteredCount = signal(0) diff --git a/apps/cloud/src/app/features/xpert/xpert/blank/blank-template.util.spec.ts b/apps/cloud/src/app/features/xpert/xpert/blank/blank-template.util.spec.ts index 0938fc48b7..fbc47182be 100644 --- a/apps/cloud/src/app/features/xpert/xpert/blank/blank-template.util.spec.ts +++ b/apps/cloud/src/app/features/xpert/xpert/blank/blank-template.util.spec.ts @@ -1,4 +1,11 @@ -import { AiModelTypeEnum, TXpertTeamDraft, WorkflowNodeTypeEnum, XpertTypeEnum } from '@xpert-ai/contracts' +import { + AiModelTypeEnum, + IWFNMiddleware, + TXpertTeamDraft, + TXpertTeamNode, + WorkflowNodeTypeEnum, + XpertTypeEnum +} from '@xpert-ai/contracts' import { BLANK_WIZARD_SKILLS_MIDDLEWARE_PROVIDER } from './blank-draft.util' import { applyAgentTemplateWizardState, @@ -443,6 +450,94 @@ describe('blank template util', () => { }) }) + it('extracts middlewares from order metadata and legacy reversed edges with required flags', () => { + const draft = createAgentTemplateDraft() + if (draft.team.agent) { + draft.team.agent.options = { + ...(draft.team.agent.options ?? {}), + middlewares: { + order: ['Middleware_guard'] + } + } + } + draft.connections = [ + ...draft.connections.filter( + (connection) => + !['Middleware_guard', 'Middleware_skills', 'Middleware_audit'].includes( + connection.from === 'Agent_primary' ? connection.to : connection.from + ) + ), + { + key: 'Middleware_skills/Agent_primary', + type: 'edge', + from: 'Middleware_skills', + to: 'Agent_primary' + }, + { + key: 'Middleware_audit/Agent_primary', + type: 'edge', + from: 'Middleware_audit', + to: 'Agent_primary' + } + ] + const guardNode = draft.nodes.find((node) => node.key === 'Middleware_guard') + const auditNode = draft.nodes.find((node) => node.key === 'Middleware_audit') + if (guardNode?.type === 'workflow') { + ;(guardNode.entity as IWFNMiddleware).required = false + } + if (auditNode?.type === 'workflow') { + ;(auditNode.entity as IWFNMiddleware).required = true + } + + const state = extractAgentTemplateWizardState(draft) + + expect(state.selections.middlewares).toEqual(['guard', BLANK_WIZARD_SKILLS_MIDDLEWARE_PROVIDER, 'audit']) + expect(state.selections.middlewareRequired).toEqual({ guard: false }) + + const result = applyAgentTemplateWizardState(draft, state.selections) + const middlewareNodes = result.nodes.filter( + (node): node is TXpertTeamNode<'workflow'> & { entity: IWFNMiddleware } => + node.type === 'workflow' && node.entity.type === WorkflowNodeTypeEnum.MIDDLEWARE + ) + const requiredByProvider = Object.fromEntries( + middlewareNodes.map((node) => [node.entity.provider, node.entity.required]) + ) + + expect(middlewareNodes).toHaveLength(3) + expect(requiredByProvider).toEqual({ + guard: false, + [BLANK_WIZARD_SKILLS_MIDDLEWARE_PROVIDER]: true, + audit: true + }) + expect( + result.connections.filter((connection) => + middlewareNodes.some((middlewareNode) => middlewareNode.key === connection.to) + ) + ).toEqual( + middlewareNodes.map((middlewareNode) => ({ + key: `Agent_primary/${middlewareNode.key}`, + type: 'workflow', + from: 'Agent_primary', + to: middlewareNode.key + })) + ) + }) + + it('does not infer disconnected middleware nodes that the template does not associate with the primary agent', () => { + const draft = createAgentTemplateDraft() + if (draft.team.agent) { + draft.team.agent.options = {} + } + draft.connections = draft.connections.filter( + (connection) => + !['Middleware_guard', 'Middleware_skills', 'Middleware_audit'].includes( + connection.from === 'Agent_primary' ? connection.to : connection.from + ) + ) + + expect(extractAgentTemplateWizardState(draft).selections.middlewares).toEqual([]) + }) + it('falls back to the primary agent model when the template team model is missing', () => { const draft = createAgentTemplateDraft() draft.team.copilotModel = undefined diff --git a/apps/cloud/src/app/features/xpert/xpert/blank/blank-template.util.ts b/apps/cloud/src/app/features/xpert/xpert/blank/blank-template.util.ts index 1b603b8ea5..4ffcd09385 100644 --- a/apps/cloud/src/app/features/xpert/xpert/blank/blank-template.util.ts +++ b/apps/cloud/src/app/features/xpert/xpert/blank/blank-template.util.ts @@ -78,7 +78,6 @@ export function extractTemplateBasicInfo(draft: TXpertTeamDraft): BlankTemplateB export function extractAgentTemplateWizardState(draft: TXpertTeamDraft): BlankAgentTemplateWizardState { const primaryAgentNode = getPrimaryAgentNode(draft) const inboundNodeKeys = getInboundNodeKeys(draft, primaryAgentNode.key) - const outboundNodeKeys = getOutboundNodeKeys(draft, primaryAgentNode.key) const triggerNodes = sortNodesByPosition( getWorkflowNodesByKeys(draft, inboundNodeKeys).filter( @@ -86,13 +85,7 @@ export function extractAgentTemplateWizardState(draft: TXpertTeamDraft): BlankAg node.entity.type === WorkflowNodeTypeEnum.TRIGGER ) ) - const middlewareNodes = sortMiddlewareNodes( - getWorkflowNodesByKeys(draft, outboundNodeKeys).filter( - (node): node is TXpertTeamNode<'workflow'> & { entity: IWFNMiddleware } => - node.entity.type === WorkflowNodeTypeEnum.MIDDLEWARE - ), - draft.team?.agent?.options?.middlewares?.order ?? [] - ) + const middlewareNodes = getPrimaryAgentMiddlewareNodes(draft, primaryAgentNode.key) return { basic: extractTemplateBasicInfo(draft), @@ -153,10 +146,12 @@ export function applyAgentTemplateWizardState( ): TXpertTeamDraft { const nextDraft = cloneDeep(draft) const primaryAgentNode = getPrimaryAgentNode(nextDraft) + const primaryAgentMiddlewareNodes = getPrimaryAgentMiddlewareNodes(nextDraft, primaryAgentNode.key) const managedNodeKeys = new Set( [ ...getInboundNodeKeys(nextDraft, primaryAgentNode.key), - ...getOutboundNodeKeys(nextDraft, primaryAgentNode.key) + ...getOutboundNodeKeys(nextDraft, primaryAgentNode.key), + ...primaryAgentMiddlewareNodes.map((node) => node.key) ].filter((key) => { const node = nextDraft.nodes.find((item) => item.key === key) return ( @@ -491,6 +486,38 @@ function getWorkflowNodesByKeys(draft: TXpertTeamDraft, keys: string[]) { ) } +/** + * Assistant templates in the wild use three middleware association formats: + * the current Agent -> Middleware workflow edge, legacy/reversed edges, and the + * primary agent's middleware order. Treat all three as authoritative so the + * creation wizard can faithfully initialize middleware selections. + */ +function getPrimaryAgentMiddlewareNodes(draft: TXpertTeamDraft, primaryAgentKey: string) { + const middlewareNodes = draft.nodes.filter( + (node): node is TXpertTeamNode<'workflow'> & { entity: IWFNMiddleware } => + node.type === 'workflow' && node.entity.type === WorkflowNodeTypeEnum.MIDDLEWARE + ) + const middlewareNodeKeys = new Set(middlewareNodes.map((node) => node.key)) + const middlewareOrder = uniqueStrings(draft.team?.agent?.options?.middlewares?.order) + const associatedNodeKeys = new Set(middlewareOrder.filter((key) => middlewareNodeKeys.has(key))) + + for (const connection of draft.connections ?? []) { + const from = normalizeConnectionEndpoint(connection.from) + const to = normalizeConnectionEndpoint(connection.to) + if (from === primaryAgentKey && middlewareNodeKeys.has(to)) { + associatedNodeKeys.add(to) + } + if (to === primaryAgentKey && middlewareNodeKeys.has(from)) { + associatedNodeKeys.add(from) + } + } + + return sortMiddlewareNodes( + middlewareNodes.filter((node) => associatedNodeKeys.has(node.key)), + middlewareOrder + ) +} + function sortMiddlewareNodes(nodes: Array & { entity: IWFNMiddleware }>, order: string[]) { const orderMap = new Map(order.map((key, index) => [key, index])) return [...nodes].sort((left, right) => { diff --git a/apps/cloud/src/app/features/xpert/xpert/blank/blank.component.spec.ts b/apps/cloud/src/app/features/xpert/xpert/blank/blank.component.spec.ts index 186c08f30d..14c4aa75ad 100644 --- a/apps/cloud/src/app/features/xpert/xpert/blank/blank.component.spec.ts +++ b/apps/cloud/src/app/features/xpert/xpert/blank/blank.component.spec.ts @@ -309,6 +309,7 @@ nodes: type: middleware provider: ModelRetryMiddleware title: ModelRetryMiddleware + required: false - type: workflow key: Middleware_web_tools position: @@ -340,22 +341,22 @@ nodes: provider: todoListMiddleware title: todoListMiddleware connections: - - key: Agent_primary/Middleware_model_retry - type: workflow - from: Agent_primary - to: Middleware_model_retry - - key: Agent_primary/Middleware_web_tools - type: workflow - from: Agent_primary - to: Middleware_web_tools - - key: Agent_primary/Middleware_summary - type: workflow - from: Agent_primary - to: Middleware_summary - - key: Agent_primary/Middleware_todo - type: workflow - from: Agent_primary - to: Middleware_todo + - key: Middleware_model_retry/Agent_primary + type: edge + from: Middleware_model_retry + to: Agent_primary + - key: Middleware_web_tools/Agent_primary + type: edge + from: Middleware_web_tools + to: Agent_primary + - key: Middleware_summary/Agent_primary + type: edge + from: Middleware_summary + to: Agent_primary + - key: Middleware_todo/Agent_primary + type: edge + from: Middleware_todo + to: Agent_primary ` } @@ -1647,6 +1648,9 @@ describe('XpertNewBlankComponent', () => { 'SummarizationMiddleware', 'todoListMiddleware' ]) + expect(component.selectedMiddlewareRequired()).toEqual({ ModelRetryMiddleware: false }) + expect(component.isMiddlewareRequired('ModelRetryMiddleware')).toBe(false) + expect(component.isMiddlewareRequired('WebTools')).toBe(true) expect(component.middlewareProviderOptions().map((provider) => provider.meta.name)).toEqual([ 'SummarizationMiddleware', 'todoListMiddleware', diff --git a/docs/plans/PLAN_xpert_user_groups_access.md b/docs/plans/PLAN_xpert_user_groups_access.md index 2f499559fd..ce357ab928 100644 --- a/docs/plans/PLAN_xpert_user_groups_access.md +++ b/docs/plans/PLAN_xpert_user_groups_access.md @@ -1,132 +1,122 @@ -# Xpert UserGroup 授权重构与 Assistant 安全加固 +# Xpert UserGroup 授权与工作空间运行权限设计 -## 背景与问题分析 +> 状态:已落地,2026-07 更新运行时权限桥接设计。 -当前 `GET /api/ai/assistants/:id` 在组织用户访问 tenant 级 assistant 时会出现 404,根因不是数据真的不存在,而是查询作用域和资源作用域不一致: +## 背景 -- `assistant.controller.ts` 当前直接调用 `XpertService.findOne(id, ...)` -- `XpertService` 继承 `TenantOrganizationAwareCrudService` -- 当请求处于 organization scope 时,基类会自动附加 `organizationId = 当前组织` -- tenant 级 xpert 的 `organizationId` 为 `NULL` -- 于是 tenant 级记录会在查询阶段被过滤掉,最终表现成 404 +已发布智能体通过 `UserGroup` 向组织用户授权。审批通过后,用户会加入该智能体绑定的访问组,因此用户可以在智能体广场看到并打开智能体。 -这个问题同时暴露了更深层的安全缺口: +运行智能体时还会读取工作空间内的工作流、技能包和连接器。此前这些内部调用复用了创作态的 `read` 校验,而被授权用户通常不是工作空间 owner/member,因此即使已获得智能体使用权,运行链路仍会报: -- `assistants/search` / `assistants/count` / `assistants/:id` / run create 四条链路使用了不同的可见性逻辑 -- tenant 级 published xpert 一旦发布,组织用户几乎没有显式授权边界 -- API key / client secret 鉴权会主动改写请求为 tenant scope,如果不保留原始 organization 上下文,后续授权容易被绕开 +```text +Access denied to workspace +``` -## 目标模型 +问题不是智能体 ACL 没有生效,而是存在两层独立权限,且第二层错误地使用了创作态权限: -本轮统一采用以下模型: +1. `PublishedXpertAccessService` 判断用户是否可以使用目标已发布智能体。 +2. `XpertWorkspaceAccessService` 判断运行过程是否可以读取该智能体依赖的工作空间资源。 -- `UserGroup` 定义为 org 级实体,不做 tenant 级 group -- `Xpert` 删除 `managers`,统一改为 `userGroups` -- `userGroups` 只控制 published xpert 的使用/运行访问 -- authoring/edit 继续沿用现有 creator/workspace/XpertGuard 规则 -- tenant 级和 org 级 published xpert 都必须绑定 group 才能被访问 -- 历史已发布但未绑定 group 的 xpert 立即关闭,不做兼容保留 +## 权限模型 -## 核心设计 +### 智能体入口 ACL -### 1. 数据模型 +`PublishedXpertAccessService` 是已发布智能体的入口边界。查询必须同时满足 tenant、organization、发布状态和资源授权范围。 -- 新增 `IUserGroup` -- 新增后端 `UserGroup` 实体: - - 继承 `TenantOrganizationBaseEntity` - - 字段:`name`、`description`、`members` - - `members` 直接关联 `User[]` -- `Xpert` 新增 `userGroups` many-to-many,join table 为 `xpert_to_user_group` -- 旧 `xpert_to_manager` 不保留兼容逻辑,也不迁移历史数据 +普通组织用户可以通过以下任一关系访问目标智能体: -### 2. 运行时授权 +- 智能体创建者; +- 工作空间 owner/member; +- 用户属于该智能体在当前组织绑定的任一 `UserGroup`; +- 智能体位于 tenant-shared 工作空间。 -新增统一的 published xpert 访问服务,负责: +API key、client secret 和公开 Chat App 继续使用各自的绑定范围。资源不存在或未发布返回 404;资源存在但无权访问返回 403。 -- 在 tenant 范围解析目标 published xpert -- 从 `currentApiPrincipal.requestedOrganizationId ?? RequestContext.getOrganizationId()` 解析授权 org -- 校验候选资源只允许: - - 当前 org 自己的 published xpert - - tenant 默认 published xpert -- 校验当前用户是否属于该 xpert 在当前 org 绑定的任一 `userGroup` +### 工作空间能力 -统一接入以下入口: +智能体入口 ACL 通过后,运行时仍要访问工作空间资源。工作空间能力明确拆分为 `read`、`run`、`write` 和 `manage`: -- `POST /api/ai/assistants/search` -- `POST /api/ai/assistants/count` -- `GET /api/ai/assistants/:id` -- run create / execute +| 身份 | canRead | canRun | canWrite | canManage | +| ------------------------------------------------------------- | ------- | ------ | -------- | --------- | +| 当前组织工作空间 owner | 是 | 是 | 是 | 是 | +| 当前组织工作空间 member | 是 | 是 | 是 | 否 | +| 当前组织中,属于该工作空间内任一已发布智能体 UserGroup 的用户 | 否 | 是 | 否 | 否 | +| 无上述关系的组织用户 | 否 | 否 | 否 | 否 | -行为统一为: +UserGroup 只补充 `canRun`,绝不提升 `canRead`、`canWrite` 或 `canManage`。因此被授权用户能够执行智能体,但不能进入工作空间查看、编辑或管理其资源。 -- 资源不存在或未发布:404 -- 资源存在但当前 org / group 无权访问:403 +`canRun` 是工作空间级运行能力,因为技能、连接器等依赖资源以 `workspaceId` 为边界。它不替代目标智能体的入口 ACL:用户即使因某个智能体获得该工作空间的 `canRun`,也不能绕过 `PublishedXpertAccessService` 去启动同工作空间内未向其授权的其他智能体。 -### 3. API key / client secret +## UserGroup 到运行权限的桥接 -当前 api key / client secret 鉴权会把请求强制改写为 tenant scope。为避免 published assistant ACL 被绕过,需要: +`XpertWorkspaceAccessService.hasPublishedXpertRunAccess()` 查询当前工作空间中是否存在满足全部条件的智能体: -- 在 `IApiPrincipal` 中新增 `requestedOrganizationId` -- 在 `ApiKeyStrategy` / `SecretTokenStrategy` 清除 `organization-id` 之前先保存原始 org -- 后续 published assistant ACL 一律优先使用这个原始 org -- 若 assistant 请求没有 org 上下文,则直接拒绝访问 +- `xpert.tenantId` 等于当前 tenant; +- `xpert.organizationId` 等于当前 organization; +- `xpert.workspaceId` 等于目标工作空间; +- `xpert.publishAt IS NOT NULL`; +- 智能体绑定的 `UserGroup` 同属当前 tenant 和 organization; +- 当前用户是该组成员。 -### 4. 发布规则 +查询使用 `xpert_to_user_group`、`user_group` 和 `user_group_to_user` 三张关联表。TypeORM 原生表名查询中的 alias 保持全小写,避免 PostgreSQL 对未加引号标识符折叠后出现 `missing FROM-clause entry`。 -在 `XpertPublishHandler` 发布前增加校验: +审批通过或管理员把用户加入智能体绑定的 UserGroup 后,不需要把用户加入工作空间,也不需要复制一份工作空间权限;下一次权限计算会直接获得 `canRun`。 -- 当前 xpert 至少绑定一个 `userGroup` -- 否则禁止发布 +## 创作态与运行态 API 分离 -同时保留运行时兜底: +`XpertWorkspaceBaseService` 保留两组语义不同的方法: -- 历史已发布且无 group 的记录在访问时直接拒绝 +- 创作态:`findOne()`、`getAllByWorkspace()`,要求 workspace `read`/authoring 权限; +- 运行态:`findOneForRuntime()`、`getAllByWorkspaceForRuntime()`,要求 workspace `run` 权限。 -### 5. 前端 +本次已将以下运行链路切换到运行态方法: -- 新增 org-only 的 `settings/groups` 页面 -- 提供 group 列表、编辑、成员维护 -- `xpert` 授权页把 managers UI 全部替换为 userGroups UI -- `XpertAPIService` 删除 managers API,改成 `getXpertUserGroups/updateXpertUserGroups` -- 修正遗留 `/settings/groups/:id` 导航,正式落到新的 groups 页面 +- `XpertChatHandler`:加载主智能体和 follow-up 智能体; +- `GetXpertWorkflowHandler`:编译运行所需工作流; +- `RuntimeCapabilitiesService`:列出工作空间技能包; +- 连接器与技能中间件继续通过 `assertCanRun()` 校验。 -## 实施步骤 +`getAllByWorkspaceForRuntime()` 在完成一次 `run` 校验后,直接使用带 tenant、organization、workspace 条件的 repository 查询。这里不能再调用通用 `findAll()`,因为后者会对 workspace 条件执行第二次 `read` 校验,从而再次拒绝只有 `canRun` 的用户。 -1. 先补 contracts: - - `IUserGroup` - - `IApiPrincipal.requestedOrganizationId` - - `IXpert.userGroups` -2. 新建 `packages/server/src/user-group/**` -3. 改造 `Xpert` 实体与接口,移除 `managers` -4. 新增统一 published xpert access service -5. 改造 assistant controller 与 run create 主链路 -6. 发布链路增加 `userGroups` 校验 -7. 前端增加 `settings/groups` 与 xpert authorization 新界面 -8. 补齐测试 +所有编辑、保存、删除、发布和工作空间管理入口仍使用创作态方法或 `write/manage` 校验,不能为了复用运行逻辑而改成 `run`。 -## Public Interfaces +## 运行调用链 -- `IApiPrincipal.requestedOrganizationId?: string | null` -- `IXpert.userGroups?: IUserGroup[]` -- 新增 org-only REST: - - `GET /user-groups` - - `POST /user-groups` - - `GET /user-groups/:id` - - `PUT /user-groups/:id` - - `DELETE /user-groups/:id` - - `PUT /user-groups/:id/members` -- xpert 授权接口改为: - - `GET /xpert/:id/user-groups` - - `PUT /xpert/:id/user-groups` -- 删除旧接口: - - `GET /xpert/:id/managers` - - `PUT /xpert/:id/managers` - - `DELETE /xpert/:id/managers/:userId` - -## Assumptions +一次组织用户聊天请求的授权顺序如下: -- `UserGroup` 为 org 级,不提供 tenant 级 group -- 不再引入 direct-user ACL,也不保留 `responsibleUsers` -- 不迁移 `xpert.managers` 历史数据,只迁移结构 -- 当前仓库继续依赖 `synchronize: true` 做 schema 变更,本轮不额外补手写 migration -- `userGroups` 不参与 xpert 编辑权限,只参与 published assistant 的使用权限 +1. Assistant 查询/运行入口通过 `PublishedXpertAccessService` 校验目标智能体 ACL。 +2. `XpertChatHandler` 通过 `findOneForRuntime()` 加载智能体。 +3. `GetXpertWorkflowHandler` 通过 `findOneForRuntime()` 加载并编译工作流。 +4. `RuntimeCapabilitiesService` 通过 `getAllByWorkspaceForRuntime()` 读取技能包。 +5. 连接器和技能执行通过 `assertCanRun()` 读取运行依赖。 +6. 任一步发现 tenant、organization、发布状态、UserGroup 成员关系或工作空间范围不匹配,立即拒绝。 + +## 安全不变量 + +- 智能体授权和工作空间授权必须分层校验,不能只保留其中一层。 +- UserGroup 授权只适用于已发布智能体;未发布智能体不能产生 workspace `canRun`。 +- UserGroup、用户、智能体和工作空间必须属于同一 tenant/organization 范围。 +- 运行态查询仍必须附加 tenant、organization 和 workspace 条件,不能在权限校验后做无作用域查询。 +- 普通 `findOne()`/`findAll()` 保持创作态语义;只有真实运行调用链可以使用 Runtime 方法。 +- 获得 `canRun` 不代表可以列出、查看、编辑或管理工作空间。 + +## 回归测试 + +关键测试覆盖: + +- UserGroup 中的用户对包含已发布智能体的组织工作空间仅获得 `canRun`; +- 未发布、跨 tenant、跨 organization 或非成员关系不会产生 `canRun`; +- SQL 关联使用兼容 PostgreSQL 的小写 alias; +- `findOneForRuntime()` 使用 `run` 而不是 `read`; +- `getAllByWorkspaceForRuntime()` 只做一次运行权限校验,并执行带完整 scope 的查询; +- Chat、工作流编译和 Runtime Capabilities 使用运行态 service 方法; +- 原有创作态读取仍要求 workspace authoring 权限。 + +## 相关实现 + +- `packages/server-ai/src/xpert/published-xpert-access.service.ts` +- `packages/server-ai/src/xpert-workspace/workspace-access.service.ts` +- `packages/server-ai/src/xpert-workspace/workspace-base.service.ts` +- `packages/server-ai/src/xpert/commands/handlers/chat.handler.ts` +- `packages/server-ai/src/xpert/queries/handlers/get-xpert-workflow.handler.ts` +- `packages/server-ai/src/ai/runtime-capabilities.service.ts` diff --git a/docs/zh-hans/guides/permission.mdx b/docs/zh-hans/guides/permission.mdx index df3f135b93..5877d9200e 100644 --- a/docs/zh-hans/guides/permission.mdx +++ b/docs/zh-hans/guides/permission.mdx @@ -302,6 +302,83 @@ POST /api/ai/threads/:thread_id/runs/wait 2. LangGraph SDK 风格的 assistant 客户端使用 `/api/ai/*`;这些接口返回成功时,表示已经通过同源的 Xpert 访问判断。 3. 不要通过 Workspace 访问权限、User Group 列表或 `assistant_binding` 自行推断 Xpert Agent 授权。 +### 5.5 Xpert 编辑态与运行态权限体系 + +Xpert 权限不能只用 `XPERT_EDIT` 或 `CHAT_VIEW` 一个权限点解释。当前实现分为三层: + +| 层级 | 解决的问题 | 主要机制 | +| ---------------- | --------------------------------------- | ------------------------------------------------------------------------- | +| 产品能力层 | 用户能否进入 Xpert 编辑或聊天功能 | Feature、`XPERT_EDIT`、`CHAT_VIEW` | +| Xpert 资源层 | 用户能否编辑或运行这个具体 Xpert | creator、Workspace owner/member、`PublishedXpertAccessService`、UserGroup | +| Workspace 动作层 | 本次操作能否读取或修改 Xpert 的依赖资源 | `canRead`、`canRun`、`canWrite`、`canManage` | + +#### 编辑态 + +编辑态包括进入 `/xpert/w`、创建 Xpert、保存 draft、配置工作流、安装资源、发布和管理 Workspace。它的权限链路是: + +1. 页面、菜单和部分接口先检查 `XPERT_EDIT`。 +2. Workspace 列表和默认 Workspace 使用 `purpose=authoring`,只返回可编辑目标。 +3. Workspace 资源服务通过 `assertCanAuthor()` 校验;当前实现中 authoring 等价于 `canWrite`。 +4. 针对已有 Xpert 的操作还会校验创建者或 Workspace owner/member 等资源关系。 + +组织 Workspace 的 owner/member 当前同时拥有 `canRead`、`canRun` 和 `canWrite`;只有 owner 拥有 `canManage`。UserGroup 成员关系不授予编辑权,不能因为用户获准运行一个已发布 Xpert,就让其进入 Workspace 或修改 draft。 + +#### 运行态 + +运行态包括 Assistant 详情、Chat、run stream、工作流编译、运行能力发现、技能和连接器调用。它使用两道不能互相替代的校验: + +1. `PublishedXpertAccessService` 校验当前用户是否有权使用目标已发布 Xpert。 +2. `XpertWorkspaceAccessService` 校验运行过程是否对目标 Workspace 拥有 `canRun`。 + +用户通过智能体访问申请或管理员授权加入 Xpert 绑定的 UserGroup 后,第一道校验允许其使用该 Xpert。第二道校验会查找当前 tenant、organization、Workspace 下绑定该用户 UserGroup 的已发布 Xpert,并只补充 Workspace `canRun`: + +| 当前组织 Workspace 身份 | canRead | canRun | canWrite | canManage | +| ------------------------------ | ------- | ------ | -------- | --------- | +| owner | 是 | 是 | 是 | 是 | +| member | 是 | 是 | 是 | 否 | +| 已发布 Xpert 的 UserGroup 成员 | 否 | 是 | 否 | 否 | +| 无资源关系 | 否 | 否 | 否 | 否 | + +Workspace 级 `canRun` 用来访问运行依赖,不代表用户可以启动该 Workspace 中的任意 Xpert。每个目标 Xpert 仍必须单独通过 `PublishedXpertAccessService` 的资源 ACL。 + +#### 服务方法语义 + +Workspace 资源服务明确区分编辑态和运行态方法: + +| 场景 | 方法 | Workspace 动作 | +| ----------------------------- | ------------------------------- | --------------------- | +| 编辑或普通资源读取 | `findOne()` | `read` | +| 编辑态 Workspace 列表 | `getAllByWorkspace()` | `authoring` / `write` | +| 运行时读取单条资源 | `findOneForRuntime()` | `run` | +| 运行时读取 Workspace 资源列表 | `getAllByWorkspaceForRuntime()` | `run` | + +当前运行链路中: + +- `XpertChatHandler` 使用 `findOneForRuntime()` 加载主 Xpert 和 follow-up Xpert。 +- `GetXpertWorkflowHandler` 使用 `findOneForRuntime()` 加载并编译工作流。 +- `RuntimeCapabilitiesService` 使用 `getAllByWorkspaceForRuntime()` 获取技能包。 +- Connector 和 Skills Middleware 使用 `assertCanRun()`。 + +运行态列表完成 `run` 校验后会直接执行附带 tenant、organization 和 workspace scope 的 repository 查询,不能再次调用通用 `findAll()`。通用 `findAll()` 会触发创作态 `read` 校验,导致只有 `canRun` 的 UserGroup 用户被二次拒绝,并表现为 `Access denied to workspace`。 + +#### 安全不变量 + +1. `XPERT_EDIT` 不能替代具体 Workspace/Xpert 的资源权限。 +2. `CHAT_VIEW` 只代表聊天产品能力可用,不能替代目标 Xpert ACL。 +3. UserGroup 只对已发布 Xpert 生效,并且只桥接 Workspace `canRun`。 +4. `canRun` 不得提升为 `canRead`、`canWrite` 或 `canManage`。 +5. 运行态查询在授权后仍必须保留 tenant、organization 和 workspace 数据范围。 +6. 编辑、保存、删除、发布和管理入口不能使用 Runtime 方法绕过 authoring 校验。 + +相关实现: + +- `packages/server-ai/src/xpert/published-xpert-access.service.ts` +- `packages/server-ai/src/xpert-workspace/workspace-access.service.ts` +- `packages/server-ai/src/xpert-workspace/workspace-base.service.ts` +- `packages/server-ai/src/xpert/commands/handlers/chat.handler.ts` +- `packages/server-ai/src/xpert/queries/handlers/get-xpert-workflow.handler.ts` +- `packages/server-ai/src/ai/runtime-capabilities.service.ts` + ## 6. 一次权限判断是如何发生的 下面是当前系统的一次典型授权链路: diff --git a/docs/zh-hans/guides/permissions-current.mdx b/docs/zh-hans/guides/permissions-current.mdx index 3c07abf852..fa2d9edc23 100644 --- a/docs/zh-hans/guides/permissions-current.mdx +++ b/docs/zh-hans/guides/permissions-current.mdx @@ -141,9 +141,9 @@ AI 默认权限主要在 `packages/server-ai/src/core/default-role-permissions.t - 有 `EDIT` - 有少量 `VIEW` -- 缺少更细的“运行”“只读知识库”“只读 Xpert”“ChatBI 专属编辑”权限 +- 缺少角色级的“运行”“只读知识库”“只读 Xpert”“ChatBI 专属编辑”权限点 -因此 AI 权限已经可用,但粒度还比较粗。 +这里的“缺少运行权限”仅指没有独立的 `XPERT_RUN` RolePermission,并不表示运行时没有授权。当前 Xpert 运行由 `CHAT_VIEW` 产品能力、`PublishedXpertAccessService` 的目标 Xpert ACL,以及 Workspace `canRun` 三层共同控制;UserGroup 授权只会补充 `canRun`,不会获得 Workspace 编辑权。完整模型见《角色与权限设计说明书》的“Xpert 编辑态与运行态权限体系”。 ## 5. Analytics 权限现状 diff --git a/packages/analytics/src/bootstrap/index.ts b/packages/analytics/src/bootstrap/index.ts index 2923f41fa5..4625117dfd 100644 --- a/packages/analytics/src/bootstrap/index.ts +++ b/packages/analytics/src/bootstrap/index.ts @@ -106,7 +106,7 @@ export async function bootstrap(options: { title: string; version: string }) { ) const globalPrefix = 'api' - app.setGlobalPrefix(globalPrefix) + app.setGlobalPrefix(globalPrefix, { exclude: ['artifacts/share', 'artifacts/share/(.*)'] }) // Seed default values const serverService = app.select(ServerAppModule).get(AppService) diff --git a/packages/contracts/src/artifact.model.ts b/packages/contracts/src/artifact.model.ts new file mode 100644 index 0000000000..4be23c041e --- /dev/null +++ b/packages/contracts/src/artifact.model.ts @@ -0,0 +1,169 @@ +import { IBasePerTenantAndOrganizationEntityModel } from './base-entity.model' + +/** + * Access policies supported by platform-managed Artifact links. + * + * `public_link` is the only mode intended for anonymous web access; `signed_preview` + * is a short-lived capability URL for temporary previews. + */ +export type ArtifactAccessMode = + | 'owner_only' + | 'workspace_all' + | 'organization_all' + | 'custom_principals' + | 'public_link' + | 'signed_preview' + +/** Lifecycle state of an Artifact container. */ +export type ArtifactStatus = 'active' | 'archived' | 'deleted' + +/** Lifecycle state of an immutable Artifact content version. */ +export type ArtifactVersionStatus = 'active' | 'deleted' + +/** Lifecycle state of a public or scoped Artifact access entrypoint. */ +export type ArtifactLinkStatus = 'active' | 'revoked' | 'expired' + +/** Artifact rendering/download family used by viewers and security policy. */ +export type ArtifactKind = 'html' | 'markdown' | 'pdf' | 'pptx' | 'image' | 'file' | 'site' | 'presentation' + +/** Whether an Artifact link follows the latest version or pins one immutable version. */ +export type ArtifactLinkVersionMode = 'latest' | 'version' + +/** Browser presentation hint for Artifact responses. */ +export type ArtifactLinkDisposition = 'inline' | 'attachment' + +/** HTML safety profile applied when serving interactive or strict single-file HTML. */ +export type ArtifactSafeHtmlProfile = 'strict' | 'interactive' + +/** Audited events emitted by Artifact open/download/management flows. */ +export type ArtifactAccessEvent = 'access' | 'download' | 'denied' | 'revoked' | 'expired' | 'archived' | 'deleted' + +/** + * Persistable reference to a Workspace Files object. + * + * The platform stores Artifact bytes in Workspace Files and keeps only this + * portable reference in Artifact metadata, so async jobs and public controllers + * can resolve content without leaking host filesystem paths. + */ +export interface IArtifactWorkspaceFileReference { + source: 'platform.workspace.files' + filePath: string + workspacePath: string + tenantId?: string | null + userId?: string | null + catalog?: 'projects' | 'users' | 'knowledges' | 'skills' | 'xperts' | null + scopeId?: string | null + projectId?: string | null + knowledgeId?: string | null + rootId?: string | null + xpertId?: string | null + isolateByUser?: boolean | null + originalName?: string | null + name?: string | null + mimeType?: string | null + size?: number | null +} + +/** + * Artifact is the durable product object created by Agents/plugins. + * + * It is a stable container for versioned content such as generated HTML, PDFs, + * decks, images, static sites, or other files. Content bytes live in versions; + * this record owns source identity, scope, title, and current-version pointer. + */ +export interface IArtifact extends IBasePerTenantAndOrganizationEntityModel { + pluginName: string + resourceType: string + resourceId: string + checksum?: string | null + kind: ArtifactKind + status: ArtifactStatus + title?: string | null + description?: string | null + currentVersionId?: string | null + currentVersion?: IArtifactVersion | null + workspaceId?: string | null + projectId?: string | null + xpertId?: string | null + userId?: string | null + metadata?: Record | null +} + +/** + * Immutable content version of an Artifact. + * + * Version rows point at a Workspace Files reference and include enough content + * metadata to validate checksums, serve downloads, and reproduce a fixed share. + */ +export interface IArtifactVersion extends IBasePerTenantAndOrganizationEntityModel { + artifact?: IArtifact | null + artifactId: string + versionNumber: number + status: ArtifactVersionStatus + sourceVersionId?: string | null + checksum?: string | null + workspaceFileRef: IArtifactWorkspaceFileReference + mimeType: string + fileName?: string | null + title?: string | null + description?: string | null + size?: number | null + sha256?: string | null + workspaceId?: string | null + projectId?: string | null + xpertId?: string | null + userId?: string | null + metadata?: Record | null +} + +/** + * Access entrypoint for an Artifact. + * + * A link owns the externally copied slug/public URL, access policy, optional + * pinned version, and counters. It can point to `latest` or a fixed version. + */ +export interface IArtifactLink extends IBasePerTenantAndOrganizationEntityModel { + artifact?: IArtifact | null + artifactId: string + artifactVersionId?: string | null + versionMode: ArtifactLinkVersionMode + slug: string + publicUrl: string + accessMode: ArtifactAccessMode + status: ArtifactLinkStatus + customPrincipals?: string[] | null + tokenHash?: string | null + expiresAt?: Date | null + revokedAt?: Date | null + accessCount: number + downloadCount: number + disposition: ArtifactLinkDisposition + allowDownload: boolean + safeHtmlProfile?: ArtifactSafeHtmlProfile | null + workspaceId?: string | null + projectId?: string | null + xpertId?: string | null + userId?: string | null + metadata?: Record | null +} + +/** + * Append-only audit row for Artifact access and lifecycle events. + * + * Logs intentionally store hashes/summaries rather than public tokens or file + * contents, so public access remains auditable without leaking sensitive data. + */ +export interface IArtifactAccessLog extends IBasePerTenantAndOrganizationEntityModel { + link?: IArtifactLink | null + linkId?: string | null + artifactId?: string | null + slug: string + event: ArtifactAccessEvent + accessMode?: ArtifactAccessMode | null + principalUserId?: string | null + ipHash?: string | null + userAgent?: string | null + statusCode?: number | null + error?: string | null + metadata?: Record | null +} diff --git a/packages/contracts/src/collaboration.model.ts b/packages/contracts/src/collaboration.model.ts new file mode 100644 index 0000000000..ace7413b9c --- /dev/null +++ b/packages/contracts/src/collaboration.model.ts @@ -0,0 +1,117 @@ +import { IBasePerTenantAndOrganizationEntityModel } from './base-entity.model' + +export type CollaborationEngine = 'yjs' +export type CollaborationDocumentStatus = 'active' | 'archived' | 'deleted' +export type CollaborationMaterializationStatus = 'ready' | 'pending' | 'failed' +export type CollaborationActorType = 'user' | 'agent' | 'system' +export type CollaborationActorStatus = 'thinking' | 'editing' | 'done' | 'failed' +export type CollaborationAccess = 'read' | 'write' | 'manage' + +/** Safe, presentation-ready identity exposed to collaboration clients. */ +export interface ICollaborationActor { + /** Opaque, stable identity for presence; it must not reveal a platform user id. */ + presenceId: string + actorType: CollaborationActorType + displayName: string + color: string + avatarUrl?: string | null +} + +/** Pointer coordinates normalized to the collaborative page, in the inclusive 0..1 range. */ +export interface ICollaborationPointer { + pageId?: string | null + x: number + y: number + visible: boolean +} + +/** Optional viewport metadata used to render remote cursors at the correct scale. */ +export interface ICollaborationViewport { + zoom: number + width: number + height: number +} + +/** Semantic location currently operated on by a collaborator. */ +export interface ICollaborationFocus { + /** Plugin-defined focus category, for example `slide`, `text`, `control`, or `element`. */ + kind: string + key?: string | null + pageId?: string | null + elementId?: string | null + fieldKey?: string | null +} + +/** A text range expressed as Yjs relative positions, or a set of selected element ids. */ +export interface ICollaborationSelection { + kind: 'text' | 'elements' + fieldKey?: string | null + elementIds?: string[] | null + anchorRelativeBase64?: string | null + headRelativeBase64?: string | null +} + +/** + * Ephemeral presence shared by users, agents, and system actors. + * Presence is intentionally not part of the persisted collaboration document. + */ +export interface ICollaborationPresence extends ICollaborationActor { + /** Socket id for users, or the opaque presence id for virtual actors. */ + clientId: string + pageId?: string | null + pointer?: ICollaborationPointer | null + focus?: ICollaborationFocus | null + selection?: ICollaborationSelection | null + viewport?: ICollaborationViewport | null + mode?: string | null + status?: CollaborationActorStatus | null + toolName?: string | null + operationLabel?: string | null + updatedAt: number +} + +/** + * Authoritative, platform-managed state of one plugin collaboration resource. + * Plugin business entities are materialized projections of this Yjs state. + */ +export interface ICollaborationDocument extends IBasePerTenantAndOrganizationEntityModel { + /** Provider registered by a plugin, such as `presentation-studio.deck`. */ + providerKey: string + /** Stable business-resource id understood by the provider. */ + resourceId: string + engine: CollaborationEngine + /** Version of the plugin-owned Yjs schema, not the platform database schema. */ + schemaVersion: number + status: CollaborationDocumentStatus + /** Complete Yjs state encoded as a base64 update. */ + stateBase64: string + /** State vector corresponding exactly to `stateBase64`. */ + stateVectorBase64: string + /** Monotonic platform sequence incremented once for each unique accepted update. */ + sequenceNumber: number + updateCount: number + /** Highest sequence successfully projected into the plugin business entity. */ + materializedSequence: number + materializationStatus: CollaborationMaterializationStatus + lastMaterializationError?: string | null + workspaceId?: string | null + projectId?: string | null + xpertId?: string | null + userId?: string | null + metadata?: Record | null +} + +/** Immutable update journal entry used for idempotency and bounded delta retention. */ +export interface ICollaborationUpdate extends IBasePerTenantAndOrganizationEntityModel { + document?: ICollaborationDocument | null + documentId: string + /** Sequence assigned while the document row is locked. */ + sequenceNumber: number + updateBase64: string + /** SHA-256 of the decoded update bytes; unique per document. */ + updateHash: string + origin?: string | null + actorType?: CollaborationActorType | null + presenceId?: string | null + userId?: string | null +} diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index 2202510e37..5f4ad6f1a5 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -64,6 +64,8 @@ export * from './integration/index' export * from './ai/index' export * from './agent/index' export * from './api-key.model' +export * from './artifact.model' +export * from './collaboration.model' export * from './schedule' export * from './tools/index' export * from './plain-object.model' diff --git a/packages/plugin-sdk/src/index.ts b/packages/plugin-sdk/src/index.ts index 504d7c789c..a0709af5ba 100644 --- a/packages/plugin-sdk/src/index.ts +++ b/packages/plugin-sdk/src/index.ts @@ -23,3 +23,4 @@ export * from './lib/sandbox/index' export * from './lib/managed-connection/index' export * from './lib/managed-queue/index' export * from './lib/view-extension/index' +export * from './lib/collaboration/index' diff --git a/packages/plugin-sdk/src/lib/collaboration/client.ts b/packages/plugin-sdk/src/lib/collaboration/client.ts new file mode 100644 index 0000000000..4ddb03b578 --- /dev/null +++ b/packages/plugin-sdk/src/lib/collaboration/client.ts @@ -0,0 +1,540 @@ +import type { + ApplyCollaborationUpdateResult, + CollaborationPresencePatch, + CollaborationSessionDescriptor, + ICollaborationActor, + ICollaborationPresence +} from './types' + +export type CollaborationTransportPayload = Record +export type CollaborationTransportHandler = (payload: CollaborationTransportPayload) => void + +/** Framework-neutral event transport used by the collaboration client. */ +export interface CollaborationTransport { + readonly connected: boolean + /** Socket/session identifier for this exact browser client, when the transport exposes one. */ + readonly clientId?: string | null + connect(): void + disconnect(): void + emit(event: string, payload?: CollaborationTransportPayload): void + on(event: string, handler: CollaborationTransportHandler): () => void +} + +/** Minimal Socket.IO-compatible shape; keeps socket.io-client out of the SDK bundle. */ +export interface CollaborationSocketLike { + readonly connected: boolean + readonly id?: string + connect(): unknown + disconnect(): unknown + emit(event: string, payload?: CollaborationTransportPayload): unknown + on(event: string, handler: CollaborationTransportHandler): unknown + off(event: string, handler: CollaborationTransportHandler): unknown +} + +/** Adapt a caller-owned Socket.IO client to the SDK's framework-neutral transport. */ +export function createSocketIoTransportAdapter(socket: CollaborationSocketLike): CollaborationTransport { + return { + get connected() { + return socket.connected + }, + get clientId() { + return socket.id ?? null + }, + connect: () => { + socket.connect() + }, + disconnect: () => { + socket.disconnect() + }, + emit: (event, payload = {}) => { + socket.emit(event, payload) + }, + on: (event, handler) => { + socket.on(event, handler) + return () => { + socket.off(event, handler) + } + } + } +} + +export type CollaborationDocumentUpdateHandler = (update: Uint8Array, origin: unknown) => void + +/** Binary CRDT operations required by the generic collaboration client. */ +export interface CollaborationBinaryDocumentAdapter { + applyUpdate(update: Uint8Array, origin: unknown): void + encodeStateVector(): Uint8Array + mergeUpdates(updates: Uint8Array[]): Uint8Array + onUpdate(handler: CollaborationDocumentUpdateHandler): () => void +} + +/** Minimal observable Yjs document surface accepted by `createYjsDocumentAdapter`. */ +export interface YjsDocumentLike { + on(event: 'update', handler: CollaborationDocumentUpdateHandler): void + off(event: 'update', handler: CollaborationDocumentUpdateHandler): void +} + +/** Caller-supplied Yjs functions, avoiding a second Yjs runtime in plugin bundles. */ +export interface YjsApiLike { + applyUpdate(document: TDocument, update: Uint8Array, origin?: unknown): void + encodeStateVector(document: TDocument): Uint8Array + mergeUpdates(updates: Uint8Array[]): Uint8Array +} + +/** Wrap a caller-owned `Y.Doc` without passing it across the runtime capability boundary. */ +export function createYjsDocumentAdapter( + document: TDocument, + yjs: YjsApiLike +): CollaborationBinaryDocumentAdapter { + return { + applyUpdate: (update, origin) => yjs.applyUpdate(document, update, origin), + encodeStateVector: () => yjs.encodeStateVector(document), + mergeUpdates: (updates) => yjs.mergeUpdates(updates), + onUpdate: (handler) => { + document.on('update', handler) + return () => document.off('update', handler) + } + } +} + +/** Timings, callbacks, transport, and document adapter used by a browser client. */ +export type CollaborationClientOptions = { + session: CollaborationSessionDescriptor + transport: CollaborationTransport + document: CollaborationBinaryDocumentAdapter + initialPresence?: CollaborationPresencePatch + batchMs?: number + syncIntervalMs?: number + presenceHeartbeatMs?: number + /** Remove a presence locally when no refresh has been received within this interval. */ + presenceStaleMs?: number + onAck?: (ack: ApplyCollaborationUpdateResult) => void + onPresence?: (presence: ICollaborationPresence) => void + onPresenceSnapshot?: (items: ICollaborationPresence[], metadata: CollaborationPresenceSnapshotMetadata) => void + onPresenceRemove?: (clientId: string) => void + onConnectionChange?: (state: 'connecting' | 'connected' | 'disconnected') => void + onError?: (error: Error) => void +} + +/** Lifecycle and presence controls returned by `createCollaborationClient`. */ +export interface CollaborationClient { + /** Stable origin applied to remote updates so local observers can prevent echo loops. */ + readonly remoteOrigin: object + /** Socket id for this exact tab/device; distinct from the stable actor `presenceId`. */ + readonly selfClientId: string | null + connect(): void + disconnect(): void + flush(): void + requestSync(): void + setPresence(patch: CollaborationPresencePatch): void +} + +/** Connection-specific metadata delivered with a presence snapshot. */ +export type CollaborationPresenceSnapshotMetadata = { + selfClientId: string | null +} + +/** Stable, framework-neutral view of active collaboration sessions and deduplicated actors. */ +export type CollaborationPresenceStoreSnapshot = { + selfClientId: string | null + /** Every active browser/Agent session keyed by `clientId`. */ + sessions: ICollaborationPresence[] + /** Sessions excluding only this exact browser client. */ + remoteSessions: ICollaborationPresence[] + /** One entry per actor identity, including the local actor when supplied. */ + collaborators: ICollaborationPresence[] +} + +export type CollaborationPresenceStoreOptions = { + selfActor?: ICollaborationActor | null + includeSelf?: boolean + onChange?: (snapshot: CollaborationPresenceStoreSnapshot) => void +} + +/** Mutable presence projection used by plugin UIs without coupling them to a framework. */ +export interface CollaborationPresenceStore { + setSelfClientId(clientId: string | null): void + replace(items: ICollaborationPresence[], selfClientId?: string | null): void + upsert(item: ICollaborationPresence): void + remove(clientId: string): void + clear(): void + snapshot(): CollaborationPresenceStoreSnapshot +} + +/** + * Keep per-client sessions for cursors while exposing one collaborator per stable actor identity. + * This prevents a second tab owned by the same user from either duplicating or hiding that user. + */ +export function createCollaborationPresenceStore( + options: CollaborationPresenceStoreOptions = {} +): CollaborationPresenceStore { + let selfClientId: string | null = null + let sessions = new Map() + + const readSnapshot = (): CollaborationPresenceStoreSnapshot => { + const activeSessions = Array.from(sessions.values()).sort((left, right) => right.updatedAt - left.updatedAt) + const remoteSessions = activeSessions.filter((item) => item.clientId !== selfClientId) + const actors = new Map() + for (const item of activeSessions) if (!actors.has(item.presenceId)) actors.set(item.presenceId, item) + if (options.includeSelf !== false && options.selfActor) { + const existing = activeSessions.find( + (item) => item.clientId === selfClientId || item.presenceId === options.selfActor?.presenceId + ) + actors.set(options.selfActor.presenceId, existing ?? actorPresence(options.selfActor, selfClientId)) + } + const collaborators = Array.from(actors.values()).sort((left, right) => { + const leftSelf = left.presenceId === options.selfActor?.presenceId ? 1 : 0 + const rightSelf = right.presenceId === options.selfActor?.presenceId ? 1 : 0 + return rightSelf - leftSelf || right.updatedAt - left.updatedAt + }) + return { selfClientId, sessions: activeSessions, remoteSessions, collaborators } + } + const publish = () => options.onChange?.(readSnapshot()) + + return { + setSelfClientId: (clientId) => { + selfClientId = clientId + publish() + }, + replace: (items, clientId = selfClientId) => { + selfClientId = clientId + sessions = new Map(items.map((item) => [item.clientId, item])) + publish() + }, + upsert: (item) => { + sessions.set(item.clientId, item) + publish() + }, + remove: (clientId) => { + if (sessions.delete(clientId)) publish() + }, + clear: () => { + sessions.clear() + selfClientId = null + publish() + }, + snapshot: readSnapshot + } +} + +/** + * Create a collaboration client with update batching, state-vector repair, presence heartbeat, + * and reconnect synchronization. The caller retains ownership of the document and transport. + */ +export function createCollaborationClient(options: CollaborationClientOptions): CollaborationClient { + const remoteOrigin = Object.freeze({ kind: 'platform.collaboration.remote' }) + const batchMs = positiveInteger(options.batchMs, 40) + const syncIntervalMs = positiveInteger(options.syncIntervalMs, 2_000) + const presenceHeartbeatMs = positiveInteger(options.presenceHeartbeatMs, 5_000) + const presenceStaleMs = positiveInteger(options.presenceStaleMs, 15_000) + let presence: CollaborationPresencePatch = { ...(options.initialPresence ?? {}) } + let pending: Uint8Array[] = [] + let flushTimer: ReturnType | undefined + let syncTimer: ReturnType | undefined + let presenceTimer: ReturnType | undefined + let presenceCleanupTimer: ReturnType | undefined + let selfClientId: string | null = options.transport.clientId ?? null + let started = false + const cleanups: Array<() => void> = [] + const presenceSeenAt = new Map() + + const reportError = (error: unknown) => options.onError?.(error instanceof Error ? error : new Error(String(error))) + const emitPresence = () => options.transport.emit('presence', { ...presence }) + + // Merge the short burst of local Yjs transactions into one network update. + const flush = () => { + if (flushTimer) clearTimeout(flushTimer) + flushTimer = undefined + if (!pending.length || !options.transport.connected) return + const updates = pending + pending = [] + options.transport.emit('update', { + updateBase64: bytesToBase64(options.document.mergeUpdates(updates)), + origin: 'plugin-client' + }) + } + + // A state vector lets the server return only bytes missing from this client. + const requestSync = () => { + if (!options.transport.connected) return + options.transport.emit('sync-request', { stateVectorBase64: bytesToBase64(options.document.encodeStateVector()) }) + } + + // Mark server updates with `remoteOrigin` so the document observer does not send them back. + const applyRemote = (payload: CollaborationTransportPayload) => { + const encoded = stringValue(payload['updateBase64']) + if (!encoded) return + try { + options.document.applyUpdate(base64ToBytes(encoded), remoteOrigin) + } catch (error) { + reportError(error) + } + } + + // Register listeners once, then start repair and presence heartbeats before opening the socket. + const connect = () => { + if (started) return + started = true + options.onConnectionChange?.('connecting') + cleanups.push( + options.document.onUpdate((update, origin) => { + if (origin === remoteOrigin) return + pending.push(update) + if (!flushTimer) flushTimer = setTimeout(flush, batchMs) + }) + ) + cleanups.push( + options.transport.on('connect', () => { + selfClientId = options.transport.clientId ?? selfClientId + options.onConnectionChange?.('connected') + requestSync() + emitPresence() + }) + ) + cleanups.push(options.transport.on('disconnect', () => options.onConnectionChange?.('disconnected'))) + cleanups.push(options.transport.on('sync', applyRemote)) + cleanups.push(options.transport.on('update', applyRemote)) + cleanups.push( + options.transport.on('update-ack', (payload) => { + const ack = parseAck(payload) + if (ack) options.onAck?.(ack) + }) + ) + cleanups.push( + options.transport.on('presence', (payload) => { + const item = parsePresence(payload) + if (item) { + presenceSeenAt.set(item.clientId, Date.now()) + options.onPresence?.(item) + } + }) + ) + cleanups.push( + options.transport.on('presence-snapshot', (payload) => { + const rawItems = payload['items'] + const items = Array.isArray(rawItems) ? rawItems.map(parsePresence).filter(isPresence) : [] + selfClientId = stringValue(payload['selfClientId']) ?? options.transport.clientId ?? selfClientId + presenceSeenAt.clear() + for (const item of items) presenceSeenAt.set(item.clientId, Date.now()) + options.onPresenceSnapshot?.(items, { selfClientId }) + }) + ) + cleanups.push( + options.transport.on('presence-remove', (payload) => { + const clientId = stringValue(payload['clientId']) + if (clientId) { + presenceSeenAt.delete(clientId) + options.onPresenceRemove?.(clientId) + } + }) + ) + cleanups.push( + options.transport.on('error', (payload) => + reportError(stringValue(payload['message']) ?? 'Collaboration failed.') + ) + ) + syncTimer = setInterval(requestSync, syncIntervalMs) + presenceTimer = setInterval(emitPresence, presenceHeartbeatMs) + presenceCleanupTimer = setInterval( + () => { + const cutoff = Date.now() - presenceStaleMs + for (const [clientId, seenAt] of presenceSeenAt) { + if (clientId === selfClientId || seenAt >= cutoff) continue + presenceSeenAt.delete(clientId) + options.onPresenceRemove?.(clientId) + } + }, + Math.min(5_000, Math.max(1_000, Math.floor(presenceStaleMs / 3))) + ) + options.transport.connect() + } + + // Flush best-effort pending state and release all timers/listeners owned by this client. + const disconnect = () => { + if (!started) return + flush() + started = false + if (flushTimer) clearTimeout(flushTimer) + if (syncTimer) clearInterval(syncTimer) + if (presenceTimer) clearInterval(presenceTimer) + if (presenceCleanupTimer) clearInterval(presenceCleanupTimer) + flushTimer = undefined + syncTimer = undefined + presenceTimer = undefined + presenceCleanupTimer = undefined + presenceSeenAt.clear() + while (cleanups.length) cleanups.pop()?.() + options.transport.disconnect() + options.onConnectionChange?.('disconnected') + } + + return { + get selfClientId() { + return selfClientId + }, + remoteOrigin, + connect, + disconnect, + flush, + requestSync, + setPresence: (patch) => { + presence = { ...presence, ...patch } + if (options.transport.connected) emitPresence() + } + } +} + +function actorPresence(actor: ICollaborationActor, clientId: string | null): ICollaborationPresence { + return { + ...actor, + clientId: clientId ?? `self:${actor.presenceId}`, + pageId: null, + pointer: null, + focus: null, + selection: null, + viewport: null, + mode: null, + status: null, + toolName: null, + operationLabel: null, + updatedAt: Date.now() + } +} + +/** Encode bytes in browsers without Node.js `Buffer`; chunks avoid argument-size limits. */ +export function bytesToBase64(bytes: Uint8Array): string { + let binary = '' + const chunkSize = 0x8000 + for (let offset = 0; offset < bytes.length; offset += chunkSize) { + binary += String.fromCharCode(...bytes.subarray(offset, Math.min(offset + chunkSize, bytes.length))) + } + return globalThis.btoa(binary) +} + +/** Decode base64 in browsers without requiring Node.js polyfills. */ +export function base64ToBytes(value: string): Uint8Array { + const binary = globalThis.atob(value) + const bytes = new Uint8Array(binary.length) + for (let index = 0; index < binary.length; index += 1) bytes[index] = binary.charCodeAt(index) + return bytes +} + +function parseAck(payload: CollaborationTransportPayload): ApplyCollaborationUpdateResult | null { + const documentId = stringValue(payload['documentId']) + const stateVectorBase64 = stringValue(payload['stateVectorBase64']) + const sequenceNumber = payload['sequenceNumber'] + if (!documentId || !stateVectorBase64 || typeof sequenceNumber !== 'number') return null + const materializationStatus = payload['materializationStatus'] + if (materializationStatus !== 'ready' && materializationStatus !== 'pending' && materializationStatus !== 'failed') + return null + return { + documentId, + duplicate: payload['duplicate'] === true, + sequenceNumber, + updateId: stringValue(payload['updateId']), + stateVectorBase64, + materializationStatus + } +} + +function parsePresence(value: unknown): ICollaborationPresence | null { + if (!value || typeof value !== 'object' || Array.isArray(value)) return null + const item = value as Record + const clientId = stringValue(item['clientId']) + const presenceId = stringValue(item['presenceId']) + const displayName = stringValue(item['displayName']) + const color = stringValue(item['color']) + const actorType = item['actorType'] + if ( + !clientId || + !presenceId || + !displayName || + !color || + (actorType !== 'user' && actorType !== 'agent' && actorType !== 'system') + ) + return null + return { + clientId, + presenceId, + displayName, + color, + actorType, + avatarUrl: nullableString(item['avatarUrl']), + pageId: nullableString(item['pageId']), + pointer: parsePointer(item['pointer']), + focus: parseFocus(item['focus']), + selection: parseSelection(item['selection']), + viewport: parseViewport(item['viewport']), + mode: nullableString(item['mode']), + status: actorStatus(item['status']), + toolName: nullableString(item['toolName']), + operationLabel: nullableString(item['operationLabel']), + updatedAt: typeof item['updatedAt'] === 'number' ? item['updatedAt'] : Date.now() + } +} + +function parsePointer(value: unknown): ICollaborationPresence['pointer'] { + const item = recordValue(value) + if (!item || typeof item['x'] !== 'number' || typeof item['y'] !== 'number') return null + return { pageId: nullableString(item['pageId']), x: item['x'], y: item['y'], visible: item['visible'] !== false } +} + +function parseFocus(value: unknown): ICollaborationPresence['focus'] { + const item = recordValue(value) + const kind = item && stringValue(item['kind']) + if (!item || !kind) return null + return { + kind, + key: nullableString(item['key']), + pageId: nullableString(item['pageId']), + elementId: nullableString(item['elementId']), + fieldKey: nullableString(item['fieldKey']) + } +} + +function parseSelection(value: unknown): ICollaborationPresence['selection'] { + const item = recordValue(value) + const kind = item?.['kind'] + if (!item || (kind !== 'text' && kind !== 'elements')) return null + return { + kind, + fieldKey: nullableString(item['fieldKey']), + elementIds: Array.isArray(item['elementIds']) + ? item['elementIds'].filter((entry): entry is string => typeof entry === 'string') + : null, + anchorRelativeBase64: nullableString(item['anchorRelativeBase64']), + headRelativeBase64: nullableString(item['headRelativeBase64']) + } +} + +function parseViewport(value: unknown): ICollaborationPresence['viewport'] { + const item = recordValue(value) + if ( + !item || + typeof item['zoom'] !== 'number' || + typeof item['width'] !== 'number' || + typeof item['height'] !== 'number' + ) + return null + return { zoom: item['zoom'], width: item['width'], height: item['height'] } +} + +function recordValue(value: unknown) { + return value && typeof value === 'object' && !Array.isArray(value) ? (value as Record) : null +} + +function isPresence(value: ICollaborationPresence | null): value is ICollaborationPresence { + return value !== null +} +function stringValue(value: unknown) { + return typeof value === 'string' && value.trim() ? value.trim() : undefined +} +function nullableString(value: unknown) { + return stringValue(value) ?? null +} +function actorStatus(value: unknown) { + return value === 'thinking' || value === 'editing' || value === 'done' || value === 'failed' ? value : null +} +function positiveInteger(value: number | undefined, fallback: number) { + return Number.isFinite(value) && Number(value) > 0 ? Math.trunc(Number(value)) : fallback +} diff --git a/packages/plugin-sdk/src/lib/collaboration/collaboration.spec.ts b/packages/plugin-sdk/src/lib/collaboration/collaboration.spec.ts new file mode 100644 index 0000000000..b1da023bed --- /dev/null +++ b/packages/plugin-sdk/src/lib/collaboration/collaboration.spec.ts @@ -0,0 +1,202 @@ +import { CollaborationRuntimeCapability } from './runtime-capability' +import { + base64ToBytes, + bytesToBase64, + createCollaborationClient, + createCollaborationPresenceStore, + type CollaborationBinaryDocumentAdapter, + type CollaborationTransport, + type CollaborationTransportHandler +} from './client' +import type { ICollaborationPresence } from './types' + +class TestTransport implements CollaborationTransport { + connected = false + clientId: string | null = 'socket-self' + readonly emitted: Array<{ event: string; payload: Record }> = [] + private readonly handlers = new Map>() + + connect() { + this.connected = true + this.dispatch('connect', {}) + } + + disconnect() { + this.connected = false + } + + emit(event: string, payload: Record = {}) { + this.emitted.push({ event, payload }) + } + + on(event: string, handler: CollaborationTransportHandler) { + const handlers = this.handlers.get(event) ?? new Set() + handlers.add(handler) + this.handlers.set(event, handlers) + return () => handlers.delete(handler) + } + + dispatch(event: string, payload: Record) { + for (const handler of this.handlers.get(event) ?? []) handler(payload) + } +} + +class TestDocument implements CollaborationBinaryDocumentAdapter { + readonly applied: Array<{ update: Uint8Array; origin: unknown }> = [] + private readonly handlers = new Set<(update: Uint8Array, origin: unknown) => void>() + + applyUpdate(update: Uint8Array, origin: unknown) { + this.applied.push({ update, origin }) + } + + encodeStateVector() { + return new Uint8Array([7]) + } + + mergeUpdates(updates: Uint8Array[]) { + return Uint8Array.from(updates.flatMap((update) => [...update])) + } + + onUpdate(handler: (update: Uint8Array, origin: unknown) => void) { + this.handlers.add(handler) + return () => this.handlers.delete(handler) + } + + update(update: Uint8Array, origin: unknown = 'local') { + for (const handler of this.handlers) handler(update, origin) + } +} + +describe('platform collaboration SDK', () => { + afterEach(() => jest.useRealTimers()) + + it('exposes the platform.collaboration runtime capability', () => { + expect(CollaborationRuntimeCapability.id).toBe('platform.collaboration') + }) + + it('round-trips browser-safe base64 payloads', () => { + const bytes = new Uint8Array([0, 1, 127, 128, 255]) + expect(base64ToBytes(bytesToBase64(bytes))).toEqual(bytes) + }) + + it('batches local updates, synchronizes on connect, and ignores remote echoes', () => { + jest.useFakeTimers() + const transport = new TestTransport() + const document = new TestDocument() + const client = createCollaborationClient({ + session: { + sessionId: 'session', + clientKey: 'key', + documentId: 'document', + namespace: '/api/collaboration', + connectionUrl: 'http://localhost:3000/api/collaboration', + access: 'write', + actor: { presenceId: 'presence', actorType: 'user', displayName: 'User', color: '#000000' }, + expiresAt: Date.now() + 60_000 + }, + transport, + document, + batchMs: 40 + }) + + client.connect() + expect(transport.emitted.map(({ event }) => event)).toEqual(['sync-request', 'presence']) + + document.update(new Uint8Array([1])) + document.update(new Uint8Array([2])) + jest.advanceTimersByTime(40) + expect(transport.emitted.at(-1)).toEqual({ + event: 'update', + payload: { updateBase64: bytesToBase64(new Uint8Array([1, 2])), origin: 'plugin-client' } + }) + + transport.dispatch('update', { updateBase64: bytesToBase64(new Uint8Array([3])) }) + expect(document.applied).toEqual([{ update: new Uint8Array([3]), origin: client.remoteOrigin }]) + document.update(new Uint8Array([3]), client.remoteOrigin) + jest.advanceTimersByTime(40) + expect(transport.emitted.filter(({ event }) => event === 'update')).toHaveLength(1) + + client.disconnect() + expect(transport.connected).toBe(false) + }) + + it('keeps actor identity separate from browser client identity', () => { + const selfActor = { presenceId: 'user-1', actorType: 'user' as const, displayName: 'User', color: '#111111' } + const snapshots: Array['snapshot']>> = [] + const store = createCollaborationPresenceStore({ selfActor, onChange: (snapshot) => snapshots.push(snapshot) }) + store.replace( + [ + presence({ clientId: 'socket-self', presenceId: 'user-1', displayName: 'User' }), + presence({ clientId: 'socket-other-tab', presenceId: 'user-1', displayName: 'User' }), + presence({ clientId: 'socket-user-2', presenceId: 'user-2', displayName: 'Other' }) + ], + 'socket-self' + ) + + const snapshot = store.snapshot() + expect(snapshot.remoteSessions.map(({ clientId }) => clientId)).toEqual(['socket-other-tab', 'socket-user-2']) + expect(snapshot.collaborators.map(({ presenceId }) => presenceId)).toEqual(['user-1', 'user-2']) + expect(snapshots).toHaveLength(1) + }) + + it('reports selfClientId and removes silent remote presence locally', () => { + jest.useFakeTimers() + const transport = new TestTransport() + const removed: string[] = [] + const snapshots: Array<{ selfClientId: string | null }> = [] + const client = createCollaborationClient({ + session: testSession(), + transport, + document: new TestDocument(), + presenceStaleMs: 3_000, + onPresenceSnapshot: (_items, metadata) => snapshots.push(metadata), + onPresenceRemove: (clientId) => removed.push(clientId) + }) + + client.connect() + transport.dispatch('presence-snapshot', { selfClientId: 'socket-self', items: [] }) + transport.dispatch('presence', presence({ clientId: 'socket-remote', presenceId: 'user-2' })) + expect(client.selfClientId).toBe('socket-self') + expect(snapshots).toEqual([{ selfClientId: 'socket-self' }]) + + jest.advanceTimersByTime(4_000) + expect(removed).toEqual(['socket-remote']) + client.disconnect() + }) +}) + +function testSession() { + return { + sessionId: 'session', + clientKey: 'key', + documentId: 'document', + namespace: '/api/collaboration', + connectionUrl: 'http://localhost:3000/api/collaboration', + access: 'write' as const, + actor: { presenceId: 'user-1', actorType: 'user' as const, displayName: 'User', color: '#000000' }, + expiresAt: Date.now() + 60_000 + } +} + +function presence( + patch: Partial & Pick +): ICollaborationPresence { + return { + clientId: patch.clientId, + presenceId: patch.presenceId, + actorType: patch.actorType ?? 'user', + displayName: patch.displayName ?? 'Remote', + color: patch.color ?? '#222222', + avatarUrl: patch.avatarUrl ?? null, + pageId: patch.pageId ?? null, + pointer: patch.pointer ?? null, + focus: patch.focus ?? null, + selection: patch.selection ?? null, + viewport: patch.viewport ?? null, + mode: patch.mode ?? null, + status: patch.status ?? null, + toolName: patch.toolName ?? null, + operationLabel: patch.operationLabel ?? null, + updatedAt: patch.updatedAt ?? Date.now() + } +} diff --git a/packages/plugin-sdk/src/lib/collaboration/index.ts b/packages/plugin-sdk/src/lib/collaboration/index.ts new file mode 100644 index 0000000000..52418eeb24 --- /dev/null +++ b/packages/plugin-sdk/src/lib/collaboration/index.ts @@ -0,0 +1,5 @@ +export * from './types' +export * from './runtime-capability' +export * from './provider.decorator' +export * from './provider.registry' +export * from './client' diff --git a/packages/plugin-sdk/src/lib/collaboration/provider.decorator.ts b/packages/plugin-sdk/src/lib/collaboration/provider.decorator.ts new file mode 100644 index 0000000000..8fed13b3b3 --- /dev/null +++ b/packages/plugin-sdk/src/lib/collaboration/provider.decorator.ts @@ -0,0 +1,11 @@ +import { applyDecorators, SetMetadata } from '@nestjs/common' +import { STRATEGY_META_KEY } from '../types' + +export const COLLABORATION_DOCUMENT_PROVIDER = 'COLLABORATION_DOCUMENT_PROVIDER' + +/** Register a plugin adapter for one stable collaboration resource type. */ +export const CollaborationDocumentProvider = (providerKey: string) => + applyDecorators( + SetMetadata(COLLABORATION_DOCUMENT_PROVIDER, providerKey), + SetMetadata(STRATEGY_META_KEY, COLLABORATION_DOCUMENT_PROVIDER) + ) diff --git a/packages/plugin-sdk/src/lib/collaboration/provider.registry.ts b/packages/plugin-sdk/src/lib/collaboration/provider.registry.ts new file mode 100644 index 0000000000..b0b3b18314 --- /dev/null +++ b/packages/plugin-sdk/src/lib/collaboration/provider.registry.ts @@ -0,0 +1,13 @@ +import { Injectable } from '@nestjs/common' +import { DiscoveryService, Reflector } from '@nestjs/core' +import { BaseStrategyRegistry } from '../strategy' +import { COLLABORATION_DOCUMENT_PROVIDER } from './provider.decorator' +import type { ICollaborationDocumentProvider } from './types' + +/** Discovers system and organization-scoped collaboration providers from loaded plugins. */ +@Injectable() +export class CollaborationDocumentProviderRegistry extends BaseStrategyRegistry { + constructor(discoveryService: DiscoveryService, reflector: Reflector) { + super(COLLABORATION_DOCUMENT_PROVIDER, discoveryService, reflector) + } +} diff --git a/packages/plugin-sdk/src/lib/collaboration/runtime-capability.ts b/packages/plugin-sdk/src/lib/collaboration/runtime-capability.ts new file mode 100644 index 0000000000..9ad93e845d --- /dev/null +++ b/packages/plugin-sdk/src/lib/collaboration/runtime-capability.ts @@ -0,0 +1,8 @@ +import { createRuntimeCapability } from '../core/runtime-capability' +import type { CollaborationApi } from './types' + +/** Platform-owned real-time collaboration capability available to scoped plugin runtimes. */ +export const CollaborationRuntimeCapability = createRuntimeCapability('platform.collaboration', { + description: + 'Create, synchronize, persist, authorize, and observe platform-managed real-time collaboration documents.' +}) diff --git a/packages/plugin-sdk/src/lib/collaboration/types.ts b/packages/plugin-sdk/src/lib/collaboration/types.ts new file mode 100644 index 0000000000..63719a13a8 --- /dev/null +++ b/packages/plugin-sdk/src/lib/collaboration/types.ts @@ -0,0 +1,231 @@ +import type { + CollaborationAccess, + CollaborationActorStatus, + CollaborationActorType, + CollaborationDocumentStatus, + CollaborationEngine, + CollaborationMaterializationStatus, + ICollaborationActor, + ICollaborationFocus, + ICollaborationPointer, + ICollaborationPresence, + ICollaborationSelection, + ICollaborationViewport +} from '@xpert-ai/contracts' + +export type { + CollaborationAccess, + CollaborationActorStatus, + CollaborationActorType, + CollaborationDocumentStatus, + CollaborationEngine, + CollaborationMaterializationStatus, + ICollaborationActor, + ICollaborationFocus, + ICollaborationPointer, + ICollaborationPresence, + ICollaborationSelection, + ICollaborationViewport +} from '@xpert-ai/contracts' + +/** Tenant and business scope inherited by every collaboration operation. */ +export type CollaborationScope = { + tenantId?: string | null + organizationId?: string | null + workspaceId?: string | null + projectId?: string | null + xpertId?: string | null + userId?: string | null +} + +/** Optional identity override for Agent or system initiated runtime calls. */ +export type CollaborationRuntimeActor = { + actorType?: CollaborationActorType | null + actorKey?: string | null + displayName?: string | null + avatarUrl?: string | null +} + +/** Metadata-only representation of the platform collaboration document. */ +export type CollaborationDocumentRecord = CollaborationScope & { + id: string + providerKey: string + resourceId: string + engine: CollaborationEngine + schemaVersion: number + status: CollaborationDocumentStatus + sequenceNumber: number + updateCount: number + materializedSequence: number + materializationStatus: CollaborationMaterializationStatus + lastMaterializationError?: string | null + metadata?: Record | null + createdAt?: string | Date + updatedAt?: string | Date +} + +/** + * A full or state-vector-relative Yjs update together with its document metadata. + * `updateBase64` is safe to apply to a client whose vector was supplied in the request. + */ +export type CollaborationDocumentState = { + document: CollaborationDocumentRecord + updateBase64: string + stateVectorBase64: string + sequenceNumber: number +} + +/** Creates the platform document lazily through the registered plugin provider. */ +export type EnsureCollaborationDocumentInput = { + providerKey: string + resourceId: string + schemaVersion?: number | null + metadata?: Record | null +} + +/** Locate a document either by platform id or by the provider's business resource key. */ +export type GetCollaborationDocumentInput = + | { documentId: string; providerKey?: never; resourceId?: never } + | { documentId?: never; providerKey: string; resourceId: string } + +export type GetCollaborationDocumentStateInput = GetCollaborationDocumentInput & { + /** Omit for complete state; provide a Yjs state vector to receive only the missing delta. */ + stateVectorBase64?: string | null +} + +/** Submit one Yjs update to the authoritative platform document. */ +export type ApplyCollaborationUpdateInput = { + documentId: string + updateBase64: string + origin?: string | null + /** Optional compare-and-set guard for destructive or order-sensitive operations. */ + expectedSequence?: number | null + actor?: CollaborationRuntimeActor | null +} + +/** Acknowledgement for a unique or deduplicated collaboration update. */ +export type ApplyCollaborationUpdateResult = { + documentId: string + duplicate: boolean + sequenceNumber: number + updateId?: string | null + stateVectorBase64: string + materializationStatus: CollaborationMaterializationStatus +} + +/** Request a short-lived browser session with no platform token exposure. */ +export type CreateCollaborationSessionInput = { + documentId: string + access?: Exclude | null +} + +/** Credentials and backend URL required by a browser transport. */ +export type CollaborationSessionDescriptor = { + sessionId: string + clientKey: string + documentId: string + namespace: string + /** Backend public base URL; clients must not derive this from `window.location`. */ + connectionUrl: string + access: Exclude + actor: ICollaborationActor + expiresAt: number +} + +/** Bounded, ephemeral state that may be broadcast without persisting document content. */ +export type CollaborationPresencePatch = { + pageId?: string | null + pointer?: ICollaborationPointer | null + focus?: ICollaborationFocus | null + selection?: ICollaborationSelection | null + viewport?: ICollaborationViewport | null + mode?: string | null + status?: CollaborationActorStatus | null + toolName?: string | null + operationLabel?: string | null +} + +/** Create or refresh an Agent/system presence without opening a WebSocket connection. */ +export type UpsertVirtualPresenceInput = { + documentId: string + actor?: CollaborationRuntimeActor | null + presence: CollaborationPresencePatch +} + +/** Remove a virtual presence explicitly; otherwise it disappears through TTL expiry. */ +export type RemoveVirtualPresenceInput = { + documentId: string + presenceId?: string | null + actorKey?: string | null +} + +/** Runtime capability exposed to scoped plugin and Agent executions. */ +export interface CollaborationApi { + /** Ensure a provider/resource pair has one platform collaboration document. */ + ensureDocument(input: EnsureCollaborationDocumentInput): Promise + /** Read document metadata and trigger materialization read repair when required. */ + getDocument(input: GetCollaborationDocumentInput): Promise + /** Read complete state or a state-vector-relative Yjs delta. */ + getDocumentState(input: GetCollaborationDocumentStateInput): Promise + /** Authorize, deduplicate, merge, persist, broadcast, and materialize an update. */ + applyUpdate(input: ApplyCollaborationUpdateInput): Promise + /** Issue single-document browser credentials with read or write access. */ + createSession(input: CreateCollaborationSessionInput): Promise + /** List currently active user and virtual collaborator presence. */ + listPresence(input: { documentId: string }): Promise + /** Publish or refresh an Agent/system collaborator. */ + upsertVirtualPresence(input: UpsertVirtualPresenceInput): Promise + /** Remove an Agent/system collaborator before its TTL expires. */ + removeVirtualPresence(input: RemoveVirtualPresenceInput): Promise + /** Stop normal editing while retaining the document. */ + archiveDocument(input: GetCollaborationDocumentInput): Promise + /** Soft-delete the platform document and notify its provider. */ + deleteDocument(input: GetCollaborationDocumentInput): Promise +} + +export type CollaborationProviderOperation = CollaborationAccess | 'initialize' | 'materialize' | 'delete' + +/** Context passed to a plugin provider for every authorization and lifecycle operation. */ +export type CollaborationProviderContext = CollaborationScope & { + documentId?: string | null + providerKey: string + resourceId: string + operation: CollaborationProviderOperation + actor?: ICollaborationActor | null +} + +/** Initial Yjs snapshot returned when the platform first sees a business resource. */ +export type CollaborationDocumentInitialization = { + stateBase64: string + schemaVersion: number + /** Preserve an existing plugin revision when migrating legacy collaborative data. */ + initialSequence?: number | null + metadata?: Record | null +} + +/** Authoritative state delivered to the plugin after an accepted update or read repair. */ +export type CollaborationMaterializationEvent = CollaborationProviderContext & { + documentId: string + stateBase64: string + stateVectorBase64: string + sequenceNumber: number + updateBase64?: string | null + origin?: string | null +} + +/** + * Plugin-owned adapter between a business resource and the platform collaboration engine. + * Authorization remains the plugin's responsibility; the platform owns transport and CRDT state. + */ +export interface ICollaborationDocumentProvider { + /** Authorize the exact resource and requested operation in the supplied platform scope. */ + authorize(context: CollaborationProviderContext): Promise | boolean + /** Return an idempotent initial snapshot without creating a business version. */ + initializeDocument( + context: CollaborationProviderContext + ): Promise | CollaborationDocumentInitialization + /** Project platform state into plugin query/export entities. The method must be idempotent. */ + materializeDocument(event: CollaborationMaterializationEvent): Promise | void + /** Optionally clean plugin-owned associations after a platform soft delete. */ + onDocumentDeleted?(context: CollaborationProviderContext): Promise | void +} diff --git a/packages/plugin-sdk/src/lib/runtime/capabilities/artifacts.ts b/packages/plugin-sdk/src/lib/runtime/capabilities/artifacts.ts new file mode 100644 index 0000000000..5b56801886 --- /dev/null +++ b/packages/plugin-sdk/src/lib/runtime/capabilities/artifacts.ts @@ -0,0 +1,203 @@ +import { createRuntimeCapability } from '../../core/runtime-capability' +import type { + ArtifactAccessMode, + ArtifactKind, + ArtifactLinkDisposition, + ArtifactLinkStatus, + ArtifactLinkVersionMode, + ArtifactSafeHtmlProfile, + ArtifactStatus, + ArtifactVersionStatus +} from '@xpert-ai/contracts' +import type { WorkspacePortableFileReference } from './workspace-files' + +export type { + ArtifactAccessMode, + ArtifactKind, + ArtifactLinkDisposition, + ArtifactLinkStatus, + ArtifactLinkVersionMode, + ArtifactSafeHtmlProfile, + ArtifactStatus, + ArtifactVersionStatus +} from '@xpert-ai/contracts' + +/** Scope fields used when an Artifact belongs to a user/workspace/project/Xpert context. */ +export type ArtifactScopeInput = { + tenantId?: string | null + organizationId?: string | null + userId?: string | null + workspaceId?: string | null + projectId?: string | null + xpertId?: string | null +} + +/** Stable plugin-owned business identity for an Artifact container. */ +export type ArtifactSourceInput = { + pluginName: string + resourceType: string + resourceId: string + checksum?: string | null +} + +/** Creates or locates an Artifact container without uploading content bytes. */ +export type CreateArtifactInput = { + source: ArtifactSourceInput + kind?: ArtifactKind | null + title?: string | null + description?: string | null + scope?: ArtifactScopeInput | null + metadata?: Record | null +} + +/** Immutable content payload metadata for a new Artifact version. */ +export type ArtifactVersionInput = { + workspaceFileRef: WorkspacePortableFileReference + mimeType: string + fileName?: string | null + title?: string | null + description?: string | null + size?: number | null + sha256?: string | null + sourceVersionId?: string | null + checksum?: string | null + setCurrent?: boolean | null + metadata?: Record | null +} + +export type CreateArtifactVersionInput = ArtifactVersionInput & { + artifactId: string +} + +/** Access policy requested for an Artifact link. */ +export type ArtifactLinkAccessInput = { + mode: ArtifactAccessMode + customPrincipals?: string[] | null + expiresAt?: string | Date | null + ttlSeconds?: number | null + userConfirmedPublicLink?: boolean | null +} + +/** Browser response hints and HTML safety profile for an Artifact link. */ +export type ArtifactLinkPresentationInput = { + disposition?: ArtifactLinkDisposition | null + allowDownload?: boolean | null + safeHtmlProfile?: ArtifactSafeHtmlProfile | null +} + +/** Creates a share/open/download entrypoint for an Artifact. */ +export type CreateArtifactLinkInput = { + artifactId: string + artifactVersionId?: string | null + versionMode?: ArtifactLinkVersionMode | null + access: ArtifactLinkAccessInput + presentation?: ArtifactLinkPresentationInput | null + metadata?: Record | null +} + +/** Creates a short-lived signed preview link; never use this as a durable share URL. */ +export type CreateSignedArtifactPreviewLinkInput = Omit & { + ttlSeconds?: number | null +} + +/** Immutable Artifact version returned to plugins. */ +export type ArtifactVersionRecord = { + id: string + artifactId: string + versionNumber: number + status: ArtifactVersionStatus + sourceVersionId?: string | null + checksum?: string | null + mimeType: string + fileName?: string | null + title?: string | null + description?: string | null + size?: number | null + sha256?: string | null + createdAt?: string | Date +} + +/** Durable Artifact container returned to plugins. */ +export type ArtifactRecord = ArtifactScopeInput & { + id: string + pluginName: string + resourceType: string + resourceId: string + checksum?: string | null + kind: ArtifactKind + status: ArtifactStatus + title?: string | null + description?: string | null + currentVersionId?: string | null + currentVersion?: ArtifactVersionRecord | null + createdAt?: string | Date + updatedAt?: string | Date +} + +/** Share/open/download entrypoint returned to plugins. */ +export type ArtifactLinkRecord = ArtifactScopeInput & { + id: string + artifactId: string + artifactVersionId?: string | null + versionMode: ArtifactLinkVersionMode + slug: string + publicUrl: string + accessMode: ArtifactAccessMode + status: ArtifactLinkStatus + customPrincipals?: string[] | null + expiresAt?: string | Date | null + revokedAt?: string | Date | null + accessCount?: number + downloadCount?: number + disposition: ArtifactLinkDisposition + allowDownload: boolean + safeHtmlProfile?: ArtifactSafeHtmlProfile | null + createdAt?: string | Date + updatedAt?: string | Date + artifact?: ArtifactRecord + version?: ArtifactVersionRecord | null +} + +/** Filter used when listing Artifacts visible to the current plugin/runtime scope. */ +export type ListArtifactsInput = { + pluginName?: string | null + resourceType?: string | null + resourceId?: string | null + status?: ArtifactStatus | 'all' | null + page?: number | null + pageSize?: number | null +} + +export type ListArtifactsResult = { + items: ArtifactRecord[] + total: number + page: number + pageSize: number +} + +/** Mutable parts of an Artifact link; content versions remain immutable. */ +export type UpdateArtifactLinkAccessInput = { + access?: ArtifactLinkAccessInput | null + presentation?: ArtifactLinkPresentationInput | null + artifactVersionId?: string | null + versionMode?: ArtifactLinkVersionMode | null +} + +/** Platform runtime capability exposed to plugins for managing generated Artifacts. */ +export interface ArtifactsApi { + createArtifact(input: CreateArtifactInput): Promise + createArtifactVersion(input: CreateArtifactVersionInput): Promise + getArtifact(idOrSlug: string): Promise + listArtifacts(input?: ListArtifactsInput): Promise + archiveArtifact(idOrSlug: string): Promise + deleteArtifact(idOrSlug: string): Promise + createArtifactLink(input: CreateArtifactLinkInput): Promise + createSignedPreviewLink(input: CreateSignedArtifactPreviewLinkInput): Promise + updateArtifactLinkAccess(idOrSlug: string, patch: UpdateArtifactLinkAccessInput): Promise + revokeArtifactLink(idOrSlug: string): Promise +} + +export const ArtifactsRuntimeCapability = createRuntimeCapability('platform.artifacts', { + description: + 'Create, version, preview, download, share, archive, and delete platform-managed plugin and Agent artifacts.' +}) diff --git a/packages/plugin-sdk/src/lib/runtime/capabilities/index.ts b/packages/plugin-sdk/src/lib/runtime/capabilities/index.ts index 75583b4a56..74ff7c9b88 100644 --- a/packages/plugin-sdk/src/lib/runtime/capabilities/index.ts +++ b/packages/plugin-sdk/src/lib/runtime/capabilities/index.ts @@ -1,3 +1,4 @@ export * from './knowledgebase' export * from './knowledgebase-documents' +export * from './artifacts' export * from './workspace-files' diff --git a/packages/server-ai/package.json b/packages/server-ai/package.json index 61f1113302..71ad0e5144 100644 --- a/packages/server-ai/package.json +++ b/packages/server-ai/package.json @@ -91,6 +91,7 @@ "uuid": "^10.0.0", "xlsx": "^0.18.5", "xmldom": "^0.6.0", + "yjs": "13.6.31", "zod-to-json-schema": "^3.23.2" }, "devDependencies": { diff --git a/packages/server-ai/src/ai/assistant.controller.spec.ts b/packages/server-ai/src/ai/assistant.controller.spec.ts index c1d1721ca9..a42517167f 100644 --- a/packages/server-ai/src/ai/assistant.controller.spec.ts +++ b/packages/server-ai/src/ai/assistant.controller.spec.ts @@ -234,7 +234,7 @@ describe('AssistantsController', () => { })) } const skillPackageService = { - getAllByWorkspace: jest.fn(async () => ({ + getAllByWorkspaceForRuntime: jest.fn(async () => ({ items: [ { id: 'skill-default', @@ -558,7 +558,7 @@ describe('AssistantsController', () => { getUserPreferenceByAssistantId: jest.fn(async () => null) } const skillPackageService = { - getAllByWorkspace: jest.fn(async () => ({ + getAllByWorkspaceForRuntime: jest.fn(async () => ({ items: [ { id: 'draft-skill', @@ -628,8 +628,8 @@ describe('AssistantsController', () => { knowledgebaseNames: [] } ]) - expect(skillPackageService.getAllByWorkspace).toHaveBeenCalled() - expect(skillPackageService.getAllByWorkspace.mock.calls[0].slice(0, 3)).toEqual([ + expect(skillPackageService.getAllByWorkspaceForRuntime).toHaveBeenCalled() + expect(skillPackageService.getAllByWorkspaceForRuntime.mock.calls[0].slice(0, 3)).toEqual([ 'workspace-1', expect.objectContaining({ relations: ['skillIndex', 'skillIndex.repository'] @@ -701,7 +701,7 @@ describe('AssistantsController', () => { })) } const skillPackageService = { - getAllByWorkspace: jest.fn(async () => ({ + getAllByWorkspaceForRuntime: jest.fn(async () => ({ items: [ { id: 'skill-enabled', @@ -873,7 +873,7 @@ describe('AssistantsController', () => { assistantBindingService, agentMiddlewareRegistry, { - getAllByWorkspace: jest.fn() + getAllByWorkspaceForRuntime: jest.fn() }, new RuntimeCommandService(), promptWorkflowService @@ -982,7 +982,7 @@ describe('AssistantsController', () => { assistantBindingService, agentMiddlewareRegistry, { - getAllByWorkspace: jest.fn() + getAllByWorkspaceForRuntime: jest.fn() }, new RuntimeCommandService(), promptWorkflowService @@ -1050,7 +1050,7 @@ describe('AssistantsController', () => { get: jest.fn() }, { - getAllByWorkspace: jest.fn() + getAllByWorkspaceForRuntime: jest.fn() }, new RuntimeCommandService(), promptWorkflowService diff --git a/packages/server-ai/src/ai/runtime-capabilities.service.spec.ts b/packages/server-ai/src/ai/runtime-capabilities.service.spec.ts index 11002abaca..636b136d50 100644 --- a/packages/server-ai/src/ai/runtime-capabilities.service.spec.ts +++ b/packages/server-ai/src/ai/runtime-capabilities.service.spec.ts @@ -18,7 +18,7 @@ describe('RuntimeCapabilitiesService', () => { const service = new RuntimeCapabilitiesService( { get: jest.fn() } as any, { - getAllByWorkspace: jest.fn(async () => ({ + getAllByWorkspaceForRuntime: jest.fn(async () => ({ items: [ { id: 'documents-skill', @@ -101,12 +101,12 @@ describe('RuntimeCapabilitiesService', () => { } } }) - const getAllByWorkspace = jest.fn(async (_workspaceId: string, query: { take?: number }) => ({ + const getAllByWorkspaceForRuntime = jest.fn(async (_workspaceId: string, query: { take?: number }) => ({ items: skillPackages.slice(0, typeof query.take === 'number' ? query.take : skillPackages.length) })) const service = new RuntimeCapabilitiesService( { get: jest.fn() } as unknown as ConstructorParameters[0], - { getAllByWorkspace } as unknown as ConstructorParameters[1], + { getAllByWorkspaceForRuntime } as unknown as ConstructorParameters[1], new RuntimeCommandService(), { resolveRuntimeCommandProfile: jest.fn(async () => ({ @@ -147,7 +147,7 @@ describe('RuntimeCapabilitiesService', () => { } as unknown as Parameters[0]) expect(result.skills.map((skill) => skill.id)).toEqual(skillPackages.map((skill) => skill.id)) - const query = getAllByWorkspace.mock.calls[0]?.[1] + const query = getAllByWorkspaceForRuntime.mock.calls[0]?.[1] expect(query).not.toHaveProperty('take') expect(query).not.toHaveProperty('skip') }) diff --git a/packages/server-ai/src/ai/runtime-capabilities.service.ts b/packages/server-ai/src/ai/runtime-capabilities.service.ts index a07818e24d..534a0904f5 100644 --- a/packages/server-ai/src/ai/runtime-capabilities.service.ts +++ b/packages/server-ai/src/ai/runtime-capabilities.service.ts @@ -180,7 +180,8 @@ export class RuntimeCapabilitiesService { where: {}, withDeleted: false } as PaginationParams - const result = await this.skillPackageService.getAllByWorkspace( + // Capability discovery is part of execution and must honor run-only workspace access. + const result = await this.skillPackageService.getAllByWorkspaceForRuntime( workspaceId, query, false, diff --git a/packages/server-ai/src/app.module.ts b/packages/server-ai/src/app.module.ts index 93aa3c921c..e453a3bbea 100644 --- a/packages/server-ai/src/app.module.ts +++ b/packages/server-ai/src/app.module.ts @@ -52,6 +52,8 @@ import { FileUnderstandingModule } from './file-understanding' import { MetricsModule } from './metrics' import { MobileModule } from './mobile' import { MembershipModule } from './membership' +import { ArtifactsModule } from './artifacts' +import { CollaborationModule } from './collaboration' @Module({ imports: [ @@ -106,7 +108,9 @@ import { MembershipModule } from './membership' KnowledgeDocumentModule, RagVStoreModule, RagWebModule, - SandboxModule + SandboxModule, + ArtifactsModule, + CollaborationModule ], controllers: [], providers: [...EventHandlers, ...CommandHandlers, ViewHostCacheSubscriber] diff --git a/packages/server-ai/src/artifacts/artifacts.controller.ts b/packages/server-ai/src/artifacts/artifacts.controller.ts new file mode 100644 index 0000000000..ca29f034fa --- /dev/null +++ b/packages/server-ai/src/artifacts/artifacts.controller.ts @@ -0,0 +1,167 @@ +import { Body, Controller, Delete, Get, Param, Patch, Post, Query, Req, Res } from '@nestjs/common' +import { Public } from '@xpert-ai/server-core' +import type { + CreateArtifactInput, + CreateArtifactLinkInput, + CreateArtifactVersionInput, + CreateSignedArtifactPreviewLinkInput, + ListArtifactsInput, + UpdateArtifactLinkAccessInput +} from '@xpert-ai/plugin-sdk' +import type { Request, Response } from 'express' +import { ArtifactsService } from './artifacts.service' + +const SIGNED_PREVIEW_QUERY_PARAM = 'xpert_artifact_preview' + +@Controller('artifacts/share') +export class ArtifactsPublicController { + constructor(private readonly service: ArtifactsService) {} + + @Public() + @Get(':artifactLinkSlug') + async open( + @Param('artifactLinkSlug') artifactLinkSlug: string, + @Query(SIGNED_PREVIEW_QUERY_PARAM) previewToken: string, + @Req() req: Request, + @Res() res: Response + ) { + const principal = this.service.resolvePrincipalFromRequest(req) + const artifact = await this.service.resolveForPublicAccess({ + slug: artifactLinkSlug, + previewToken, + principal, + requestSummary: this.service.summarizeRequest(req) + }) + sendArtifactResponse(res, artifact) + } + + @Public() + @Get(':artifactLinkSlug/download') + async download( + @Param('artifactLinkSlug') artifactLinkSlug: string, + @Query(SIGNED_PREVIEW_QUERY_PARAM) previewToken: string, + @Req() req: Request, + @Res() res: Response + ) { + const principal = this.service.resolvePrincipalFromRequest(req) + const artifact = await this.service.resolveForPublicAccess({ + slug: artifactLinkSlug, + download: true, + previewToken, + principal, + requestSummary: this.service.summarizeRequest(req) + }) + sendArtifactResponse(res, artifact) + } +} + +@Controller('artifacts') +export class ArtifactsManagementController { + constructor(private readonly service: ArtifactsService) {} + + @Post() + async createArtifact(@Body() input: CreateArtifactInput) { + return this.service.createArtifact(input) + } + + @Get() + async listArtifacts(@Query() query: ListArtifactsInput) { + return this.service.listArtifacts(query) + } + + @Get(':idOrSlug') + async getArtifact(@Param('idOrSlug') idOrSlug: string) { + return this.service.getArtifact(idOrSlug) + } + + @Post(':artifactId/versions') + async createVersion( + @Param('artifactId') artifactId: string, + @Body() input: Omit + ) { + return this.service.createArtifactVersion({ ...input, artifactId }) + } + + @Patch(':idOrSlug/archive') + async archiveArtifact(@Param('idOrSlug') idOrSlug: string) { + return this.service.archiveArtifact(idOrSlug) + } + + @Delete(':idOrSlug') + async deleteArtifact(@Param('idOrSlug') idOrSlug: string) { + return this.service.deleteArtifact(idOrSlug) + } + + @Post(':artifactId/links') + async createLink( + @Param('artifactId') artifactId: string, + @Body() input: Omit + ) { + return this.service.createArtifactLink({ ...input, artifactId }) + } + + @Post(':artifactId/links/signed-preview') + async createSignedPreviewLink( + @Param('artifactId') artifactId: string, + @Body() input: Omit + ) { + return this.service.createSignedPreviewLink({ ...input, artifactId }) + } + + @Patch('links/:idOrSlug/access') + async updateLinkAccess(@Param('idOrSlug') idOrSlug: string, @Body() patch: UpdateArtifactLinkAccessInput) { + return this.service.updateArtifactLinkAccess(idOrSlug, patch) + } + + @Delete('links/:idOrSlug') + async revokeLink(@Param('idOrSlug') idOrSlug: string) { + return this.service.revokeArtifactLink(idOrSlug) + } +} + +type ArtifactResponse = Awaited> + +function sendArtifactResponse(res: Response, artifact: ArtifactResponse) { + res.setHeader('Content-Type', artifact.mimeType) + res.setHeader('X-Content-Type-Options', 'nosniff') + res.setHeader('Referrer-Policy', 'no-referrer') + res.setHeader('Cache-Control', 'no-store') + res.setHeader('Content-Disposition', buildContentDisposition(artifact.disposition, artifact.fileName)) + if (artifact.mimeType === 'text/html') { + res.setHeader('Content-Security-Policy', buildHtmlCsp(artifact.safeHtmlProfile)) + } + res.send(artifact.buffer) +} + +function buildContentDisposition(disposition: 'inline' | 'attachment', fileName: string) { + const safeFileName = fileName.replace(/[\r\n"]/g, '_') + const encoded = encodeURIComponent(safeFileName) + return `${disposition}; filename="${encoded}"; filename*=UTF-8''${encoded}` +} + +function buildHtmlCsp(profile?: 'strict' | 'interactive' | null) { + if (profile === 'interactive') { + return [ + "default-src 'self' data: blob:", + "script-src 'unsafe-inline' 'unsafe-eval' data: blob:", + "style-src 'unsafe-inline' data: blob:", + "img-src 'self' data: blob:", + "font-src 'self' data: blob:", + "media-src 'self' data: blob:", + "connect-src 'self' data: blob:", + "base-uri 'none'", + "form-action 'none'", + "frame-ancestors 'none'" + ].join('; ') + } + return [ + "default-src 'none'", + "style-src 'unsafe-inline'", + 'img-src data: blob:', + 'font-src data:', + 'media-src data: blob:', + "base-uri 'none'", + "form-action 'none'", + "frame-ancestors 'none'" + ].join('; ') +} diff --git a/packages/server-ai/src/artifacts/artifacts.module.ts b/packages/server-ai/src/artifacts/artifacts.module.ts new file mode 100644 index 0000000000..31a24e21d8 --- /dev/null +++ b/packages/server-ai/src/artifacts/artifacts.module.ts @@ -0,0 +1,14 @@ +import { Module } from '@nestjs/common' +import { TypeOrmModule } from '@nestjs/typeorm' +import { VolumeModule } from '../shared/volume' +import { Artifact, ArtifactAccessLog, ArtifactLink, ArtifactVersion } from './entities' +import { ArtifactsManagementController, ArtifactsPublicController } from './artifacts.controller' +import { ArtifactsService } from './artifacts.service' + +@Module({ + imports: [TypeOrmModule.forFeature([Artifact, ArtifactVersion, ArtifactLink, ArtifactAccessLog]), VolumeModule], + controllers: [ArtifactsPublicController, ArtifactsManagementController], + providers: [ArtifactsService], + exports: [ArtifactsService] +}) +export class ArtifactsModule {} diff --git a/packages/server-ai/src/artifacts/artifacts.service.spec.ts b/packages/server-ai/src/artifacts/artifacts.service.spec.ts new file mode 100644 index 0000000000..d457f9f59d --- /dev/null +++ b/packages/server-ai/src/artifacts/artifacts.service.spec.ts @@ -0,0 +1,205 @@ +import { BadRequestException } from '@nestjs/common' +import { createHash } from 'node:crypto' +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import path from 'node:path' +import { WORKSPACE_FILES_SOURCE } from '@xpert-ai/plugin-sdk' +import { VolumeHandle } from '../shared/volume' +import { ArtifactsService } from './artifacts.service' + +describe('ArtifactsService', () => { + let volumeRoot: string + let artifactRepository: MemoryRepository + let versionRepository: MemoryRepository + let linkRepository: MemoryRepository + let accessLogRepository: MemoryRepository + let service: ArtifactsService + + beforeEach(() => { + volumeRoot = mkdtempSync(path.join(tmpdir(), 'xpert-artifacts-')) + artifactRepository = new MemoryRepository('artifact') + versionRepository = new MemoryRepository('version') + linkRepository = new MemoryRepository('link') + accessLogRepository = new MemoryRepository('access-log') + service = new ArtifactsService( + artifactRepository as never, + versionRepository as never, + linkRepository as never, + accessLogRepository as never, + { + resolve: (scope: Record) => + new VolumeHandle(scope as never, volumeRoot, volumeRoot, 'https://files.test') + } as never + ) + }) + + afterEach(() => { + rmSync(volumeRoot, { recursive: true, force: true }) + }) + + it('rejects public artifact links without explicit user confirmation before saving a link', async () => { + const api = service.createScopedApi({ tenantId: 'tenant-1', userId: 'user-1' }) + const artifact = await api.createArtifact({ + source: { pluginName: '@xpert-ai/plugin-demo', resourceType: 'demo', resourceId: 'demo-1' }, + kind: 'html' + }) + + await expect( + api.createArtifactLink({ + artifactId: artifact.id, + access: { mode: 'public_link' } + }) + ).rejects.toBeInstanceOf(BadRequestException) + + expect(artifactRepository.items).toHaveLength(1) + expect(linkRepository.items).toHaveLength(0) + }) + + it('creates a signed preview artifact link and resolves the current version with the preview token', async () => { + const html = 'Artifact deck' + const filePath = 'exports/deck.html' + writeWorkspaceFile(filePath, html) + const api = service.createScopedApi({ tenantId: 'tenant-1', userId: 'user-1' }) + + const artifact = await api.createArtifact({ + source: { + pluginName: '@xpert-ai/plugin-presentation-studio', + resourceType: 'presentation_export', + resourceId: 'export-1', + checksum: 'deck-checksum' + }, + kind: 'presentation', + title: 'Deck export' + }) + + const version = await api.createArtifactVersion({ + artifactId: artifact.id, + workspaceFileRef: workspaceRef(filePath), + mimeType: 'text/html', + fileName: 'deck.html', + size: Buffer.byteLength(html), + sha256: createHash('sha256').update(html).digest('hex'), + sourceVersionId: 'version-1', + checksum: 'deck-checksum' + }) + + const link = await api.createSignedPreviewLink({ + artifactId: artifact.id, + artifactVersionId: version.id, + versionMode: 'version', + presentation: { + disposition: 'inline', + allowDownload: false, + safeHtmlProfile: 'strict' + }, + ttlSeconds: 60 + }) + + const previewToken = new URL(link.publicUrl).searchParams.get('xpert_artifact_preview') + const resolved = await service.resolveForPublicAccess({ + slug: link.slug, + previewToken + }) + + expect(previewToken).toBeTruthy() + expect(resolved.mimeType).toBe('text/html') + expect(resolved.fileName).toBe('deck.html') + expect(resolved.buffer.toString('utf8')).toBe(html) + expect(linkRepository.items[0].accessCount).toBe(1) + expect(link.slug).toMatch(/^[23456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz]{12}$/) + expect(link.slug).not.toContain('-') + expect(link.publicUrl).toContain(`/artifacts/share/${link.slug}`) + expect(accessLogRepository.items).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + event: 'access', + statusCode: 200 + }) + ]) + ) + }) + + function writeWorkspaceFile(relativePath: string, contents: string) { + const absolutePath = path.join(volumeRoot, relativePath) + mkdirSync(path.dirname(absolutePath), { recursive: true }) + writeFileSync(absolutePath, contents) + } +}) + +function workspaceRef(filePath: string) { + return { + source: WORKSPACE_FILES_SOURCE, + tenantId: 'tenant-1', + userId: 'user-1', + catalog: 'users' as const, + scopeId: 'user-1', + filePath, + workspacePath: filePath, + originalName: path.posix.basename(filePath) + } +} + +class MemoryRepository { + readonly items: Array> = [] + private sequence = 0 + + constructor(private readonly prefix: string) {} + + create(value: Record) { + return { ...value } + } + + async save(value: Record) { + const entity = { + ...value, + id: value.id ?? `${this.prefix}-${(this.sequence += 1)}`, + createdAt: value.createdAt ?? new Date(), + updatedAt: new Date() + } + const stored = { ...entity } + const index = this.items.findIndex((item) => item.id === entity.id) + if (index >= 0) { + this.items[index] = stored + } else { + this.items.push(stored) + } + return entity + } + + async find(options: { where?: Record }) { + const where = options.where ?? {} + return this.items.filter((candidate) => matchesWhere(candidate, where)).map((item) => ({ ...item })) + } + + async findOne(options: { + where?: Record + order?: Record + relations?: string[] + }) { + const where = options.where ?? {} + let candidates = this.items.filter((candidate) => matchesWhere(candidate, where)) + if (options.order) { + const [field, direction] = Object.entries(options.order)[0] ?? [] + if (field) { + candidates = [...candidates].sort((left, right) => { + const leftValue = Number(left[field] ?? 0) + const rightValue = Number(right[field] ?? 0) + return direction === 'DESC' ? rightValue - leftValue : leftValue - rightValue + }) + } + } + const item = candidates[0] + return item ? { ...item } : null + } + + async increment(criteria: Record, field: string, amount: number) { + const item = this.items.find((candidate) => matchesWhere(candidate, criteria)) + if (item) { + item[field] = Number(item[field] ?? 0) + amount + } + } +} + +function matchesWhere(candidate: Record, where: Record) { + return Object.entries(where).every(([key, value]) => candidate[key] === value) +} diff --git a/packages/server-ai/src/artifacts/artifacts.service.ts b/packages/server-ai/src/artifacts/artifacts.service.ts new file mode 100644 index 0000000000..e62a130729 --- /dev/null +++ b/packages/server-ai/src/artifacts/artifacts.service.ts @@ -0,0 +1,1271 @@ +import { createHash, randomBytes, timingSafeEqual } from 'node:crypto' +import path from 'node:path' +import { + BadRequestException, + ForbiddenException, + GoneException, + Inject, + Injectable, + NotFoundException, + UnauthorizedException +} from '@nestjs/common' +import { InjectRepository } from '@nestjs/typeorm' +import type { ArtifactAccessEvent } from '@xpert-ai/contracts' +import { environment } from '@xpert-ai/server-config' +import { + ArtifactAccessMode, + ArtifactKind, + ArtifactLinkAccessInput, + ArtifactLinkDisposition, + ArtifactLinkRecord, + ArtifactLinkStatus, + ArtifactLinkVersionMode, + ArtifactRecord, + ArtifactsApi, + ArtifactSafeHtmlProfile, + ArtifactVersionRecord, + CreateArtifactInput, + CreateArtifactLinkInput, + CreateArtifactVersionInput, + CreateSignedArtifactPreviewLinkInput, + ListArtifactsInput, + ListArtifactsResult, + RequestContext, + UpdateArtifactLinkAccessInput, + WORKSPACE_FILES_SOURCE, + WorkspacePortableFileReference +} from '@xpert-ai/plugin-sdk' +import { verify } from 'jsonwebtoken' +import { Repository } from 'typeorm' +import { resolveWorkspaceVolumeScope } from '../file-understanding' +import { VOLUME_CLIENT, VolumeClient, VolumeSubtreeClient } from '../shared/volume' +import { Artifact, ArtifactAccessLog, ArtifactLink, ArtifactVersion } from './entities' + +const SIGNED_PREVIEW_QUERY_PARAM = 'xpert_artifact_preview' +const DEFAULT_SIGNED_PREVIEW_TTL_SECONDS = 15 * 60 +const MAX_TTL_SECONDS = 60 * 60 * 24 * 365 +const DEFAULT_PAGE = 1 +const DEFAULT_PAGE_SIZE = 20 +const MAX_PAGE_SIZE = 100 +/** + * Public Artifact links use a short random slug instead of UUIDs. + * + * Twelve URL-safe characters are compact enough for sharing while still giving + * very large entropy; the database unique index remains the final collision + * guard. + */ +const ARTIFACT_LINK_SLUG_LENGTH = 12 +const ARTIFACT_LINK_SLUG_ALPHABET = '23456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz' + +const PPTX_MIME_TYPE = 'application/vnd.openxmlformats-officedocument.presentationml.presentation' +const ALLOWED_EXACT_MIME_TYPES = new Set([ + 'application/json', + 'application/octet-stream', + 'application/pdf', + 'application/zip', + PPTX_MIME_TYPE, + 'text/csv', + 'text/html', + 'text/markdown', + 'text/plain' +]) + +export type ArtifactsRuntimeScope = { + tenantId?: string | null + organizationId?: string | null + userId?: string | null + workspaceId?: string | null + projectId?: string | null + xpertId?: string | null +} + +export type ArtifactAccessPrincipal = { + userId?: string | null + tenantId?: string | null + organizationId?: string | null +} + +export type ArtifactResolvedVersion = { + link: ArtifactLink + artifact: Artifact + version: ArtifactVersion + buffer: Buffer + mimeType: string + fileName: string + disposition: ArtifactLinkDisposition + safeHtmlProfile?: ArtifactSafeHtmlProfile | null +} + +@Injectable() +export class ArtifactsService implements ArtifactsApi { + constructor( + @InjectRepository(Artifact) + private readonly artifactRepository: Repository, + @InjectRepository(ArtifactVersion) + private readonly versionRepository: Repository, + @InjectRepository(ArtifactLink) + private readonly linkRepository: Repository, + @InjectRepository(ArtifactAccessLog) + private readonly accessLogRepository: Repository, + @Inject(VOLUME_CLIENT) + private readonly volumeClient: VolumeClient + ) {} + + createScopedApi(defaults: ArtifactsRuntimeScope): ArtifactsApi { + return { + createArtifact: (input) => this.createArtifactWithDefaults(input, defaults), + createArtifactVersion: (input) => this.createArtifactVersionWithDefaults(input, defaults), + getArtifact: (idOrSlug) => this.getArtifactWithDefaults(idOrSlug, defaults), + listArtifacts: (input) => this.listArtifactsWithDefaults(input, defaults), + archiveArtifact: (idOrSlug) => this.archiveArtifactWithDefaults(idOrSlug, defaults), + deleteArtifact: (idOrSlug) => this.deleteArtifactWithDefaults(idOrSlug, defaults), + createArtifactLink: (input) => this.createArtifactLinkWithDefaults(input, defaults), + createSignedPreviewLink: (input) => this.createSignedPreviewLinkWithDefaults(input, defaults), + updateArtifactLinkAccess: (idOrSlug, patch) => + this.updateArtifactLinkAccessWithDefaults(idOrSlug, patch, defaults), + revokeArtifactLink: (idOrSlug) => this.revokeArtifactLinkWithDefaults(idOrSlug, defaults) + } + } + + async createArtifact(input: CreateArtifactInput): Promise { + return this.createArtifactWithDefaults(input, {}) + } + + async createArtifactVersion(input: CreateArtifactVersionInput): Promise { + return this.createArtifactVersionWithDefaults(input, {}) + } + + async getArtifact(idOrSlug: string): Promise { + return this.getArtifactWithDefaults(idOrSlug, {}) + } + + async listArtifacts(input: ListArtifactsInput = {}): Promise { + return this.listArtifactsWithDefaults(input, {}) + } + + async archiveArtifact(idOrSlug: string): Promise { + return this.archiveArtifactWithDefaults(idOrSlug, {}) + } + + async deleteArtifact(idOrSlug: string): Promise { + return this.deleteArtifactWithDefaults(idOrSlug, {}) + } + + async createArtifactLink(input: CreateArtifactLinkInput): Promise { + return this.createArtifactLinkWithDefaults(input, {}) + } + + async createSignedPreviewLink(input: CreateSignedArtifactPreviewLinkInput): Promise { + return this.createSignedPreviewLinkWithDefaults(input, {}) + } + + async updateArtifactLinkAccess( + idOrSlug: string, + patch: UpdateArtifactLinkAccessInput + ): Promise { + return this.updateArtifactLinkAccessWithDefaults(idOrSlug, patch, {}) + } + + async revokeArtifactLink(idOrSlug: string): Promise { + return this.revokeArtifactLinkWithDefaults(idOrSlug, {}) + } + + async resolveForPublicAccess(input: { + slug: string + download?: boolean + previewToken?: string | null + principal?: ArtifactAccessPrincipal | null + requestSummary?: ArtifactRequestSummary + }): Promise { + const link = await this.linkRepository.findOne({ + where: { slug: normalizeRequiredString(input.slug, 'slug') }, + relations: ['artifact'] + }) + if (!link || !link.artifact) { + throw new NotFoundException('Artifact link was not found') + } + if (input.download && !link.allowDownload) { + await this.recordAccessLog(link, 'denied', { + statusCode: 403, + error: 'download_not_allowed', + requestSummary: input.requestSummary + }) + throw new ForbiddenException('Download is not allowed for this artifact link') + } + + await this.assertPublicAccess(link, input.previewToken, input.principal ?? null, input.requestSummary) + const artifact = link.artifact + if (artifact.status !== 'active') { + await this.recordAccessLog(link, 'denied', { + statusCode: 410, + error: `artifact_${artifact.status}`, + requestSummary: input.requestSummary + }) + throw new GoneException('Artifact is no longer active') + } + + const version = await this.resolveLinkVersion(link, artifact) + const file = await this.readWorkspaceArtifact(version.workspaceFileRef) + const sha256 = digestBuffer(file.buffer) + if (version.sha256 && !safeEqualString(version.sha256, sha256)) { + await this.recordAccessLog(link, 'denied', { + statusCode: 409, + error: 'artifact_checksum_mismatch', + requestSummary: input.requestSummary + }) + throw new GoneException('Artifact changed after the link was created') + } + + const event: ArtifactAccessEvent = input.download ? 'download' : 'access' + await this.incrementCounters(link, input.download ? 'download' : 'access') + await this.recordAccessLog(link, event, { + statusCode: 200, + principalUserId: input.principal?.userId, + requestSummary: input.requestSummary + }) + + return { + link, + artifact, + version, + buffer: file.buffer, + mimeType: version.mimeType, + fileName: version.fileName ?? file.name, + disposition: input.download ? 'attachment' : link.disposition, + safeHtmlProfile: link.safeHtmlProfile + } + } + + resolvePrincipalFromRequest(request: ArtifactHttpRequest): ArtifactAccessPrincipal { + const token = extractBearerToken(request.headers) + const organizationId = getHeaderValue(request.headers, 'organization-id') + if (!token) { + return { organizationId } + } + try { + const payload = verify(token, environment.JWT_SECRET) as ArtifactJwtPayload + return { + userId: normalizeOptionalString(payload.id), + tenantId: normalizeOptionalString(payload.tenantId), + organizationId + } + } catch { + return { organizationId } + } + } + + summarizeRequest(request: ArtifactHttpRequest): ArtifactRequestSummary { + return { + ipHash: hashString(getRequestIp(request)), + userAgent: normalizeOptionalString(getHeaderValue(request.headers, 'user-agent')) + } + } + + private async createArtifactWithDefaults( + input: CreateArtifactInput, + defaults: ArtifactsRuntimeScope + ): Promise { + const source = normalizeSourceInput(input.source) + const scope = this.resolveCreateScope(input.scope, defaults, 'create an artifact') + const existing = await this.findExistingArtifact(source, scope) + if (existing && existing.status !== 'deleted') { + existing.status = existing.status === 'archived' ? 'active' : existing.status + existing.kind = input.kind ?? existing.kind + existing.checksum = source.checksum ?? existing.checksum + existing.title = normalizeOptionalString(input.title) ?? existing.title + existing.description = normalizeOptionalString(input.description) ?? existing.description + existing.metadata = input.metadata ?? existing.metadata ?? null + return serializeArtifact(await this.artifactRepository.save(existing)) + } + + const artifact = await this.artifactRepository.save( + this.artifactRepository.create({ + ...scope, + createdById: scope.userId ?? undefined, + pluginName: source.pluginName, + resourceType: source.resourceType, + resourceId: source.resourceId, + checksum: source.checksum, + kind: input.kind ?? 'file', + status: 'active', + title: normalizeOptionalString(input.title), + description: normalizeOptionalString(input.description), + currentVersionId: null, + metadata: input.metadata ?? null + }) + ) + return serializeArtifact(artifact) + } + + private async createArtifactVersionWithDefaults( + input: CreateArtifactVersionInput, + defaults: ArtifactsRuntimeScope + ): Promise { + const artifact = await this.resolveScopedArtifact(input.artifactId, defaults) + if (artifact.status === 'deleted') { + throw new GoneException('Artifact was deleted') + } + const versionInput = this.normalizeArtifactVersionInput(input) + const file = await this.readWorkspaceArtifact(versionInput.workspaceFileRef) + const sha256 = digestBuffer(file.buffer) + const size = file.buffer.length + if (versionInput.sha256 && !safeEqualString(versionInput.sha256, sha256)) { + throw new BadRequestException('Artifact version checksum does not match the workspace file') + } + if (versionInput.size !== undefined && versionInput.size !== null && versionInput.size !== size) { + throw new BadRequestException('Artifact version size does not match the workspace file') + } + + const previous = await this.versionRepository.findOne({ + where: { artifactId: normalizeRequiredString(artifact.id, 'artifact.id') }, + order: { versionNumber: 'DESC' } + }) + const versionNumber = Number(previous?.versionNumber ?? 0) + 1 + const version = await this.versionRepository.save( + this.versionRepository.create({ + tenantId: artifact.tenantId, + organizationId: artifact.organizationId, + createdById: defaults.userId ?? artifact.userId ?? undefined, + artifactId: normalizeRequiredString(artifact.id, 'artifact.id'), + artifact, + versionNumber, + status: 'active', + sourceVersionId: versionInput.sourceVersionId, + checksum: versionInput.checksum, + workspaceFileRef: versionInput.workspaceFileRef, + mimeType: versionInput.mimeType, + fileName: versionInput.fileName ?? file.name, + title: versionInput.title, + description: versionInput.description, + size, + sha256, + workspaceId: artifact.workspaceId, + projectId: artifact.projectId, + xpertId: artifact.xpertId, + userId: artifact.userId, + metadata: versionInput.metadata ?? null + }) + ) + + if (versionInput.setCurrent !== false) { + artifact.currentVersionId = normalizeRequiredString(version.id, 'version.id') + artifact.status = 'active' + await this.artifactRepository.save(artifact) + } + return serializeArtifactVersion(version) + } + + private async getArtifactWithDefaults(idOrSlug: string, defaults: ArtifactsRuntimeScope): Promise { + const artifact = await this.resolveScopedArtifact(idOrSlug, defaults, true) + return serializeArtifact(artifact, await this.findCurrentVersion(artifact)) + } + + private async listArtifactsWithDefaults( + input: ListArtifactsInput = {}, + defaults: ArtifactsRuntimeScope + ): Promise { + const scope = this.resolveCurrentScope(defaults) + const page = normalizePositiveInteger(input.page, DEFAULT_PAGE) + const pageSize = Math.min(normalizePositiveInteger(input.pageSize, DEFAULT_PAGE_SIZE), MAX_PAGE_SIZE) + const qb = this.artifactRepository + .createQueryBuilder('artifact') + .where('artifact.tenantId = :tenantId', { tenantId: scope.tenantId }) + .orderBy('artifact.updatedAt', 'DESC') + .skip((page - 1) * pageSize) + .take(pageSize) + + if (scope.organizationId) { + qb.andWhere('artifact.organizationId = :organizationId', { organizationId: scope.organizationId }) + } + if (input.status && input.status !== 'all') { + qb.andWhere('artifact.status = :status', { status: input.status }) + } else { + qb.andWhere('artifact.status != :deletedStatus', { deletedStatus: 'deleted' }) + } + if (input.pluginName) { + qb.andWhere('artifact.pluginName = :pluginName', { pluginName: input.pluginName }) + } + if (input.resourceType) { + qb.andWhere('artifact.resourceType = :resourceType', { resourceType: input.resourceType }) + } + if (input.resourceId) { + qb.andWhere('artifact.resourceId = :resourceId', { resourceId: input.resourceId }) + } + + const [items, total] = await qb.getManyAndCount() + return { + items: items.map((artifact) => serializeArtifact(artifact)), + total, + page, + pageSize + } + } + + private async archiveArtifactWithDefaults( + idOrSlug: string, + defaults: ArtifactsRuntimeScope + ): Promise { + const artifact = await this.resolveScopedArtifact(idOrSlug, defaults) + artifact.status = 'archived' + const saved = await this.artifactRepository.save(artifact) + await this.recordArtifactAccessLog(saved, 'archived', { statusCode: 200 }) + return serializeArtifact(saved, await this.findCurrentVersion(saved)) + } + + private async deleteArtifactWithDefaults( + idOrSlug: string, + defaults: ArtifactsRuntimeScope + ): Promise { + const artifact = await this.resolveScopedArtifact(idOrSlug, defaults, true) + artifact.status = 'deleted' + const saved = await this.artifactRepository.save(artifact) + const links = await this.linkRepository.find({ + where: { artifactId: normalizeRequiredString(artifact.id, 'artifact.id') } + }) + for (const link of links) { + if (link.status !== 'revoked') { + link.status = 'revoked' + link.revokedAt = new Date() + await this.linkRepository.save(link) + } + } + await this.recordArtifactAccessLog(saved, 'deleted', { statusCode: 200 }) + return serializeArtifact(saved) + } + + private async createArtifactLinkWithDefaults( + input: CreateArtifactLinkInput, + defaults: ArtifactsRuntimeScope + ): Promise { + const artifact = await this.resolveScopedArtifact(input.artifactId, defaults) + const versionMode = normalizeVersionMode(input.versionMode, input.artifactVersionId) + const version = await this.resolveRequestedVersion(artifact, versionMode, input.artifactVersionId) + const access = normalizeAccessInput(input.access) + const scope = artifactToScope(artifact) + const token = this.assertAndApplyPublicLinkPolicy(access, { + ...scope, + userId: defaults.userId ?? artifact.userId + }) + const slug = await this.createUniqueSlug() + const link = this.linkRepository.create({ + ...scope, + createdById: defaults.userId ?? artifact.userId ?? undefined, + artifactId: normalizeRequiredString(artifact.id, 'artifact.id'), + artifact, + artifactVersionId: versionMode === 'version' ? normalizeRequiredString(version.id, 'version.id') : null, + versionMode, + slug, + publicUrl: this.buildPublicUrl(slug), + accessMode: access.mode, + status: 'active', + customPrincipals: normalizeStringArray(access.customPrincipals), + expiresAt: resolveExpiresAt(access), + accessCount: 0, + downloadCount: 0, + disposition: input.presentation?.disposition ?? 'inline', + allowDownload: input.presentation?.allowDownload ?? true, + safeHtmlProfile: input.presentation?.safeHtmlProfile ?? defaultSafeHtmlProfile(version.mimeType), + metadata: input.metadata ?? null + }) + if (access.mode === 'signed_preview') { + link.tokenHash = digestString(token ?? createSignedPreviewToken()) + } + const saved = await this.linkRepository.save(link) + return this.serializeLink(saved, token, artifact, version) + } + + private async createSignedPreviewLinkWithDefaults( + input: CreateSignedArtifactPreviewLinkInput, + defaults: ArtifactsRuntimeScope + ): Promise { + return this.createArtifactLinkWithDefaults( + { + ...input, + access: { + mode: 'signed_preview', + ttlSeconds: input.ttlSeconds ?? DEFAULT_SIGNED_PREVIEW_TTL_SECONDS + } + }, + defaults + ) + } + + private async updateArtifactLinkAccessWithDefaults( + idOrSlug: string, + patch: UpdateArtifactLinkAccessInput, + defaults: ArtifactsRuntimeScope + ): Promise { + const link = await this.resolveScopedLink(idOrSlug, defaults) + const token = patch.access ? this.applyAccessPatch(link, patch.access) : undefined + if (patch.presentation) { + this.applyPresentationPatch(link, patch.presentation) + } + if (patch.versionMode || patch.artifactVersionId !== undefined) { + const artifact = link.artifact ?? (await this.resolveScopedArtifact(link.artifactId, defaults)) + const versionMode = normalizeVersionMode(patch.versionMode, patch.artifactVersionId) + const version = await this.resolveRequestedVersion(artifact, versionMode, patch.artifactVersionId) + link.versionMode = versionMode + link.artifactVersionId = + versionMode === 'version' ? normalizeRequiredString(version.id, 'version.id') : null + } + const saved = await this.linkRepository.save(link) + return this.serializeLink(saved, token) + } + + private async revokeArtifactLinkWithDefaults( + idOrSlug: string, + defaults: ArtifactsRuntimeScope + ): Promise { + const link = await this.resolveScopedLink(idOrSlug, defaults) + if (link.status !== 'revoked') { + link.status = 'revoked' + link.revokedAt = new Date() + await this.linkRepository.save(link) + await this.recordAccessLog(link, 'revoked', { statusCode: 200 }) + } + return this.serializeLink(link) + } + + private normalizeArtifactVersionInput(input: CreateArtifactVersionInput) { + if (!input?.workspaceFileRef) { + throw new BadRequestException('workspaceFileRef is required') + } + const workspaceFileRef = normalizeWorkspaceFileReference(input.workspaceFileRef) + const mimeType = normalizeMimeType(input.mimeType) + return { + ...input, + workspaceFileRef, + mimeType, + fileName: normalizeOptionalString(input.fileName), + title: normalizeOptionalString(input.title), + description: normalizeOptionalString(input.description), + sourceVersionId: normalizeOptionalString(input.sourceVersionId), + checksum: normalizeOptionalString(input.checksum) + } + } + + private resolveCreateScope( + input: ArtifactsRuntimeScope | null | undefined, + defaults: ArtifactsRuntimeScope, + action: string + ): Required> & ArtifactsRuntimeScope { + const tenantId = + normalizeOptionalString(input?.tenantId) ?? + normalizeOptionalString(defaults.tenantId) ?? + normalizeOptionalString(RequestContext.currentTenantId()) + if (!tenantId) { + throw new BadRequestException(`tenantId is required to ${action}`) + } + return { + tenantId, + organizationId: + normalizeOptionalString(input?.organizationId) ?? + normalizeOptionalString(defaults.organizationId) ?? + normalizeOptionalString(RequestContext.getOrganizationId()), + userId: + normalizeOptionalString(input?.userId) ?? + normalizeOptionalString(defaults.userId) ?? + normalizeOptionalString(RequestContext.currentUserId()), + workspaceId: normalizeOptionalString(input?.workspaceId) ?? normalizeOptionalString(defaults.workspaceId), + projectId: normalizeOptionalString(input?.projectId) ?? normalizeOptionalString(defaults.projectId), + xpertId: normalizeOptionalString(input?.xpertId) ?? normalizeOptionalString(defaults.xpertId) + } + } + + private resolveCurrentScope(defaults: ArtifactsRuntimeScope) { + const tenantId = + normalizeOptionalString(defaults.tenantId) ?? normalizeOptionalString(RequestContext.currentTenantId()) + if (!tenantId) { + throw new UnauthorizedException('Login is required') + } + return { + tenantId, + organizationId: + normalizeOptionalString(defaults.organizationId) ?? + normalizeOptionalString(RequestContext.getOrganizationId()), + userId: normalizeOptionalString(defaults.userId) ?? normalizeOptionalString(RequestContext.currentUserId()) + } + } + + private async findExistingArtifact( + source: NormalizedArtifactSource, + scope: Required> & ArtifactsRuntimeScope + ) { + return this.artifactRepository.findOne({ + where: { + tenantId: scope.tenantId, + organizationId: scope.organizationId, + pluginName: source.pluginName, + resourceType: source.resourceType, + resourceId: source.resourceId + } + }) + } + + private async resolveScopedArtifact( + idOrSlug: string, + defaults: ArtifactsRuntimeScope, + allowDeleted = false + ): Promise { + const normalized = normalizeRequiredString(idOrSlug, 'artifact id') + const scope = this.resolveCurrentScope(defaults) + const byId = await this.artifactRepository.findOne({ where: { id: normalized } }) + const artifact = + byId ?? + ( + await this.linkRepository.findOne({ + where: { slug: normalized }, + relations: ['artifact'] + }) + )?.artifact + if (!artifact || artifact.tenantId !== scope.tenantId) { + throw new NotFoundException('Artifact was not found') + } + if (scope.organizationId && artifact.organizationId !== scope.organizationId) { + throw new NotFoundException('Artifact was not found') + } + if (!allowDeleted && artifact.status === 'deleted') { + throw new GoneException('Artifact was deleted') + } + return artifact + } + + private async resolveScopedLink(idOrSlug: string, defaults: ArtifactsRuntimeScope): Promise { + const normalized = normalizeRequiredString(idOrSlug, 'idOrSlug') + const scope = this.resolveCurrentScope(defaults) + const qb = this.linkRepository + .createQueryBuilder('link') + .leftJoinAndSelect('link.artifact', 'artifact') + .where('(link.id = :idOrSlug OR link.slug = :idOrSlug)', { idOrSlug: normalized }) + .andWhere('link.tenantId = :tenantId', { tenantId: scope.tenantId }) + if (scope.organizationId) { + qb.andWhere('link.organizationId = :organizationId', { organizationId: scope.organizationId }) + } + const link = await qb.getOne() + if (!link) { + throw new NotFoundException('Artifact link was not found') + } + return link + } + + private async findCurrentVersion(artifact: Artifact) { + if (!artifact.currentVersionId) { + return null + } + return this.versionRepository.findOne({ where: { id: artifact.currentVersionId, artifactId: artifact.id } }) + } + + private async resolveRequestedVersion( + artifact: Artifact, + versionMode: ArtifactLinkVersionMode, + artifactVersionId?: string | null + ) { + const id = + versionMode === 'version' + ? normalizeRequiredString(artifactVersionId, 'artifactVersionId') + : artifact.currentVersionId + if (!id) { + throw new BadRequestException('Artifact has no current version') + } + const version = await this.versionRepository.findOne({ where: { id, artifactId: artifact.id } }) + if (!version || version.status !== 'active') { + throw new NotFoundException('Artifact version was not found') + } + return version + } + + private async resolveLinkVersion(link: ArtifactLink, artifact: Artifact) { + return this.resolveRequestedVersion(artifact, link.versionMode ?? 'latest', link.artifactVersionId) + } + + private async assertPublicAccess( + link: ArtifactLink, + previewToken?: string | null, + principal?: ArtifactAccessPrincipal | null, + requestSummary?: ArtifactRequestSummary + ) { + if (link.status === 'revoked') { + await this.recordAccessLog(link, 'revoked', { statusCode: 410, requestSummary }) + throw new GoneException('Artifact link was revoked') + } + if (isExpired(link)) { + link.status = 'expired' + await this.linkRepository.save(link) + await this.recordAccessLog(link, 'expired', { statusCode: 410, requestSummary }) + throw new GoneException('Artifact link expired') + } + + const allowed = this.canAccessLink(link, previewToken, principal ?? null) + if (!allowed) { + await this.recordAccessLog(link, 'denied', { + statusCode: principal?.userId ? 403 : 401, + principalUserId: principal?.userId, + requestSummary + }) + if (!principal?.userId && link.accessMode !== 'public_link' && link.accessMode !== 'signed_preview') { + throw new UnauthorizedException('Login is required to access this artifact link') + } + throw new ForbiddenException('You do not have access to this artifact link') + } + } + + /** + * Evaluates link policy without mutating state. + * + * Revoked/expired transitions and audit writes are handled by assertPublicAccess; + * this helper only answers whether the supplied principal/token is allowed. + */ + private canAccessLink( + link: ArtifactLink, + previewToken?: string | null, + principal?: ArtifactAccessPrincipal | null + ) { + switch (link.accessMode) { + case 'public_link': + return true + case 'signed_preview': + return this.verifySignedPreviewToken(link, previewToken) + case 'owner_only': + return Boolean( + principal?.userId && (principal.userId === link.createdById || principal.userId === link.userId) + ) + case 'workspace_all': + case 'organization_all': + return sameTenantAndOrganization(link, principal) + case 'custom_principals': + return sameTenantAndOrganization(link, principal) && matchesCustomPrincipal(link, principal) + default: + return false + } + } + + private verifySignedPreviewToken(link: ArtifactLink, token?: string | null) { + const normalized = normalizeOptionalString(token) + if (!normalized || !link.tokenHash) { + return false + } + return safeEqualString(link.tokenHash, digestString(normalized)) + } + + private assertAndApplyPublicLinkPolicy( + access: ArtifactLinkAccessInput, + scope: ArtifactsRuntimeScope + ): string | undefined { + if (access.mode === 'public_link' && !access.userConfirmedPublicLink) { + throw new BadRequestException('public_link requires explicit user confirmation') + } + if (access.mode === 'signed_preview') { + return createSignedPreviewToken() + } + if (access.mode === 'custom_principals' && !normalizeStringArray(access.customPrincipals)?.length) { + throw new BadRequestException('custom_principals requires customPrincipals') + } + if (!scope.userId && access.mode === 'public_link') { + throw new BadRequestException('public_link requires a user-scoped operation') + } + return undefined + } + + private applyAccessPatch(link: ArtifactLink, access: ArtifactLinkAccessInput): string | undefined { + const normalized = normalizeAccessInput(access) + const token = this.assertAndApplyPublicLinkPolicy(normalized, { + tenantId: link.tenantId, + organizationId: link.organizationId, + userId: RequestContext.currentUserId() + }) + link.accessMode = normalized.mode + link.customPrincipals = normalizeStringArray(normalized.customPrincipals) + link.expiresAt = resolveExpiresAt(normalized) + link.status = 'active' + link.revokedAt = null + link.tokenHash = normalized.mode === 'signed_preview' ? digestString(token ?? createSignedPreviewToken()) : null + return token + } + + private applyPresentationPatch( + link: ArtifactLink, + presentation: NonNullable + ) { + if (presentation.disposition) { + link.disposition = presentation.disposition + } + if (presentation.allowDownload !== undefined && presentation.allowDownload !== null) { + link.allowDownload = presentation.allowDownload + } + if (presentation.safeHtmlProfile !== undefined) { + link.safeHtmlProfile = presentation.safeHtmlProfile + } + } + + private async readWorkspaceArtifact(ref: WorkspacePortableFileReference) { + const filePath = normalizeWorkspaceFilePath(ref.filePath) + const resolved = resolveWorkspaceVolumeScope(ref, { + tenantId: ref.tenantId, + userId: ref.userId, + inferUserScope: true + }) + if (!resolved) { + throw new BadRequestException('Unable to resolve artifact workspace scope') + } + const client = new VolumeSubtreeClient(this.volumeClient.resolve(resolved.volumeScope), { + allowRootWorkspace: true + }) + const buffer = await client.readBuffer('', filePath) + return { + buffer, + name: ref.originalName ?? ref.name ?? path.posix.basename(filePath) + } + } + + /** + * Allocates the externally visible Artifact link slug. + * + * Collisions are unlikely but still checked against the repository so the + * unique database index does not become normal control flow. + */ + private async createUniqueSlug() { + for (let index = 0; index < 8; index += 1) { + const slug = createArtifactLinkSlug() + const existing = await this.linkRepository.findOne({ where: { slug } }) + if (!existing) { + return slug + } + } + throw new BadRequestException('Unable to allocate an artifact link slug') + } + + /** + * Builds the browser-facing Artifact URL from the configured public base. + * + * The API may be configured with a `/api` base URL locally, so this strips + * that suffix before appending `/artifacts/share/:artifactLinkSlug`. + */ + private buildPublicUrl(slug: string, previewToken?: string) { + const parsed = new URL(resolveArtifactsPublicBaseUrl()) + const normalizedPathname = parsed.pathname.replace(/\/+$/, '') + const basePath = normalizedPathname.endsWith('/api') ? normalizedPathname.slice(0, -4) : normalizedPathname + parsed.pathname = path.posix.join(basePath || '/', 'artifacts', 'share', encodeURIComponent(slug)) + parsed.search = '' + parsed.hash = '' + if (previewToken) { + parsed.searchParams.set(SIGNED_PREVIEW_QUERY_PARAM, previewToken) + } + return parsed.toString() + } + + private serializeLink( + link: ArtifactLink, + previewToken?: string, + artifact?: Artifact, + version?: ArtifactVersion + ): ArtifactLinkRecord { + const resolvedArtifact = artifact ?? link.artifact + return { + id: normalizeRequiredString(link.id, 'link.id'), + artifactId: link.artifactId, + artifactVersionId: link.artifactVersionId, + versionMode: link.versionMode ?? 'latest', + slug: link.slug, + publicUrl: + link.accessMode === 'signed_preview' && previewToken + ? this.buildPublicUrl(link.slug, previewToken) + : link.publicUrl, + accessMode: link.accessMode, + status: normalizeLinkStatus(link), + customPrincipals: link.customPrincipals ?? null, + expiresAt: link.expiresAt ?? null, + revokedAt: link.revokedAt ?? null, + accessCount: link.accessCount, + downloadCount: link.downloadCount, + disposition: link.disposition, + allowDownload: link.allowDownload, + safeHtmlProfile: link.safeHtmlProfile ?? null, + tenantId: link.tenantId, + organizationId: link.organizationId, + userId: link.userId, + workspaceId: link.workspaceId, + projectId: link.projectId, + xpertId: link.xpertId, + createdAt: link.createdAt, + updatedAt: link.updatedAt, + ...(resolvedArtifact ? { artifact: serializeArtifact(resolvedArtifact) } : {}), + ...(version ? { version: serializeArtifactVersion(version) } : {}) + } + } + + private async incrementCounters(link: ArtifactLink, kind: 'access' | 'download') { + if (!link.id) { + return + } + await this.linkRepository.increment({ id: link.id }, 'accessCount', 1) + if (kind === 'download') { + await this.linkRepository.increment({ id: link.id }, 'downloadCount', 1) + link.downloadCount += 1 + } + link.accessCount += 1 + } + + private async recordArtifactAccessLog( + artifact: Artifact, + event: ArtifactAccessEvent, + options: { + statusCode?: number + error?: string + } = {} + ) { + try { + await this.accessLogRepository.save( + this.accessLogRepository.create({ + tenantId: artifact.tenantId, + organizationId: artifact.organizationId, + linkId: null, + artifactId: artifact.id, + slug: `artifact:${artifact.id}`, + event, + accessMode: null, + statusCode: options.statusCode, + error: normalizeOptionalString(options.error) + }) + ) + } catch { + // Audit logging must not make a valid artifact operation fail. + } + } + + private async recordAccessLog( + link: ArtifactLink, + event: ArtifactAccessEvent, + options: { + statusCode?: number + error?: string + principalUserId?: string | null + requestSummary?: ArtifactRequestSummary + } = {} + ) { + try { + await this.accessLogRepository.save( + this.accessLogRepository.create({ + tenantId: link.tenantId, + organizationId: link.organizationId, + linkId: link.id, + artifactId: link.artifactId, + slug: link.slug, + event, + accessMode: link.accessMode, + principalUserId: normalizeOptionalString(options.principalUserId), + ipHash: options.requestSummary?.ipHash, + userAgent: options.requestSummary?.userAgent, + statusCode: options.statusCode, + error: normalizeOptionalString(options.error) + }) + ) + } catch { + // Access logging must not make a valid artifact unreachable. + } + } +} + +type ArtifactJwtPayload = { + id?: string + tenantId?: string +} + +type ArtifactHeaders = Record + +type NormalizedArtifactSource = { + pluginName: string + resourceType: string + resourceId: string + checksum?: string +} + +export type ArtifactHttpRequest = { + headers: ArtifactHeaders + ip?: string + ips?: string[] + socket?: { + remoteAddress?: string + } +} + +export type ArtifactRequestSummary = { + ipHash?: string + userAgent?: string +} + +function normalizeSourceInput(input?: CreateArtifactInput['source']): NormalizedArtifactSource { + if (!input) { + throw new BadRequestException('source is required') + } + return { + pluginName: normalizeRequiredString(input.pluginName, 'source.pluginName'), + resourceType: normalizeRequiredString(input.resourceType, 'source.resourceType'), + resourceId: normalizeRequiredString(input.resourceId, 'source.resourceId'), + checksum: normalizeOptionalString(input.checksum) + } +} + +function normalizeAccessInput(input?: ArtifactLinkAccessInput | null): ArtifactLinkAccessInput { + if (!input?.mode) { + throw new BadRequestException('access.mode is required') + } + return { + ...input, + mode: input.mode + } +} + +function normalizeVersionMode( + mode?: ArtifactLinkVersionMode | null, + artifactVersionId?: string | null +): ArtifactLinkVersionMode { + if (mode === 'latest' || mode === 'version') { + return mode + } + return artifactVersionId ? 'version' : 'latest' +} + +function normalizeWorkspaceFileReference(ref: WorkspacePortableFileReference): WorkspacePortableFileReference { + if (ref.source !== WORKSPACE_FILES_SOURCE) { + throw new BadRequestException('workspaceFileRef must be a platform workspace file reference') + } + return { + ...ref, + filePath: normalizeWorkspaceFilePath(ref.filePath), + workspacePath: normalizeWorkspaceFilePath(ref.workspacePath ?? ref.filePath) + } +} + +function normalizeWorkspaceFilePath(value?: string | null) { + const raw = normalizeRequiredString(value, 'workspace file path') + if (raw.includes('\0')) { + throw new BadRequestException('workspace file path contains an invalid character') + } + const normalized = path.posix.normalize(raw.replace(/\\/g, '/').replace(/^\/+/, '')) + if (!normalized || normalized === '.' || normalized === '..' || normalized.startsWith('../')) { + throw new BadRequestException('workspace file path is invalid') + } + return normalized +} + +function normalizeMimeType(value?: string | null) { + const mimeType = normalizeRequiredString(value, 'mimeType').toLowerCase() + if (mimeType === 'image/svg+xml') { + throw new BadRequestException('SVG artifacts cannot be shared directly') + } + if (ALLOWED_EXACT_MIME_TYPES.has(mimeType) || mimeType.startsWith('image/')) { + return mimeType + } + throw new BadRequestException(`Unsupported artifact MIME type: ${mimeType}`) +} + +function inferArtifactKind(mimeType: string): ArtifactKind { + if (mimeType === 'text/html') return 'html' + if (mimeType === 'text/markdown') return 'markdown' + if (mimeType === 'application/pdf') return 'pdf' + if (mimeType === PPTX_MIME_TYPE) return 'pptx' + if (mimeType.startsWith('image/')) return 'image' + return 'file' +} + +function defaultSafeHtmlProfile(mimeType: string): ArtifactSafeHtmlProfile | null { + return mimeType === 'text/html' ? 'strict' : null +} + +function resolveExpiresAt(access: ArtifactLinkAccessInput) { + if (access.expiresAt) { + const expiresAt = access.expiresAt instanceof Date ? access.expiresAt : new Date(access.expiresAt) + if (!Number.isFinite(expiresAt.getTime())) { + throw new BadRequestException('access.expiresAt is invalid') + } + return expiresAt + } + if (access.ttlSeconds !== undefined && access.ttlSeconds !== null) { + const ttlSeconds = normalizePositiveInteger(access.ttlSeconds, DEFAULT_SIGNED_PREVIEW_TTL_SECONDS) + if (ttlSeconds > MAX_TTL_SECONDS) { + throw new BadRequestException('access.ttlSeconds is too large') + } + return new Date(Date.now() + ttlSeconds * 1000) + } + if (access.mode === 'signed_preview') { + return new Date(Date.now() + DEFAULT_SIGNED_PREVIEW_TTL_SECONDS * 1000) + } + return null +} + +function serializeArtifact(artifact: Artifact, currentVersion?: ArtifactVersion | null): ArtifactRecord { + return { + id: normalizeRequiredString(artifact.id, 'artifact.id'), + pluginName: artifact.pluginName, + resourceType: artifact.resourceType, + resourceId: artifact.resourceId, + checksum: artifact.checksum, + kind: artifact.kind, + status: artifact.status, + title: artifact.title, + description: artifact.description, + currentVersionId: artifact.currentVersionId, + currentVersion: currentVersion ? serializeArtifactVersion(currentVersion) : null, + tenantId: artifact.tenantId, + organizationId: artifact.organizationId, + userId: artifact.userId, + workspaceId: artifact.workspaceId, + projectId: artifact.projectId, + xpertId: artifact.xpertId, + createdAt: artifact.createdAt, + updatedAt: artifact.updatedAt + } +} + +function serializeArtifactVersion(version: ArtifactVersion): ArtifactVersionRecord { + return { + id: normalizeRequiredString(version.id, 'version.id'), + artifactId: version.artifactId, + versionNumber: version.versionNumber, + status: version.status, + sourceVersionId: version.sourceVersionId, + checksum: version.checksum, + mimeType: version.mimeType, + fileName: version.fileName, + title: version.title, + description: version.description, + size: version.size, + sha256: version.sha256, + createdAt: version.createdAt + } +} + +function normalizeLinkStatus(link: ArtifactLink): ArtifactLinkStatus { + if (link.status === 'active' && isExpired(link)) { + return 'expired' + } + return link.status +} + +function isExpired(link: Pick) { + return Boolean(link.expiresAt && link.expiresAt.getTime() <= Date.now()) +} + +function sameTenantAndOrganization( + link: Pick, + principal?: ArtifactAccessPrincipal | null +) { + return Boolean( + principal?.tenantId && + principal.tenantId === link.tenantId && + principal.organizationId && + principal.organizationId === link.organizationId + ) +} + +function matchesCustomPrincipal( + link: Pick, + principal?: ArtifactAccessPrincipal | null +) { + const customPrincipals = normalizeStringArray(link.customPrincipals) ?? [] + const candidates = [ + principal?.userId, + principal?.userId ? `user:${principal.userId}` : null, + principal?.organizationId ? `organization:${principal.organizationId}` : null + ].filter((item): item is string => Boolean(item)) + return candidates.some((candidate) => customPrincipals.includes(candidate)) +} + +/** Resolves the host used in copied Artifact links, preferring the frontend origin. */ +function resolveArtifactsPublicBaseUrl() { + return ( + normalizeOptionalString(process.env.PUBLIC_BASE_URL) ?? + normalizeOptionalString(process.env.ARTIFACTS_PUBLIC_BASE_URL) ?? + normalizeOptionalString(environment.clientBaseUrl) ?? + normalizeOptionalString(environment.baseUrl) ?? + 'http://localhost:3000' + ) +} + +function artifactToScope( + artifact: Artifact +): Required> & ArtifactsRuntimeScope { + return { + tenantId: normalizeRequiredString(artifact.tenantId, 'artifact.tenantId'), + organizationId: artifact.organizationId, + userId: artifact.userId, + workspaceId: artifact.workspaceId, + projectId: artifact.projectId, + xpertId: artifact.xpertId + } +} + +/** Creates a short-lived bearer token for signed Artifact previews. */ +function createSignedPreviewToken() { + return randomBytes(24).toString('base64url') +} + +/** Creates one URL-safe, non-guessable Artifact link slug. */ +function createArtifactLinkSlug() { + const bytes = randomBytes(ARTIFACT_LINK_SLUG_LENGTH) + let slug = '' + for (const byte of bytes) { + slug += ARTIFACT_LINK_SLUG_ALPHABET[byte % ARTIFACT_LINK_SLUG_ALPHABET.length] + } + return slug +} + +function digestBuffer(buffer: Buffer) { + return createHash('sha256').update(buffer).digest('hex') +} + +function digestString(value: string) { + return createHash('sha256').update(value).digest('hex') +} + +function hashString(value?: string | null) { + return value ? digestString(value) : undefined +} + +function safeEqualString(left: string, right: string) { + const leftBuffer = Buffer.from(left) + const rightBuffer = Buffer.from(right) + return leftBuffer.length === rightBuffer.length && timingSafeEqual(leftBuffer, rightBuffer) +} + +function normalizeRequiredString(value: unknown, field: string) { + const normalized = normalizeOptionalString(value) + if (!normalized) { + throw new BadRequestException(`${field} is required`) + } + return normalized +} + +function normalizeOptionalString(value: unknown) { + return typeof value === 'string' && value.trim() ? value.trim() : undefined +} + +function normalizeStringArray(value?: string[] | null) { + const result = Array.isArray(value) + ? value.map((item) => normalizeOptionalString(item)).filter((item): item is string => Boolean(item)) + : [] + return result.length ? Array.from(new Set(result)) : null +} + +function normalizePositiveInteger(value: unknown, fallback: number) { + const numberValue = typeof value === 'number' ? value : Number(value) + if (!Number.isFinite(numberValue) || numberValue <= 0) { + return fallback + } + return Math.trunc(numberValue) +} + +function getHeaderValue(headers: ArtifactHeaders, key: string) { + const value = headers[key] ?? headers[key.toLowerCase()] + return Array.isArray(value) ? value[0] : value +} + +function extractBearerToken(headers: ArtifactHeaders) { + const authorization = getHeaderValue(headers, 'authorization') + const match = authorization?.match(/^Bearer\s+(.+)$/i) + return match?.[1] +} + +function getRequestIp(request: ArtifactHttpRequest) { + return request.ips?.[0] ?? request.ip ?? request.socket?.remoteAddress +} diff --git a/packages/server-ai/src/artifacts/entities/artifact-access-log.entity.ts b/packages/server-ai/src/artifacts/entities/artifact-access-log.entity.ts new file mode 100644 index 0000000000..57e008be69 --- /dev/null +++ b/packages/server-ai/src/artifacts/entities/artifact-access-log.entity.ts @@ -0,0 +1,55 @@ +import { TenantOrganizationBaseEntity } from '@xpert-ai/server-core' +import type { ArtifactAccessEvent, ArtifactAccessMode, IArtifactAccessLog } from '@xpert-ai/contracts' +import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm' +import { ArtifactLink } from './artifact-link.entity' + +/** + * Audit trail for Artifact public/scoped access and lifecycle events. + * + * It records summaries such as status, hashed IP, and user agent; it must never + * persist preview tokens, platform credentials, or Artifact file contents. + */ +@Entity('artifact_access_log') +@Index(['tenantId', 'organizationId', 'linkId']) +@Index(['slug']) +export class ArtifactAccessLog extends TenantOrganizationBaseEntity implements IArtifactAccessLog { + @ManyToOne(() => ArtifactLink, { + nullable: true, + onDelete: 'SET NULL' + }) + @JoinColumn({ name: 'linkId' }) + link?: ArtifactLink | null + + @Column({ type: 'uuid', nullable: true }) + linkId?: string | null + + @Column({ type: 'uuid', nullable: true }) + artifactId?: string | null + + @Column({ type: 'varchar' }) + slug: string + + @Column({ type: 'varchar' }) + event: ArtifactAccessEvent + + @Column({ type: 'varchar', nullable: true }) + accessMode?: ArtifactAccessMode | null + + @Column({ type: 'varchar', nullable: true }) + principalUserId?: string | null + + @Column({ type: 'varchar', nullable: true }) + ipHash?: string | null + + @Column({ type: 'text', nullable: true }) + userAgent?: string | null + + @Column({ type: 'int', nullable: true }) + statusCode?: number | null + + @Column({ type: 'text', nullable: true }) + error?: string | null + + @Column({ type: 'json', nullable: true }) + metadata?: Record | null +} diff --git a/packages/server-ai/src/artifacts/entities/artifact-link.entity.ts b/packages/server-ai/src/artifacts/entities/artifact-link.entity.ts new file mode 100644 index 0000000000..fd67f2a908 --- /dev/null +++ b/packages/server-ai/src/artifacts/entities/artifact-link.entity.ts @@ -0,0 +1,93 @@ +import { TenantOrganizationBaseEntity } from '@xpert-ai/server-core' +import type { + ArtifactAccessMode, + ArtifactLinkDisposition, + ArtifactLinkStatus, + ArtifactLinkVersionMode, + ArtifactSafeHtmlProfile, + IArtifactLink +} from '@xpert-ai/contracts' +import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm' +import { Artifact } from './artifact.entity' + +/** + * Public or scoped entrypoint for opening/downloading an Artifact. + * + * The `slug` is the stable, short, non-guessable handle copied into URLs; access + * policy and version mode live here rather than in the Artifact container. + */ +@Entity('artifact_link') +@Index(['slug'], { unique: true }) +@Index(['tenantId', 'organizationId', 'artifactId']) +@Index(['tenantId', 'organizationId', 'status']) +export class ArtifactLink extends TenantOrganizationBaseEntity implements IArtifactLink { + @ManyToOne(() => Artifact, { + nullable: false, + onDelete: 'CASCADE' + }) + @JoinColumn({ name: 'artifactId' }) + artifact?: Artifact + + @Column({ type: 'uuid' }) + artifactId: string + + @Column({ type: 'uuid', nullable: true }) + artifactVersionId?: string | null + + @Column({ type: 'varchar', default: 'latest' }) + versionMode: ArtifactLinkVersionMode + + @Column({ type: 'varchar' }) + slug: string + + @Column({ type: 'text' }) + publicUrl: string + + @Column({ type: 'varchar' }) + accessMode: ArtifactAccessMode + + @Column({ type: 'varchar', default: 'active' }) + status: ArtifactLinkStatus + + @Column({ type: 'json', nullable: true }) + customPrincipals?: string[] | null + + @Column({ type: 'varchar', nullable: true }) + tokenHash?: string | null + + @Column({ type: 'timestamptz', nullable: true }) + expiresAt?: Date | null + + @Column({ type: 'timestamptz', nullable: true }) + revokedAt?: Date | null + + @Column({ type: 'int', default: 0 }) + accessCount: number + + @Column({ type: 'int', default: 0 }) + downloadCount: number + + @Column({ type: 'varchar', default: 'inline' }) + disposition: ArtifactLinkDisposition + + @Column({ type: 'boolean', default: true }) + allowDownload: boolean + + @Column({ type: 'varchar', nullable: true }) + safeHtmlProfile?: ArtifactSafeHtmlProfile | null + + @Column({ type: 'varchar', nullable: true }) + workspaceId?: string | null + + @Column({ type: 'varchar', nullable: true }) + projectId?: string | null + + @Column({ type: 'varchar', nullable: true }) + xpertId?: string | null + + @Column({ type: 'varchar', nullable: true }) + userId?: string | null + + @Column({ type: 'json', nullable: true }) + metadata?: Record | null +} diff --git a/packages/server-ai/src/artifacts/entities/artifact-version.entity.ts b/packages/server-ai/src/artifacts/entities/artifact-version.entity.ts new file mode 100644 index 0000000000..827b5295a8 --- /dev/null +++ b/packages/server-ai/src/artifacts/entities/artifact-version.entity.ts @@ -0,0 +1,81 @@ +import { TenantOrganizationBaseEntity } from '@xpert-ai/server-core' +import type { ArtifactVersionStatus, IArtifactVersion } from '@xpert-ai/contracts' +import type { WorkspacePortableFileReference } from '@xpert-ai/plugin-sdk' +import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm' +import { Artifact } from './artifact.entity' + +/** Converts PostgreSQL bigint strings into JavaScript numbers for file sizes. */ +const bigintNumberTransformer = { + to: (value?: number | null) => value, + from: (value: string | null) => (value !== null ? Number(value) : null) +} + +/** + * Immutable content snapshot for an Artifact. + * + * Each version points to a portable Workspace Files reference and records + * content metadata used by preview, download, and checksum validation. + */ +@Entity('artifact_version') +@Index(['tenantId', 'organizationId', 'artifactId', 'versionNumber'], { unique: true }) +@Index(['tenantId', 'organizationId', 'artifactId', 'sourceVersionId']) +@Index(['sha256']) +export class ArtifactVersion extends TenantOrganizationBaseEntity implements IArtifactVersion { + @ManyToOne(() => Artifact, { + nullable: false, + onDelete: 'CASCADE' + }) + @JoinColumn({ name: 'artifactId' }) + artifact?: Artifact + + @Column({ type: 'uuid' }) + artifactId: string + + @Column({ type: 'int' }) + versionNumber: number + + @Column({ type: 'varchar', default: 'active' }) + status: ArtifactVersionStatus + + @Column({ type: 'varchar', nullable: true }) + sourceVersionId?: string | null + + @Column({ type: 'varchar', nullable: true }) + checksum?: string | null + + @Column({ type: 'json' }) + workspaceFileRef: WorkspacePortableFileReference + + @Column({ type: 'varchar' }) + mimeType: string + + @Column({ type: 'varchar', nullable: true }) + fileName?: string | null + + @Column({ type: 'varchar', nullable: true }) + title?: string | null + + @Column({ type: 'text', nullable: true }) + description?: string | null + + @Column({ type: 'bigint', nullable: true, transformer: bigintNumberTransformer }) + size?: number | null + + @Column({ type: 'varchar', nullable: true }) + sha256?: string | null + + @Column({ type: 'varchar', nullable: true }) + workspaceId?: string | null + + @Column({ type: 'varchar', nullable: true }) + projectId?: string | null + + @Column({ type: 'varchar', nullable: true }) + xpertId?: string | null + + @Column({ type: 'varchar', nullable: true }) + userId?: string | null + + @Column({ type: 'json', nullable: true }) + metadata?: Record | null +} diff --git a/packages/server-ai/src/artifacts/entities/artifact.entity.ts b/packages/server-ai/src/artifacts/entities/artifact.entity.ts new file mode 100644 index 0000000000..c67f5bdb8d --- /dev/null +++ b/packages/server-ai/src/artifacts/entities/artifact.entity.ts @@ -0,0 +1,55 @@ +import { TenantOrganizationBaseEntity } from '@xpert-ai/server-core' +import type { ArtifactKind, ArtifactStatus, IArtifact } from '@xpert-ai/contracts' +import { Column, Entity, Index } from 'typeorm' + +/** + * Durable Artifact container owned by a plugin/Agent resource. + * + * Bytes are stored on ArtifactVersion rows; this entity keeps source identity, + * tenant scope, current-version pointer, and user-facing metadata. + */ +@Entity('artifact') +@Index(['tenantId', 'organizationId', 'pluginName', 'resourceType', 'resourceId']) +export class Artifact extends TenantOrganizationBaseEntity implements IArtifact { + @Column({ type: 'varchar' }) + pluginName: string + + @Column({ type: 'varchar' }) + resourceType: string + + @Column({ type: 'varchar' }) + resourceId: string + + @Column({ type: 'varchar', nullable: true }) + checksum?: string | null + + @Column({ type: 'varchar', default: 'file' }) + kind: ArtifactKind + + @Column({ type: 'varchar', default: 'active' }) + status: ArtifactStatus + + @Column({ type: 'varchar', nullable: true }) + title?: string | null + + @Column({ type: 'text', nullable: true }) + description?: string | null + + @Column({ type: 'uuid', nullable: true }) + currentVersionId?: string | null + + @Column({ type: 'varchar', nullable: true }) + workspaceId?: string | null + + @Column({ type: 'varchar', nullable: true }) + projectId?: string | null + + @Column({ type: 'varchar', nullable: true }) + xpertId?: string | null + + @Column({ type: 'varchar', nullable: true }) + userId?: string | null + + @Column({ type: 'json', nullable: true }) + metadata?: Record | null +} diff --git a/packages/server-ai/src/artifacts/entities/index.ts b/packages/server-ai/src/artifacts/entities/index.ts new file mode 100644 index 0000000000..3980226a6c --- /dev/null +++ b/packages/server-ai/src/artifacts/entities/index.ts @@ -0,0 +1,4 @@ +export * from './artifact.entity' +export * from './artifact-version.entity' +export * from './artifact-link.entity' +export * from './artifact-access-log.entity' diff --git a/packages/server-ai/src/artifacts/index.ts b/packages/server-ai/src/artifacts/index.ts new file mode 100644 index 0000000000..35afad4a63 --- /dev/null +++ b/packages/server-ai/src/artifacts/index.ts @@ -0,0 +1,4 @@ +export * from './entities' +export * from './artifacts.controller' +export * from './artifacts.module' +export * from './artifacts.service' diff --git a/packages/server-ai/src/collaboration/collaboration-materialization.processor.ts b/packages/server-ai/src/collaboration/collaboration-materialization.processor.ts new file mode 100644 index 0000000000..f74ae18c09 --- /dev/null +++ b/packages/server-ai/src/collaboration/collaboration-materialization.processor.ts @@ -0,0 +1,21 @@ +import { Injectable } from '@nestjs/common' +import { PluginJobProcessor, type ManagedQueueJob } from '@xpert-ai/plugin-sdk' +import { CollaborationService } from './collaboration.service' + +type CollaborationMaterializationJob = { documentId: string } + +@Injectable() +@PluginJobProcessor({ + pluginName: '@xpert-ai/platform', + queueName: 'collaboration', + jobName: 'materialize', + concurrency: 2 +}) +/** Retries failed provider projections against the newest authoritative document state. */ +export class CollaborationMaterializationProcessor { + constructor(private readonly collaboration: CollaborationService) {} + + async handle(job: ManagedQueueJob) { + await this.collaboration.retryMaterialization(job.data.documentId) + } +} diff --git a/packages/server-ai/src/collaboration/collaboration.gateway.spec.ts b/packages/server-ai/src/collaboration/collaboration.gateway.spec.ts new file mode 100644 index 0000000000..ea28834bcf --- /dev/null +++ b/packages/server-ai/src/collaboration/collaboration.gateway.spec.ts @@ -0,0 +1,41 @@ +import type { CollaborationSession } from './collaboration.service' +import { CollaborationGateway } from './collaboration.gateway' + +describe('CollaborationGateway', () => { + it('identifies the exact socket in the initial presence snapshot', async () => { + const session: CollaborationSession = { + sessionId: 'session', + clientKeyHash: 'hash', + documentId: 'document', + providerKey: 'example.document', + resourceId: 'resource', + access: 'write', + actor: { presenceId: 'user-1', actorType: 'user', displayName: 'User', color: '#111111' }, + expiresAt: Date.now() + 60_000, + tenantId: 'tenant', + organizationId: 'organization' + } + const collaboration = { + onBroadcast: jest.fn(() => () => undefined), + resolveSession: jest.fn(async () => session), + getStateForSession: jest.fn(async () => ({ updateBase64: '', stateVectorBase64: '' })), + listPresenceForSession: jest.fn(async () => []) + } + const emitted: Array<{ event: string; payload: object }> = [] + const client = { + id: 'socket-self', + handshake: { auth: { sessionId: 'session', clientKey: 'key', documentId: 'document' }, query: {} }, + join: jest.fn(async () => undefined), + emit: jest.fn((event: string, payload: object) => emitted.push({ event, payload })), + disconnect: jest.fn() + } + + const gateway = new CollaborationGateway(collaboration as never) + await gateway.handleConnection(client as never) + + expect(emitted).toContainEqual({ + event: 'presence-snapshot', + payload: { selfClientId: 'socket-self', items: [] } + }) + }) +}) diff --git a/packages/server-ai/src/collaboration/collaboration.gateway.ts b/packages/server-ai/src/collaboration/collaboration.gateway.ts new file mode 100644 index 0000000000..b6dd76f294 --- /dev/null +++ b/packages/server-ai/src/collaboration/collaboration.gateway.ts @@ -0,0 +1,184 @@ +import { Logger, OnModuleDestroy } from '@nestjs/common' +import { + ConnectedSocket, + MessageBody, + OnGatewayConnection, + OnGatewayDisconnect, + SubscribeMessage, + WebSocketGateway, + WebSocketServer +} from '@nestjs/websockets' +import type { CollaborationPresencePatch } from '@xpert-ai/plugin-sdk' +import type { Server, Socket } from 'socket.io' +import type { CollaborationBroadcast, CollaborationSession } from './collaboration.service' +import { CollaborationService } from './collaboration.service' + +type ClientState = { session: CollaborationSession } + +/** Fixed platform gateway shared by every plugin collaboration provider. */ +@WebSocketGateway({ namespace: '/api/collaboration', cors: { origin: '*' }, transports: ['websocket'] }) +export class CollaborationGateway implements OnGatewayConnection, OnGatewayDisconnect, OnModuleDestroy { + @WebSocketServer() private readonly server: Server + private readonly logger = new Logger(CollaborationGateway.name) + private readonly clients = new WeakMap() + private readonly stopBroadcast: () => void + + constructor(private readonly collaboration: CollaborationService) { + this.stopBroadcast = this.collaboration.onBroadcast((event) => this.broadcast(event)) + } + + onModuleDestroy() { + this.stopBroadcast() + } + + /** Authenticate the opaque session, join only its document room, and send initial state/presence. */ + async handleConnection(client: Socket) { + const auth = objectValue(client.handshake.auth) + const documentId = stringValue(auth.documentId ?? client.handshake.query.documentId) + const session = await this.collaboration.resolveSession( + stringValue(auth.sessionId ?? client.handshake.query.sessionId), + stringValue(auth.clientKey), + documentId + ) + if (!session) { + client.emit('error', { message: 'Invalid or expired collaboration session.' }) + client.disconnect(true) + return + } + this.clients.set(client, { session }) + await client.join(room(session.documentId)) + client.emit('sync', await this.collaboration.getStateForSession(session)) + client.emit('presence-snapshot', { + selfClientId: client.id, + items: await this.collaboration.listPresenceForSession(session) + }) + } + + async handleDisconnect(client: Socket) { + const state = this.clients.get(client) + if (state) await this.collaboration.removePresenceForSession(state.session, client.id) + } + + @SubscribeMessage('update') + /** Persist a client update before acknowledging it; service broadcasts after commit. */ + async update(@MessageBody() data: unknown, @ConnectedSocket() client: Socket) { + const state = this.clients.get(client) + if (!state) return + try { + const input = objectValue(data) + const result = await this.collaboration.applyUpdateForSession(state.session, { + documentId: state.session.documentId, + updateBase64: stringValue(input.updateBase64) ?? '', + origin: stringValue(input.origin), + expectedSequence: typeof input.expectedSequence === 'number' ? input.expectedSequence : undefined + }) + client.emit('update-ack', result) + } catch (error) { + client.emit('error', { message: errorMessage(error) }) + } + } + + @SubscribeMessage('sync-request') + /** Repair reconnect or packet loss with a state-vector-relative delta. */ + async sync(@MessageBody() data: unknown, @ConnectedSocket() client: Socket) { + const state = this.clients.get(client) + if (!state) return + const input = objectValue(data) + client.emit( + 'sync', + await this.collaboration.getStateForSession(state.session, stringValue(input.stateVectorBase64)) + ) + } + + @SubscribeMessage('presence') + /** Refresh ephemeral presence independently from document updates. */ + async presence(@MessageBody() data: unknown, @ConnectedSocket() client: Socket) { + const state = this.clients.get(client) + if (!state) return + try { + await this.collaboration.upsertPresenceForSession(state.session, client.id, sanitizePatch(data)) + } catch (error) { + client.emit('error', { message: errorMessage(error) }) + } + } + + /** Fan out a local or cross-node event to clients of exactly one document. */ + private broadcast(event: CollaborationBroadcast) { + const eventName = event.type === 'presence-remove' ? 'presence-remove' : event.type + this.server?.to(room(event.documentId)).emit(eventName, event.payload) + } +} + +function sanitizePatch(value: unknown): CollaborationPresencePatch { + const input = objectValue(value) + const pointer = objectValue(input.pointer) + const focus = objectValue(input.focus) + const selection = objectValue(input.selection) + const viewport = objectValue(input.viewport) + return { + pageId: stringValue(input.pageId) ?? null, + pointer: + typeof pointer.x === 'number' && typeof pointer.y === 'number' + ? { + pageId: stringValue(pointer.pageId) ?? null, + x: pointer.x, + y: pointer.y, + visible: pointer.visible !== false + } + : null, + focus: stringValue(focus.kind) + ? { + kind: stringValue(focus.kind) as string, + key: stringValue(focus.key) ?? null, + pageId: stringValue(focus.pageId) ?? null, + elementId: stringValue(focus.elementId) ?? null, + fieldKey: stringValue(focus.fieldKey) ?? null + } + : null, + selection: + selection.kind === 'text' || selection.kind === 'elements' + ? { + kind: selection.kind, + fieldKey: stringValue(selection.fieldKey ?? selection.textKey) ?? null, + elementIds: Array.isArray(selection.elementIds) + ? selection.elementIds.filter((item): item is string => typeof item === 'string') + : null, + anchorRelativeBase64: stringValue(selection.anchorRelativeBase64) ?? null, + headRelativeBase64: stringValue(selection.headRelativeBase64) ?? null + } + : null, + viewport: + typeof viewport.zoom === 'number' && + typeof viewport.width === 'number' && + typeof viewport.height === 'number' + ? { + zoom: viewport.zoom, + width: viewport.width, + height: viewport.height + } + : null, + mode: stringValue(input.mode) ?? null, + status: + input.status === 'thinking' || + input.status === 'editing' || + input.status === 'done' || + input.status === 'failed' + ? input.status + : null, + toolName: stringValue(input.toolName) ?? null, + operationLabel: stringValue(input.operationLabel) ?? null + } +} + +function room(documentId: string) { + return `collaboration:${documentId}` +} +function objectValue(value: unknown): Record { + return value && typeof value === 'object' && !Array.isArray(value) ? (value as Record) : {} +} +function stringValue(value: unknown) { + return typeof value === 'string' && value.trim() ? value.trim() : undefined +} +function errorMessage(error: unknown) { + return error instanceof Error ? error.message : String(error) +} diff --git a/packages/server-ai/src/collaboration/collaboration.module.ts b/packages/server-ai/src/collaboration/collaboration.module.ts new file mode 100644 index 0000000000..d64663f1ad --- /dev/null +++ b/packages/server-ai/src/collaboration/collaboration.module.ts @@ -0,0 +1,22 @@ +import { Global, Module } from '@nestjs/common' +import { DiscoveryModule } from '@nestjs/core' +import { TypeOrmModule } from '@nestjs/typeorm' +import { CollaborationDocumentProviderRegistry } from '@xpert-ai/plugin-sdk' +import { RedisModule } from '@xpert-ai/server-core' +import { CollaborationGateway } from './collaboration.gateway' +import { CollaborationMaterializationProcessor } from './collaboration-materialization.processor' +import { CollaborationService } from './collaboration.service' +import { CollaborationDocument, CollaborationUpdate } from './entities' + +@Global() +@Module({ + imports: [DiscoveryModule, RedisModule, TypeOrmModule.forFeature([CollaborationDocument, CollaborationUpdate])], + providers: [ + CollaborationDocumentProviderRegistry, + CollaborationService, + CollaborationGateway, + CollaborationMaterializationProcessor + ], + exports: [CollaborationDocumentProviderRegistry, CollaborationService] +}) +export class CollaborationModule {} diff --git a/packages/server-ai/src/collaboration/collaboration.service.spec.ts b/packages/server-ai/src/collaboration/collaboration.service.spec.ts new file mode 100644 index 0000000000..a0584e5d22 --- /dev/null +++ b/packages/server-ai/src/collaboration/collaboration.service.spec.ts @@ -0,0 +1,200 @@ +import type { ICollaborationDocumentProvider } from '@xpert-ai/plugin-sdk' +import * as Y from 'yjs' +import { CollaborationDocument, CollaborationUpdate } from './entities' +import { CollaborationService } from './collaboration.service' + +function matches(value: Record, where: Record) { + return Object.entries(where).every(([key, expected]) => value[key] === expected) +} + +function createHarness(redis?: TestRedis) { + const documents: CollaborationDocument[] = [] + const updates: CollaborationUpdate[] = [] + let id = 0 + + const documentRepository = { + manager: { + transaction: async (work: (manager: { getRepository: (entity: unknown) => unknown }) => unknown) => + work({ + getRepository: (entity) => + entity === CollaborationDocument ? documentRepository : updateRepository + }) + }, + create: (value: Partial) => Object.assign(new CollaborationDocument(), value), + save: async (value: CollaborationDocument) => { + if (!value.id) { + value.id = `document-${++id}` + value.createdAt = new Date() + } + value.updatedAt = new Date() + const index = documents.findIndex(({ id: documentId }) => documentId === value.id) + if (index < 0) documents.push(value) + else documents[index] = value + return value + }, + findOne: async ({ where }: { where: Record }) => + documents.find((item) => matches(item as unknown as Record, where)) ?? null, + findOneBy: async (where: Record) => + documents.find((item) => matches(item as unknown as Record, where)) ?? null, + findOneByOrFail: async (where: Record) => { + const value = documents.find((item) => matches(item as unknown as Record, where)) + if (!value) throw new Error('missing document') + return value + }, + update: async (id: string, patch: Partial) => { + const value = documents.find((item) => item.id === id) + if (value) Object.assign(value, patch) + } + } + const updateRepository = { + create: (value: Partial) => Object.assign(new CollaborationUpdate(), value), + save: async (value: CollaborationUpdate) => { + if (!value.id) { + value.id = `update-${++id}` + value.createdAt = new Date() + } + value.updatedAt = new Date() + updates.push(value) + return value + }, + findOne: async ({ where }: { where: Record }) => + updates.find((item) => matches(item as unknown as Record, where)) ?? null, + createQueryBuilder: () => ({ + delete() { + return this + }, + where() { + return this + }, + andWhere() { + return this + }, + execute: async () => ({ affected: 0 }) + }) + } + const provider: ICollaborationDocumentProvider = { + authorize: jest.fn(() => true), + initializeDocument: jest.fn(() => { + const document = new Y.Doc() + return { + stateBase64: Buffer.from(Y.encodeStateAsUpdate(document)).toString('base64'), + schemaVersion: 2, + initialSequence: 4 + } + }), + materializeDocument: jest.fn() + } + const providers = { get: jest.fn(() => provider) } + const service = new CollaborationService( + documentRepository as never, + updateRepository as never, + providers as never, + redis as never, + undefined + ) + const api = service.createScopedApi({ tenantId: 'tenant', organizationId: 'organization', userId: 'user' }) + return { api, documents, updates, provider } +} + +class TestRedis { + readonly strings = new Map() + readonly sets = new Map>() + readonly published: Array<{ channel: string; message: string }> = [] + + async set(key: string, value: string) { + this.strings.set(key, value) + return 'OK' + } + async get(key: string) { + return this.strings.get(key) ?? null + } + async del(key: string) { + return this.strings.delete(key) ? 1 : 0 + } + async sAdd(key: string, value: string) { + const values = this.sets.get(key) ?? new Set() + values.add(value) + this.sets.set(key, values) + return 1 + } + async sRem(key: string, value: string) { + return this.sets.get(key)?.delete(value) ? 1 : 0 + } + async sMembers(key: string) { + return Array.from(this.sets.get(key) ?? []) + } + async expire() { + return true + } + async publish(channel: string, message: string) { + this.published.push({ channel, message }) + return 1 + } +} + +describe('CollaborationService', () => { + it('initializes once, applies updates monotonically, and deduplicates by hash', async () => { + const { api, provider, updates } = createHarness() + const created = await api.ensureDocument({ providerKey: 'example.document', resourceId: 'resource' }) + const repeated = await api.ensureDocument({ providerKey: 'example.document', resourceId: 'resource' }) + expect(repeated.id).toBe(created.id) + expect(created.sequenceNumber).toBe(4) + expect(provider.initializeDocument).toHaveBeenCalledTimes(1) + + const local = new Y.Doc() + local.getMap('content').set('title', 'Collaborative') + const updateBase64 = Buffer.from(Y.encodeStateAsUpdate(local)).toString('base64') + const first = await api.applyUpdate({ documentId: created.id, updateBase64, origin: 'test' }) + const duplicate = await api.applyUpdate({ documentId: created.id, updateBase64, origin: 'retry' }) + + expect(first.sequenceNumber).toBe(5) + expect(first.duplicate).toBe(false) + expect(duplicate.sequenceNumber).toBe(5) + expect(duplicate.duplicate).toBe(true) + expect(updates).toHaveLength(1) + expect(provider.materializeDocument).toHaveBeenCalledTimes(1) + }) + + it('returns a true Yjs state-vector delta and rejects destructive sequence conflicts', async () => { + const { api } = createHarness() + const created = await api.ensureDocument({ providerKey: 'example.document', resourceId: 'resource' }) + const before = await api.getDocumentState({ documentId: created.id }) + + const local = new Y.Doc() + local.getText('body').insert(0, 'hello') + const updateBase64 = Buffer.from(Y.encodeStateAsUpdate(local)).toString('base64') + await api.applyUpdate({ documentId: created.id, updateBase64 }) + + const delta = await api.getDocumentState({ + documentId: created.id, + stateVectorBase64: before.stateVectorBase64 + }) + const reconstructed = new Y.Doc() + Y.applyUpdate(reconstructed, Buffer.from(before.updateBase64, 'base64')) + Y.applyUpdate(reconstructed, Buffer.from(delta.updateBase64, 'base64')) + expect(reconstructed.getText('body').toString()).toBe('hello') + + await expect(api.applyUpdate({ documentId: created.id, updateBase64, expectedSequence: 4 })).rejects.toThrow( + /sequence conflict/i + ) + }) + + it('stores and removes each virtual presence independently', async () => { + const redis = new TestRedis() + const { api } = createHarness(redis) + const created = await api.ensureDocument({ providerKey: 'example.document', resourceId: 'resource' }) + + await api.upsertVirtualPresence({ + documentId: created.id, + actor: { actorKey: 'agent-a', displayName: 'Agent A' }, + presence: { status: 'editing', pageId: 'page-1' } + }) + expect(await api.listPresence({ documentId: created.id })).toEqual([ + expect.objectContaining({ actorType: 'agent', displayName: 'Agent A', status: 'editing' }) + ]) + + await api.removeVirtualPresence({ documentId: created.id, actorKey: 'agent-a' }) + expect(await api.listPresence({ documentId: created.id })).toEqual([]) + expect(redis.published.some(({ message }) => message.includes('presence-remove'))).toBe(true) + }) +}) diff --git a/packages/server-ai/src/collaboration/collaboration.service.ts b/packages/server-ai/src/collaboration/collaboration.service.ts new file mode 100644 index 0000000000..b82f8bfdab --- /dev/null +++ b/packages/server-ai/src/collaboration/collaboration.service.ts @@ -0,0 +1,1007 @@ +import { createHash, randomBytes, randomUUID, timingSafeEqual } from 'node:crypto' +import { EventEmitter } from 'node:events' +import { + BadRequestException, + ForbiddenException, + Inject, + Injectable, + Logger, + NotFoundException, + OnModuleDestroy, + OnModuleInit, + Optional +} from '@nestjs/common' +import { InjectRepository } from '@nestjs/typeorm' +import type { IUser } from '@xpert-ai/contracts' +import { + CollaborationDocumentProviderRegistry, + type ApplyCollaborationUpdateInput, + type ApplyCollaborationUpdateResult, + type CollaborationApi, + type CollaborationDocumentRecord, + type CollaborationDocumentState, + type CollaborationPresencePatch, + type CollaborationProviderContext, + type CollaborationRuntimeActor, + type CollaborationScope, + type CollaborationSessionDescriptor, + type CreateCollaborationSessionInput, + type EnsureCollaborationDocumentInput, + type GetCollaborationDocumentInput, + type GetCollaborationDocumentStateInput, + type ICollaborationActor, + type ICollaborationPresence, + MANAGED_QUEUE_SERVICE_TOKEN, + type ManagedQueueService, + RequestContext, + type UpsertVirtualPresenceInput +} from '@xpert-ai/plugin-sdk' +import { environment } from '@xpert-ai/server-config' +import { REDIS_CLIENT } from '@xpert-ai/server-core' +import type { RedisClientType } from 'redis' +import { Repository } from 'typeorm' +import * as Y from 'yjs' +import { CollaborationDocument, CollaborationUpdate } from './entities' + +const SESSION_TTL_SECONDS = 600 +const PRESENCE_TTL_SECONDS = 30 +const PRESENCE_STALE_MS = 15_000 +const MAX_UPDATE_BYTES = 2 * 1024 * 1024 +const MAX_DOCUMENT_BYTES = 32 * 1024 * 1024 +const MAX_PRESENCE_BYTES = 16 * 1024 +const UPDATE_RETENTION_COUNT = 200 +const UPDATE_RETENTION_MS = 24 * 60 * 60 * 1000 +const COLLABORATION_NAMESPACE = '/api/collaboration' +const BROADCAST_CHANNEL = 'xpert:collaboration:broadcast' +const MATERIALIZATION_PLUGIN = '@xpert-ai/platform' + +/** Identity and scope inherited by a runtime-scoped Collaboration API instance. */ +export type CollaborationRuntimeDefaults = CollaborationScope & { + conversationId?: string | null + agentKey?: string | null + xpertName?: string | null + executionId?: string | null +} + +/** Server-side session record; only the hash of `clientKey` is retained. */ +export type CollaborationSession = CollaborationScope & { + sessionId: string + clientKeyHash: string + documentId: string + providerKey: string + resourceId: string + access: 'read' | 'write' + actor: ICollaborationActor + expiresAt: number +} + +/** Cross-node message envelope shared through Redis and the local gateway event bus. */ +export type CollaborationBroadcast = + | { nodeId: string; type: 'update'; documentId: string; payload: Record } + | { nodeId: string; type: 'presence'; documentId: string; payload: Record } + | { nodeId: string; type: 'presence-remove'; documentId: string; payload: Record } + +/** + * Owns authoritative Yjs state, scoped access, browser sessions, presence, and materialization. + * Plugin providers own resource authorization and projection into business entities. + */ +@Injectable() +export class CollaborationService implements CollaborationApi, OnModuleInit, OnModuleDestroy { + private readonly logger = new Logger(CollaborationService.name) + private readonly nodeId = randomUUID() + private readonly events = new EventEmitter() + private readonly localSessions = new Map() + private subscriber?: RedisClientType + readonly api: CollaborationApi = this.createScopedApi() + + constructor( + @InjectRepository(CollaborationDocument) private readonly documentRepository: Repository, + @InjectRepository(CollaborationUpdate) private readonly updateRepository: Repository, + private readonly providers: CollaborationDocumentProviderRegistry, + @Optional() @Inject(REDIS_CLIENT) private readonly redis?: RedisClientType, + @Optional() @Inject(MANAGED_QUEUE_SERVICE_TOKEN) private readonly queue?: ManagedQueueService + ) {} + + /** Start a dedicated subscriber so broadcasts reach gateways on every API node. */ + async onModuleInit() { + if (!this.redis) return + try { + this.subscriber = this.redis.duplicate() + await this.subscriber.connect() + await this.subscriber.subscribe(BROADCAST_CHANNEL, (message) => this.receiveBroadcast(message)) + } catch (error) { + this.logger.warn(`Collaboration Redis subscriber is unavailable: ${errorMessage(error)}`) + } + } + + async onModuleDestroy() { + try { + if (this.subscriber?.isOpen) { + await this.subscriber.unsubscribe(BROADCAST_CHANNEL) + await this.subscriber.quit() + } + } catch (error) { + this.logger.warn(`Failed to close collaboration subscriber: ${errorMessage(error)}`) + } + } + + /** Bind runtime identity and scope once so plugins cannot provide arbitrary tenant context per call. */ + createScopedApi(defaults: CollaborationRuntimeDefaults = {}): CollaborationApi { + return { + ensureDocument: (input) => this.ensureDocumentWithScope(input, this.resolveScope(defaults)), + getDocument: (input) => this.getDocumentWithScope(input, this.resolveScope(defaults)), + getDocumentState: (input) => this.getDocumentStateWithScope(input, this.resolveScope(defaults)), + applyUpdate: (input) => this.applyUpdateWithScope(input, this.resolveScope(defaults)), + createSession: (input) => this.createSessionWithScope(input, this.resolveScope(defaults)), + listPresence: (input) => this.listPresenceWithScope(input.documentId, this.resolveScope(defaults)), + upsertVirtualPresence: (input) => + this.upsertVirtualPresenceWithScope(input, this.resolveScope(defaults), defaults), + removeVirtualPresence: (input) => + this.removeVirtualPresenceWithScope( + input.documentId, + input.presenceId, + input.actorKey, + this.resolveScope(defaults), + defaults + ), + archiveDocument: (input) => this.setDocumentStatus(input, 'archived', this.resolveScope(defaults)), + deleteDocument: (input) => this.deleteDocumentWithScope(input, this.resolveScope(defaults)) + } + } + + ensureDocument(input: EnsureCollaborationDocumentInput) { + return this.api.ensureDocument(input) + } + getDocument(input: GetCollaborationDocumentInput) { + return this.api.getDocument(input) + } + getDocumentState(input: GetCollaborationDocumentStateInput) { + return this.api.getDocumentState(input) + } + applyUpdate(input: ApplyCollaborationUpdateInput) { + return this.api.applyUpdate(input) + } + createSession(input: CreateCollaborationSessionInput) { + return this.api.createSession(input) + } + listPresence(input: { documentId: string }) { + return this.api.listPresence(input) + } + upsertVirtualPresence(input: UpsertVirtualPresenceInput) { + return this.api.upsertVirtualPresence(input) + } + removeVirtualPresence(input: { documentId: string; presenceId?: string | null; actorKey?: string | null }) { + return this.api.removeVirtualPresence(input) + } + archiveDocument(input: GetCollaborationDocumentInput) { + return this.api.archiveDocument(input) + } + deleteDocument(input: GetCollaborationDocumentInput) { + return this.api.deleteDocument(input) + } + + /** Subscribe the WebSocket gateway to both local and Redis-originated broadcasts. */ + onBroadcast(listener: (event: CollaborationBroadcast) => void) { + this.events.on('broadcast', listener) + return () => this.events.off('broadcast', listener) + } + + /** Resolve and timing-safely validate a single-document browser session. */ + async resolveSession( + sessionId?: string, + clientKey?: string, + documentId?: string + ): Promise { + if (!sessionId || !clientKey || !documentId) return null + this.cleanupSessions() + let session = this.localSessions.get(sessionId) + if (!session && this.redis) { + const raw = await this.redis.get(sessionKey(sessionId)).catch(() => null) + session = raw ? (parseSession(raw) ?? undefined) : undefined + if (session) this.localSessions.set(sessionId, session) + } + if (!session || session.documentId !== documentId || session.expiresAt < Date.now()) return null + if (!timingSafeHashEqual(session.clientKeyHash, hashSecret(clientKey))) return null + return session + } + + async getStateForSession(session: CollaborationSession, stateVectorBase64?: string | null) { + return this.getDocumentStateWithScope({ documentId: session.documentId, stateVectorBase64 }, session) + } + + async applyUpdateForSession(session: CollaborationSession, input: ApplyCollaborationUpdateInput) { + if (session.access !== 'write') throw new ForbiddenException('Collaboration session is read-only.') + return this.applyUpdateWithScope( + { ...input, documentId: session.documentId, actor: actorToRuntime(session.actor) }, + session, + session.actor + ) + } + + async listPresenceForSession(session: CollaborationSession) { + return this.listPresenceWithScope(session.documentId, session) + } + + async upsertPresenceForSession(session: CollaborationSession, clientId: string, patch: CollaborationPresencePatch) { + return this.persistPresence(session.documentId, clientId, session.actor, patch, session) + } + + async removePresenceForSession(session: CollaborationSession, clientId: string) { + return this.removePresence(session.documentId, clientId, session) + } + + /** Retry projection of the latest authoritative state; stale intermediate jobs are harmless. */ + async retryMaterialization(documentId: string) { + const document = await this.documentRepository.findOne({ where: { id: documentId } }) + if (!document || document.status === 'deleted') return + await this.materialize(document, null, 'platform:materialization-retry') + } + + /** Lazily initialize exactly one collaboration document per scope/provider/resource tuple. */ + private async ensureDocumentWithScope(input: EnsureCollaborationDocumentInput, scope: CollaborationScope) { + const providerKey = requiredText(input.providerKey, 'providerKey', 160) + const resourceId = requiredText(input.resourceId, 'resourceId', 256) + const scopeKey = collaborationScopeKey(scope) + const existing = await this.documentRepository.findOne({ where: { scopeKey, providerKey, resourceId } }) + if (existing) { + await this.authorize(existing, scope, 'read') + return toRecord(existing) + } + const provider = this.providers.get(providerKey, scope.organizationId ?? undefined) + const context = providerContext(providerKey, resourceId, scope, 'initialize') + if (!(await provider.authorize(context))) + throw new ForbiddenException('Collaboration document access was denied.') + const initialized = await provider.initializeDocument(context) + const state = parseBase64(initialized.stateBase64, 'initial collaboration state', MAX_DOCUMENT_BYTES) + const ydoc = new Y.Doc() + if (state.byteLength) Y.applyUpdate(ydoc, state) + const encoded = encodeDocument(ydoc) + const sequenceNumber = positiveInteger(initialized.initialSequence, 0) + const document = await this.documentRepository.save( + this.documentRepository.create({ + ...scopeColumns(scope), + scopeKey, + providerKey, + resourceId, + engine: 'yjs', + schemaVersion: positiveInteger(input.schemaVersion ?? initialized.schemaVersion, 1), + status: 'active', + stateBase64: encoded.stateBase64, + stateVectorBase64: encoded.stateVectorBase64, + sequenceNumber, + updateCount: 0, + materializedSequence: sequenceNumber, + materializationStatus: 'ready', + metadata: input.metadata ?? initialized.metadata ?? null + }) + ) + return toRecord(document) + } + + /** Return metadata and asynchronously repair a lagging plugin projection. */ + private async getDocumentWithScope(input: GetCollaborationDocumentInput, scope: CollaborationScope) { + const document = await this.requireDocument(input, scope) + await this.authorize(document, scope, 'read') + if (document.materializedSequence < document.sequenceNumber) + void this.materialize(document, null, 'platform:read-repair') + return toRecord(document) + } + + /** Encode full state or the minimal Yjs delta relative to the caller's state vector. */ + private async getDocumentStateWithScope( + input: GetCollaborationDocumentStateInput, + scope: CollaborationScope + ): Promise { + const document = await this.requireDocument(input, scope) + await this.authorize(document, scope, 'read') + const doc = decodeDocument(document.stateBase64) + const vector = input.stateVectorBase64 + ? parseBase64(input.stateVectorBase64, 'Yjs state vector', MAX_UPDATE_BYTES) + : undefined + return { + document: toRecord(document), + updateBase64: Buffer.from(Y.encodeStateAsUpdate(doc, vector)).toString('base64'), + stateVectorBase64: document.stateVectorBase64, + sequenceNumber: document.sequenceNumber + } + } + + /** + * Accept one update under a pessimistic document lock, deduplicate it by byte hash, + * advance the sequence, then broadcast and materialize after the transaction commits. + */ + private async applyUpdateWithScope( + input: ApplyCollaborationUpdateInput, + scope: CollaborationScope, + actorOverride?: ICollaborationActor + ): Promise { + const update = parseBase64(input.updateBase64, 'Yjs update', MAX_UPDATE_BYTES) + if (!update.byteLength) throw new BadRequestException('Collaboration update is empty.') + const actor = actorOverride ?? createRuntimeActor(scope, input.actor) + const persisted = await this.documentRepository.manager.transaction(async (manager) => { + const documents = manager.getRepository(CollaborationDocument) + const updates = manager.getRepository(CollaborationUpdate) + const document = await documents.findOne({ + where: scopedDocumentIdWhere(input.documentId, scope), + lock: { mode: 'pessimistic_write' } + }) + if (!document || document.status !== 'active') + throw new NotFoundException('Collaboration document was not found.') + await this.authorize(document, scope, 'write', actor) + if ( + input.expectedSequence !== undefined && + input.expectedSequence !== null && + document.sequenceNumber !== input.expectedSequence + ) { + throw new BadRequestException( + `Collaboration sequence conflict. Current sequence is ${document.sequenceNumber}.` + ) + } + const updateHash = createHash('sha256').update(update).digest('hex') + const duplicate = await updates.findOne({ where: { documentId: document.id, updateHash } }) + if (duplicate) return { document, duplicate, saved: duplicate } + const doc = decodeDocument(document.stateBase64) + Y.applyUpdate(doc, update, input.origin ?? 'platform.collaboration') + const encoded = encodeDocument(doc) + if (Buffer.byteLength(encoded.stateBase64, 'base64') > MAX_DOCUMENT_BYTES) + throw new BadRequestException('Collaboration document exceeds the platform size limit.') + document.sequenceNumber += 1 + document.updateCount += 1 + document.stateBase64 = encoded.stateBase64 + document.stateVectorBase64 = encoded.stateVectorBase64 + document.materializationStatus = 'pending' + const saved = await updates.save( + updates.create({ + ...scopeColumns(scope), + documentId: document.id, + sequenceNumber: document.sequenceNumber, + updateBase64: input.updateBase64, + updateHash, + origin: optionalText(input.origin, 256), + actorType: actor.actorType, + presenceId: actor.presenceId, + userId: scope.userId ?? null, + createdById: scope.userId ?? undefined + }) + ) + await documents.save(document) + return { document, duplicate: null, saved } + }) + if (!persisted.duplicate) { + await this.publishBroadcast({ + nodeId: this.nodeId, + type: 'update', + documentId: persisted.document.id, + payload: { + documentId: persisted.document.id, + updateBase64: input.updateBase64, + sequenceNumber: persisted.document.sequenceNumber, + origin: optionalText(input.origin, 256), + presenceId: actor.presenceId + } + }) + await this.materialize(persisted.document, input.updateBase64, input.origin ?? null) + void this.pruneUpdates(persisted.document.id, persisted.document.sequenceNumber) + } + const latest = await this.documentRepository.findOneByOrFail({ id: persisted.document.id }) + return { + documentId: latest.id, + duplicate: Boolean(persisted.duplicate), + updateId: persisted.saved.id, + sequenceNumber: latest.sequenceNumber, + stateVectorBase64: latest.stateVectorBase64, + materializationStatus: latest.materializationStatus + } + } + + /** Issue random browser credentials without exposing platform authentication or scope ids. */ + private async createSessionWithScope( + input: CreateCollaborationSessionInput, + scope: CollaborationScope + ): Promise { + const document = await this.requireDocument({ documentId: input.documentId }, scope) + const access = input.access === 'read' ? 'read' : 'write' + await this.authorize(document, scope, access) + const sessionId = randomBytes(32).toString('base64url') + const clientKey = randomBytes(24).toString('base64url') + const actor = createUserActor(RequestContext.currentUser() ?? null, scope.userId, sessionId) + const expiresAt = Date.now() + SESSION_TTL_SECONDS * 1000 + const session: CollaborationSession = { + ...scope, + sessionId, + clientKeyHash: hashSecret(clientKey), + documentId: document.id, + providerKey: document.providerKey, + resourceId: document.resourceId, + access, + actor, + expiresAt + } + this.localSessions.set(sessionId, session) + if (this.redis) + await this.redis + .set(sessionKey(sessionId), JSON.stringify(session), { EX: SESSION_TTL_SECONDS }) + .catch(() => undefined) + return { + sessionId, + clientKey, + documentId: document.id, + namespace: COLLABORATION_NAMESPACE, + connectionUrl: collaborationConnectionUrl(), + access, + actor, + expiresAt + } + } + + private async listPresenceWithScope(documentId: string, scope: CollaborationScope) { + const document = await this.requireDocument({ documentId }, scope) + await this.authorize(document, scope, 'read') + return this.readPresence(document.id) + } + + /** Publish Agent/system activity through the same ephemeral presence channel as users. */ + private async upsertVirtualPresenceWithScope( + input: UpsertVirtualPresenceInput, + scope: CollaborationScope, + defaults: CollaborationRuntimeDefaults + ) { + const document = await this.requireDocument({ documentId: input.documentId }, scope) + await this.authorize(document, scope, 'write') + const actor = createVirtualActor(document.id, scope, defaults, input.actor) + return this.persistPresence(document.id, actor.presenceId, actor, input.presence, scope) + } + + private async removeVirtualPresenceWithScope( + documentId: string, + presenceId: string | null | undefined, + actorKey: string | null | undefined, + scope: CollaborationScope, + defaults: CollaborationRuntimeDefaults + ) { + const document = await this.requireDocument({ documentId }, scope) + await this.authorize(document, scope, 'write') + const resolved = presenceId ?? createVirtualActor(document.id, scope, defaults, { actorKey }).presenceId + await this.removePresence(document.id, resolved, scope) + } + + private async setDocumentStatus( + input: GetCollaborationDocumentInput, + status: 'archived', + scope: CollaborationScope + ) { + const document = await this.requireDocument(input, scope) + await this.authorize(document, scope, 'manage') + document.status = status + return toRecord(await this.documentRepository.save(document)) + } + + private async deleteDocumentWithScope(input: GetCollaborationDocumentInput, scope: CollaborationScope) { + const document = await this.requireDocument(input, scope) + await this.authorize(document, scope, 'manage') + document.status = 'deleted' + const saved = await this.documentRepository.save(document) + const provider = this.providers.get(document.providerKey, scope.organizationId ?? undefined) + await provider.onDocumentDeleted?.( + providerContext(document.providerKey, document.resourceId, scope, 'delete', document.id) + ) + return toRecord(saved) + } + + private async authorize( + document: CollaborationDocument, + scope: CollaborationScope, + operation: 'read' | 'write' | 'manage', + actor?: ICollaborationActor + ) { + const provider = this.providers.get(document.providerKey, scope.organizationId ?? undefined) + const allowed = await provider.authorize( + providerContext(document.providerKey, document.resourceId, scope, operation, document.id, actor) + ) + if (!allowed) throw new ForbiddenException('Collaboration document access was denied.') + } + + /** + * Project authoritative state into the plugin model. A projection failure never rejects an + * already committed CRDT update; it marks the document failed and schedules retry instead. + */ + private async materialize(document: CollaborationDocument, updateBase64: string | null, origin: string | null) { + try { + const provider = this.providers.get(document.providerKey, document.organizationId ?? undefined) + const scope = documentScope(document) + await provider.materializeDocument({ + ...providerContext(document.providerKey, document.resourceId, scope, 'materialize', document.id), + documentId: document.id, + stateBase64: document.stateBase64, + stateVectorBase64: document.stateVectorBase64, + sequenceNumber: document.sequenceNumber, + updateBase64, + origin + }) + await this.documentRepository.update(document.id, { + materializedSequence: document.sequenceNumber, + materializationStatus: 'ready', + lastMaterializationError: null + }) + document.materializedSequence = document.sequenceNumber + document.materializationStatus = 'ready' + document.lastMaterializationError = null + } catch (error) { + const message = errorMessage(error).slice(0, 2_000) + document.materializationStatus = 'failed' + document.lastMaterializationError = message + await this.documentRepository.update(document.id, { + materializationStatus: 'failed', + lastMaterializationError: message + }) + this.logger.warn(`Collaboration materialization failed for ${document.id}: ${message}`) + await this.enqueueMaterialization(document).catch((queueError) => + this.logger.warn(`Failed to enqueue collaboration materialization: ${errorMessage(queueError)}`) + ) + } + } + + private async enqueueMaterialization(document: CollaborationDocument) { + if (!this.queue) return + await this.queue.enqueue({ + pluginName: MATERIALIZATION_PLUGIN, + queueName: 'collaboration', + jobName: 'materialize', + payload: { documentId: document.id }, + tenantId: document.tenantId, + organizationId: document.organizationId, + scopeKey: document.organizationId ?? null, + jobId: `collaboration-materialize-${document.id}-${document.sequenceNumber}`, + attempts: 3, + backoffMs: { type: 'exponential', delay: 1_000 }, + removeOnComplete: true + }) + } + + /** Sanitize, TTL-store, and broadcast bounded presence without writing document history. */ + private async persistPresence( + documentId: string, + clientId: string, + actor: ICollaborationActor, + patch: CollaborationPresencePatch, + scope: CollaborationScope + ) { + const presence = sanitizePresence({ clientId, ...actor, ...patch, updatedAt: Date.now() }) + if (Buffer.byteLength(JSON.stringify(presence)) > MAX_PRESENCE_BYTES) + throw new BadRequestException('Collaboration presence exceeds the platform size limit.') + if (this.redis) { + const indexKey = presenceIndexKey(scope, documentId) + await Promise.all([ + this.redis.set(presenceEntryKey(scope, documentId, clientId), JSON.stringify(presence), { + EX: PRESENCE_TTL_SECONDS + }), + this.redis.sAdd(indexKey, clientId) + ]) + await this.redis.expire(indexKey, PRESENCE_TTL_SECONDS * 2) + } + await this.publishBroadcast({ nodeId: this.nodeId, type: 'presence', documentId, payload: { ...presence } }) + return presence + } + + private async removePresence(documentId: string, clientId: string, scope: CollaborationScope) { + if (this.redis) + await Promise.all([ + this.redis.del(presenceEntryKey(scope, documentId, clientId)), + this.redis.sRem(presenceIndexKey(scope, documentId), clientId) + ]).catch(() => undefined) + await this.publishBroadcast({ nodeId: this.nodeId, type: 'presence-remove', documentId, payload: { clientId } }) + } + + /** Return active presence and opportunistically remove entries whose heartbeat is stale. */ + private async readPresence(documentId: string): Promise { + if (!this.redis) return [] + const document = await this.documentRepository.findOneBy({ id: documentId }) + if (!document) return [] + const scope = documentScope(document) + const indexKey = presenceIndexKey(scope, documentId) + const clientIds = await this.redis.sMembers(indexKey).catch(() => []) + const values = await Promise.all( + clientIds.map(async (clientId) => ({ + clientId, + raw: await this.redis?.get(presenceEntryKey(scope, documentId, clientId)).catch(() => null) + })) + ) + const cutoff = Date.now() - PRESENCE_STALE_MS + const active: ICollaborationPresence[] = [] + const stale: string[] = [] + for (const { clientId, raw } of values) { + const parsed = raw ? parsePresence(raw) : null + if (!parsed || parsed.updatedAt < cutoff) stale.push(clientId) + else active.push(parsed) + } + if (stale.length) { + await Promise.all( + stale.flatMap((clientId) => [ + this.redis!.del(presenceEntryKey(scope, documentId, clientId)), + this.redis!.sRem(indexKey, clientId) + ]) + ).catch(() => undefined) + for (const clientId of stale) + await this.publishBroadcast({ + nodeId: this.nodeId, + type: 'presence-remove', + documentId, + payload: { clientId } + }) + } + return active + } + + /** Emit locally first for low latency, then publish for other API nodes. */ + private async publishBroadcast(event: CollaborationBroadcast) { + this.events.emit('broadcast', event) + if (this.redis) await this.redis.publish(BROADCAST_CHANNEL, JSON.stringify(event)).catch(() => undefined) + } + + private receiveBroadcast(message: string) { + try { + const event = JSON.parse(message) as CollaborationBroadcast + if (!event || event.nodeId === this.nodeId || !event.documentId) return + this.events.emit('broadcast', event) + } catch (error) { + this.logger.warn(`Invalid collaboration broadcast: ${errorMessage(error)}`) + } + } + + /** Resolve a document only inside the hash-derived tenant/organization boundary. */ + private async requireDocument(input: GetCollaborationDocumentInput, scope: CollaborationScope) { + const where = + 'documentId' in input && input.documentId + ? scopedDocumentIdWhere(input.documentId, scope) + : { + scopeKey: collaborationScopeKey(scope), + providerKey: input.providerKey, + resourceId: input.resourceId + } + const document = await this.documentRepository.findOne({ where }) + if (!document || document.status === 'deleted') + throw new NotFoundException('Collaboration document was not found.') + return document + } + + private resolveScope(defaults: CollaborationRuntimeDefaults): CollaborationScope { + return { + tenantId: defaults.tenantId ?? RequestContext.currentTenantId() ?? null, + organizationId: defaults.organizationId ?? RequestContext.getOrganizationId() ?? null, + workspaceId: defaults.workspaceId ?? null, + projectId: defaults.projectId ?? null, + xpertId: defaults.xpertId ?? null, + userId: defaults.userId ?? RequestContext.currentUserId() ?? null + } + } + + private cleanupSessions() { + const now = Date.now() + for (const [key, value] of this.localSessions.entries()) + if (value.expiresAt < now) this.localSessions.delete(key) + } + + private async pruneUpdates(documentId: string, sequenceNumber: number) { + const sequenceCutoff = Math.max(0, sequenceNumber - UPDATE_RETENTION_COUNT) + const dateCutoff = new Date(Date.now() - UPDATE_RETENTION_MS) + await this.updateRepository + .createQueryBuilder() + .delete() + .where('documentId = :documentId', { documentId }) + .andWhere('sequenceNumber <= :sequenceCutoff', { sequenceCutoff }) + .andWhere('createdAt < :dateCutoff', { dateCutoff }) + .execute() + } +} + +function toRecord(document: CollaborationDocument): CollaborationDocumentRecord { + return { + id: document.id, + providerKey: document.providerKey, + resourceId: document.resourceId, + engine: document.engine, + schemaVersion: document.schemaVersion, + status: document.status, + sequenceNumber: document.sequenceNumber, + updateCount: document.updateCount, + materializedSequence: document.materializedSequence, + materializationStatus: document.materializationStatus, + lastMaterializationError: document.lastMaterializationError ?? null, + tenantId: document.tenantId ?? null, + organizationId: document.organizationId ?? null, + workspaceId: document.workspaceId ?? null, + projectId: document.projectId ?? null, + xpertId: document.xpertId ?? null, + userId: document.userId ?? null, + metadata: document.metadata ?? null, + createdAt: document.createdAt, + updatedAt: document.updatedAt + } +} + +function encodeDocument(doc: Y.Doc) { + return { + stateBase64: Buffer.from(Y.encodeStateAsUpdate(doc)).toString('base64'), + stateVectorBase64: Buffer.from(Y.encodeStateVector(doc)).toString('base64') + } +} + +function decodeDocument(stateBase64: string) { + const doc = new Y.Doc() + if (stateBase64) Y.applyUpdate(doc, Buffer.from(stateBase64, 'base64')) + return doc +} + +function parseBase64(value: string, label: string, maxBytes: number) { + if (!value || !/^[A-Za-z0-9+/=_-]+$/.test(value)) throw new BadRequestException(`${label} is invalid.`) + const buffer = Buffer.from(value, 'base64') + if (buffer.byteLength > maxBytes) throw new BadRequestException(`${label} exceeds the platform size limit.`) + return buffer +} + +/** Stable storage boundary that deliberately excludes public business identifiers. */ +function collaborationScopeKey(scope: CollaborationScope) { + return createHash('sha256') + .update([scope.tenantId ?? '-', scope.organizationId ?? '-'].join(':')) + .digest('hex') +} + +function scopeColumns(scope: CollaborationScope) { + return { + tenantId: scope.tenantId ?? null, + organizationId: scope.organizationId ?? null, + workspaceId: scope.workspaceId ?? null, + projectId: scope.projectId ?? null, + xpertId: scope.xpertId ?? null, + userId: scope.userId ?? null + } +} + +function documentScope(document: CollaborationDocument): CollaborationScope { + return scopeColumns(document) +} + +function scopedDocumentIdWhere(id: string, scope: CollaborationScope) { + return { id, scopeKey: collaborationScopeKey(scope) } +} + +function providerContext( + providerKey: string, + resourceId: string, + scope: CollaborationScope, + operation: CollaborationProviderContext['operation'], + documentId?: string, + actor?: ICollaborationActor +): CollaborationProviderContext { + return { ...scope, providerKey, resourceId, operation, documentId: documentId ?? null, actor: actor ?? null } +} + +function createUserActor( + user: IUser | null, + userId: string | null | undefined, + sessionId: string +): ICollaborationActor { + const displayName = userDisplayName(user) ?? 'Collaborator' + const identity = userId ?? sessionId + return { + presenceId: `user_${createHash('sha256').update(identity).digest('base64url').slice(0, 22)}`, + actorType: 'user', + displayName: displayName.slice(0, 64), + color: actorColor(identity), + avatarUrl: optionalText(user?.imageUrl, 2_048) ?? null + } +} + +function createRuntimeActor(scope: CollaborationScope, input?: CollaborationRuntimeActor | null): ICollaborationActor { + const actorType = input?.actorType === 'system' ? 'system' : input?.actorType === 'user' ? 'user' : 'agent' + const key = input?.actorKey ?? scope.xpertId ?? scope.userId ?? randomUUID() + return { + presenceId: `${actorType}_${createHash('sha256').update(key).digest('base64url').slice(0, 22)}`, + actorType, + displayName: + optionalText(input?.displayName, 64) ?? + (actorType === 'agent' ? 'Xpert Agent' : actorType === 'system' ? 'System' : 'Collaborator'), + color: actorColor(key), + avatarUrl: optionalText(input?.avatarUrl, 2_048) ?? null + } +} + +/** Build a stable Agent identity per xpert/agent/conversation/document combination. */ +function createVirtualActor( + documentId: string, + scope: CollaborationScope, + defaults: CollaborationRuntimeDefaults, + input?: CollaborationRuntimeActor | null +) { + const actorType = input?.actorType === 'system' ? 'system' : 'agent' + const actorKey = + input?.actorKey ?? + [ + defaults.xpertId ?? scope.xpertId ?? '-', + defaults.agentKey ?? '-', + defaults.conversationId ?? '-', + documentId + ].join(':') + return createRuntimeActor(scope, { + actorType, + actorKey, + displayName: + input?.displayName ?? + defaults.xpertName ?? + defaults.agentKey ?? + (actorType === 'agent' ? 'Xpert Agent' : 'System'), + avatarUrl: input?.avatarUrl + }) +} + +function actorToRuntime(actor: ICollaborationActor): CollaborationRuntimeActor { + return { + actorType: actor.actorType, + actorKey: actor.presenceId, + displayName: actor.displayName, + avatarUrl: actor.avatarUrl + } +} + +function userDisplayName(user: IUser | null) { + if (!user) return null + const candidate = [user.firstName, user.lastName].filter(Boolean).join(' ').trim() + return optionalText(candidate || user.name || user.email, 64) ?? null +} + +function actorColor(identity: string) { + const hue = (createHash('sha256').update(identity).digest()[0] / 255) * 330 + return hslToHex(hue, 72, 46) +} + +function hslToHex(h: number, s: number, l: number) { + const saturation = s / 100 + const lightness = l / 100 + const chroma = (1 - Math.abs(2 * lightness - 1)) * saturation + const x = chroma * (1 - Math.abs(((h / 60) % 2) - 1)) + const m = lightness - chroma / 2 + const [r, g, b] = + h < 60 + ? [chroma, x, 0] + : h < 120 + ? [x, chroma, 0] + : h < 180 + ? [0, chroma, x] + : h < 240 + ? [0, x, chroma] + : h < 300 + ? [x, 0, chroma] + : [chroma, 0, x] + return `#${[r, g, b] + .map((value) => + Math.round((value + m) * 255) + .toString(16) + .padStart(2, '0') + ) + .join('')}` +} + +/** Enforce presence size semantics before data reaches Redis or another client. */ +function sanitizePresence(value: ICollaborationPresence): ICollaborationPresence { + return { + clientId: requiredText(value.clientId, 'clientId', 128), + presenceId: requiredText(value.presenceId, 'presenceId', 128), + actorType: value.actorType, + displayName: requiredText(value.displayName, 'displayName', 64), + color: requiredText(value.color, 'color', 32), + avatarUrl: optionalText(value.avatarUrl, 2_048) ?? null, + pageId: optionalText(value.pageId, 256) ?? null, + pointer: + value.pointer && Number.isFinite(value.pointer.x) && Number.isFinite(value.pointer.y) + ? { + pageId: optionalText(value.pointer.pageId, 256) ?? null, + x: clamp(value.pointer.x, 0, 1), + y: clamp(value.pointer.y, 0, 1), + visible: value.pointer.visible !== false + } + : null, + focus: value.focus + ? { + kind: requiredText(value.focus.kind, 'focus kind', 64), + key: optionalText(value.focus.key, 512) ?? null, + pageId: optionalText(value.focus.pageId, 256) ?? null, + elementId: optionalText(value.focus.elementId, 256) ?? null, + fieldKey: optionalText(value.focus.fieldKey, 512) ?? null + } + : null, + selection: sanitizeSelection(value.selection), + viewport: + value.viewport && + Number.isFinite(value.viewport.zoom) && + Number.isFinite(value.viewport.width) && + Number.isFinite(value.viewport.height) + ? { + zoom: clamp(value.viewport.zoom, 0.05, 16), + width: clamp(value.viewport.width, 1, 100_000), + height: clamp(value.viewport.height, 1, 100_000) + } + : null, + mode: optionalText(value.mode, 64) ?? null, + status: value.status ?? null, + toolName: optionalText(value.toolName, 160) ?? null, + operationLabel: optionalText(value.operationLabel, 256) ?? null, + updatedAt: Date.now() + } +} + +function sanitizeSelection(selection: ICollaborationPresence['selection']) { + if (!selection) return null + return { + kind: selection.kind, + fieldKey: optionalText(selection.fieldKey, 512) ?? null, + elementIds: selection.elementIds?.slice(0, 128).map((item) => requiredText(item, 'elementId', 256)) ?? null, + anchorRelativeBase64: optionalBase64(selection.anchorRelativeBase64, 8_192), + headRelativeBase64: optionalBase64(selection.headRelativeBase64, 8_192) + } +} + +function parsePresence(raw: string): ICollaborationPresence | null { + try { + const value = JSON.parse(raw) as ICollaborationPresence + return value && typeof value.updatedAt === 'number' ? value : null + } catch { + return null + } +} + +function sessionKey(sessionId: string) { + return `xpert:collaboration:session:${hashSecret(sessionId)}` +} +function presenceKey(scope: CollaborationScope, documentId: string) { + return `xpert:collaboration:presence:${collaborationScopeKey(scope)}:${documentId}` +} +function presenceIndexKey(scope: CollaborationScope, documentId: string) { + return `${presenceKey(scope, documentId)}:index` +} +function presenceEntryKey(scope: CollaborationScope, documentId: string, clientId: string) { + return `${presenceKey(scope, documentId)}:entry:${createHash('sha256').update(clientId).digest('base64url')}` +} +function hashSecret(value: string) { + return createHash('sha256').update(value).digest('hex') +} +function timingSafeHashEqual(left: string, right: string) { + const leftBuffer = Buffer.from(left) + const rightBuffer = Buffer.from(right) + return leftBuffer.length === rightBuffer.length && timingSafeEqual(leftBuffer, rightBuffer) +} + +function parseSession(raw: string): CollaborationSession | null { + try { + const value = JSON.parse(raw) as CollaborationSession + return value?.sessionId && value?.documentId && value?.clientKeyHash ? value : null + } catch { + return null + } +} + +function collaborationConnectionUrl() { + const base = optionalText(environment.baseUrl, 2_048) ?? 'http://localhost:3000' + return new URL(COLLABORATION_NAMESPACE, base.endsWith('/') ? base : `${base}/`).toString().replace(/\/$/, '') +} + +function requiredText(value: unknown, label: string, max: number) { + const text = optionalText(value, max) + if (!text) throw new BadRequestException(`${label} is required.`) + return text +} +function optionalText(value: unknown, max: number) { + return typeof value === 'string' && value.trim() && value.trim().length <= max ? value.trim() : undefined +} +function optionalBase64(value: unknown, max: number) { + const text = optionalText(value, max) + return text && /^[A-Za-z0-9+/=_-]+$/.test(text) ? text : null +} +function positiveInteger(value: unknown, fallback: number) { + return typeof value === 'number' && Number.isFinite(value) && value >= 0 ? Math.trunc(value) : fallback +} +function clamp(value: number, min: number, max: number) { + return Math.min(max, Math.max(min, value)) +} +function errorMessage(error: unknown) { + return error instanceof Error ? error.message : String(error) +} diff --git a/packages/server-ai/src/collaboration/entities/collaboration-document.entity.ts b/packages/server-ai/src/collaboration/entities/collaboration-document.entity.ts new file mode 100644 index 0000000000..79efd87956 --- /dev/null +++ b/packages/server-ai/src/collaboration/entities/collaboration-document.entity.ts @@ -0,0 +1,68 @@ +import type { + CollaborationDocumentStatus, + CollaborationEngine, + CollaborationMaterializationStatus, + ICollaborationDocument +} from '@xpert-ai/contracts' +import { TenantOrganizationBaseEntity } from '@xpert-ai/server-core' +import { Column, Entity, Index } from 'typeorm' + +@Entity('collaboration_document') +@Index(['scopeKey', 'providerKey', 'resourceId'], { unique: true }) +@Index(['tenantId', 'organizationId', 'providerKey', 'status']) +/** Persistent authoritative CRDT snapshot and its plugin materialization checkpoint. */ +export class CollaborationDocument extends TenantOrganizationBaseEntity implements ICollaborationDocument { + @Column({ type: 'varchar' }) + scopeKey: string + + @Column({ type: 'varchar' }) + providerKey: string + + @Column({ type: 'varchar' }) + resourceId: string + + @Column({ type: 'varchar', default: 'yjs' }) + engine: CollaborationEngine + + @Column({ type: 'int', default: 1 }) + schemaVersion: number + + @Column({ type: 'varchar', default: 'active' }) + status: CollaborationDocumentStatus + + @Column({ type: 'text' }) + stateBase64: string + + @Column({ type: 'text' }) + stateVectorBase64: string + + @Column({ type: 'int', default: 0 }) + sequenceNumber: number + + @Column({ type: 'int', default: 0 }) + updateCount: number + + @Column({ type: 'int', default: 0 }) + materializedSequence: number + + @Column({ type: 'varchar', default: 'ready' }) + materializationStatus: CollaborationMaterializationStatus + + @Column({ type: 'text', nullable: true }) + lastMaterializationError?: string | null + + @Column({ type: 'varchar', nullable: true }) + workspaceId?: string | null + + @Column({ type: 'varchar', nullable: true }) + projectId?: string | null + + @Column({ type: 'varchar', nullable: true }) + xpertId?: string | null + + @Column({ type: 'varchar', nullable: true }) + userId?: string | null + + @Column({ type: 'json', nullable: true }) + metadata?: Record | null +} diff --git a/packages/server-ai/src/collaboration/entities/collaboration-update.entity.ts b/packages/server-ai/src/collaboration/entities/collaboration-update.entity.ts new file mode 100644 index 0000000000..be9918719e --- /dev/null +++ b/packages/server-ai/src/collaboration/entities/collaboration-update.entity.ts @@ -0,0 +1,39 @@ +import type { CollaborationActorType, ICollaborationUpdate } from '@xpert-ai/contracts' +import { TenantOrganizationBaseEntity } from '@xpert-ai/server-core' +import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm' +import { CollaborationDocument } from './collaboration-document.entity' + +@Entity('collaboration_update') +@Index(['documentId', 'sequenceNumber'], { unique: true }) +@Index(['documentId', 'updateHash'], { unique: true }) +@Index(['tenantId', 'organizationId', 'documentId']) +/** Bounded immutable journal used for update idempotency and operational diagnostics. */ +export class CollaborationUpdate extends TenantOrganizationBaseEntity implements ICollaborationUpdate { + @ManyToOne(() => CollaborationDocument, { nullable: false, onDelete: 'CASCADE' }) + @JoinColumn({ name: 'documentId' }) + document?: CollaborationDocument + + @Column({ type: 'uuid' }) + documentId: string + + @Column({ type: 'int' }) + sequenceNumber: number + + @Column({ type: 'text' }) + updateBase64: string + + @Column({ type: 'varchar' }) + updateHash: string + + @Column({ type: 'varchar', nullable: true }) + origin?: string | null + + @Column({ type: 'varchar', nullable: true }) + actorType?: CollaborationActorType | null + + @Column({ type: 'varchar', nullable: true }) + presenceId?: string | null + + @Column({ type: 'varchar', nullable: true }) + userId?: string | null +} diff --git a/packages/server-ai/src/collaboration/entities/index.ts b/packages/server-ai/src/collaboration/entities/index.ts new file mode 100644 index 0000000000..a7acda91b9 --- /dev/null +++ b/packages/server-ai/src/collaboration/entities/index.ts @@ -0,0 +1,2 @@ +export * from './collaboration-document.entity' +export * from './collaboration-update.entity' diff --git a/packages/server-ai/src/collaboration/index.ts b/packages/server-ai/src/collaboration/index.ts new file mode 100644 index 0000000000..d8527ce16d --- /dev/null +++ b/packages/server-ai/src/collaboration/index.ts @@ -0,0 +1,4 @@ +export * from './entities' +export * from './collaboration.service' +export * from './collaboration.gateway' +export * from './collaboration.module' diff --git a/packages/server-ai/src/core/entities/index.ts b/packages/server-ai/src/core/entities/index.ts index e7c4b29ca2..3cc10c8064 100644 --- a/packages/server-ai/src/core/entities/index.ts +++ b/packages/server-ai/src/core/entities/index.ts @@ -50,7 +50,11 @@ import { PluginResourceInstallation, McpRuntimeInstanceEntity, AssistantBinding, - AssistantBindingUserPreference + AssistantBindingUserPreference, + Artifact, + ArtifactVersion, + ArtifactLink, + ArtifactAccessLog } from './internal' export const ALL_AI_ENTITIES = [ @@ -105,5 +109,9 @@ export const ALL_AI_ENTITIES = [ PluginResourceInstallation, McpRuntimeInstanceEntity, AssistantBinding, - AssistantBindingUserPreference + AssistantBindingUserPreference, + Artifact, + ArtifactVersion, + ArtifactLink, + ArtifactAccessLog ] diff --git a/packages/server-ai/src/core/entities/internal.ts b/packages/server-ai/src/core/entities/internal.ts index 3e8fc35026..9cbbdc94f7 100644 --- a/packages/server-ai/src/core/entities/internal.ts +++ b/packages/server-ai/src/core/entities/internal.ts @@ -48,3 +48,4 @@ export * from '../../skill-package/skill-package.entity' export * from '../../prompt-workflow/prompt-workflow.entity' export * from '../../plugin-resource/plugin-resource-installation.entity' export * from '../../xpert-toolset/mcp-runtime-instance.entity' +export * from '../../artifacts/entities' diff --git a/packages/server-ai/src/i18n/en-US.json b/packages/server-ai/src/i18n/en-US.json index 22dd81dc53..e69b037157 100644 --- a/packages/server-ai/src/i18n/en-US.json +++ b/packages/server-ai/src/i18n/en-US.json @@ -29,6 +29,7 @@ "XpertMarketplaceUserRequired": "User context is required to access the assistant marketplace.", "XpertMarketplaceAlreadyAccessible": "You already have access to this assistant.", "XpertMarketplaceReviewForbidden": "You are not allowed to review this request.", + "XpertMarketplaceSelfReviewForbidden": "You cannot review your own access request.", "XpertMarketplaceRequesterNotFound": "The request user was not found.", "XpertMarketplaceNotFound": "The marketplace assistant was not found.", "XpertMarketplaceRequestNotFound": "The access request was not found." diff --git a/packages/server-ai/src/i18n/en.json b/packages/server-ai/src/i18n/en.json index a8ad8e1d1a..53b7dd7e7a 100644 --- a/packages/server-ai/src/i18n/en.json +++ b/packages/server-ai/src/i18n/en.json @@ -28,6 +28,7 @@ "XpertMarketplaceUserRequired": "User context is required to access the assistant marketplace.", "XpertMarketplaceAlreadyAccessible": "You already have access to this assistant.", "XpertMarketplaceReviewForbidden": "You are not allowed to review this request.", + "XpertMarketplaceSelfReviewForbidden": "You cannot review your own access request.", "XpertMarketplaceRequesterNotFound": "The request user was not found.", "XpertMarketplaceNotFound": "The marketplace assistant was not found.", "XpertMarketplaceRequestNotFound": "The access request was not found." diff --git a/packages/server-ai/src/i18n/zh-Hans.json b/packages/server-ai/src/i18n/zh-Hans.json index 40e89b2e19..11d24dacde 100644 --- a/packages/server-ai/src/i18n/zh-Hans.json +++ b/packages/server-ai/src/i18n/zh-Hans.json @@ -29,6 +29,7 @@ "XpertMarketplaceUserRequired": "访问智能体广场需要用户上下文。", "XpertMarketplaceAlreadyAccessible": "你已经拥有此智能体的访问权限。", "XpertMarketplaceReviewForbidden": "你无权审批此申请。", + "XpertMarketplaceSelfReviewForbidden": "你不能审批自己提交的访问申请。", "XpertMarketplaceRequesterNotFound": "未找到申请用户。", "XpertMarketplaceNotFound": "未找到该广场智能体。", "XpertMarketplaceRequestNotFound": "未找到该访问申请。" diff --git a/packages/server-ai/src/index.ts b/packages/server-ai/src/index.ts index c5c313e83d..9e370cccbe 100644 --- a/packages/server-ai/src/index.ts +++ b/packages/server-ai/src/index.ts @@ -26,6 +26,8 @@ export * from './mcp/index' export * from './metrics/index' export * from './mobile/index' export * from './plugin-resource/index' +export * from './artifacts/index' +export * from './collaboration/index' export * from './tracing/index' export * from './shared/index' export * from './xpert-table/index' diff --git a/packages/server-ai/src/knowledgebase/plugins/knowledge-workbench/remote-components/knowledge-workbench/app.js b/packages/server-ai/src/knowledgebase/plugins/knowledge-workbench/remote-components/knowledge-workbench/app.js index 86b9a8f693..c983494286 100644 --- a/packages/server-ai/src/knowledgebase/plugins/knowledge-workbench/remote-components/knowledge-workbench/app.js +++ b/packages/server-ai/src/knowledgebase/plugins/knowledge-workbench/remote-components/knowledge-workbench/app.js @@ -163,8 +163,8 @@ --secondary-foreground: var(--xui-color-secondary-foreground, var(--foreground)); --muted: var(--xui-color-muted, #f4f4f5); --muted-foreground: var(--xui-color-muted-foreground, #71717a); - --accent: var(--xui-color-accent, color-mix(in srgb, var(--primary) 10%, var(--background))); - --accent-foreground: var(--xui-color-accent-foreground, var(--foreground)); + --accent: var(--xui-color-accent, oklch(0.58 0.18 255)); + --accent-foreground: var(--xui-color-accent-foreground, oklch(0.985 0 0)); --destructive: var(--xui-color-destructive, #dc2626); --destructive-foreground: var(--xui-color-destructive-foreground, #ffffff); --border: var(--xui-color-border, #e4e4e7); @@ -191,8 +191,8 @@ --secondary-foreground: var(--xui-color-secondary-foreground, var(--foreground)); --muted: var(--xui-color-muted, #27272a); --muted-foreground: var(--xui-color-muted-foreground, #a1a1aa); - --accent: var(--xui-color-accent, #27272a); - --accent-foreground: var(--xui-color-accent-foreground, var(--foreground)); + --accent: var(--xui-color-accent, oklch(0.58 0.18 255)); + --accent-foreground: var(--xui-color-accent-foreground, oklch(0.985 0 0)); --border: var(--xui-color-border, #27272a); --input: var(--xui-color-input, var(--border)); } diff --git a/packages/server-ai/src/shared/agent/middleware-runtime.module.ts b/packages/server-ai/src/shared/agent/middleware-runtime.module.ts index 2feed163f7..3e5d95facb 100644 --- a/packages/server-ai/src/shared/agent/middleware-runtime.module.ts +++ b/packages/server-ai/src/shared/agent/middleware-runtime.module.ts @@ -5,10 +5,12 @@ import { AgentMiddlewareRuntimeService } from './middleware-runtime.service' import { VolumeModule } from '../volume' import { WorkspaceFilesRuntimeCapabilityService } from '../runtime/workspace-files-runtime-capability.service' import { ConnectorModule } from '../../connector/connector.module' +import { ArtifactsModule } from '../../artifacts' +import { CollaborationModule } from '../../collaboration' @Global() @Module({ - imports: [CqrsModule, VolumeModule, ConnectorModule], + imports: [CqrsModule, VolumeModule, ConnectorModule, ArtifactsModule, CollaborationModule], providers: [ WorkspaceFilesRuntimeCapabilityService, AgentMiddlewareRuntimeService, @@ -20,6 +22,8 @@ import { ConnectorModule } from '../../connector/connector.module' ], exports: [ ConnectorModule, + ArtifactsModule, + CollaborationModule, AgentMiddlewareRuntimeService, WorkspaceFilesRuntimeCapabilityService, XPERT_RUNTIME_CAPABILITIES_TOKEN diff --git a/packages/server-ai/src/shared/agent/middleware-runtime.service.spec.ts b/packages/server-ai/src/shared/agent/middleware-runtime.service.spec.ts index 6b549e6ebd..32a4c16754 100644 --- a/packages/server-ai/src/shared/agent/middleware-runtime.service.spec.ts +++ b/packages/server-ai/src/shared/agent/middleware-runtime.service.spec.ts @@ -69,6 +69,7 @@ import { KnowledgebaseDocumentsRuntimeCapability, KnowledgebaseRuntimeCapability, RequestContext, + ArtifactsRuntimeCapability, WorkspaceFilesRuntimeCapability } from '@xpert-ai/plugin-sdk' import { ConnectorRuntimeCapability } from '@xpert-ai/plugin-sdk' @@ -103,6 +104,7 @@ describe('AgentMiddlewareRuntimeService', () => { let volumeClient: { resolve: jest.Mock } let volumeRoot: string let workspaceFiles: WorkspaceFilesRuntimeCapabilityService + let artifacts: { createScopedApi: jest.Mock } let service: AgentMiddlewareRuntimeService beforeEach(() => { @@ -117,6 +119,23 @@ describe('AgentMiddlewareRuntimeService', () => { resolve: jest.fn((scope) => createTestVolumeHandle(scope, volumeRoot)) } workspaceFiles = new WorkspaceFilesRuntimeCapabilityService(commandBus, volumeClient) + artifacts = { + createScopedApi: jest.fn((defaults) => ({ + createArtifact: jest.fn(), + createArtifactVersion: jest.fn(), + createArtifactLink: jest + .fn() + .mockResolvedValue({ id: 'link-1', publicUrl: 'https://share.test/artifacts/share/one' }), + createSignedPreviewLink: jest.fn(), + getArtifact: jest.fn(), + listArtifacts: jest.fn(), + archiveArtifact: jest.fn(), + deleteArtifact: jest.fn(), + updateArtifactLinkAccess: jest.fn(), + revokeArtifactLink: jest.fn(), + defaults + })) + } service = new AgentMiddlewareRuntimeService( commandBus as any, queryBus as any, @@ -126,7 +145,8 @@ describe('AgentMiddlewareRuntimeService', () => { { getRuntimeConnector: jest.fn().mockResolvedValue(undefined) } as any, - workspaceFiles + workspaceFiles, + artifacts as any ) jest.spyOn(RequestContext, 'currentTenantId').mockReturnValue('tenant-1') @@ -182,6 +202,36 @@ describe('AgentMiddlewareRuntimeService', () => { ).resolves.toBeUndefined() }) + it('registers the artifacts runtime capability with the middleware scope', async () => { + const runtime = service.createScopedApi({ + tenantId: 'tenant-1', + organizationId: 'org-1', + userId: 'user-1', + workspaceId: 'workspace-1', + xpertId: 'xpert-1' + }) + const artifactsApi = runtime.capabilities?.require(ArtifactsRuntimeCapability) as { + defaults?: Record + } + + expect(artifacts.createScopedApi).toHaveBeenCalledWith( + expect.objectContaining({ + tenantId: 'tenant-1', + organizationId: 'org-1', + userId: 'user-1', + workspaceId: 'workspace-1', + xpertId: 'xpert-1' + }) + ) + expect(artifactsApi.defaults).toMatchObject({ + tenantId: 'tenant-1', + organizationId: 'org-1', + userId: 'user-1', + workspaceId: 'workspace-1', + xpertId: 'xpert-1' + }) + }) + function mockCreateModelClientDependencies(options?: { tokenRecordError?: Error }) { const modelInstance = { invoke: jest.fn() diff --git a/packages/server-ai/src/shared/agent/middleware-runtime.service.ts b/packages/server-ai/src/shared/agent/middleware-runtime.service.ts index afb6bdfc5d..fce01b0cd1 100644 --- a/packages/server-ai/src/shared/agent/middleware-runtime.service.ts +++ b/packages/server-ai/src/shared/agent/middleware-runtime.service.ts @@ -50,11 +50,13 @@ import { AgentMiddlewareWrapWorkflowNodeExecutionResult, AssistantTaskRuntimeCapability, ConnectorRuntimeCapability, + CollaborationRuntimeCapability, DefaultRuntimeCapabilityRegistry, FileRuntimeCapability, KnowledgebaseDocumentsRuntimeCapability, KnowledgebaseRuntimeCapability, RequestContext, + ArtifactsRuntimeCapability, WorkspaceFilesRuntimeCapability } from '@xpert-ai/plugin-sdk' import { FileStorage, GetStorageFileQuery } from '@xpert-ai/server-core' @@ -83,6 +85,8 @@ import { GetChatConversationQuery } from '../../chat-conversation/queries/conver import { FileAsset, GetFileAssetQuery } from '../../file-understanding' import { XpertChatCommand } from '../../xpert/commands/chat.command' import { ConnectorService } from '../../connector/connector.service' +import { ArtifactsService } from '../../artifacts' +import { CollaborationService } from '../../collaboration' import { WorkspaceFilesRuntimeCapabilityService } from '../runtime/workspace-files-runtime-capability.service' import { wrapAgentExecution } from './execution' @@ -92,9 +96,15 @@ import { wrapAgentExecution } from './execution' */ export type AgentMiddlewareRuntimeScope = { tenantId?: string | null + organizationId?: string | null userId?: string | null + workspaceId?: string | null projectId?: string | null xpertId?: string | null + xpertName?: string | null + conversationId?: string | null + agentKey?: string | null + executionId?: string | null workspaceRoot?: string | null workspacePath?: string | null } @@ -441,7 +451,9 @@ export class AgentMiddlewareRuntimeService { private readonly queryBus: QueryBus, private readonly i18nService: I18nService, private readonly connectors: ConnectorService, - private readonly workspaceFiles: WorkspaceFilesRuntimeCapabilityService + private readonly workspaceFiles: WorkspaceFilesRuntimeCapabilityService, + private readonly artifacts: ArtifactsService, + private readonly collaboration: CollaborationService ) { this.api = this.createScopedApi() } @@ -457,6 +469,11 @@ export class AgentMiddlewareRuntimeService { const workspaceFilesApi = hasRuntimeWorkspaceScope(scope) ? this.workspaceFiles.createScopedApi(scope) : this.workspaceFiles.api + const artifactsApi = this.artifacts.createScopedApi({ + ...scope, + organizationId: scope.organizationId ?? RequestContext.getOrganizationId() + }) + const collaborationApi = this.collaboration.createScopedApi(scope) const capabilities = new DefaultRuntimeCapabilityRegistry([ [ KnowledgebaseRuntimeCapability, @@ -497,6 +514,8 @@ export class AgentMiddlewareRuntimeService { getConnector: (input) => this.connectors.getRuntimeConnector(input) } ], + [ArtifactsRuntimeCapability, artifactsApi], + [CollaborationRuntimeCapability, collaborationApi], [WorkspaceFilesRuntimeCapability, workspaceFilesApi] ]) diff --git a/packages/server-ai/src/xpert-agent/commands/handlers/subgraph.handler.ts b/packages/server-ai/src/xpert-agent/commands/handlers/subgraph.handler.ts index 7b5e07bf2a..af64ed166d 100644 --- a/packages/server-ai/src/xpert-agent/commands/handlers/subgraph.handler.ts +++ b/packages/server-ai/src/xpert-agent/commands/handlers/subgraph.handler.ts @@ -756,9 +756,14 @@ export class XpertAgentSubgraphHandler implements ICommandHandler = {}) { } as XpertAccessRequest } +function createWorkspace(overrides: Partial = {}) { + return { + id: 'workspace-1', + tenantId: 'tenant-1', + organizationId: 'org-1', + name: 'Workspace', + status: 'active', + ownerId: 'owner-1', + members: [], + ...overrides + } as XpertWorkspace +} + function createDiscoverableQueryBuilder(xpert: Xpert | null = createDiscoverableXpert()) { return { leftJoinAndSelect: jest.fn().mockReturnThis(), @@ -67,8 +82,10 @@ function createService(options?: { xpert?: Xpert | null existingRequest?: XpertAccessRequest | null decisionRequest?: XpertAccessRequest | null + reviewableRequests?: XpertAccessRequest[] managedGroup?: UserGroup | null requester?: User | null + canManageWorkspace?: boolean }) { const queryBuilder = createDiscoverableQueryBuilder(options?.xpert) const requestQueryBuilder = { @@ -82,7 +99,7 @@ function createService(options?: { } const requestRepository = { createQueryBuilder: jest.fn().mockReturnValue(requestQueryBuilder), - find: jest.fn().mockResolvedValue([]), + find: jest.fn().mockResolvedValue(options?.reviewableRequests ?? []), findOne: jest.fn().mockResolvedValueOnce(options?.existingRequest ?? options?.decisionRequest ?? null), create: jest.fn().mockImplementation((input: Partial) => input as XpertAccessRequest), save: jest.fn().mockImplementation(async (entity: XpertAccessRequest) => entity) @@ -116,12 +133,23 @@ function createService(options?: { : options.requester ) } + const workspaceAccessService = new XpertWorkspaceAccessService(asRepository({})) + jest.spyOn(workspaceAccessService, 'getCapabilities').mockImplementation(async (workspace: XpertWorkspace) => { + const canManage = options?.canManageWorkspace ?? workspace.ownerId === RequestContext.currentUserId() + return { + canRead: canManage, + canRun: canManage, + canWrite: canManage, + canManage + } + }) const service = new XpertMarketplaceService( asRepository(xpertRepository), asRepository(requestRepository), asRepository(userGroupRepository), - asRepository(userRepository) + asRepository(userRepository), + workspaceAccessService ) return { @@ -132,6 +160,7 @@ function createService(options?: { requestRepository, userGroupRepository, userRepository, + workspaceAccessService, createdGroup } } @@ -175,27 +204,105 @@ describe('XpertMarketplaceService', () => { expect(requestRepository.save).not.toHaveBeenCalled() }) - it('rejects approve decisions from users who cannot review the xpert', async () => { + it('only returns requests for workspaces the current user can manage', async () => { + jest.spyOn(RequestContext, 'hasPermission').mockReturnValue(true) + const reviewableRequest = createRequest({ + requesterId: 'requester-2', + xpert: createDiscoverableXpert({ + workspace: createWorkspace() + }) + }) + const { service, workspaceAccessService } = createService({ + reviewableRequests: [reviewableRequest], + canManageWorkspace: false + }) + + await expect(service.findReviewableRequests()).resolves.toEqual([]) + expect(workspaceAccessService.getCapabilities).toHaveBeenCalledWith(reviewableRequest.xpert?.workspace) + }) + + it('does not return the current users own request even when they manage the workspace', async () => { + const ownRequest = createRequest({ + xpert: createDiscoverableXpert({ + createdById: 'user-1', + workspace: createWorkspace({ ownerId: 'user-1' }) + }) + }) + const { service, workspaceAccessService } = createService({ + reviewableRequests: [ownRequest], + canManageWorkspace: true + }) + + await expect(service.findReviewableRequests()).resolves.toEqual([]) + expect(workspaceAccessService.getCapabilities).not.toHaveBeenCalled() + }) + + it('rejects approve decisions when global xpert edit permission lacks workspace management access', async () => { + jest.spyOn(RequestContext, 'hasPermission').mockReturnValue(true) const decisionRequest = createRequest({ + requesterId: 'requester-2', xpert: createDiscoverableXpert({ createdById: 'owner-1', - workspace: { - id: 'workspace-1', - name: 'Workspace', - status: 'active', - ownerId: 'owner-1', - members: [] - } + workspace: createWorkspace() }) }) const { service, requestRepository } = createService({ - decisionRequest + decisionRequest, + canManageWorkspace: false }) await expect(service.approveRequest('request-1')).rejects.toThrow(ForbiddenException) expect(requestRepository.save).not.toHaveBeenCalled() }) + it('rejects reject decisions without workspace management access', async () => { + const decisionRequest = createRequest({ + requesterId: 'requester-2', + xpert: createDiscoverableXpert({ + workspace: createWorkspace() + }) + }) + const { service, requestRepository } = createService({ + decisionRequest, + canManageWorkspace: false + }) + + await expect(service.rejectRequest('request-1')).rejects.toThrow(ForbiddenException) + expect(requestRepository.save).not.toHaveBeenCalled() + }) + + it('rejects approve decisions for the current users own request', async () => { + const decisionRequest = createRequest({ + xpert: createDiscoverableXpert({ + createdById: 'user-1', + workspace: createWorkspace({ ownerId: 'user-1' }) + }) + }) + const { service, requestRepository } = createService({ + decisionRequest, + canManageWorkspace: true + }) + + await expect(service.approveRequest('request-1')).rejects.toThrow(ForbiddenException) + expect(requestRepository.save).not.toHaveBeenCalled() + }) + + it('rejects reject decisions for the current users own request', async () => { + const decisionRequest = createRequest({ + xpert: createDiscoverableXpert({ + createdById: 'user-1', + workspace: createWorkspace({ ownerId: 'user-1' }) + }) + }) + const { service, requestRepository } = createService({ + decisionRequest, + canManageWorkspace: true + }) + + await expect(service.rejectRequest('request-1')).rejects.toThrow(ForbiddenException) + expect(requestRepository.save).not.toHaveBeenCalled() + }) + it('approves by creating the managed marketplace access user group and binding it to the xpert', async () => { jest.spyOn(RequestContext, 'currentUserId').mockReturnValue('owner-1') jest.spyOn(RequestContext, 'currentUser').mockReturnValue({ @@ -209,6 +316,7 @@ describe('XpertMarketplaceService', () => { const xpert = createDiscoverableXpert({ createdById: 'owner-1', + workspace: createWorkspace(), userGroups: [] }) const decisionRequest = createRequest({ @@ -291,6 +399,7 @@ describe('XpertMarketplaceService', () => { } as UserGroup const xpert = createDiscoverableXpert({ createdById: 'owner-1', + workspace: createWorkspace(), userGroups: [] }) const decisionRequest = createRequest({ diff --git a/packages/server-ai/src/xpert-marketplace/xpert-marketplace.service.ts b/packages/server-ai/src/xpert-marketplace/xpert-marketplace.service.ts index da9378d0e8..d361b9bab0 100644 --- a/packages/server-ai/src/xpert-marketplace/xpert-marketplace.service.ts +++ b/packages/server-ai/src/xpert-marketplace/xpert-marketplace.service.ts @@ -1,12 +1,9 @@ import { - AIPermissionsEnum, IXpert, IXpertAccessRequest, IXpertMarketplaceItem, IXpertMarketplaceListResponse, IUser, - PermissionsEnum, - RolesEnum, TXpertAccessRequestCreateInput, TXpertAccessRequestDecisionInput, TXpertMarketplaceAccessStatus, @@ -25,6 +22,7 @@ import { RequestContext, User, UserGroup } from '@xpert-ai/server-core' import { t } from 'i18next' import { Brackets, Repository } from 'typeorm' import { Xpert } from '../xpert/xpert.entity' +import { XpertWorkspaceAccessService } from '../xpert-workspace' import { XpertAccessRequest } from './xpert-access-request.entity' const TENANT_SHARED_WORKSPACE_FILTER = `COALESCE((workspace.settings)::jsonb -> 'access' ->> 'visibility', 'private') = 'tenant-shared'` @@ -43,7 +41,8 @@ export class XpertMarketplaceService { @InjectRepository(UserGroup) private readonly userGroupRepository: Repository, @InjectRepository(User) - private readonly userRepository: Repository + private readonly userRepository: Repository, + private readonly workspaceAccessService: XpertWorkspaceAccessService ) {} async findMarketplace(query: TXpertMarketplaceQuery = {}): Promise { @@ -51,9 +50,9 @@ export class XpertMarketplaceService { const requests = await this.findCurrentUserRequests(xperts.map((xpert) => xpert.id)) const requestsByXpertId = new Map(requests.map((request) => [request.xpertId, request])) - const allItems = xperts - .map((xpert) => this.toMarketplaceItem(xpert, requestsByXpertId.get(xpert.id))) - .filter((item) => this.matchesFilters(item, query)) + const allItems = ( + await Promise.all(xperts.map((xpert) => this.toMarketplaceItem(xpert, requestsByXpertId.get(xpert.id)))) + ).filter((item) => this.matchesFilters(item, query)) this.sortItems(allItems, query.sort) @@ -72,7 +71,7 @@ export class XpertMarketplaceService { async getMarketplaceItem(id: string): Promise { const xpert = await this.findDiscoverableXpert(id) const request = await this.findCurrentUserRequest(id) - return this.toMarketplaceItem(xpert, request) + return await this.toMarketplaceItem(xpert, request) } async requestAccess(id: string, input?: TXpertAccessRequestCreateInput): Promise { @@ -163,21 +162,23 @@ export class XpertMarketplaceService { } }) - return requests - .filter((request) => request.xpert && this.canReviewXpert(request.xpert)) - .map((request) => this.toPublicRequest(request)) + const reviewableRequests: IXpertAccessRequest[] = [] + for (const request of requests) { + if ( + request.requesterId !== this.currentUserId() && + request.xpert && + (await this.canReviewXpert(request.xpert)) + ) { + reviewableRequests.push(this.toPublicRequest(request)) + } + } + + return reviewableRequests } async approveRequest(id: string, input?: TXpertAccessRequestDecisionInput): Promise { const request = await this.findRequestForDecision(id) - const xpert = request.xpert - if (!xpert || !this.canReviewXpert(xpert)) { - throw new ForbiddenException( - t('server-ai:Error.XpertMarketplaceReviewForbidden', { - defaultValue: 'You are not allowed to review this request.' - }) - ) - } + const xpert = await this.assertCanReviewRequest(request) const group = await this.ensureAccessGroup(xpert) const requester = await this.userRepository.findOne({ @@ -215,13 +216,7 @@ export class XpertMarketplaceService { async rejectRequest(id: string, input?: TXpertAccessRequestDecisionInput): Promise { const request = await this.findRequestForDecision(id) - if (!request.xpert || !this.canReviewXpert(request.xpert)) { - throw new ForbiddenException( - t('server-ai:Error.XpertMarketplaceReviewForbidden', { - defaultValue: 'You are not allowed to review this request.' - }) - ) - } + await this.assertCanReviewRequest(request) request.status = XpertAccessRequestStatusEnum.REJECTED request.reviewerId = this.currentUserId() @@ -384,14 +379,14 @@ export class XpertMarketplaceService { return name.length > 100 ? name.slice(0, 100) : name } - private toMarketplaceItem(xpert: Xpert, request?: XpertAccessRequest | null): IXpertMarketplaceItem { + private async toMarketplaceItem(xpert: Xpert, request?: XpertAccessRequest | null): Promise { const marketplace = this.resolveMarketplaceProfile(xpert) return { xpert: this.toMarketplaceXpert(xpert, marketplace), marketplace, accessStatus: this.resolveAccessStatus(xpert, request ?? null), request: request ? this.toPublicRequest(request) : null, - canReview: this.canReviewXpert(xpert) + canReview: await this.canReviewXpert(xpert) } } @@ -442,24 +437,33 @@ export class XpertMarketplaceService { ) } - private canReviewXpert(xpert: Xpert) { - const userId = this.currentUserId() - if (xpert.createdById === userId || xpert.workspace?.ownerId === userId) { - return true + private async assertCanReviewRequest(request: XpertAccessRequest) { + if (request.requesterId === this.currentUserId()) { + throw new ForbiddenException( + t('server-ai:Error.XpertMarketplaceSelfReviewForbidden', { + defaultValue: 'You cannot review your own access request.' + }) + ) } - if (this.isOrgOrTenantAdmin()) { - return true + + if (!request.xpert || !(await this.canReviewXpert(request.xpert))) { + throw new ForbiddenException( + t('server-ai:Error.XpertMarketplaceReviewForbidden', { + defaultValue: 'You are not allowed to review this request.' + }) + ) } - return ( - RequestContext.hasPermission(PermissionsEnum.ALL_ORG_EDIT, false) || - RequestContext.hasPermission(AIPermissionsEnum.XPERT_EDIT, false) - ) + return request.xpert } - private isOrgOrTenantAdmin() { - const role = RequestContext.currentUser()?.role?.name - return role === RolesEnum.SUPER_ADMIN || role === RolesEnum.ADMIN + private async canReviewXpert(xpert: Xpert) { + if (xpert.workspace) { + const capabilities = await this.workspaceAccessService.getCapabilities(xpert.workspace) + return capabilities.canManage + } + + return xpert.createdById === this.currentUserId() } private matchesFilters(item: IXpertMarketplaceItem, query: TXpertMarketplaceQuery) { diff --git a/packages/server-ai/src/xpert-workspace/workspace-access.service.spec.ts b/packages/server-ai/src/xpert-workspace/workspace-access.service.spec.ts index 8f8db6f1c1..d50fcd81b4 100644 --- a/packages/server-ai/src/xpert-workspace/workspace-access.service.spec.ts +++ b/packages/server-ai/src/xpert-workspace/workspace-access.service.spec.ts @@ -18,10 +18,12 @@ describe('XpertWorkspaceAccessService', () => { select: jest.Mock addSelect: jest.Mock from: jest.Mock + innerJoin: jest.Mock where: jest.Mock andWhere: jest.Mock limit: jest.Mock getRawOne: jest.Mock + getCount: jest.Mock } beforeEach(() => { @@ -40,13 +42,15 @@ describe('XpertWorkspaceAccessService', () => { select: jest.fn(), addSelect: jest.fn(), from: jest.fn(), + innerJoin: jest.fn(), where: jest.fn(), andWhere: jest.fn(), limit: jest.fn(), - getRawOne: jest.fn() + getRawOne: jest.fn(), + getCount: jest.fn().mockResolvedValue(0) } Object.values(xpertQueryBuilder) - .filter((mock) => mock !== xpertQueryBuilder.getRawOne) + .filter((mock) => mock !== xpertQueryBuilder.getRawOne && mock !== xpertQueryBuilder.getCount) .forEach((mock) => mock.mockReturnValue(xpertQueryBuilder)) workspaceRepository = { createQueryBuilder: jest.fn(() => workspaceQueryBuilder), @@ -171,6 +175,32 @@ describe('XpertWorkspaceAccessService', () => { }) }) + it('grants runtime-only workspace access through a published xpert user-group grant', async () => { + xpertQueryBuilder.getCount.mockResolvedValue(1) + const workspace = Object.assign(new XpertWorkspace(), { + id: 'workspace-1', + tenantId: 'tenant-1', + organizationId: 'org-1', + ownerId: 'owner-1', + settings: { access: { visibility: 'private' } }, + members: [] + }) + + await expect(service.getCapabilities(workspace)).resolves.toEqual({ + canRead: false, + canRun: true, + canWrite: false, + canManage: false + }) + expect(xpertQueryBuilder.innerJoin).toHaveBeenCalledWith( + 'user_group_to_user', + 'ugu', + 'ugu."userGroupId" = ug.id AND ugu."userId" = :userId', + { userId: 'user-1' } + ) + expect(xpertQueryBuilder.andWhere).toHaveBeenCalledWith('xpert."publishAt" IS NOT NULL') + }) + it('allows tenant-scope owners to manage tenant-shared workspaces', async () => { ;(RequestContext.currentUser as jest.Mock).mockReturnValue({ id: 'owner-1', diff --git a/packages/server-ai/src/xpert-workspace/workspace-access.service.ts b/packages/server-ai/src/xpert-workspace/workspace-access.service.ts index 893a107d58..e3c438716b 100644 --- a/packages/server-ai/src/xpert-workspace/workspace-access.service.ts +++ b/packages/server-ai/src/xpert-workspace/workspace-access.service.ts @@ -192,9 +192,18 @@ export class XpertWorkspaceAccessService { if (isCurrentOrganizationWorkspace) { const canRead = isOwner || isMember const canWrite = canRead + // A published Xpert grant unlocks execution only; it must not expose workspace authoring resources. + const canRun = + canRead || + (await this.hasPublishedXpertRunAccess({ + workspaceId: workspace.id, + tenantId, + organizationId, + userId + })) return { canRead, - canRun: canRead, + canRun, canWrite, canManage: isOwner } @@ -223,6 +232,41 @@ export class XpertWorkspaceAccessService { return this.emptyCapabilities() } + private async hasPublishedXpertRunAccess(input: { + workspaceId: string + tenantId: string + organizationId: string + userId: string + }) { + // Keep aliases lower-case because PostgreSQL folds unquoted identifiers and TypeORM reuses them in join SQL. + const count = await this.workspaceRepository.manager + .createQueryBuilder() + .select('xpert.id') + .from('xpert', 'xpert') + .innerJoin('xpert_to_user_group', 'xug', 'xug."xpertId" = xpert.id') + .innerJoin( + 'user_group', + 'ug', + 'ug.id = xug."userGroupId" AND ug."tenantId" = :tenantId AND ug."organizationId" = :organizationId', + { + tenantId: input.tenantId, + organizationId: input.organizationId + } + ) + .innerJoin('user_group_to_user', 'ugu', 'ugu."userGroupId" = ug.id AND ugu."userId" = :userId', { + userId: input.userId + }) + .where('xpert."workspaceId" = :workspaceId', { workspaceId: input.workspaceId }) + .andWhere('xpert."tenantId" = :tenantId', { tenantId: input.tenantId }) + .andWhere('xpert."organizationId" = :organizationId', { + organizationId: input.organizationId + }) + .andWhere('xpert."publishAt" IS NOT NULL') + .getCount() + + return count > 0 + } + isTenantSharedWorkspace(workspace?: Pick | null) { return isTenantSharedXpertWorkspace(workspace) } diff --git a/packages/server-ai/src/xpert-workspace/workspace-base.service.spec.ts b/packages/server-ai/src/xpert-workspace/workspace-base.service.spec.ts index b367289679..3ae2c532c5 100644 --- a/packages/server-ai/src/xpert-workspace/workspace-base.service.spec.ts +++ b/packages/server-ai/src/xpert-workspace/workspace-base.service.spec.ts @@ -1,5 +1,6 @@ +import { IUser } from '@xpert-ai/contracts' import { FindManyOptions, FindOptionsWhere, In, IsNull, Repository } from 'typeorm' -import { RequestContext } from '@xpert-ai/server-core' +import { PaginationParams, RequestContext } from '@xpert-ai/server-core' import { WorkspaceBaseEntity } from '../core/entities/base.entity' import { XpertWorkspaceAccessService } from './workspace-access.service' import { XpertWorkspaceBaseService } from './workspace-base.service' @@ -90,4 +91,68 @@ describe('XpertWorkspaceBaseService', () => { createdById: 'user-1' }) }) + + it('loads workspace records for runtime with run access instead of read access', async () => { + jest.spyOn(RequestContext, 'currentTenantId').mockReturnValue('tenant-1') + + const record = { + id: 'xpert-1', + tenantId: 'tenant-1', + organizationId: 'org-1', + workspaceId: 'workspace-1' + } + const repository = { + findOne: jest.fn().mockResolvedValue(record) + } as Pick, 'findOne'> + const assertCan = jest.fn().mockResolvedValue({ workspace: { id: 'workspace-1' } }) + const workspaceAccessService = { + assertCan + } as Pick + const service = new XpertWorkspaceBaseService( + repository as Repository, + workspaceAccessService as XpertWorkspaceAccessService + ) + + await expect(service.findOneForRuntime('xpert-1')).resolves.toBe(record) + expect(assertCan).toHaveBeenCalledWith('workspace-1', 'run') + }) + + it('lists workspace records for runtime with run access instead of authoring access', async () => { + const repository = { + findAndCount: jest.fn().mockResolvedValue([[], 0]) + } as Pick, 'findAndCount'> + const assertCan = jest.fn().mockResolvedValue({ + workspace: { + id: 'workspace-1', + tenantId: 'tenant-1', + organizationId: 'org-1' + } + }) + const workspaceAccessService = { + assertCan + } as Pick + const service = new XpertWorkspaceBaseService( + repository as Repository, + workspaceAccessService as XpertWorkspaceAccessService + ) + + await service.getAllByWorkspaceForRuntime( + 'workspace-1', + { where: {} } as PaginationParams, + false, + { id: 'user-1' } as IUser + ) + + expect(assertCan).toHaveBeenCalledWith('workspace-1', 'run') + expect(assertCan).toHaveBeenCalledTimes(1) + expect(repository.findAndCount).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + workspaceId: 'workspace-1', + tenantId: 'tenant-1', + organizationId: 'org-1' + }) + }) + ) + }) }) diff --git a/packages/server-ai/src/xpert-workspace/workspace-base.service.ts b/packages/server-ai/src/xpert-workspace/workspace-base.service.ts index 1e5de7cf97..974b21d771 100644 --- a/packages/server-ai/src/xpert-workspace/workspace-base.service.ts +++ b/packages/server-ai/src/xpert-workspace/workspace-base.service.ts @@ -56,9 +56,24 @@ export class XpertWorkspaceBaseService extends Te } async getAllByWorkspace(workspaceId: string, data: PaginationParams, published: boolean, user: IUser) { + return this.getAllByWorkspaceWithAccess(workspaceId, data, published, user, 'authoring') + } + + async getAllByWorkspaceForRuntime(workspaceId: string, data: PaginationParams, published: boolean, user: IUser) { + return this.getAllByWorkspaceWithAccess(workspaceId, data, published, user, 'run') + } + + private async getAllByWorkspaceWithAccess( + workspaceId: string, + data: PaginationParams, + published: boolean, + user: IUser, + action: 'authoring' | 'run' + ) { const { select, relations, order, take } = data ?? {} let { where } = data ?? {} where = transformWhere(where ?? {}) + let runtimeWorkspace: XpertWorkspace | null = null if (this.isEmptyWorkspaceId(workspaceId)) { where = { @@ -67,7 +82,11 @@ export class XpertWorkspaceBaseService extends Te createdById: user.id } } else { - await this.assertWorkspaceAuthoringAccess(workspaceId) + if (action === 'run') { + runtimeWorkspace = (await this.assertWorkspaceRunAccess(workspaceId)).workspace + } else { + await this.assertWorkspaceAuthoringAccess(workspaceId) + } where = { ...(where as FindOptionsWhere), workspaceId @@ -78,6 +97,18 @@ export class XpertWorkspaceBaseService extends Te where.publishAt = Not(IsNull()) } + if (runtimeWorkspace) { + // findAll() performs an authoring read check; query directly after the run check to preserve run-only access. + const [items, total] = await this.repository.findAndCount({ + select, + where: this.mergeWorkspaceScopeWhere(where as FindOptionsWhere, runtimeWorkspace), + relations, + order, + take + }) + return { items, total } + } + return this.findAll({ select, where, @@ -98,6 +129,19 @@ export class XpertWorkspaceBaseService extends Te } public async findOne(id: string | number | FindOneOptions, options?: FindOneOptions): Promise { + return this.findOneWithWorkspaceAccess(id, options, 'read') + } + + public async findOneForRuntime(id: string | number | FindOneOptions, options?: FindOneOptions): Promise { + // Runtime callers may hold canRun without canRead through a published Xpert UserGroup grant. + return this.findOneWithWorkspaceAccess(id, options, 'run') + } + + private async findOneWithWorkspaceAccess( + id: string | number | FindOneOptions, + options: FindOneOptions | undefined, + action: 'read' | 'run' + ): Promise { if (typeof id === 'string' || typeof id === 'number') { const scopedSelect = this.withReadableScopeSelect(options) const record = await this.repository.findOne({ @@ -105,14 +149,17 @@ export class XpertWorkspaceBaseService extends Te where: this.mergeFindOneWhereWithTenant(id, options?.where) }) return this.stripReadableScopeSelectFields( - await this.assertRecordReadable(record), + await this.assertRecordAccessible(record, action), scopedSelect.addedFields ) } const scopedSelect = this.withReadableScopeSelect(id) const record = await this.repository.findOne(scopedSelect.options) - return this.stripReadableScopeSelectFields(await this.assertRecordReadable(record), scopedSelect.addedFields) + return this.stripReadableScopeSelectFields( + await this.assertRecordAccessible(record, action), + scopedSelect.addedFields + ) } public async findOneByIdString(id: string, options?: FindOneOptions): Promise { @@ -340,13 +387,13 @@ export class XpertWorkspaceBaseService extends Te return record } - private async assertRecordReadable(record: T | null): Promise { + private async assertRecordAccessible(record: T | null, action: 'read' | 'run'): Promise { if (!record) { throw new NotFoundException(`The requested record was not found`) } if (record.workspaceId) { - await this.assertWorkspaceReadAccess(record.workspaceId) + await this.assertWorkspaceAccess(record.workspaceId, action) return record } diff --git a/packages/server-ai/src/xpert/commands/handlers/chat.handler.spec.ts b/packages/server-ai/src/xpert/commands/handlers/chat.handler.spec.ts index 9a4fad45f5..ce4b44e33b 100644 --- a/packages/server-ai/src/xpert/commands/handlers/chat.handler.spec.ts +++ b/packages/server-ai/src/xpert/commands/handlers/chat.handler.spec.ts @@ -42,7 +42,7 @@ import { XpertChatHandler } from './chat.handler' type XpertChatCommandOptions = NonNullable[1]> describe('XpertChatHandler', () => { - let xpertService: { findOne: jest.Mock } + let xpertService: { findOneForRuntime: jest.Mock } let assistantBindingService: { getBinding: jest.Mock getUserPreferenceByAssistantId: jest.Mock @@ -66,7 +66,7 @@ describe('XpertChatHandler', () => { beforeEach(() => { xpertService = { - findOne: jest.fn().mockResolvedValue(xpert) + findOneForRuntime: jest.fn().mockResolvedValue(xpert) } assistantBindingService = { getBinding: jest.fn().mockResolvedValue({ diff --git a/packages/server-ai/src/xpert/commands/handlers/chat.handler.ts b/packages/server-ai/src/xpert/commands/handlers/chat.handler.ts index 94dea121c1..d1953486e6 100644 --- a/packages/server-ai/src/xpert/commands/handlers/chat.handler.ts +++ b/packages/server-ai/src/xpert/commands/handlers/chat.handler.ts @@ -310,7 +310,7 @@ export class XpertChatHandler implements ICommandHandler { }) ) const followUpXpert = xpertId - ? await this.xpertService.findOne(xpertId, { relations: ['agent'] }).catch(() => null) + ? await this.xpertService.findOneForRuntime(xpertId, { relations: ['agent'] }).catch(() => null) : null await attachChatFileAssetsToConversation(this.commandBus, conversation, followUpInput.files, { xpertId: followUpXpert?.id ?? xpertId, @@ -331,7 +331,8 @@ export class XpertChatHandler implements ICommandHandler { const timeStart = Date.now() - const xpert = await this.xpertService.findOne(xpertId, { relations: ['agent', 'knowledgebase'] }) + // Published assistant execution can be granted by UserGroup without workspace read membership. + const xpert = await this.xpertService.findOneForRuntime(xpertId, { relations: ['agent', 'knowledgebase'] }) const [userPreference, clawXpertBinding] = await Promise.all([ this.assistantBindingService.getUserPreferenceByAssistantId(xpertId), this.assistantBindingService.getBinding(AssistantCode.CLAWXPERT, AssistantBindingScope.USER) diff --git a/packages/server-ai/src/xpert/queries/handlers/get-xpert-workflow.handler.spec.ts b/packages/server-ai/src/xpert/queries/handlers/get-xpert-workflow.handler.spec.ts new file mode 100644 index 0000000000..76c8fe2edc --- /dev/null +++ b/packages/server-ai/src/xpert/queries/handlers/get-xpert-workflow.handler.spec.ts @@ -0,0 +1,38 @@ +import { GetXpertWorkflowQuery } from '../get-xpert-workflow.query' +import { GetXpertWorkflowHandler } from './get-xpert-workflow.handler' + +describe('GetXpertWorkflowHandler', () => { + it('loads the xpert with runtime workspace access', async () => { + const graph = { + nodes: [], + connections: [] + } + const service = { + findOneForRuntime: jest.fn().mockResolvedValue({ + id: 'xpert-1', + tenantId: 'tenant-1', + graph, + agent: null, + agents: [], + executors: [] + }) + } + const handler = new GetXpertWorkflowHandler( + service as unknown as ConstructorParameters[0], + { + translate: jest.fn() + } as unknown as ConstructorParameters[1], + { + execute: jest.fn() + } as unknown as ConstructorParameters[2] + ) + + await expect(handler.execute(new GetXpertWorkflowQuery('xpert-1'))).resolves.toEqual({ graph }) + expect(service.findOneForRuntime).toHaveBeenCalledWith( + 'xpert-1', + expect.objectContaining({ + relations: expect.arrayContaining(['agent', 'knowledgebases', 'toolsets']) + }) + ) + }) +}) diff --git a/packages/server-ai/src/xpert/queries/handlers/get-xpert-workflow.handler.ts b/packages/server-ai/src/xpert/queries/handlers/get-xpert-workflow.handler.ts index 84e67ab35e..04cabd022d 100644 --- a/packages/server-ai/src/xpert/queries/handlers/get-xpert-workflow.handler.ts +++ b/packages/server-ai/src/xpert/queries/handlers/get-xpert-workflow.handler.ts @@ -19,7 +19,8 @@ export class GetXpertWorkflowHandler implements IQueryHandler { const { id, agentKey: keyOrName, draft } = command - const xpert = await this.service.findOne(id, { + // Workflow compilation is a runtime read; authoring findOne() would reject run-only UserGroup users. + const xpert = await this.service.findOneForRuntime(id, { relations: [ 'agent', 'agent.copilotModel', diff --git a/packages/server/src/bootstrap/index.ts b/packages/server/src/bootstrap/index.ts index d31f5d39ca..2bb58a082b 100644 --- a/packages/server/src/bootstrap/index.ts +++ b/packages/server/src/bootstrap/index.ts @@ -43,8 +43,7 @@ export async function bootstrap(pluginConfig?: Partial): Promise): Promise = {}) { const plugins = pluginConfig.plugins ?? getConfig().plugins const { subscribers: pluginSubscribers } = collectPluginOrmMetadata(plugins) - return mergeSubscriberClasses( - coreSubscribers as Array>, - pluginSubscribers - ) + return mergeSubscriberClasses(coreSubscribers as Array>, pluginSubscribers) } export * from './cache' diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6c63e8ad42..c357492ce8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -669,8 +669,8 @@ importers: specifier: ~0.4.5 version: 0.4.5(@a2ui/lit@0.8.3(signal-polyfill@0.2.2))(@langchain/core@0.3.72(@opentelemetry/api@1.9.1)(@opentelemetry/sdk-trace-base@2.7.1(@opentelemetry/api@1.9.1))(openai@5.23.2(ws@8.21.0)(zod@3.25.67))) '@xpert-ai/chatkit-ui': - specifier: ~0.4.6 - version: 0.4.6(@a2ui/lit@0.8.3(signal-polyfill@0.2.2))(@opentelemetry/api@1.9.1)(@opentelemetry/sdk-trace-base@2.7.1(@opentelemetry/api@1.9.1))(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(babel-plugin-macros@3.1.0)(openai@5.23.2(ws@8.21.0)(zod@3.25.67))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(signal-polyfill@0.2.2)(typescript@5.9.3)(vite@7.3.0(@types/node@22.20.0)(jiti@2.7.0)(less@4.5.1)(lightningcss@1.32.0)(sass-embedded@1.97.3)(sass@1.97.3)(stylus@0.59.0)(terser@5.46.0)(yaml@2.8.2)) + specifier: ~0.4.7 + version: 0.4.7(@a2ui/lit@0.8.3(signal-polyfill@0.2.2))(@opentelemetry/api@1.9.1)(@opentelemetry/sdk-trace-base@2.7.1(@opentelemetry/api@1.9.1))(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(babel-plugin-macros@3.1.0)(openai@5.23.2(ws@8.21.0)(zod@3.25.67))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(signal-polyfill@0.2.2)(typescript@5.9.3)(vite@7.3.0(@types/node@22.20.0)(jiti@2.7.0)(less@4.5.1)(lightningcss@1.32.0)(sass-embedded@1.97.3)(sass@1.97.3)(stylus@0.59.0)(terser@5.46.0)(yaml@2.8.2)) '@xpert-ai/chatkit-web-component': specifier: ~0.4.0 version: 0.4.0(@a2ui/lit@0.8.3(signal-polyfill@0.2.2))(@langchain/core@0.3.72(@opentelemetry/api@1.9.1)(@opentelemetry/sdk-trace-base@2.7.1(@opentelemetry/api@1.9.1))(openai@5.23.2(ws@8.21.0)(zod@3.25.67))) @@ -1831,6 +1831,9 @@ importers: xmldom: specifier: ^0.6.0 version: 0.6.0 + yjs: + specifier: 13.6.31 + version: 13.6.31 zod-to-json-schema: specifier: ^3.23.2 version: 3.25.1(zod@3.25.67) @@ -10067,8 +10070,8 @@ packages: '@a2ui/lit': ^0.8.1 '@langchain/core': 0.3.72 - '@xpert-ai/chatkit-ui@0.4.6': - resolution: {integrity: sha512-neWsFQrgPaCwETeuKxYw/mJJIFpl+tW1vZP7DJTeaYc/9pjwVm+BecLrPxz4w+CBwbDXGs3ce2WYCmA334J4tQ==} + '@xpert-ai/chatkit-ui@0.4.7': + resolution: {integrity: sha512-8/dS9abuNjB8o2E4xHBJaXZBhKB8Iw5FLZIZxgyXlLMkoGS64i9AxsYI7z7owITKdlg/c6r8TeeavrpuBPva/w==} peerDependencies: react: '>=19' react-dom: '>=19' @@ -14916,6 +14919,9 @@ packages: peerDependencies: ws: '*' + isomorphic.js@0.2.5: + resolution: {integrity: sha512-PIeMbHqMt4DnUP3MA/Flc0HElYjMXArsw1qwJZcm9sqR8mq3l8NYizFMty0pWwE/tzIGH3EKK5+jes5mAr85yw==} + isstream@0.1.2: resolution: {integrity: sha512-Yljz7ffyPbrLpLngrMtZ7NduUgVvi6wG9RJ9IUcyCd59YQ911PBJphODUcbOVbqYfxe1wuYf/LJ8PauMRwsM/g==} @@ -15612,6 +15618,11 @@ packages: resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} engines: {node: '>= 0.8.0'} + lib0@0.2.117: + resolution: {integrity: sha512-DeXj9X5xDCjgKLU/7RR+/HQEVzuuEUiwldwOGsHK/sfAfELGWEyTcf0x+uOvCvK3O2zPmZePXWL85vtia6GyZw==} + engines: {node: '>=16'} + hasBin: true + libbase64@1.3.0: resolution: {integrity: sha512-GgOXd0Eo6phYgh0DJtjQ2tO8dc0IVINtZJeARPeiIJqge+HdsWSuaDTe8ztQ7j/cONByDZ3zeB325AHiv5O0dg==} @@ -22037,6 +22048,10 @@ packages: resolution: {integrity: sha512-Ow9nuGZE+qp1u4JIPvg+uCiUr7xGQWdff7JQSk5VGYTAZMDe2q8lxJ10ygv10qmSj031Ty/6FNJpLO4o1Sgc+w==} engines: {node: '>=12'} + yjs@13.6.31: + resolution: {integrity: sha512-Eq+5BRfbeGyqGVrTJL3bEcr8gKkxPuyuoHmAwpk52fDb8kOVMrfVSTRPd6yiGgX5Fskb96qCRjzjbRjrL4YEnw==} + engines: {node: '>=16.0.0', npm: '>=8.0.0'} + yn@3.1.1: resolution: {integrity: sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==} engines: {node: '>=6'} @@ -33369,7 +33384,7 @@ snapshots: '@a2ui/lit': 0.8.3(signal-polyfill@0.2.2) '@langchain/core': 0.3.72(@opentelemetry/api@1.9.1)(@opentelemetry/sdk-trace-base@2.7.1(@opentelemetry/api@1.9.1))(openai@5.23.2(ws@8.21.0)(zod@3.25.67)) - '@xpert-ai/chatkit-ui@0.4.6(@a2ui/lit@0.8.3(signal-polyfill@0.2.2))(@opentelemetry/api@1.9.1)(@opentelemetry/sdk-trace-base@2.7.1(@opentelemetry/api@1.9.1))(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(babel-plugin-macros@3.1.0)(openai@5.23.2(ws@8.21.0)(zod@3.25.67))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(signal-polyfill@0.2.2)(typescript@5.9.3)(vite@7.3.0(@types/node@22.20.0)(jiti@2.7.0)(less@4.5.1)(lightningcss@1.32.0)(sass-embedded@1.97.3)(sass@1.97.3)(stylus@0.59.0)(terser@5.46.0)(yaml@2.8.2))': + '@xpert-ai/chatkit-ui@0.4.7(@a2ui/lit@0.8.3(signal-polyfill@0.2.2))(@opentelemetry/api@1.9.1)(@opentelemetry/sdk-trace-base@2.7.1(@opentelemetry/api@1.9.1))(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(babel-plugin-macros@3.1.0)(openai@5.23.2(ws@8.21.0)(zod@3.25.67))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(signal-polyfill@0.2.2)(typescript@5.9.3)(vite@7.3.0(@types/node@22.20.0)(jiti@2.7.0)(less@4.5.1)(lightningcss@1.32.0)(sass-embedded@1.97.3)(sass@1.97.3)(stylus@0.59.0)(terser@5.46.0)(yaml@2.8.2))': dependencies: '@fontsource-variable/geist': 5.2.9 '@fontsource-variable/inter': 5.2.8 @@ -38679,7 +38694,7 @@ snapshots: isstream: 0.1.2 jsonwebtoken: 9.0.3 mime-types: 2.1.35 - retry-axios: 2.6.0(axios@1.18.1(debug@4.4.3)) + retry-axios: 2.6.0(axios@1.18.1) tough-cookie: 4.1.4 transitivePeerDependencies: - supports-color @@ -39108,6 +39123,8 @@ snapshots: dependencies: ws: 8.18.0 + isomorphic.js@0.2.5: {} + isstream@0.1.2: {} istanbul-lib-coverage@3.2.2: {} @@ -40165,6 +40182,10 @@ snapshots: prelude-ls: 1.2.1 type-check: 0.4.0 + lib0@0.2.117: + dependencies: + isomorphic.js: 0.2.5 + libbase64@1.3.0: {} libmime@5.3.7: @@ -44936,7 +44957,7 @@ snapshots: ret@0.1.15: {} - retry-axios@2.6.0(axios@1.18.1(debug@4.4.3)): + retry-axios@2.6.0(axios@1.18.1): dependencies: axios: 1.18.1(debug@4.4.3) @@ -48273,6 +48294,10 @@ snapshots: buffer-crc32: 0.2.13 pend: 1.2.0 + yjs@13.6.31: + dependencies: + lib0: 0.2.117 + yn@3.1.1: {} yocto-queue@0.1.0: {}