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:
@@ -48,9 +48,8 @@ export async function cleanupTestDb(): Promise<void> {
|
||||
export async function clearAllTables(client: PrismaClient): Promise<void> {
|
||||
// Delete in order respecting foreign keys
|
||||
await client.auditLog.deleteMany();
|
||||
await client.projectMcpProfile.deleteMany();
|
||||
await client.mcpInstance.deleteMany();
|
||||
await client.mcpProfile.deleteMany();
|
||||
await client.secret.deleteMany();
|
||||
await client.session.deleteMany();
|
||||
await client.project.deleteMany();
|
||||
await client.mcpServer.deleteMany();
|
||||
|
||||
@@ -123,7 +123,7 @@ describe('McpServer', () => {
|
||||
const server = await createServer();
|
||||
expect(server.transport).toBe('STDIO');
|
||||
expect(server.version).toBe(1);
|
||||
expect(server.envTemplate).toEqual([]);
|
||||
expect(server.env).toEqual([]);
|
||||
});
|
||||
|
||||
it('enforces unique name', async () => {
|
||||
@@ -131,18 +131,18 @@ describe('McpServer', () => {
|
||||
await expect(createServer({ name: 'slack' })).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('stores envTemplate as JSON', async () => {
|
||||
it('stores env as JSON', async () => {
|
||||
const server = await prisma.mcpServer.create({
|
||||
data: {
|
||||
name: 'with-env',
|
||||
envTemplate: [
|
||||
{ name: 'API_KEY', description: 'Key', isSecret: true },
|
||||
env: [
|
||||
{ name: 'API_KEY', value: 'test-key' },
|
||||
],
|
||||
},
|
||||
});
|
||||
const envTemplate = server.envTemplate as Array<{ name: string }>;
|
||||
expect(envTemplate).toHaveLength(1);
|
||||
expect(envTemplate[0].name).toBe('API_KEY');
|
||||
const env = server.env as Array<{ name: string }>;
|
||||
expect(env).toHaveLength(1);
|
||||
expect(env[0].name).toBe('API_KEY');
|
||||
});
|
||||
|
||||
it('supports SSE transport', async () => {
|
||||
@@ -151,43 +151,46 @@ describe('McpServer', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ── McpProfile model ──
|
||||
// ── Secret model ──
|
||||
|
||||
describe('McpProfile', () => {
|
||||
it('creates a profile linked to server', async () => {
|
||||
const server = await createServer();
|
||||
const profile = await prisma.mcpProfile.create({
|
||||
describe('Secret', () => {
|
||||
it('creates a secret with defaults', async () => {
|
||||
const secret = await prisma.secret.create({
|
||||
data: { name: 'my-secret' },
|
||||
});
|
||||
expect(secret.name).toBe('my-secret');
|
||||
expect(secret.data).toEqual({});
|
||||
expect(secret.version).toBe(1);
|
||||
});
|
||||
|
||||
it('stores key-value data as JSON', async () => {
|
||||
const secret = await prisma.secret.create({
|
||||
data: {
|
||||
name: 'readonly',
|
||||
serverId: server.id,
|
||||
permissions: ['read'],
|
||||
name: 'api-keys',
|
||||
data: { API_KEY: 'test-key', API_SECRET: 'test-secret' },
|
||||
},
|
||||
});
|
||||
expect(profile.name).toBe('readonly');
|
||||
expect(profile.serverId).toBe(server.id);
|
||||
const data = secret.data as Record<string, string>;
|
||||
expect(data['API_KEY']).toBe('test-key');
|
||||
expect(data['API_SECRET']).toBe('test-secret');
|
||||
});
|
||||
|
||||
it('enforces unique name per server', async () => {
|
||||
const server = await createServer();
|
||||
const data = { name: 'default', serverId: server.id };
|
||||
await prisma.mcpProfile.create({ data });
|
||||
await expect(prisma.mcpProfile.create({ data })).rejects.toThrow();
|
||||
it('enforces unique name', async () => {
|
||||
await prisma.secret.create({ data: { name: 'dup-secret' } });
|
||||
await expect(prisma.secret.create({ data: { name: 'dup-secret' } })).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('allows same profile name on different servers', async () => {
|
||||
const server1 = await createServer({ name: 'server-1' });
|
||||
const server2 = await createServer({ name: 'server-2' });
|
||||
await prisma.mcpProfile.create({ data: { name: 'default', serverId: server1.id } });
|
||||
const profile2 = await prisma.mcpProfile.create({ data: { name: 'default', serverId: server2.id } });
|
||||
expect(profile2.name).toBe('default');
|
||||
});
|
||||
|
||||
it('cascades delete when server is deleted', async () => {
|
||||
const server = await createServer();
|
||||
await prisma.mcpProfile.create({ data: { name: 'test', serverId: server.id } });
|
||||
await prisma.mcpServer.delete({ where: { id: server.id } });
|
||||
const profiles = await prisma.mcpProfile.findMany({ where: { serverId: server.id } });
|
||||
expect(profiles).toHaveLength(0);
|
||||
it('updates data', async () => {
|
||||
const secret = await prisma.secret.create({
|
||||
data: { name: 'updatable', data: { KEY: 'old' } },
|
||||
});
|
||||
const updated = await prisma.secret.update({
|
||||
where: { id: secret.id },
|
||||
data: { data: { KEY: 'new', EXTRA: 'added' } },
|
||||
});
|
||||
const data = updated.data as Record<string, string>;
|
||||
expect(data['KEY']).toBe('new');
|
||||
expect(data['EXTRA']).toBe('added');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -220,62 +223,6 @@ describe('Project', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ── ProjectMcpProfile (join table) ──
|
||||
|
||||
describe('ProjectMcpProfile', () => {
|
||||
it('links project to profile', async () => {
|
||||
const user = await createUser();
|
||||
const server = await createServer();
|
||||
const profile = await prisma.mcpProfile.create({
|
||||
data: { name: 'default', serverId: server.id },
|
||||
});
|
||||
const project = await prisma.project.create({
|
||||
data: { name: 'test-project', ownerId: user.id },
|
||||
});
|
||||
|
||||
const link = await prisma.projectMcpProfile.create({
|
||||
data: { projectId: project.id, profileId: profile.id },
|
||||
});
|
||||
expect(link.projectId).toBe(project.id);
|
||||
expect(link.profileId).toBe(profile.id);
|
||||
});
|
||||
|
||||
it('enforces unique project+profile combination', async () => {
|
||||
const user = await createUser();
|
||||
const server = await createServer();
|
||||
const profile = await prisma.mcpProfile.create({
|
||||
data: { name: 'default', serverId: server.id },
|
||||
});
|
||||
const project = await prisma.project.create({
|
||||
data: { name: 'test-project', ownerId: user.id },
|
||||
});
|
||||
|
||||
const data = { projectId: project.id, profileId: profile.id };
|
||||
await prisma.projectMcpProfile.create({ data });
|
||||
await expect(prisma.projectMcpProfile.create({ data })).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('loads profiles through project include', async () => {
|
||||
const user = await createUser();
|
||||
const server = await createServer();
|
||||
const profile = await prisma.mcpProfile.create({
|
||||
data: { name: 'slack-ro', serverId: server.id },
|
||||
});
|
||||
const project = await prisma.project.create({
|
||||
data: { name: 'reports', ownerId: user.id },
|
||||
});
|
||||
await prisma.projectMcpProfile.create({
|
||||
data: { projectId: project.id, profileId: profile.id },
|
||||
});
|
||||
|
||||
const loaded = await prisma.project.findUnique({
|
||||
where: { id: project.id },
|
||||
include: { profiles: { include: { profile: true } } },
|
||||
});
|
||||
expect(loaded!.profiles).toHaveLength(1);
|
||||
expect(loaded!.profiles[0].profile.name).toBe('slack-ro');
|
||||
});
|
||||
});
|
||||
|
||||
// ── McpInstance model ──
|
||||
|
||||
|
||||
@@ -41,13 +41,11 @@ describe('seedMcpServers', () => {
|
||||
expect(servers).toHaveLength(defaultServers.length);
|
||||
});
|
||||
|
||||
it('seeds envTemplate correctly', async () => {
|
||||
it('seeds env correctly', async () => {
|
||||
await seedMcpServers(prisma);
|
||||
const slack = await prisma.mcpServer.findUnique({ where: { name: 'slack' } });
|
||||
const envTemplate = slack!.envTemplate as Array<{ name: string; isSecret: boolean }>;
|
||||
expect(envTemplate).toHaveLength(2);
|
||||
expect(envTemplate[0].name).toBe('SLACK_BOT_TOKEN');
|
||||
expect(envTemplate[0].isSecret).toBe(true);
|
||||
const env = slack!.env as Array<{ name: string; value?: string }>;
|
||||
expect(env).toEqual([]);
|
||||
});
|
||||
|
||||
it('accepts custom server list', async () => {
|
||||
@@ -58,7 +56,7 @@ describe('seedMcpServers', () => {
|
||||
packageName: '@test/custom',
|
||||
transport: 'STDIO' as const,
|
||||
repositoryUrl: 'https://example.com',
|
||||
envTemplate: [],
|
||||
env: [],
|
||||
},
|
||||
];
|
||||
const count = await seedMcpServers(prisma, custom);
|
||||
|
||||
Reference in New Issue
Block a user