fix(apps): compose-aware domains; loud apps.update ignore list

Two live-test bugs surfaced while deploying Twenty CRM:

1. apps.domains.set silently 422'd on compose apps
   Coolify hard-rejects top-level `domains` for dockercompose build
   packs — they must use `docker_compose_domains` (per-service JSON).
   setApplicationDomains now detects build_pack (fetched via GET if
   not passed) and dispatches correctly. Default service is `server`
   (matches Twenty, Plane, Cal.com); override with `service` param.

2. apps.update silently dropped unrecognised fields
   Caller got `{ok:true}` even when zero fields persisted. This
   created false-positive "bug reports" (e.g. the user-reported
   "fqdn returns ok but doesn't persist" — fqdn was never forwarded
   at all). apps.update now returns:
     - applied:  fields that were forwarded to Coolify
     - ignored:  unknown fields (agent typos, stale field names)
     - rerouted: fields that belong to a different tool
                 (fqdn/domains → apps.domains.set,
                  git_repository → apps.rewire_git)
   400 when nothing applied, 200 with diagnostics otherwise.

Made-with: Cursor
This commit is contained in:
2026-04-23 13:25:16 -07:00
parent d86f2bea03
commit e766315ecd
2 changed files with 123 additions and 18 deletions

View File

@@ -617,15 +617,65 @@ async function toolAppsUpdate(principal: Principal, params: Record<string, any>)
'base_directory', 'dockerfile_location', 'docker_compose_location',
'is_auto_deploy_enabled', 'is_force_https_enabled', 'static_image',
]);
// ── Control params (never forwarded to Coolify) ─────────────────────
// `uuid`/`appUuid` identify the target; we've consumed them already.
const control = new Set(['uuid', 'appUuid', 'patch']);
// ── Fields we deliberately DO NOT forward from apps.update ─────────
// Each maps to a different tool; silently dropping them the way we
// used to caused real live-test bugs (PATCH returns ok, nothing
// persists, agent thinks it worked).
const redirected: Record<string, string> = {
fqdn: 'apps.domains.set',
domains: 'apps.domains.set',
docker_compose_domains: 'apps.domains.set',
git_repository: 'apps.rewire_git',
};
// Support both the flat `{ uuid, name, description, ... }` shape and
// the explicit `{ uuid, patch: { name, description, ... } }` shape.
const source: Record<string, unknown> =
params.patch && typeof params.patch === 'object' && !Array.isArray(params.patch)
? (params.patch as Record<string, unknown>)
: params;
const patch: Record<string, unknown> = {};
for (const [k, v] of Object.entries(params)) {
if (allowed.has(k) && v !== undefined) patch[k] = v;
const ignored: string[] = [];
const rerouted: Array<{ field: string; use: string }> = [];
for (const [k, v] of Object.entries(source)) {
if (v === undefined) continue;
if (control.has(k) && source === params) continue;
if (redirected[k]) { rerouted.push({ field: k, use: redirected[k] }); continue; }
if (allowed.has(k)) { patch[k] = v; continue; }
ignored.push(k);
}
if (Object.keys(patch).length === 0) {
return NextResponse.json({ error: 'No updatable fields in params' }, { status: 400 });
return NextResponse.json(
{
error:
rerouted.length
? 'No updatable fields in params. Some fields must be set via other tools — see `rerouted`.'
: 'No updatable fields in params. See `ignored` and `allowed`.',
rerouted,
ignored,
allowed: [...allowed],
},
{ status: 400 },
);
}
await updateApplication(appUuid, patch);
return NextResponse.json({ result: { ok: true, uuid: appUuid } });
return NextResponse.json({
result: {
ok: true,
uuid: appUuid,
applied: Object.keys(patch),
// Non-empty `ignored`/`rerouted` are NOT errors but callers need to
// see them; silently dropping unrecognised keys was the original
// "fqdn returns ok but doesn't persist" false-positive.
...(ignored.length ? { ignored } : {}),
...(rerouted.length ? { rerouted } : {}),
},
});
}
/**
@@ -742,7 +792,7 @@ async function toolAppsDomainsSet(principal: Principal, params: Record<string, a
if (!appUuid || domainsIn.length === 0) {
return NextResponse.json({ error: 'Params "uuid" and "domains[]" are required' }, { status: 400 });
}
await getApplicationInProject(appUuid, projectUuid);
const app = await getApplicationInProject(appUuid, projectUuid);
const normalized: string[] = [];
for (const d of domainsIn) {
if (typeof d !== 'string' || !d.trim()) continue;
@@ -755,8 +805,29 @@ async function toolAppsDomainsSet(principal: Principal, params: Record<string, a
}
normalized.push(clean);
}
await setApplicationDomains(appUuid, normalized, { forceOverride: true });
return NextResponse.json({ result: { uuid: appUuid, domains: normalized } });
const buildPack = (app.build_pack ?? 'nixpacks') as string;
const composeService =
typeof params.service === 'string' && params.service.trim()
? params.service.trim()
: typeof params.composeService === 'string' && params.composeService.trim()
? params.composeService.trim()
: undefined;
await setApplicationDomains(appUuid, normalized, {
forceOverride: true,
buildPack,
composeService,
});
return NextResponse.json({
result: {
uuid: appUuid,
domains: normalized,
buildPack,
routedTo:
buildPack === 'dockercompose'
? { field: 'docker_compose_domains', service: composeService ?? 'server' }
: { field: 'domains' },
},
});
}
// ──────────────────────────────────────────────────