diff --git a/src/mcpd/src/bootstrap/ungated-global.ts b/src/mcpd/src/bootstrap/ungated-global.ts index 0225d15..1787447 100644 --- a/src/mcpd/src/bootstrap/ungated-global.ts +++ b/src/mcpd/src/bootstrap/ungated-global.ts @@ -15,6 +15,7 @@ import type { PrismaClient } from '@prisma/client'; import { SYSTEM_SETTINGS_SECRET } from './system-settings.js'; +import { DEFAULT_PLAINTEXT_BACKEND_NAME } from './secret-backends.js'; /** Well-known project whose servers are exposed under every project, pre-gate. */ export const UNGATED_GLOBAL_PROJECT_NAME = 'ungated-global'; @@ -61,10 +62,22 @@ async function markSeeded(prisma: PrismaClient): Promise { data: { data, keyNames, version: existing.version + 1 }, }); } else { - // backendId '' is repointed at the default plaintext backend by - // bootstrapSecretBackends — same convention as pre-existing rows. + // The Secret.backendId FK is enforced at insert time, so a fresh row + // must point at a real backend. bootstrapUngatedGlobal runs after + // bootstrapSecretBackends, so the default plaintext backend exists; + // fall back to any backend if the name ever changes. + const backend = + (await prisma.secretBackend.findUnique({ where: { name: DEFAULT_PLAINTEXT_BACKEND_NAME } })) + ?? (await prisma.secretBackend.findFirst({ where: { isDefault: true } })) + ?? (await prisma.secretBackend.findFirst()); + if (!backend) { + // No backend at all — skip marking rather than crash startup. The seed + // above still ran; worst case it re-runs (idempotent upserts) next boot. + console.warn('ungated-global: no SecretBackend found, seed marker not written'); + return; + } await prisma.secret.create({ - data: { name: SYSTEM_SETTINGS_SECRET, data, keyNames }, + data: { name: SYSTEM_SETTINGS_SECRET, data, keyNames, backendId: backend.id }, }); } } diff --git a/src/mcpd/src/main.ts b/src/mcpd/src/main.ts index 070527b..7e4c87c 100644 --- a/src/mcpd/src/main.ts +++ b/src/mcpd/src/main.ts @@ -414,8 +414,6 @@ async function main(): Promise { await bootstrapSystemProject(prisma); // PR-4: bootstrap system-owned global skills (e.g. propose-learnings). await bootstrapSystemSkills(prisma); - // Seed-once ungated-global project (default calcmcp server, deletable). - await bootstrapUngatedGlobal(prisma); // Repositories const serverRepo = new McpServerRepository(prisma); @@ -438,6 +436,11 @@ async function main(): Promise { // pre-existing `Secret` rows are pointed at it. Idempotent per run. await bootstrapSecretBackends(prisma); + // Seed-once ungated-global project (default calcmcp server, deletable). + // MUST run after bootstrapSecretBackends: its seed marker lives in the + // mcpctl-system-settings Secret, whose creation needs a valid backend FK. + await bootstrapUngatedGlobal(prisma); + // CUID detection for RBAC name resolution const CUID_RE = /^c[^\s-]{8,}$/i; const nameResolvers: Record }> = { diff --git a/src/mcpd/tests/bootstrap-ungated-global.test.ts b/src/mcpd/tests/bootstrap-ungated-global.test.ts index 9123f1a..7bb7099 100644 --- a/src/mcpd/tests/bootstrap-ungated-global.test.ts +++ b/src/mcpd/tests/bootstrap-ungated-global.test.ts @@ -18,6 +18,10 @@ function mockPrisma(opts: { settingsData?: Record | null } = {} update: vi.fn(async () => ({})), create: vi.fn(async () => ({})), }, + secretBackend: { + findUnique: vi.fn(async () => ({ id: 'backend-default', name: 'default', isDefault: true })), + findFirst: vi.fn(async () => ({ id: 'backend-default', name: 'default', isDefault: true })), + }, user: { upsert: vi.fn(async () => ({ id: 'sys-user-id', email: 'system@mcpctl.local' })), }, @@ -68,11 +72,12 @@ describe('bootstrapUngatedGlobal', () => { it('marks the seed as done in the system settings secret', async () => { await bootstrapUngatedGlobal(prisma); - // Settings secret didn't exist → created with the marker key + // Settings secret didn't exist → created with the marker key and a valid backend FK expect(prisma.secret.create).toHaveBeenCalledWith( expect.objectContaining({ data: expect.objectContaining({ name: SYSTEM_SETTINGS_SECRET, + backendId: 'backend-default', data: expect.objectContaining({ [SETTING_UNGATED_GLOBAL_SEEDED]: true }), }), }),