refactor: move skill sync admin access to api package

This commit is contained in:
Danny Avila 2026-06-07 21:41:18 -04:00
parent 13508fcbe1
commit c47bc1ecfc
5 changed files with 427 additions and 260 deletions

View file

@ -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,
);

View file

@ -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();
});
});

View file

@ -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';

View file

@ -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<boolean> => {
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<boolean> => {
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<boolean> => {
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);
});
});

View file

@ -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<string, unknown>;
} & Record<string, unknown>;
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<boolean>;
};
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<boolean> {
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<void> => {
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<void> => {
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<void> => {
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<void> => {
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;