feat: replace profiles with kubernetes-style secrets
Replace the confused Profile abstraction with a dedicated Secret resource
following Kubernetes conventions. Servers now have env entries with inline
values or secretRef references. Env vars are resolved and passed to
containers at startup (fixes existing gap).
- Add Secret CRUD (model, repo, service, routes, CLI commands)
- Server env: {name, value} or {name, valueFrom: {secretRef: {name, key}}}
- Add env-resolver utility shared by instance startup and config generation
- Remove all profile-related code (models, services, routes, CLI, tests)
- Update backup/restore for secrets instead of profiles
- describe secret masks values by default, --show-values to reveal
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -4,7 +4,7 @@ import { BackupService } from '../src/services/backup/backup-service.js';
|
||||
import { RestoreService } from '../src/services/backup/restore-service.js';
|
||||
import { encrypt, decrypt, isSensitiveKey } from '../src/services/backup/crypto.js';
|
||||
import { registerBackupRoutes } from '../src/routes/backup.js';
|
||||
import type { IMcpServerRepository, IMcpProfileRepository } from '../src/repositories/interfaces.js';
|
||||
import type { IMcpServerRepository, ISecretRepository } from '../src/repositories/interfaces.js';
|
||||
import type { IProjectRepository } from '../src/repositories/project.repository.js';
|
||||
|
||||
// Mock data
|
||||
@@ -12,19 +12,19 @@ const mockServers = [
|
||||
{
|
||||
id: 's1', name: 'github', description: 'GitHub MCP', packageName: '@mcp/github',
|
||||
dockerImage: null, transport: 'STDIO' as const, repositoryUrl: null,
|
||||
envTemplate: [], version: 1, createdAt: new Date(), updatedAt: new Date(),
|
||||
env: [], version: 1, createdAt: new Date(), updatedAt: new Date(),
|
||||
},
|
||||
{
|
||||
id: 's2', name: 'slack', description: 'Slack MCP', packageName: null,
|
||||
dockerImage: 'mcp/slack:latest', transport: 'SSE' as const, repositoryUrl: null,
|
||||
envTemplate: [], version: 1, createdAt: new Date(), updatedAt: new Date(),
|
||||
env: [], version: 1, createdAt: new Date(), updatedAt: new Date(),
|
||||
},
|
||||
];
|
||||
|
||||
const mockProfiles = [
|
||||
const mockSecrets = [
|
||||
{
|
||||
id: 'p1', name: 'default', serverId: 's1', permissions: ['read'],
|
||||
envOverrides: { GITHUB_TOKEN: 'ghp_secret123' },
|
||||
id: 'sec1', name: 'github-secrets',
|
||||
data: { GITHUB_TOKEN: 'ghp_secret123' },
|
||||
version: 1, createdAt: new Date(), updatedAt: new Date(),
|
||||
},
|
||||
];
|
||||
@@ -41,19 +41,19 @@ function mockServerRepo(): IMcpServerRepository {
|
||||
findAll: vi.fn(async () => [...mockServers]),
|
||||
findById: vi.fn(async (id: string) => mockServers.find((s) => s.id === id) ?? null),
|
||||
findByName: vi.fn(async (name: string) => mockServers.find((s) => s.name === name) ?? null),
|
||||
create: vi.fn(async (data) => ({ id: 'new-s', ...data, envTemplate: [], version: 1, createdAt: new Date(), updatedAt: new Date() } as typeof mockServers[0])),
|
||||
create: vi.fn(async (data) => ({ id: 'new-s', ...data, env: [], version: 1, createdAt: new Date(), updatedAt: new Date() } as typeof mockServers[0])),
|
||||
update: vi.fn(async (id, data) => ({ ...mockServers.find((s) => s.id === id)!, ...data })),
|
||||
delete: vi.fn(async () => {}),
|
||||
};
|
||||
}
|
||||
|
||||
function mockProfileRepo(): IMcpProfileRepository {
|
||||
function mockSecretRepo(): ISecretRepository {
|
||||
return {
|
||||
findAll: vi.fn(async () => [...mockProfiles]),
|
||||
findById: vi.fn(async (id: string) => mockProfiles.find((p) => p.id === id) ?? null),
|
||||
findByServerAndName: vi.fn(async () => null),
|
||||
create: vi.fn(async (data) => ({ id: 'new-p', ...data, version: 1, createdAt: new Date(), updatedAt: new Date() } as typeof mockProfiles[0])),
|
||||
update: vi.fn(async (id, data) => ({ ...mockProfiles.find((p) => p.id === id)!, ...data })),
|
||||
findAll: vi.fn(async () => [...mockSecrets]),
|
||||
findById: vi.fn(async (id: string) => mockSecrets.find((s) => s.id === id) ?? null),
|
||||
findByName: vi.fn(async (name: string) => mockSecrets.find((s) => s.name === name) ?? null),
|
||||
create: vi.fn(async (data) => ({ id: 'new-sec', ...data, version: 1, createdAt: new Date(), updatedAt: new Date() } as typeof mockSecrets[0])),
|
||||
update: vi.fn(async (id, data) => ({ ...mockSecrets.find((s) => s.id === id)!, ...data })),
|
||||
delete: vi.fn(async () => {}),
|
||||
};
|
||||
}
|
||||
@@ -66,8 +66,6 @@ function mockProjectRepo(): IProjectRepository {
|
||||
create: vi.fn(async (data) => ({ id: 'new-proj', ...data, version: 1, createdAt: new Date(), updatedAt: new Date() } as typeof mockProjects[0])),
|
||||
update: vi.fn(async (id, data) => ({ ...mockProjects.find((p) => p.id === id)!, ...data })),
|
||||
delete: vi.fn(async () => {}),
|
||||
setProfiles: vi.fn(async () => {}),
|
||||
getProfileIds: vi.fn(async () => ['p1']),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -112,7 +110,7 @@ describe('BackupService', () => {
|
||||
let backupService: BackupService;
|
||||
|
||||
beforeEach(() => {
|
||||
backupService = new BackupService(mockServerRepo(), mockProfileRepo(), mockProjectRepo());
|
||||
backupService = new BackupService(mockServerRepo(), mockProjectRepo(), mockSecretRepo());
|
||||
});
|
||||
|
||||
it('creates backup with all resources', async () => {
|
||||
@@ -121,43 +119,43 @@ describe('BackupService', () => {
|
||||
expect(bundle.version).toBe('1');
|
||||
expect(bundle.encrypted).toBe(false);
|
||||
expect(bundle.servers).toHaveLength(2);
|
||||
expect(bundle.profiles).toHaveLength(1);
|
||||
expect(bundle.secrets).toHaveLength(1);
|
||||
expect(bundle.projects).toHaveLength(1);
|
||||
expect(bundle.servers[0]!.name).toBe('github');
|
||||
expect(bundle.profiles[0]!.serverName).toBe('github');
|
||||
expect(bundle.secrets[0]!.name).toBe('github-secrets');
|
||||
expect(bundle.projects[0]!.name).toBe('my-project');
|
||||
});
|
||||
|
||||
it('filters resources', async () => {
|
||||
const bundle = await backupService.createBackup({ resources: ['servers'] });
|
||||
expect(bundle.servers).toHaveLength(2);
|
||||
expect(bundle.profiles).toHaveLength(0);
|
||||
expect(bundle.secrets).toHaveLength(0);
|
||||
expect(bundle.projects).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('encrypts sensitive env values when password provided', async () => {
|
||||
it('encrypts sensitive secret values when password provided', async () => {
|
||||
const bundle = await backupService.createBackup({ password: 'test-pass' });
|
||||
|
||||
expect(bundle.encrypted).toBe(true);
|
||||
expect(bundle.encryptedSecrets).toBeDefined();
|
||||
// The GITHUB_TOKEN should be replaced with placeholder
|
||||
const overrides = bundle.profiles[0]!.envOverrides as Record<string, string>;
|
||||
expect(overrides['GITHUB_TOKEN']).toContain('__ENCRYPTED:');
|
||||
const data = bundle.secrets[0]!.data;
|
||||
expect(data['GITHUB_TOKEN']).toContain('__ENCRYPTED:');
|
||||
});
|
||||
|
||||
it('handles empty repositories', async () => {
|
||||
const emptyServerRepo = mockServerRepo();
|
||||
(emptyServerRepo.findAll as ReturnType<typeof vi.fn>).mockResolvedValue([]);
|
||||
const emptyProfileRepo = mockProfileRepo();
|
||||
(emptyProfileRepo.findAll as ReturnType<typeof vi.fn>).mockResolvedValue([]);
|
||||
const emptySecretRepo = mockSecretRepo();
|
||||
(emptySecretRepo.findAll as ReturnType<typeof vi.fn>).mockResolvedValue([]);
|
||||
const emptyProjectRepo = mockProjectRepo();
|
||||
(emptyProjectRepo.findAll as ReturnType<typeof vi.fn>).mockResolvedValue([]);
|
||||
|
||||
const service = new BackupService(emptyServerRepo, emptyProfileRepo, emptyProjectRepo);
|
||||
const service = new BackupService(emptyServerRepo, emptyProjectRepo, emptySecretRepo);
|
||||
const bundle = await service.createBackup();
|
||||
|
||||
expect(bundle.servers).toHaveLength(0);
|
||||
expect(bundle.profiles).toHaveLength(0);
|
||||
expect(bundle.secrets).toHaveLength(0);
|
||||
expect(bundle.projects).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -165,18 +163,18 @@ describe('BackupService', () => {
|
||||
describe('RestoreService', () => {
|
||||
let restoreService: RestoreService;
|
||||
let serverRepo: IMcpServerRepository;
|
||||
let profileRepo: IMcpProfileRepository;
|
||||
let secretRepo: ISecretRepository;
|
||||
let projectRepo: IProjectRepository;
|
||||
|
||||
beforeEach(() => {
|
||||
serverRepo = mockServerRepo();
|
||||
profileRepo = mockProfileRepo();
|
||||
secretRepo = mockSecretRepo();
|
||||
projectRepo = mockProjectRepo();
|
||||
// Default: nothing exists yet
|
||||
(serverRepo.findByName as ReturnType<typeof vi.fn>).mockResolvedValue(null);
|
||||
(profileRepo.findByServerAndName as ReturnType<typeof vi.fn>).mockResolvedValue(null);
|
||||
(secretRepo.findByName as ReturnType<typeof vi.fn>).mockResolvedValue(null);
|
||||
(projectRepo.findByName as ReturnType<typeof vi.fn>).mockResolvedValue(null);
|
||||
restoreService = new RestoreService(serverRepo, profileRepo, projectRepo);
|
||||
restoreService = new RestoreService(serverRepo, projectRepo, secretRepo);
|
||||
});
|
||||
|
||||
const validBundle = {
|
||||
@@ -184,9 +182,9 @@ describe('RestoreService', () => {
|
||||
mcpctlVersion: '0.1.0',
|
||||
createdAt: new Date().toISOString(),
|
||||
encrypted: false,
|
||||
servers: [{ name: 'github', description: 'GitHub', packageName: null, dockerImage: null, transport: 'STDIO', repositoryUrl: null, envTemplate: [] }],
|
||||
profiles: [{ name: 'default', serverName: 'github', permissions: ['read'], envOverrides: {} }],
|
||||
projects: [{ name: 'test-proj', description: 'Test', profileNames: ['default'] }],
|
||||
servers: [{ name: 'github', description: 'GitHub', packageName: null, dockerImage: null, transport: 'STDIO', repositoryUrl: null, env: [] }],
|
||||
secrets: [{ name: 'github-secrets', data: { GITHUB_TOKEN: 'ghp_123' } }],
|
||||
projects: [{ name: 'test-proj', description: 'Test' }],
|
||||
};
|
||||
|
||||
it('validates valid bundle', () => {
|
||||
@@ -203,11 +201,11 @@ describe('RestoreService', () => {
|
||||
const result = await restoreService.restore(validBundle);
|
||||
|
||||
expect(result.serversCreated).toBe(1);
|
||||
expect(result.profilesCreated).toBe(1);
|
||||
expect(result.secretsCreated).toBe(1);
|
||||
expect(result.projectsCreated).toBe(1);
|
||||
expect(result.errors).toHaveLength(0);
|
||||
expect(serverRepo.create).toHaveBeenCalled();
|
||||
expect(profileRepo.create).toHaveBeenCalled();
|
||||
expect(secretRepo.create).toHaveBeenCalled();
|
||||
expect(projectRepo.create).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -242,17 +240,17 @@ describe('RestoreService', () => {
|
||||
});
|
||||
|
||||
it('restores encrypted bundle with correct password', async () => {
|
||||
const secrets = { 'profile:default:API_KEY': 'secret-val' };
|
||||
const encryptedData = { 'secret:github-secrets:GITHUB_TOKEN': 'ghp_decrypted' };
|
||||
const encBundle = {
|
||||
...validBundle,
|
||||
encrypted: true,
|
||||
encryptedSecrets: encrypt(JSON.stringify(secrets), 'test-pw'),
|
||||
profiles: [{ ...validBundle.profiles[0]!, envOverrides: { API_KEY: '__ENCRYPTED:profile:default:API_KEY__' } }],
|
||||
encryptedSecrets: encrypt(JSON.stringify(encryptedData), 'test-pw'),
|
||||
secrets: [{ name: 'github-secrets', data: { GITHUB_TOKEN: '__ENCRYPTED:secret:github-secrets:GITHUB_TOKEN__' } }],
|
||||
};
|
||||
|
||||
const result = await restoreService.restore(encBundle, { password: 'test-pw' });
|
||||
expect(result.errors).toHaveLength(0);
|
||||
expect(result.profilesCreated).toBe(1);
|
||||
expect(result.secretsCreated).toBe(1);
|
||||
});
|
||||
|
||||
it('fails with wrong decryption password', async () => {
|
||||
@@ -272,17 +270,17 @@ describe('Backup Routes', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
const sRepo = mockServerRepo();
|
||||
const pRepo = mockProfileRepo();
|
||||
const secRepo = mockSecretRepo();
|
||||
const prRepo = mockProjectRepo();
|
||||
backupService = new BackupService(sRepo, pRepo, prRepo);
|
||||
backupService = new BackupService(sRepo, prRepo, secRepo);
|
||||
|
||||
const rSRepo = mockServerRepo();
|
||||
(rSRepo.findByName as ReturnType<typeof vi.fn>).mockResolvedValue(null);
|
||||
const rPRepo = mockProfileRepo();
|
||||
(rPRepo.findByServerAndName as ReturnType<typeof vi.fn>).mockResolvedValue(null);
|
||||
const rSecRepo = mockSecretRepo();
|
||||
(rSecRepo.findByName as ReturnType<typeof vi.fn>).mockResolvedValue(null);
|
||||
const rPrRepo = mockProjectRepo();
|
||||
(rPrRepo.findByName as ReturnType<typeof vi.fn>).mockResolvedValue(null);
|
||||
restoreService = new RestoreService(rSRepo, rPRepo, rPrRepo);
|
||||
restoreService = new RestoreService(rSRepo, rPrRepo, rSecRepo);
|
||||
});
|
||||
|
||||
async function buildApp() {
|
||||
@@ -303,7 +301,7 @@ describe('Backup Routes', () => {
|
||||
const body = res.json();
|
||||
expect(body.version).toBe('1');
|
||||
expect(body.servers).toBeDefined();
|
||||
expect(body.profiles).toBeDefined();
|
||||
expect(body.secrets).toBeDefined();
|
||||
expect(body.projects).toBeDefined();
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user