Coolify API tokens are team-scoped, and a wrong-team token does not error: the API resolves what it cannot see to `null` (getResourceByUuid walks resource → environment → project → team_id and returns null on a mismatch). To cast, `null` is indistinguishable from "this resource does not exist yet" — an invitation to create it. So an apply with a token minted under the wrong team would not fail loudly; it would provision a duplicate set of resources into the wrong team, against whatever server that team owns. Silent, mutating, discovered late. That makes this a correctness bug, not hardening. - environments.yaml carries a required `team:` per environment (id, name, or both). Required is the point: an environment with no declared team is one cast cannot verify it is pointed at. - Every command that reaches a live Coolify (apply, diff, server add, smoke) resolves GET /teams/current — the only endpoint that answers "what team does this token act as?" — and aborts on mismatch before its first READ, not merely its first write: a wrong-team diff reports "everything is absent", which is the very lie an apply would then act on. - server add and smoke take --env for this reason. A server belongs to exactly one team forever (no pivot, no is_system_wide escape hatch), and smoke writes env vars onto a live app. - New read-only `cast team` prints the token's team, so the binding can be filled in without a chicken-and-egg. With --env it also checks the binding: the dry run for "would apply refuse?". Team id 0 is a first-class value, not a falsy absent — it is the Root Team that a single-admin instance keeps everything in (app/Models/User.php). Also records the #4 investigation in docs/semantics.md: GithubApp `is_system_wide` IS the supported way to serve every team — list_github_apps scopes to `team_id = token's team OR is_system_wide`, and POST /github-apps accepts the flag — so per-team App duplication is unnecessary. Corollary: resolving a GitHub App by name is NOT a proxy for being in the right team, which is the second reason the assert has to be explicit. Closes #9 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
118 lines
3.9 KiB
TypeScript
118 lines
3.9 KiB
TypeScript
type Json = Record<string, unknown> | unknown[] | null;
|
|
|
|
// The team a token acts as. Coolify's `Team` model carries more than this
|
|
// (description, personal_team, timestamps); cast only ever needs identity.
|
|
export type Team = { id: number; name: string };
|
|
|
|
// Thrown by req/reqText on a non-2xx response. `status` lets callers narrow
|
|
// handling (e.g. "treat 404 as absent, rethrow everything else") via
|
|
// `instanceof HttpError` without parsing the message string; the message
|
|
// format itself is unchanged (asserted by coolify.test.ts).
|
|
export class HttpError extends Error {
|
|
constructor(
|
|
method: string,
|
|
path: string,
|
|
public readonly status: number,
|
|
body: string,
|
|
) {
|
|
super(`${method} ${path} → ${status}: ${body}`);
|
|
this.name = "HttpError";
|
|
}
|
|
}
|
|
|
|
export class CoolifyClient {
|
|
constructor(
|
|
private readonly baseUrl: string,
|
|
private readonly token: string,
|
|
private readonly fetchImpl: typeof fetch = fetch,
|
|
) {}
|
|
|
|
private async req(
|
|
method: string,
|
|
path: string,
|
|
body?: unknown,
|
|
): Promise<Json> {
|
|
const res = await this.fetchImpl(`${this.baseUrl}/api/v1${path}`, {
|
|
method,
|
|
headers: {
|
|
Authorization: `Bearer ${this.token}`,
|
|
"Content-Type": "application/json",
|
|
},
|
|
body: body === undefined ? undefined : JSON.stringify(body),
|
|
});
|
|
if (!res.ok) {
|
|
throw new HttpError(method, path, res.status, await res.text());
|
|
}
|
|
return res.status === 204 ? null : ((await res.json()) as Json);
|
|
}
|
|
|
|
private async reqText(method: string, path: string): Promise<string> {
|
|
const res = await this.fetchImpl(`${this.baseUrl}/api/v1${path}`, {
|
|
method,
|
|
headers: {
|
|
Authorization: `Bearer ${this.token}`,
|
|
},
|
|
});
|
|
if (!res.ok) {
|
|
throw new HttpError(method, path, res.status, await res.text());
|
|
}
|
|
return res.text();
|
|
}
|
|
|
|
get = (path: string) => this.req("GET", path);
|
|
post = (path: string, body?: unknown) => this.req("POST", path, body);
|
|
patch = (path: string, body?: unknown) => this.req("PATCH", path, body);
|
|
delete_ = (path: string) => this.req("DELETE", path);
|
|
|
|
async version(): Promise<string> {
|
|
return this.reqText("GET", "/version");
|
|
}
|
|
|
|
// The team this TOKEN acts as — the question every mutation depends on
|
|
// (see team.ts for why). GET /teams/current resolves it from the token
|
|
// itself, not from a session: TeamController@current_team calls
|
|
// getTeamIdFromToken() and 404s if that team is gone
|
|
// (coollabsio/coolify v4.1.2). It is the only endpoint that answers it.
|
|
async currentTeam(): Promise<Team> {
|
|
const raw = (await this.get("/teams/current")) as Record<
|
|
string,
|
|
unknown
|
|
> | null;
|
|
const id = raw?.id;
|
|
const name = raw?.name;
|
|
// A shape we can't read is not "no team" — it's an unknown answer to the
|
|
// one question we must not guess at. Fail rather than degrade.
|
|
if (typeof id !== "number" || typeof name !== "string") {
|
|
throw new Error(
|
|
`GET /teams/current returned no usable team identity: ${JSON.stringify(raw)}`,
|
|
);
|
|
}
|
|
return { id, name };
|
|
}
|
|
|
|
private async resolve(
|
|
kind: string,
|
|
listPath: string,
|
|
name: string,
|
|
): Promise<string> {
|
|
const items = (await this.get(listPath)) as Array<{
|
|
uuid: string;
|
|
name: string;
|
|
}>;
|
|
const hit = items.find((i) => i.name === name);
|
|
if (!hit) throw new Error(`not found in Coolify: ${kind} ${name}`);
|
|
return hit.uuid;
|
|
}
|
|
|
|
serverUuid = (name: string) => this.resolve("server", "/servers", name);
|
|
githubAppUuid = (name: string) =>
|
|
this.resolve("github app", "/github-apps", name);
|
|
projectUuid = (name: string) => this.resolve("project", "/projects", name);
|
|
|
|
async deploy(uuid: string): Promise<void> {
|
|
await this.post(`/deploy?uuid=${encodeURIComponent(uuid)}`);
|
|
}
|
|
async restart(uuid: string): Promise<void> {
|
|
await this.post(`/services/${encodeURIComponent(uuid)}/restart`);
|
|
}
|
|
}
|