From fe00f42bce43c4ff0123334549f34b410299076c Mon Sep 17 00:00:00 2001 From: Michal Date: Sat, 3 Oct 2026 01:57:37 +0100 Subject: [PATCH] fix(ungated-global): create settings secret with a valid backend FK bootstrapUngatedGlobal wrote its seed marker into the mcpctl-system-settings Secret, creating the row with the default backendId '' when it did not exist. The Secret_backendId_fkey constraint is enforced at insert time, so on a DB where that secret was absent, mcpd crash-looped on startup (P2003). Fix: run the seed after bootstrapSecretBackends (so the default plaintext backend exists) and set backendId to the resolved default on create. If no backend exists at all, skip writing the marker rather than crash. Co-Authored-By: Claude Opus 4.8 --- src/mcpd/src/bootstrap/ungated-global.ts | 19 ++++++++++++++++--- src/mcpd/src/main.ts | 7 +++++-- .../tests/bootstrap-ungated-global.test.ts | 7 ++++++- 3 files changed, 27 insertions(+), 6 deletions(-) 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 }), }), }), -- 2.49.1