diff --git a/api/server/routes/admin/skills.js b/api/server/routes/admin/skills.js index 0de17b9adf..54e54004ff 100644 --- a/api/server/routes/admin/skills.js +++ b/api/server/routes/admin/skills.js @@ -1,6 +1,5 @@ const express = require('express'); -const { skillSyncConfigSchema } = require('librechat-data-provider'); -const { createAdminSkillsSyncHandlers } = require('@librechat/api'); +const { createAdminSkillsSyncAccess, createAdminSkillsSyncHandlers } = require('@librechat/api'); const { SystemCapabilities } = require('@librechat/data-schemas'); const { hasCapability, requireCapability } = require('~/server/middleware/roles/capabilities'); const { requireJwtAuth } = require('~/server/middleware'); @@ -12,117 +11,9 @@ const configMiddleware = require('~/server/middleware/config/app'); const router = express.Router(); const requireAdminAccess = requireCapability(SystemCapabilities.ACCESS_ADMIN); -function getCapabilityUser(req, { platformOnly = false } = {}) { - const id = req.user?.id ?? req.user?._id?.toString?.(); - if (!id) { - return null; - } - return { - id, - role: req.user?.role ?? '', - ...(platformOnly ? {} : { tenantId: req.user?.tenantId }), - }; -} - -function parseSkillSyncConfig(raw) { - if (!raw || typeof raw !== 'object') { - return undefined; - } - const parsed = skillSyncConfigSchema.safeParse(raw); - return parsed.success ? parsed.data : undefined; -} - -function isSameSkillSyncConfig(left, right) { - return JSON.stringify(left ?? null) === JSON.stringify(right ?? null); -} - -function hasResolvedSkillSyncOverride(req) { - const resolved = parseSkillSyncConfig(req.config?.skillSync); - const base = parseSkillSyncConfig(req.config?.config?.skillSync); - return Boolean(resolved?.github && !isSameSkillSyncConfig(resolved, base)); -} - -async function attachBaseSkillSyncConfig(req, res, next) { - try { - const baseConfig = await getAppConfig({ baseOnly: true }); - req.config = { - ...(req.config ?? {}), - config: { - ...(req.config?.config ?? {}), - skillSync: baseConfig?.skillSync, - }, - }; - return next(); - } catch { - return res.status(500).json({ message: 'Internal Server Error' }); - } -} - -async function hasSkillCapability(req, capability, { platformOnly = false } = {}) { - const user = getCapabilityUser(req, { platformOnly }); - if (!user) { - return false; - } - return hasCapability(user, capability); -} - -function requireSkillCapability(capability, { platformOnly = false } = {}) { - return async (req, res, next) => { - try { - const user = getCapabilityUser(req, { platformOnly }); - if (!user) { - return res.status(401).json({ message: 'Authentication required' }); - } - if (await hasCapability(user, capability)) { - return next(); - } - return res.status(403).json({ message: 'Forbidden' }); - } catch { - return res.status(500).json({ message: 'Internal Server Error' }); - } - }; -} - -async function attachCredentialReadAccess(req, res, next) { - try { - const canReadCredentials = await hasSkillCapability(req, SystemCapabilities.READ_SKILLS, { - platformOnly: true, - }); - req.skillSyncCanReadCredentials = canReadCredentials; - req.skillSyncAllowServerCredentials = canReadCredentials; - return next(); - } catch { - return res.status(500).json({ message: 'Internal Server Error' }); - } -} - -async function requireSyncRunCapability(req, res, next) { - try { - const canManagePlatform = await hasSkillCapability(req, SystemCapabilities.MANAGE_SKILLS, { - platformOnly: true, - }); - if (canManagePlatform) { - req.skillSyncAllowServerCredentials = true; - req.skillSyncCanReadCredentials = true; - return next(); - } - if ( - hasResolvedSkillSyncOverride(req) && - (await hasSkillCapability(req, SystemCapabilities.MANAGE_SKILLS)) - ) { - return res.status(403).json({ - message: 'Tenant-scoped manual skill sync requires platform credential access', - }); - } - return res.status(403).json({ message: 'Forbidden' }); - } catch { - return res.status(500).json({ message: 'Internal Server Error' }); - } -} - -const requireReadSkills = requireSkillCapability(SystemCapabilities.READ_SKILLS); -const requirePlatformManageSkills = requireSkillCapability(SystemCapabilities.MANAGE_SKILLS, { - platformOnly: true, +const syncAccess = createAdminSkillsSyncAccess({ + getAppConfig, + hasCapability, }); const handlers = createAdminSkillsSyncHandlers({ @@ -131,14 +22,28 @@ const handlers = createAdminSkillsSyncHandlers({ deleteCredential: deleteSkillSyncCredential, }); -router.use(requireJwtAuth, requireAdminAccess, configMiddleware, attachBaseSkillSyncConfig); +router.use( + requireJwtAuth, + requireAdminAccess, + configMiddleware, + syncAccess.attachBaseSkillSyncConfig, +); -router.get('/sync/status', requireReadSkills, attachCredentialReadAccess, handlers.getSyncStatus); -router.post('/sync/run', requireSyncRunCapability, handlers.runSync); -router.put('/sync/credentials/:credentialKey', requirePlatformManageSkills, handlers.setCredential); +router.get( + '/sync/status', + syncAccess.requireReadSkills, + syncAccess.attachCredentialReadAccess, + handlers.getSyncStatus, +); +router.post('/sync/run', syncAccess.requireSyncRunCapability, handlers.runSync); +router.put( + '/sync/credentials/:credentialKey', + syncAccess.requirePlatformManageSkills, + handlers.setCredential, +); router.delete( '/sync/credentials/:credentialKey', - requirePlatformManageSkills, + syncAccess.requirePlatformManageSkills, handlers.deleteCredential, ); diff --git a/api/server/routes/admin/skills.test.js b/api/server/routes/admin/skills.test.js index 382cac71f5..f452d6ea21 100644 --- a/api/server/routes/admin/skills.test.js +++ b/api/server/routes/admin/skills.test.js @@ -8,9 +8,8 @@ const mockRequireJwtAuth = jest.fn((req, res, next) => { const mockCapabilityMiddleware = jest.fn((req, res, next) => next()); const mockRequireCapability = jest.fn(() => mockCapabilityMiddleware); const mockHasCapability = jest.fn().mockResolvedValue(true); -let mockResolvedConfig = { skillSync: { github: { enabled: false, sources: [] } } }; const mockConfigMiddleware = jest.fn((req, res, next) => { - req.config = mockResolvedConfig; + req.config = { skillSync: { github: { enabled: false, sources: [] } } }; next(); }); const mockGetAppConfig = jest.fn(); @@ -21,16 +20,22 @@ const mockHandlers = { setCredential: jest.fn((req, res) => res.status(200).json({ ok: true })), deleteCredential: jest.fn((req, res) => res.status(200).json({ ok: true })), }; +const mockSyncAccess = { + attachBaseSkillSyncConfig: jest.fn((req, res, next) => next()), + requireReadSkills: jest.fn((req, res, next) => next()), + attachCredentialReadAccess: jest.fn((req, res, next) => next()), + requireSyncRunCapability: jest.fn((req, res, next) => next()), + requirePlatformManageSkills: jest.fn((req, res, next) => next()), +}; jest.mock('@librechat/data-schemas', () => ({ SystemCapabilities: { ACCESS_ADMIN: 'access:admin', - READ_SKILLS: 'read:skills', - MANAGE_SKILLS: 'manage:skills', }, })); jest.mock('@librechat/api', () => ({ + createAdminSkillsSyncAccess: jest.fn(() => mockSyncAccess), createAdminSkillsSyncHandlers: jest.fn(() => mockHandlers), })); @@ -61,12 +66,10 @@ jest.mock('~/server/services/Skills/sync', () => ({ describe('admin skills sync routes', () => { beforeEach(() => { jest.clearAllMocks(); - mockHasCapability.mockResolvedValue(true); - mockResolvedConfig = { skillSync: { github: { enabled: false, sources: [] } } }; - mockGetAppConfig.mockResolvedValue({ skillSync: undefined }); }); function createApp() { + delete require.cache[require.resolve('./skills')]; const router = require('./skills'); const app = express(); app.use(express.json()); @@ -74,143 +77,43 @@ describe('admin skills sync routes', () => { return app; } - it('requires JWT auth and admin capabilities for sync endpoints', async () => { + it('delegates skill sync access policy to the API package', async () => { const app = createApp(); await request(app).get('/api/admin/skills/sync/status').expect(200); + + const { + createAdminSkillsSyncAccess, + createAdminSkillsSyncHandlers, + } = require('@librechat/api'); + expect(mockRequireCapability).toHaveBeenCalledWith('access:admin'); + expect(createAdminSkillsSyncAccess).toHaveBeenCalledWith({ + getAppConfig: mockGetAppConfig, + hasCapability: mockHasCapability, + }); + expect(createAdminSkillsSyncHandlers).toHaveBeenCalledWith( + expect.objectContaining({ getRunner: mockGetGitHubSkillSyncRunnerForRequest }), + ); + expect(mockRequireJwtAuth).toHaveBeenCalled(); + expect(mockCapabilityMiddleware).toHaveBeenCalled(); + expect(mockConfigMiddleware).toHaveBeenCalled(); + expect(mockSyncAccess.attachBaseSkillSyncConfig).toHaveBeenCalled(); + expect(mockSyncAccess.requireReadSkills).toHaveBeenCalled(); + expect(mockSyncAccess.attachCredentialReadAccess).toHaveBeenCalled(); + expect(mockHandlers.getSyncStatus).toHaveBeenCalled(); + }); + + it('mounts package access middlewares before each sync endpoint handler', async () => { + const app = createApp(); + await request(app).post('/api/admin/skills/sync/run').expect(200); await request(app).put('/api/admin/skills/sync/credentials/default').send({}).expect(200); await request(app).delete('/api/admin/skills/sync/credentials/default').expect(200); - expect(mockRequireCapability).toHaveBeenCalledWith('access:admin'); - expect(mockRequireJwtAuth).toHaveBeenCalled(); - expect(mockCapabilityMiddleware).toHaveBeenCalled(); - expect(mockConfigMiddleware).toHaveBeenCalled(); - expect(mockHasCapability).toHaveBeenCalledTimes(5); - expect(mockHasCapability).toHaveBeenCalledWith( - { id: 'user-1', role: 'ADMIN', tenantId: 'tenant-a' }, - 'read:skills', - ); - expect(mockHasCapability).toHaveBeenCalledWith({ id: 'user-1', role: 'ADMIN' }, 'read:skills'); - expect(mockHasCapability).toHaveBeenCalledWith( - { id: 'user-1', role: 'ADMIN' }, - 'manage:skills', - ); - const { createAdminSkillsSyncHandlers } = require('@librechat/api'); - expect(createAdminSkillsSyncHandlers).toHaveBeenCalledWith( - expect.objectContaining({ getRunner: mockGetGitHubSkillSyncRunnerForRequest }), - ); - }); - - it('marks credential metadata hidden for tenant-scoped status reads', async () => { - mockHasCapability.mockImplementation(async (user, capability) => { - if (capability === 'read:skills') { - return Boolean(user.tenantId); - } - return true; - }); - const app = createApp(); - - await request(app).get('/api/admin/skills/sync/status').expect(200); - - const req = mockHandlers.getSyncStatus.mock.calls[0][0]; - expect(req.skillSyncCanReadCredentials).toBe(false); - expect(req.skillSyncAllowServerCredentials).toBe(false); - }); - - it('prevents tenant admins from running overrides that require server credentials', async () => { - const skillSync = { - github: { - enabled: true, - intervalMinutes: 60, - runOnStartup: false, - sources: [ - { - id: 'tenant-skills', - owner: 'LibreChat', - repo: 'skills', - ref: 'main', - paths: ['skills'], - token: '${GITHUB_SKILLS_TOKEN}', - }, - ], - }, - }; - mockResolvedConfig = { skillSync, config: {} }; - mockHasCapability.mockImplementation(async (user, capability) => { - if (capability === 'manage:skills') { - return Boolean(user.tenantId); - } - return true; - }); - mockGetAppConfig.mockResolvedValue({ skillSync: undefined }); - const app = createApp(); - - await request(app).post('/api/admin/skills/sync/run').expect(403); - - expect(mockHandlers.runSync).not.toHaveBeenCalled(); - }); - - it('prevents tenant admins from manually running base skill sync config', async () => { - const skillSync = { - github: { - enabled: true, - intervalMinutes: 60, - runOnStartup: false, - sources: [ - { - id: 'base-skills', - owner: 'LibreChat', - repo: 'skills', - ref: 'main', - paths: ['skills'], - token: '${GITHUB_SKILLS_TOKEN}', - }, - ], - }, - }; - mockResolvedConfig = { skillSync }; - mockGetAppConfig.mockResolvedValue({ skillSync }); - mockHasCapability.mockImplementation(async (user, capability) => { - if (capability === 'manage:skills') { - return Boolean(user.tenantId); - } - return true; - }); - const app = createApp(); - - await request(app).post('/api/admin/skills/sync/run').expect(403); - - expect(mockHandlers.runSync).not.toHaveBeenCalled(); - }); - - it('allows platform admins to manually run base skill sync config with server credentials', async () => { - const skillSync = { - github: { - enabled: true, - intervalMinutes: 60, - runOnStartup: false, - sources: [ - { - id: 'base-skills', - owner: 'LibreChat', - repo: 'skills', - ref: 'main', - paths: ['skills'], - token: '${GITHUB_SKILLS_TOKEN}', - }, - ], - }, - }; - mockResolvedConfig = { skillSync }; - mockGetAppConfig.mockResolvedValue({ skillSync }); - const app = createApp(); - - await request(app).post('/api/admin/skills/sync/run').expect(200); - - const req = mockHandlers.runSync.mock.calls[0][0]; - expect(req.skillSyncAllowServerCredentials).toBe(true); - expect(req.skillSyncCanReadCredentials).toBe(true); - expect(req.config.config.skillSync).toEqual(skillSync); + expect(mockSyncAccess.requireSyncRunCapability).toHaveBeenCalled(); + expect(mockHandlers.runSync).toHaveBeenCalled(); + expect(mockSyncAccess.requirePlatformManageSkills).toHaveBeenCalledTimes(2); + expect(mockHandlers.setCredential).toHaveBeenCalled(); + expect(mockHandlers.deleteCredential).toHaveBeenCalled(); }); }); diff --git a/packages/api/src/admin/index.ts b/packages/api/src/admin/index.ts index d39e2e4fa1..90f826d7bf 100644 --- a/packages/api/src/admin/index.ts +++ b/packages/api/src/admin/index.ts @@ -2,10 +2,11 @@ export { createAdminConfigHandlers } from './config'; export { createAdminGrantsHandlers } from './grants'; export { createAdminGroupsHandlers } from './groups'; export { createAdminRolesHandlers } from './roles'; -export { createAdminSkillsSyncHandlers } from './skills'; +export { createAdminSkillsSyncAccess, createAdminSkillsSyncHandlers } from './skills'; export { createAdminUsersHandlers } from './users'; export type { AdminConfigDeps } from './config'; export type { AdminGrantsDeps, GrantPrincipalType } from './grants'; export type { AdminGroupsDeps } from './groups'; export type { AdminRolesDeps } from './roles'; +export type { AdminSkillSyncAccessDeps, AdminSkillSyncDeps } from './skills'; export type { AdminUsersDeps } from './users'; diff --git a/packages/api/src/admin/skills.spec.ts b/packages/api/src/admin/skills.spec.ts index 4f52d135fc..4453babbfb 100644 --- a/packages/api/src/admin/skills.spec.ts +++ b/packages/api/src/admin/skills.spec.ts @@ -1,5 +1,6 @@ -import type { Response } from 'express'; -import { createAdminSkillsSyncHandlers } from './skills'; +import { SystemCapabilities } from '@librechat/data-schemas'; +import type { NextFunction, Response } from 'express'; +import { createAdminSkillsSyncAccess, createAdminSkillsSyncHandlers } from './skills'; function createResponse() { const res = { @@ -12,6 +13,10 @@ function createResponse() { }; } +function createNext(): NextFunction & jest.Mock { + return jest.fn() as NextFunction & jest.Mock; +} + function createHandlers({ statusErrorCode, statusErrorMessage, @@ -184,3 +189,167 @@ describe('createAdminSkillsSyncHandlers', () => { ); }); }); + +describe('createAdminSkillsSyncAccess', () => { + const baseSkillSync = { + github: { + enabled: true, + intervalMinutes: 60, + runOnStartup: false, + sources: [ + { + id: 'base-skills', + owner: 'LibreChat', + repo: 'skills', + ref: 'main', + paths: ['skills'], + token: '${GITHUB_SKILLS_TOKEN}', + }, + ], + }, + }; + + function createAccess({ + hasCapability = jest.fn().mockResolvedValue(true), + getAppConfig = jest.fn().mockResolvedValue({ skillSync: undefined }), + }: { + hasCapability?: jest.Mock; + getAppConfig?: jest.Mock; + } = {}) { + return { + access: createAdminSkillsSyncAccess({ getAppConfig, hasCapability }), + getAppConfig, + hasCapability, + }; + } + + it('attaches the base skill sync config for override comparison', async () => { + const getAppConfig = jest.fn().mockResolvedValue({ skillSync: baseSkillSync }); + const { access } = createAccess({ getAppConfig }); + const req = { config: { skillSync: undefined, config: { endpoints: {} } } }; + const res = createResponse(); + const next = createNext(); + + await access.attachBaseSkillSyncConfig(req as never, res, next); + + expect(getAppConfig).toHaveBeenCalledWith({ baseOnly: true }); + expect(req.config.config).toEqual({ endpoints: {}, skillSync: baseSkillSync }); + expect(next).toHaveBeenCalledTimes(1); + }); + + it('marks credential metadata hidden for tenant-scoped status reads', async () => { + const hasCapability = jest.fn( + async (user: { tenantId?: string }, capability: string): Promise => { + if (capability === SystemCapabilities.READ_SKILLS) { + return Boolean(user.tenantId); + } + return true; + }, + ); + const { access } = createAccess({ hasCapability }); + const req = { user: { id: 'user-1', role: 'ADMIN', tenantId: 'tenant-a' } }; + const res = createResponse(); + const next = createNext(); + + await access.requireReadSkills(req as never, res, next); + await access.attachCredentialReadAccess(req as never, res, next); + + expect(req).toMatchObject({ + skillSyncCanReadCredentials: false, + skillSyncAllowServerCredentials: false, + }); + expect(hasCapability).toHaveBeenCalledWith( + { id: 'user-1', role: 'ADMIN', tenantId: 'tenant-a' }, + SystemCapabilities.READ_SKILLS, + ); + expect(hasCapability).toHaveBeenCalledWith( + { id: 'user-1', role: 'ADMIN' }, + SystemCapabilities.READ_SKILLS, + ); + }); + + it('prevents tenant admins from running overrides that require server credentials', async () => { + const tenantSkillSync = { + github: { + enabled: true, + intervalMinutes: 60, + runOnStartup: false, + sources: [ + { + id: 'tenant-skills', + owner: 'LibreChat', + repo: 'skills', + ref: 'main', + paths: ['skills'], + token: '${GITHUB_SKILLS_TOKEN}', + }, + ], + }, + }; + const hasCapability = jest.fn( + async (user: { tenantId?: string }, capability: string): Promise => { + if (capability === SystemCapabilities.MANAGE_SKILLS) { + return Boolean(user.tenantId); + } + return true; + }, + ); + const { access } = createAccess({ hasCapability }); + const req = { + user: { id: 'user-1', role: 'ADMIN', tenantId: 'tenant-a' }, + config: { skillSync: tenantSkillSync, config: {} }, + }; + const res = createResponse(); + const next = createNext(); + + await access.requireSyncRunCapability(req as never, res, next); + + expect(res.status).toHaveBeenCalledWith(403); + expect(res.json).toHaveBeenCalledWith({ + message: 'Tenant-scoped manual skill sync requires platform credential access', + }); + expect(next).not.toHaveBeenCalled(); + }); + + it('prevents tenant admins from manually running base skill sync config', async () => { + const hasCapability = jest.fn( + async (user: { tenantId?: string }, capability: string): Promise => { + if (capability === SystemCapabilities.MANAGE_SKILLS) { + return Boolean(user.tenantId); + } + return true; + }, + ); + const { access } = createAccess({ hasCapability }); + const req = { + user: { id: 'user-1', role: 'ADMIN', tenantId: 'tenant-a' }, + config: { skillSync: baseSkillSync, config: { skillSync: baseSkillSync } }, + }; + const res = createResponse(); + const next = createNext(); + + await access.requireSyncRunCapability(req as never, res, next); + + expect(res.status).toHaveBeenCalledWith(403); + expect(res.json).toHaveBeenCalledWith({ message: 'Forbidden' }); + expect(next).not.toHaveBeenCalled(); + }); + + it('allows platform admins to manually run base skill sync config with server credentials', async () => { + const { access } = createAccess(); + const req = { + user: { id: 'user-1', role: 'ADMIN', tenantId: 'tenant-a' }, + config: { skillSync: baseSkillSync, config: { skillSync: baseSkillSync } }, + }; + const res = createResponse(); + const next = createNext(); + + await access.requireSyncRunCapability(req as never, res, next); + + expect(req).toMatchObject({ + skillSyncAllowServerCredentials: true, + skillSyncCanReadCredentials: true, + }); + expect(next).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/api/src/admin/skills.ts b/packages/api/src/admin/skills.ts index d236b31be9..d6d6db3291 100644 --- a/packages/api/src/admin/skills.ts +++ b/packages/api/src/admin/skills.ts @@ -1,16 +1,20 @@ +import { SystemCapabilities } from '@librechat/data-schemas'; +import { skillSyncConfigSchema } from 'librechat-data-provider'; import type { TGitHubSkillSyncStatusResponse, TGitHubSkillSyncSourceStatus, TGitHubSkillSyncCredentialSummary, TGitHubSkillSyncManualRunResponse, + SkillSyncConfig, } from 'librechat-data-provider'; import type { ISkillSyncStatus, SkillSyncProvider, SkillSyncCredentialSummary, UpsertSkillSyncCredentialInput, + SystemCapability, } from '@librechat/data-schemas'; -import type { Request, Response } from 'express'; +import type { NextFunction, Request, RequestHandler, Response } from 'express'; import type { Types } from 'mongoose'; import type { GitHubSkillSyncRunner } from '~/skills/sync'; @@ -23,6 +27,31 @@ type AdminSkillsRequest = Request & { skillSyncCanReadCredentials?: boolean; }; +type SkillSyncConfigContainer = { + skillSync?: unknown; + config?: { + skillSync?: unknown; + } & Record; +} & Record; + +type AdminSkillSyncAccessRequest = Request & { + user?: { + _id?: Types.ObjectId | { toString(): string }; + id?: string; + role?: string; + tenantId?: string; + }; + config?: SkillSyncConfigContainer; + skillSyncAllowServerCredentials?: boolean; + skillSyncCanReadCredentials?: boolean; +}; + +type SkillSyncCapabilityUser = { + id: string; + role: string; + tenantId?: string; +}; + export type AdminSkillSyncDeps = { runner?: GitHubSkillSyncRunner; getRunner?: (req: Request) => GitHubSkillSyncRunner; @@ -33,6 +62,11 @@ export type AdminSkillSyncDeps = { ) => Promise<{ deleted: boolean }>; }; +export type AdminSkillSyncAccessDeps = { + getAppConfig: (options: { baseOnly: true }) => Promise<{ skillSync?: unknown } | undefined>; + hasCapability: (user: SkillSyncCapabilityUser, capability: SystemCapability) => Promise; +}; + const CREDENTIAL_KEY_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$/; function toIso(date: Date | undefined): string | undefined { @@ -109,6 +143,161 @@ function getUserObjectId(req: AdminSkillsRequest): Types.ObjectId | undefined { return req.user?._id; } +function getCapabilityUser( + req: AdminSkillSyncAccessRequest, + { platformOnly = false }: { platformOnly?: boolean } = {}, +): SkillSyncCapabilityUser | null { + const id = req.user?.id ?? req.user?._id?.toString?.(); + if (!id) { + return null; + } + return { + id, + role: req.user?.role ?? '', + ...(platformOnly ? {} : { tenantId: req.user?.tenantId }), + }; +} + +function parseSkillSyncConfig(raw: unknown): SkillSyncConfig | undefined { + if (!raw || typeof raw !== 'object') { + return undefined; + } + const parsed = skillSyncConfigSchema.safeParse(raw); + return parsed.success ? parsed.data : undefined; +} + +function isSameSkillSyncConfig(left: SkillSyncConfig, right: SkillSyncConfig): boolean { + return JSON.stringify(left ?? null) === JSON.stringify(right ?? null); +} + +function hasResolvedSkillSyncOverride(req: AdminSkillSyncAccessRequest): boolean { + const resolved = parseSkillSyncConfig(req.config?.skillSync); + const base = parseSkillSyncConfig(req.config?.config?.skillSync); + return Boolean(resolved?.github && !isSameSkillSyncConfig(resolved, base)); +} + +function sendInternalServerError(res: Response): void { + res.status(500).json({ message: 'Internal Server Error' }); +} + +export function createAdminSkillsSyncAccess(deps: AdminSkillSyncAccessDeps) { + async function hasSkillCapability( + req: AdminSkillSyncAccessRequest, + capability: SystemCapability, + { platformOnly = false }: { platformOnly?: boolean } = {}, + ): Promise { + const user = getCapabilityUser(req, { platformOnly }); + if (!user) { + return false; + } + return deps.hasCapability(user, capability); + } + + function requireSkillCapability( + capability: SystemCapability, + { platformOnly = false }: { platformOnly?: boolean } = {}, + ): RequestHandler { + return async ( + req: AdminSkillSyncAccessRequest, + res: Response, + next: NextFunction, + ): Promise => { + try { + const user = getCapabilityUser(req, { platformOnly }); + if (!user) { + res.status(401).json({ message: 'Authentication required' }); + return; + } + if (await deps.hasCapability(user, capability)) { + next(); + return; + } + res.status(403).json({ message: 'Forbidden' }); + } catch { + sendInternalServerError(res); + } + }; + } + + const attachBaseSkillSyncConfig: RequestHandler = async ( + req: AdminSkillSyncAccessRequest, + res: Response, + next: NextFunction, + ): Promise => { + try { + const baseConfig = await deps.getAppConfig({ baseOnly: true }); + const existingConfig = req.config ?? {}; + req.config = { + ...existingConfig, + config: { + ...(existingConfig.config ?? {}), + skillSync: baseConfig?.skillSync, + }, + }; + next(); + } catch { + sendInternalServerError(res); + } + }; + + const attachCredentialReadAccess: RequestHandler = async ( + req: AdminSkillSyncAccessRequest, + res: Response, + next: NextFunction, + ): Promise => { + try { + const canReadCredentials = await hasSkillCapability(req, SystemCapabilities.READ_SKILLS, { + platformOnly: true, + }); + req.skillSyncCanReadCredentials = canReadCredentials; + req.skillSyncAllowServerCredentials = canReadCredentials; + next(); + } catch { + sendInternalServerError(res); + } + }; + + const requireSyncRunCapability: RequestHandler = async ( + req: AdminSkillSyncAccessRequest, + res: Response, + next: NextFunction, + ): Promise => { + try { + const canManagePlatform = await hasSkillCapability(req, SystemCapabilities.MANAGE_SKILLS, { + platformOnly: true, + }); + if (canManagePlatform) { + req.skillSyncAllowServerCredentials = true; + req.skillSyncCanReadCredentials = true; + next(); + return; + } + if ( + hasResolvedSkillSyncOverride(req) && + (await hasSkillCapability(req, SystemCapabilities.MANAGE_SKILLS)) + ) { + res.status(403).json({ + message: 'Tenant-scoped manual skill sync requires platform credential access', + }); + return; + } + res.status(403).json({ message: 'Forbidden' }); + } catch { + sendInternalServerError(res); + } + }; + + return { + attachBaseSkillSyncConfig, + attachCredentialReadAccess, + requireReadSkills: requireSkillCapability(SystemCapabilities.READ_SKILLS), + requirePlatformManageSkills: requireSkillCapability(SystemCapabilities.MANAGE_SKILLS, { + platformOnly: true, + }), + requireSyncRunCapability, + }; +} + export function createAdminSkillsSyncHandlers(deps: AdminSkillSyncDeps) { function getRunner(req: Request): GitHubSkillSyncRunner { const runner = deps.getRunner?.(req) ?? deps.runner;