diff --git a/src/index.ts b/src/index.ts index 47b6e20..ec76254 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,6 +2,8 @@ import type { Env } from "./types"; import { authenticate, json } from "./auth"; import { getSecret, putSecret, deleteSecret, listSecrets } from "./routes/secrets"; import { getFile, putFile, deleteFile, listFiles } from "./routes/files"; +import { getProject, putProject, deleteProject, listProjects } from "./routes/projects"; +import { syncCoolify } from "./routes/coolify"; export default { async fetch(request: Request, env: Env): Promise { @@ -13,13 +15,46 @@ export default { const method = request.method; const path = url.pathname; - // Parse: /:project/:env/secrets[/:key] - // Parse: /:project/:env/files[/:path+] + // GET /projects — list all registered projects + if (path === "/projects" && method === "GET") { + return listProjects(request, env.CONFIG_META); + } + + // /:project/meta — project registry + const metaMatch = path.match(/^\/([a-zA-Z0-9_-]+)\/meta$/); + if (metaMatch) { + const [, project] = metaMatch; + switch (method) { + case "GET": + return getProject(project, env.CONFIG_META); + case "PUT": + return putProject(project, request, env.CONFIG_META); + case "DELETE": + return deleteProject(project, env.CONFIG_META); + default: + return json(405, { error: "Method not allowed" }); + } + } + + // POST /:project/:env/sync-coolify — push secrets to Coolify + const syncMatch = path.match(/^\/([a-zA-Z0-9_-]+)\/([a-zA-Z0-9_-]+)\/sync-coolify$/); + if (syncMatch && method === "POST") { + const [, project, environment] = syncMatch; + return syncCoolify(project, environment, env); + } + + // /:project/:env/secrets[/:key] or /:project/:env/files[/:path+] const match = path.match(/^\/([a-zA-Z0-9_-]+)\/([a-zA-Z0-9_-]+)\/(secrets|files)(?:\/(.+))?$/); if (!match) { return json(404, { error: "Not found", - usage: "/:project/:env/secrets[/:key] or /:project/:env/files[/:path]", + routes: [ + "GET /projects", + "GET|PUT|DELETE /:project/meta", + "GET|PUT|DELETE /:project/:env/secrets[/:key]", + "GET|PUT|DELETE /:project/:env/files[/:path]", + "POST /:project/:env/sync-coolify", + ], }); } diff --git a/src/routes/coolify.ts b/src/routes/coolify.ts new file mode 100644 index 0000000..92462f8 --- /dev/null +++ b/src/routes/coolify.ts @@ -0,0 +1,112 @@ +import type { Env, ProjectMeta } from "../types"; +import { json } from "../auth"; + +/** + * POST /:project/:env/sync-coolify + * + * Reads all secrets for project/env from KV, fetches project metadata + * for the Coolify app UUID, then PATCHes the env vars on the Coolify app. + * + * Coolify API: PATCH /api/v1/applications/{uuid}/envs + * Body: { key, value, is_preview: false } (one per env var) + * + * We bulk-create/update each secret as a Coolify env var. + */ +export async function syncCoolify( + project: string, + environment: string, + env: Env, +): Promise { + // 1. Load project metadata + const metaRaw = await env.CONFIG_META.get(`project:${project}`); + if (!metaRaw) { + return json(404, { error: `Project '${project}' not registered. PUT /${project}/meta first.` }); + } + const meta: ProjectMeta = JSON.parse(metaRaw); + + if (!meta.coolifyAppId) { + return json(400, { + error: `Project '${project}' has no coolifyAppId set. Update /${project}/meta with coolifyAppId.`, + }); + } + + if (!env.COOLIFY_API_TOKEN || !env.COOLIFY_BASE_URL) { + return json(500, { error: "COOLIFY_API_TOKEN or COOLIFY_BASE_URL not configured as worker secrets." }); + } + + // 2. List all secrets for this project/env + const prefix = `secret:${project}:${environment}:`; + const allKeys: { name: string }[] = []; + let cursor: string | undefined; + do { + const page = await env.CONFIG_SECRETS.list({ prefix, cursor, limit: 1000 }); + allKeys.push(...page.keys); + cursor = page.list_complete ? undefined : page.cursor; + } while (cursor); + + if (allKeys.length === 0) { + return json(200, { + project, + env: environment, + coolifyAppId: meta.coolifyAppId, + synced: 0, + message: "No secrets found for this project/env", + }); + } + + // 3. Fetch values and push to Coolify + const results: { key: string; status: "ok" | "error"; detail?: string }[] = []; + + for (const kv of allKeys) { + const secretKey = kv.name.slice(prefix.length); + const value = await env.CONFIG_SECRETS.get(kv.name); + if (value === null) continue; + + // Convert key format: "api-key" -> "API_KEY" for env var convention + const envVarName = secretKey.toUpperCase().replace(/-/g, "_"); + + try { + const resp = await fetch( + `${env.COOLIFY_BASE_URL}/api/v1/applications/${meta.coolifyAppId}/envs`, + { + method: "PATCH", + headers: { + Authorization: `Bearer ${env.COOLIFY_API_TOKEN}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + key: envVarName, + value, + is_preview: false, + is_build_time: false, + }), + }, + ); + + if (resp.ok) { + results.push({ key: envVarName, status: "ok" }); + } else { + const body = await resp.text(); + results.push({ key: envVarName, status: "error", detail: `${resp.status}: ${body.slice(0, 200)}` }); + } + } catch (e) { + results.push({ + key: envVarName, + status: "error", + detail: e instanceof Error ? e.message : String(e), + }); + } + } + + const okCount = results.filter((r) => r.status === "ok").length; + const errors = results.filter((r) => r.status === "error"); + + return json(errors.length > 0 ? 207 : 200, { + project, + env: environment, + coolifyAppId: meta.coolifyAppId, + synced: okCount, + errors: errors.length > 0 ? errors : undefined, + total: results.length, + }); +} diff --git a/src/routes/projects.ts b/src/routes/projects.ts new file mode 100644 index 0000000..992d8b5 --- /dev/null +++ b/src/routes/projects.ts @@ -0,0 +1,70 @@ +import type { Env, ProjectMeta } from "../types"; +import { json } from "../auth"; + +const PREFIX = "project"; + +function kvKey(project: string): string { + return `${PREFIX}:${project}`; +} + +export async function getProject(project: string, kv: KVNamespace): Promise { + const raw = await kv.get(kvKey(project)); + if (!raw) { + return json(404, { error: "Project not found" }); + } + return json(200, JSON.parse(raw)); +} + +export async function putProject(project: string, request: Request, kv: KVNamespace): Promise { + const body = await request.json>().catch(() => null); + if (!body) { + return json(400, { error: "Invalid JSON body" }); + } + + if (!body.gitea || typeof body.gitea !== "string") { + return json(400, { error: "Field 'gitea' is required (e.g. git@git.saiden.dev:org/repo.git)" }); + } + + // Merge with existing if present + const existing = await kv.get(kvKey(project)); + const prev: Partial = existing ? JSON.parse(existing) : {}; + + const meta: ProjectMeta = { + project, + gitea: body.gitea ?? prev.gitea ?? "", + coolifyAppId: body.coolifyAppId ?? prev.coolifyAppId, + description: body.description ?? prev.description, + updatedAt: new Date().toISOString(), + }; + + await kv.put(kvKey(project), JSON.stringify(meta)); + return json(200, { ...meta, stored: true }); +} + +export async function deleteProject(project: string, kv: KVNamespace): Promise { + await kv.delete(kvKey(project)); + return json(200, { project, deleted: true }); +} + +export async function listProjects(request: Request, kv: KVNamespace): Promise { + const url = new URL(request.url); + const cursor = url.searchParams.get("cursor") ?? undefined; + const limit = Math.min(parseInt(url.searchParams.get("limit") ?? "100", 10), 1000); + + const prefix = `${PREFIX}:`; + const result = await kv.list({ prefix, cursor, limit }); + + const projects: ProjectMeta[] = []; + for (const key of result.keys) { + const raw = await kv.get(key.name); + if (raw) { + projects.push(JSON.parse(raw)); + } + } + + return json(200, { + projects, + cursor: result.list_complete ? undefined : result.cursor, + complete: result.list_complete, + }); +} diff --git a/src/types.ts b/src/types.ts index 1bd65d3..0563ca4 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,7 +1,10 @@ export interface Env { CONFIG_SECRETS: KVNamespace; CONFIG_FILES: KVNamespace; + CONFIG_META: KVNamespace; API_KEY: string; + COOLIFY_API_TOKEN: string; + COOLIFY_BASE_URL: string; } export interface RouteParams { @@ -28,3 +31,11 @@ export interface FileMetadata { updatedAt: string; size: number; } + +export interface ProjectMeta { + project: string; + gitea: string; // e.g. git@git.saiden.dev:madcat-os/campus-os.git + coolifyAppId?: string; // Coolify application UUID + description?: string; + updatedAt: string; +} diff --git a/wrangler.toml b/wrangler.toml index a7755da..b5594d4 100644 --- a/wrangler.toml +++ b/wrangler.toml @@ -13,3 +13,7 @@ id = "425dd5e9d641479e8130d20b83fa0224" [[kv_namespaces]] binding = "CONFIG_FILES" id = "f2c224cbfce04852ba4616d51fa545b7" + +[[kv_namespaces]] +binding = "CONFIG_META" +id = "6ab687ed121e471990160f3a8e46cf18"