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:
@@ -617,15 +617,65 @@ async function toolAppsUpdate(principal: Principal, params: Record<string, any>)
|
|||||||
'base_directory', 'dockerfile_location', 'docker_compose_location',
|
'base_directory', 'dockerfile_location', 'docker_compose_location',
|
||||||
'is_auto_deploy_enabled', 'is_force_https_enabled', 'static_image',
|
'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> = {};
|
const patch: Record<string, unknown> = {};
|
||||||
for (const [k, v] of Object.entries(params)) {
|
const ignored: string[] = [];
|
||||||
if (allowed.has(k) && v !== undefined) patch[k] = v;
|
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) {
|
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);
|
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) {
|
if (!appUuid || domainsIn.length === 0) {
|
||||||
return NextResponse.json({ error: 'Params "uuid" and "domains[]" are required' }, { status: 400 });
|
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[] = [];
|
const normalized: string[] = [];
|
||||||
for (const d of domainsIn) {
|
for (const d of domainsIn) {
|
||||||
if (typeof d !== 'string' || !d.trim()) continue;
|
if (typeof d !== 'string' || !d.trim()) continue;
|
||||||
@@ -755,8 +805,29 @@ async function toolAppsDomainsSet(principal: Principal, params: Record<string, a
|
|||||||
}
|
}
|
||||||
normalized.push(clean);
|
normalized.push(clean);
|
||||||
}
|
}
|
||||||
await setApplicationDomains(appUuid, normalized, { forceOverride: true });
|
const buildPack = (app.build_pack ?? 'nixpacks') as string;
|
||||||
return NextResponse.json({ result: { uuid: appUuid, domains: normalized } });
|
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' },
|
||||||
|
},
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ──────────────────────────────────────────────────
|
// ──────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -468,7 +468,23 @@ export async function updateApplication(
|
|||||||
export async function setApplicationDomains(
|
export async function setApplicationDomains(
|
||||||
uuid: string,
|
uuid: string,
|
||||||
domains: string[],
|
domains: string[],
|
||||||
opts: { forceOverride?: boolean } = {}
|
opts: {
|
||||||
|
forceOverride?: boolean;
|
||||||
|
/**
|
||||||
|
* Build pack of the target app. Required to dispatch to the right
|
||||||
|
* Coolify field:
|
||||||
|
* - dockercompose → docker_compose_domains (per-service JSON)
|
||||||
|
* - everything else → domains (comma-separated string)
|
||||||
|
* If omitted we GET the app and detect it.
|
||||||
|
*/
|
||||||
|
buildPack?: string;
|
||||||
|
/**
|
||||||
|
* For compose apps only: which compose service should receive the
|
||||||
|
* public domain(s). Defaults to 'server' (matches Twenty, Plane,
|
||||||
|
* Cal.com). Ignored for non-compose apps.
|
||||||
|
*/
|
||||||
|
composeService?: string;
|
||||||
|
} = {}
|
||||||
): Promise<{ uuid: string }> {
|
): Promise<{ uuid: string }> {
|
||||||
// Coolify validates each entry as a URL, so bare hostnames need a scheme.
|
// Coolify validates each entry as a URL, so bare hostnames need a scheme.
|
||||||
const normalized = domains.map(d => {
|
const normalized = domains.map(d => {
|
||||||
@@ -476,16 +492,34 @@ export async function setApplicationDomains(
|
|||||||
if (/^https?:\/\//i.test(trimmed)) return trimmed;
|
if (/^https?:\/\//i.test(trimmed)) return trimmed;
|
||||||
return `https://${trimmed}`;
|
return `https://${trimmed}`;
|
||||||
});
|
});
|
||||||
// Coolify API: send `domains` (NOT `fqdn`). The controller maps it to
|
|
||||||
// the DB's `fqdn` column internally, but only when the destination
|
let buildPack = opts.buildPack;
|
||||||
// server has `proxy.type=TRAEFIK` (or CADDY) AND `is_build_server=false`
|
if (!buildPack) {
|
||||||
// — i.e. when Server::isProxyShouldRun() returns true. If either is
|
const app = await getApplication(uuid);
|
||||||
// misconfigured, the controller silently drops the field (PATCH returns
|
buildPack = (app.build_pack ?? 'nixpacks') as string;
|
||||||
// 200, fqdn unchanged). We hit this on the missinglettr-test app on
|
}
|
||||||
// 2026-04-22; the underlying server had proxy.type=null and
|
|
||||||
// is_build_server=true. Fix is in Coolify server-config (UI/DB), not
|
// ── Compose apps: set per-service domains ──────────────────────────
|
||||||
// the client. Sending `fqdn` directly is rejected with 422 ("This
|
// Coolify hard-rejects the top-level `domains` field for dockercompose
|
||||||
// field is not allowed").
|
// (HTTP 422: "Use docker_compose_domains instead"). Domains live per
|
||||||
|
// compose service — we default to the `server` service, which matches
|
||||||
|
// the majority of self-hostable apps (Twenty, Plane, Cal.com, etc.).
|
||||||
|
if (buildPack === 'dockercompose') {
|
||||||
|
const service = (opts.composeService ?? 'server').trim();
|
||||||
|
// Coolify accepts an array of {name, domain}; ONE entry per service.
|
||||||
|
// Multiple domains → comma-join into the single service's `domain`
|
||||||
|
// field (Coolify splits on comma internally).
|
||||||
|
const payload = [{ name: service, domain: normalized.join(',') }];
|
||||||
|
return updateApplication(uuid, { docker_compose_domains: payload });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Single-container apps: top-level `domains` (maps to fqdn) ──────
|
||||||
|
// Coolify maps `domains` → the DB `fqdn` column, but only when the
|
||||||
|
// destination server has `proxy.type=TRAEFIK`/`CADDY` AND
|
||||||
|
// `is_build_server=false` (Server::isProxyShouldRun() returns true).
|
||||||
|
// If either is misconfigured, the PATCH silently drops the field
|
||||||
|
// (200 OK but fqdn unchanged). Fix that in Coolify's server config,
|
||||||
|
// not here. Sending `fqdn` directly returns 422.
|
||||||
return updateApplication(uuid, {
|
return updateApplication(uuid, {
|
||||||
domains: normalized.join(','),
|
domains: normalized.join(','),
|
||||||
force_domain_override: opts.forceOverride ?? true,
|
force_domain_override: opts.forceOverride ?? true,
|
||||||
|
|||||||
Reference in New Issue
Block a user