diff --git a/.gitignore b/.gitignore index 406964b..9cd0930 100644 --- a/.gitignore +++ b/.gitignore @@ -43,3 +43,5 @@ infisical/.env # typescript *.tsbuildinfo next-env.d.ts +api/node_modules/ +api/data/ diff --git a/api/Dockerfile b/api/Dockerfile new file mode 100644 index 0000000..fec65f1 --- /dev/null +++ b/api/Dockerfile @@ -0,0 +1,18 @@ +FROM oven/bun:1-alpine + +WORKDIR /app + +COPY package.json bun.lock* ./ +RUN bun install --frozen-lockfile 2>/dev/null || bun install + +COPY tsconfig.json ./ +COPY src/ ./src/ + +RUN mkdir -p /data + +ENV PORT=3001 +ENV DATABASE_PATH=/data/instances.db + +EXPOSE 3001 + +CMD ["bun", "run", "src/index.ts"] diff --git a/api/bun.lock b/api/bun.lock new file mode 100644 index 0000000..1b2fc64 --- /dev/null +++ b/api/bun.lock @@ -0,0 +1,40 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "rsocials-api", + "dependencies": { + "hono": "^4.7.0", + "js-yaml": "^4.1.0", + "nanoid": "^5.1.0", + }, + "devDependencies": { + "@types/bun": "^1.3.9", + "@types/js-yaml": "^4.0.9", + "typescript": "^5.7.0", + }, + }, + }, + "packages": { + "@types/bun": ["@types/bun@1.3.9", "", { "dependencies": { "bun-types": "1.3.9" } }, "sha512-KQ571yULOdWJiMH+RIWIOZ7B2RXQGpL1YQrBtLIV3FqDcCu6FsbFUBwhdKUlCKUpS3PJDsHlJ1QKlpxoVR+xtw=="], + + "@types/js-yaml": ["@types/js-yaml@4.0.9", "", {}, "sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg=="], + + "@types/node": ["@types/node@25.3.0", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-4K3bqJpXpqfg2XKGK9bpDTc6xO/xoUP/RBWS7AtRMug6zZFaRekiLzjVtAoZMquxoAbzBvy5nxQ7veS5eYzf8A=="], + + "argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], + + "bun-types": ["bun-types@1.3.9", "", { "dependencies": { "@types/node": "*" } }, "sha512-+UBWWOakIP4Tswh0Bt0QD0alpTY8cb5hvgiYeWCMet9YukHbzuruIEeXC2D7nMJPB12kbh8C7XJykSexEqGKJg=="], + + "hono": ["hono@4.12.2", "", {}, "sha512-gJnaDHXKDayjt8ue0n8Gs0A007yKXj4Xzb8+cNjZeYsSzzwKc0Lr+OZgYwVfB0pHfUs17EPoLvrOsEaJ9mj+Tg=="], + + "js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="], + + "nanoid": ["nanoid@5.1.6", "", { "bin": { "nanoid": "bin/nanoid.js" } }, "sha512-c7+7RQ+dMB5dPwwCp4ee1/iV/q2P6aK1mTZcfr1BTuVlyW9hJYiMPybJCcnBlQtuSmTIWNeazm/zqNoZSSElBg=="], + + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + + "undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="], + } +} diff --git a/api/package.json b/api/package.json new file mode 100644 index 0000000..d367b82 --- /dev/null +++ b/api/package.json @@ -0,0 +1,20 @@ +{ + "name": "rsocials-api", + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "bun run --watch src/index.ts", + "start": "bun run src/index.ts", + "typecheck": "bun run tsc --noEmit" + }, + "dependencies": { + "hono": "^4.7.0", + "js-yaml": "^4.1.0", + "nanoid": "^5.1.0" + }, + "devDependencies": { + "@types/bun": "^1.3.9", + "@types/js-yaml": "^4.0.9", + "typescript": "^5.7.0" + } +} diff --git a/api/src/db/queries.ts b/api/src/db/queries.ts new file mode 100644 index 0000000..507221e --- /dev/null +++ b/api/src/db/queries.ts @@ -0,0 +1,126 @@ +import { Database } from "bun:sqlite"; +import type { Instance, ProvisionLog } from "../types.js"; + +export class InstanceStore { + private db: Database; + + constructor(db: Database) { + this.db = db; + } + + create(instance: Omit): Instance { + const stmt = this.db.prepare(` + INSERT INTO instances (id, slug, display_name, primary_domain, fallback_domain, owner, status, compose_path) + VALUES ($id, $slug, $displayName, $primaryDomain, $fallbackDomain, $owner, $status, $composePath) + `); + stmt.run({ + $id: instance.id, + $slug: instance.slug, + $displayName: instance.displayName, + $primaryDomain: instance.primaryDomain, + $fallbackDomain: instance.fallbackDomain, + $owner: instance.owner, + $status: instance.status, + $composePath: instance.composePath, + }); + return this.getById(instance.id)!; + } + + getById(id: string): Instance | null { + const row = this.db + .prepare("SELECT * FROM instances WHERE id = ?") + .get(id) as Record | null; + return row ? this.rowToInstance(row) : null; + } + + getBySlug(slug: string): Instance | null { + const row = this.db + .prepare("SELECT * FROM instances WHERE slug = ?") + .get(slug) as Record | null; + return row ? this.rowToInstance(row) : null; + } + + list(owner?: string): Instance[] { + if (owner) { + const rows = this.db + .prepare( + "SELECT * FROM instances WHERE owner = ? ORDER BY created_at DESC" + ) + .all(owner) as Record[]; + return rows.map((r) => this.rowToInstance(r)); + } + const rows = this.db + .prepare("SELECT * FROM instances ORDER BY created_at DESC") + .all() as Record[]; + return rows.map((r) => this.rowToInstance(r)); + } + + updateStatus(id: string, status: string): void { + this.db + .prepare( + "UPDATE instances SET status = ?, updated_at = datetime('now') WHERE id = ?" + ) + .run(status, id); + } + + countTotal(): number { + const row = this.db + .prepare( + "SELECT COUNT(*) as count FROM instances WHERE status NOT IN ('destroyed', 'failed')" + ) + .get() as { count: number }; + return row.count; + } + + countByOwner(owner: string): number { + const row = this.db + .prepare( + "SELECT COUNT(*) as count FROM instances WHERE owner = ? AND status NOT IN ('destroyed', 'failed')" + ) + .get(owner) as { count: number }; + return row.count; + } + + delete(id: string): void { + this.db.prepare("DELETE FROM provision_log WHERE instance_id = ?").run(id); + this.db.prepare("DELETE FROM instances WHERE id = ?").run(id); + } + + addLog(instanceId: string, action: string, detail?: string): void { + this.db + .prepare( + "INSERT INTO provision_log (instance_id, action, detail) VALUES (?, ?, ?)" + ) + .run(instanceId, action, detail ?? null); + } + + getLogs(instanceId: string): ProvisionLog[] { + const rows = this.db + .prepare( + "SELECT * FROM provision_log WHERE instance_id = ? ORDER BY created_at ASC" + ) + .all(instanceId) as Record[]; + return rows.map((r) => ({ + id: r.id as number, + instanceId: r.instance_id as string, + action: r.action as string, + detail: r.detail as string | null, + createdAt: r.created_at as string, + })); + } + + private rowToInstance(row: Record): Instance { + return { + id: row.id as string, + slug: row.slug as string, + displayName: row.display_name as string, + primaryDomain: row.primary_domain as string, + fallbackDomain: row.fallback_domain as string, + owner: row.owner as string, + status: row.status as Instance["status"], + composePath: row.compose_path as string | null, + createdAt: row.created_at as string, + updatedAt: row.updated_at as string, + }; + } +} diff --git a/api/src/db/schema.ts b/api/src/db/schema.ts new file mode 100644 index 0000000..3d50db6 --- /dev/null +++ b/api/src/db/schema.ts @@ -0,0 +1,36 @@ +import { Database } from "bun:sqlite"; + +export function initDb(path: string): Database { + const db = new Database(path, { create: true }); + db.exec("PRAGMA journal_mode = WAL"); + db.exec("PRAGMA foreign_keys = ON"); + + db.exec(` + CREATE TABLE IF NOT EXISTS instances ( + id TEXT PRIMARY KEY, + slug TEXT UNIQUE NOT NULL, + display_name TEXT NOT NULL, + primary_domain TEXT NOT NULL, + fallback_domain TEXT NOT NULL, + owner TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'provisioning', + compose_path TEXT, + created_at TEXT DEFAULT (datetime('now')), + updated_at TEXT DEFAULT (datetime('now')) + ); + + CREATE TABLE IF NOT EXISTS provision_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + instance_id TEXT NOT NULL REFERENCES instances(id), + action TEXT NOT NULL, + detail TEXT, + created_at TEXT DEFAULT (datetime('now')) + ); + + CREATE INDEX IF NOT EXISTS idx_instances_slug ON instances(slug); + CREATE INDEX IF NOT EXISTS idx_instances_owner ON instances(owner); + CREATE INDEX IF NOT EXISTS idx_provision_log_instance ON provision_log(instance_id); + `); + + return db; +} diff --git a/api/src/index.ts b/api/src/index.ts new file mode 100644 index 0000000..ddfe1b5 --- /dev/null +++ b/api/src/index.ts @@ -0,0 +1,47 @@ +import { Hono } from "hono"; +import { logger } from "hono/logger"; +import { cors } from "hono/cors"; +import { initDb } from "./db/schema.js"; +import { InstanceStore } from "./db/queries.js"; +import { apiKeyAuth } from "./middleware/auth.js"; +import { healthRoutes } from "./routes/health.js"; +import { spacesRoutes } from "./routes/spaces.js"; +import { provisionRoutes } from "./routes/provision.js"; + +const PORT = parseInt(process.env.PORT ?? "3001", 10); +const DB_PATH = process.env.DATABASE_PATH ?? "./data/instances.db"; + +// Initialize database +const db = initDb(DB_PATH); +const store = new InstanceStore(db); + +// Create app +const app = new Hono(); + +// Global middleware +app.use("*", logger()); +app.use("*", cors()); + +// Public routes +app.route("/health", healthRoutes(store)); + +// Protected routes +app.use("/v1/*", apiKeyAuth); +app.route("/v1/spaces", provisionRoutes(store)); +app.route("/v1/spaces", spacesRoutes(store)); + +// 404 handler +app.notFound((c) => c.json({ error: "Not found" }, 404)); + +// Error handler +app.onError((err, c) => { + console.error("Unhandled error:", err); + return c.json({ error: "Internal server error" }, 500); +}); + +console.log(`rSocials API starting on port ${PORT}`); + +export default { + port: PORT, + fetch: app.fetch, +}; diff --git a/api/src/middleware/auth.ts b/api/src/middleware/auth.ts new file mode 100644 index 0000000..d3ba71e --- /dev/null +++ b/api/src/middleware/auth.ts @@ -0,0 +1,22 @@ +import type { Context, Next } from "hono"; + +const ADMIN_API_KEY = process.env.ADMIN_API_KEY; + +export async function apiKeyAuth(c: Context, next: Next) { + if (!ADMIN_API_KEY) { + return c.json({ error: "Server misconfigured: no API key set" }, 500); + } + + const key = c.req.header("X-API-Key"); + if (!key) { + return c.json({ error: "Missing X-API-Key header" }, 401); + } + + if (key !== ADMIN_API_KEY) { + return c.json({ error: "Invalid API key" }, 403); + } + + // Store owner identity (API key holder = admin for now, SIWE in Phase 2) + c.set("owner", "admin"); + await next(); +} diff --git a/api/src/routes/health.ts b/api/src/routes/health.ts new file mode 100644 index 0000000..ccea80f --- /dev/null +++ b/api/src/routes/health.ts @@ -0,0 +1,25 @@ +import { Hono } from "hono"; +import type { InstanceStore } from "../db/queries.js"; +import { checkResources } from "../services/resource-monitor.js"; + +export function healthRoutes(store: InstanceStore) { + const app = new Hono(); + + app.get("/", (c) => { + const instanceCount = store.countTotal(); + const resources = checkResources(instanceCount); + + return c.json({ + status: "ok", + version: "0.1.0", + instances: instanceCount, + resources: { + totalMemMB: resources.totalMemMB, + availMemMB: resources.availMemMB, + canProvision: resources.canProvision, + }, + }); + }); + + return app; +} diff --git a/api/src/routes/provision.ts b/api/src/routes/provision.ts new file mode 100644 index 0000000..b030571 --- /dev/null +++ b/api/src/routes/provision.ts @@ -0,0 +1,186 @@ +import { Hono } from "hono"; +import { nanoid } from "nanoid"; +import type { InstanceStore } from "../db/queries.js"; +import type { ProvisionRequest, SpaceConfig } from "../types.js"; +import { checkResources } from "../services/resource-monitor.js"; +import { deploySpace, teardownSpace, checkContainerHealth } from "../services/docker-deployer.js"; +import { addSablierEntry, removeSablierEntry } from "../services/sablier-config.js"; +import { addTunnelHostnames, removeTunnelHostnames, restartCloudflared } from "../services/tunnel-config.js"; + +const SLUG_RE = /^[a-z0-9][a-z0-9-]{1,28}[a-z0-9]$/; + +const RESERVED_SLUGS = new Set([ + "www", "api", "admin", "mail", "app", "staging", "test", "dev", + "socials", "cc", "crypto-commons", "votc", "p2pf", "bcrg", + "bondingcurve", "p2pfoundation", +]); + +const MAX_PER_OWNER = parseInt(process.env.MAX_PER_OWNER ?? "3", 10); + +export function provisionRoutes(store: InstanceStore) { + const app = new Hono(); + + // Provision a new space + app.post("/", async (c) => { + const body = await c.req.json(); + const owner = (c.get("owner" as never) as string) || "admin"; + + // 1. Validate slug + if (!body.slug || !SLUG_RE.test(body.slug)) { + return c.json( + { error: "Invalid slug: must be 3-30 chars, lowercase alphanumeric + hyphens" }, + 400 + ); + } + + if (RESERVED_SLUGS.has(body.slug)) { + return c.json({ error: `Slug '${body.slug}' is reserved` }, 400); + } + + if (!body.displayName || body.displayName.length > 100) { + return c.json({ error: "displayName required (max 100 chars)" }, 400); + } + + // Check if slug already taken + if (store.getBySlug(body.slug)) { + return c.json({ error: `Slug '${body.slug}' already in use` }, 409); + } + + // 2. Check limits + const instanceCount = store.countTotal(); + const resources = checkResources(instanceCount); + if (!resources.canProvision) { + return c.json({ error: resources.reason }, 503); + } + + const ownerCount = store.countByOwner(owner); + if (ownerCount >= MAX_PER_OWNER) { + return c.json( + { error: `Owner limit reached (${MAX_PER_OWNER} instances max)` }, + 429 + ); + } + + // 3. Create instance record + const id = nanoid(12); + const primaryDomain = body.primaryDomain ?? `${body.slug}.rsocials.online`; + const fallbackDomain = `${body.slug}.rsocials.online`; + + const instance = store.create({ + id, + slug: body.slug, + displayName: body.displayName, + primaryDomain, + fallbackDomain, + owner, + status: "provisioning", + composePath: null, + }); + + store.addLog(id, "provision_start", `Owner: ${owner}`); + + // Run provisioning in background (don't block the response) + runProvisioning(store, instance.id, { + slug: body.slug, + displayName: body.displayName, + primaryDomain, + fallbackDomain, + emailFrom: body.emailFrom ?? "noreply@rmail.online", + postiz: { + disableRegistration: body.disableRegistration ?? false, + emailFromName: body.displayName, + }, + }); + + return c.json({ instance, message: "Provisioning started" }, 201); + }); + + // Teardown a space + app.delete("/:slug", async (c) => { + const slug = c.req.param("slug"); + const instance = store.getBySlug(slug); + if (!instance) { + return c.json({ error: "Instance not found" }, 404); + } + + if (instance.status === "destroyed") { + return c.json({ error: "Instance already destroyed" }, 400); + } + + store.updateStatus(instance.id, "teardown"); + store.addLog(instance.id, "teardown_start"); + + // Run teardown in background + runTeardown(store, instance); + + return c.json({ message: "Teardown started", instance }); + }); + + return app; +} + +async function runProvisioning( + store: InstanceStore, + instanceId: string, + config: SpaceConfig +) { + try { + // 5. Generate compose + deploy + store.addLog(instanceId, "deploy_start", "Generating compose and deploying"); + const result = await deploySpace(config); + store.addLog(instanceId, "deploy_complete", `Compose: ${result.composePath}`); + + // 8. Wait for health + store.addLog(instanceId, "health_check", "Waiting for container to be healthy"); + const healthy = await checkContainerHealth(config.slug); + if (!healthy) { + store.addLog(instanceId, "health_timeout", "Container did not become healthy in 120s"); + store.updateStatus(instanceId, "failed"); + return; + } + store.addLog(instanceId, "health_ok", "Container is running"); + + // 9. Update Sablier config + store.addLog(instanceId, "sablier_config", "Adding Sablier routing"); + addSablierEntry(config.slug, config.displayName, config.primaryDomain, config.fallbackDomain); + store.addLog(instanceId, "sablier_ok"); + + // 10. Update tunnel config + restart + store.addLog(instanceId, "tunnel_config", "Adding tunnel hostnames"); + addTunnelHostnames(config.primaryDomain, config.fallbackDomain); + await restartCloudflared(); + store.addLog(instanceId, "tunnel_ok"); + + // 11. Mark active + store.updateStatus(instanceId, "active"); + store.addLog(instanceId, "provision_complete", `Live at https://${config.primaryDomain}`); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + store.addLog(instanceId, "provision_error", msg); + store.updateStatus(instanceId, "failed"); + } +} + +async function runTeardown(store: InstanceStore, instance: ReturnType & {}) { + try { + // Remove Sablier config + store.addLog(instance.id, "sablier_remove"); + removeSablierEntry(instance.slug); + + // Remove tunnel hostnames + store.addLog(instance.id, "tunnel_remove"); + removeTunnelHostnames(instance.primaryDomain, instance.fallbackDomain); + await restartCloudflared(); + + // Tear down containers + store.addLog(instance.id, "docker_down"); + await teardownSpace(instance.slug); + + store.updateStatus(instance.id, "destroyed"); + store.addLog(instance.id, "teardown_complete"); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + store.addLog(instance.id, "teardown_error", msg); + store.updateStatus(instance.id, "failed"); + } +} diff --git a/api/src/routes/spaces.ts b/api/src/routes/spaces.ts new file mode 100644 index 0000000..2dce468 --- /dev/null +++ b/api/src/routes/spaces.ts @@ -0,0 +1,27 @@ +import { Hono } from "hono"; +import type { InstanceStore } from "../db/queries.js"; + +export function spacesRoutes(store: InstanceStore) { + const app = new Hono(); + + // List all instances + app.get("/", (c) => { + const owner = c.req.query("owner"); + const instances = store.list(owner); + return c.json({ instances }); + }); + + // Get instance details + app.get("/:slug", (c) => { + const slug = c.req.param("slug"); + const instance = store.getBySlug(slug); + if (!instance) { + return c.json({ error: "Instance not found" }, 404); + } + + const logs = store.getLogs(instance.id); + return c.json({ instance, logs }); + }); + + return app; +} diff --git a/api/src/services/docker-deployer.ts b/api/src/services/docker-deployer.ts new file mode 100644 index 0000000..4ac4bca --- /dev/null +++ b/api/src/services/docker-deployer.ts @@ -0,0 +1,114 @@ +import { writeFileSync, existsSync, mkdirSync } from "fs"; +import { join } from "path"; +import { generateComposeFile } from "./template-engine.js"; +import { generateSecrets } from "./secret-generator.js"; +import type { SpaceConfig } from "../types.js"; + +const GENERATED_DIR = process.env.GENERATED_DIR ?? "../generated"; + +export interface DeployResult { + composePath: string; + envPath: string; +} + +export async function deploySpace(config: SpaceConfig): Promise { + if (!existsSync(GENERATED_DIR)) { + mkdirSync(GENERATED_DIR, { recursive: true }); + } + + const composePath = join(GENERATED_DIR, `docker-compose.space-${config.slug}.yml`); + const envPath = join(GENERATED_DIR, `env.space-${config.slug}`); + + // Generate compose file + const composeContent = generateComposeFile(config); + writeFileSync(composePath, composeContent); + + // Generate .env with secrets + const secrets = generateSecrets(); + const envContent = [ + `# Auto-generated for ${config.slug}`, + `INFISICAL_CLIENT_ID=\${INFISICAL_CLIENT_ID}`, + `INFISICAL_CLIENT_SECRET=\${INFISICAL_CLIENT_SECRET}`, + `POSTGRES_PASSWORD=${secrets.postgresPassword}`, + ].join("\n"); + writeFileSync(envPath, envContent, { mode: 0o600 }); + + // Deploy with docker compose + const proc = Bun.spawn( + [ + "docker", + "compose", + "-f", + composePath, + "--env-file", + envPath, + "-p", + `postiz-${config.slug}`, + "up", + "-d", + "--build", + ], + { stdout: "pipe", stderr: "pipe" } + ); + + const exitCode = await proc.exited; + if (exitCode !== 0) { + const stderr = await new Response(proc.stderr).text(); + throw new Error(`docker compose failed (exit ${exitCode}): ${stderr}`); + } + + return { composePath, envPath }; +} + +export async function teardownSpace(slug: string): Promise { + const composePath = join(GENERATED_DIR, `docker-compose.space-${slug}.yml`); + const envPath = join(GENERATED_DIR, `env.space-${slug}`); + + if (!existsSync(composePath)) { + throw new Error(`Compose file not found: ${composePath}`); + } + + const proc = Bun.spawn( + [ + "docker", + "compose", + "-f", + composePath, + "--env-file", + envPath, + "-p", + `postiz-${slug}`, + "down", + "-v", + ], + { stdout: "pipe", stderr: "pipe" } + ); + + const exitCode = await proc.exited; + if (exitCode !== 0) { + const stderr = await new Response(proc.stderr).text(); + throw new Error(`docker compose down failed (exit ${exitCode}): ${stderr}`); + } +} + +export async function checkContainerHealth( + slug: string, + timeoutMs = 120_000 +): Promise { + const containerName = `postiz-${slug}`; + const start = Date.now(); + + while (Date.now() - start < timeoutMs) { + const proc = Bun.spawn( + ["docker", "inspect", "--format", "{{.State.Running}}", containerName], + { stdout: "pipe", stderr: "pipe" } + ); + const output = await new Response(proc.stdout).text(); + if (output.trim() === "true") { + return true; + } + await Bun.sleep(3000); + } + + return false; +} diff --git a/api/src/services/resource-monitor.ts b/api/src/services/resource-monitor.ts new file mode 100644 index 0000000..7ed1c94 --- /dev/null +++ b/api/src/services/resource-monitor.ts @@ -0,0 +1,51 @@ +import { readFileSync } from "fs"; +import type { ResourceCheck } from "../types.js"; + +const MAX_INSTANCES = parseInt(process.env.MAX_TOTAL_INSTANCES ?? "12", 10); +const INSTANCE_MEM_MB = 2500; // ~2.5GB per Postiz stack +const MIN_HEADROOM_MB = 4000; // Keep 4GB free for system + other services + +export function checkResources(currentInstanceCount: number): ResourceCheck { + let totalMemMB = 0; + let availMemMB = 0; + + try { + const meminfo = readFileSync("/proc/meminfo", "utf-8"); + const totalMatch = meminfo.match(/MemTotal:\s+(\d+)\s+kB/); + const availMatch = meminfo.match(/MemAvailable:\s+(\d+)\s+kB/); + + if (totalMatch) totalMemMB = Math.floor(parseInt(totalMatch[1]) / 1024); + if (availMatch) availMemMB = Math.floor(parseInt(availMatch[1]) / 1024); + } catch { + // Fallback if /proc/meminfo not accessible (e.g., macOS dev) + totalMemMB = 65536; + availMemMB = 20000; + } + + if (currentInstanceCount >= MAX_INSTANCES) { + return { + totalMemMB, + availMemMB, + instanceCount: currentInstanceCount, + canProvision: false, + reason: `Maximum instances reached (${MAX_INSTANCES})`, + }; + } + + if (availMemMB < INSTANCE_MEM_MB + MIN_HEADROOM_MB) { + return { + totalMemMB, + availMemMB, + instanceCount: currentInstanceCount, + canProvision: false, + reason: `Insufficient memory (${availMemMB}MB available, need ${INSTANCE_MEM_MB + MIN_HEADROOM_MB}MB)`, + }; + } + + return { + totalMemMB, + availMemMB, + instanceCount: currentInstanceCount, + canProvision: true, + }; +} diff --git a/api/src/services/sablier-config.ts b/api/src/services/sablier-config.ts new file mode 100644 index 0000000..a4ed41f --- /dev/null +++ b/api/src/services/sablier-config.ts @@ -0,0 +1,79 @@ +import { readFileSync, writeFileSync, existsSync } from "fs"; +import yaml from "js-yaml"; + +const SABLIER_CONFIG_PATH = + process.env.SABLIER_CONFIG_PATH ?? "/root/traefik/config/postiz-sablier.yml"; + +interface SablierConfig { + http: { + middlewares: Record; + routers: Record; + services: Record; + }; +} + +function loadConfig(): SablierConfig { + if (!existsSync(SABLIER_CONFIG_PATH)) { + return { http: { middlewares: {}, routers: {}, services: {} } }; + } + const content = readFileSync(SABLIER_CONFIG_PATH, "utf-8"); + return (yaml.load(content) as SablierConfig) ?? { + http: { middlewares: {}, routers: {}, services: {} }, + }; +} + +function saveConfig(config: SablierConfig): void { + writeFileSync(SABLIER_CONFIG_PATH, yaml.dump(config, { lineWidth: 120 })); +} + +export function addSablierEntry( + slug: string, + displayName: string, + primaryDomain: string, + fallbackDomain: string +): void { + const config = loadConfig(); + const key = `postiz-${slug}`; + + config.http.middlewares[`sablier-${key}`] = { + plugin: { + sablier: { + sablierUrl: "http://sablier:10000", + group: key, + sessionDuration: "30m", + dynamic: { + displayName, + theme: "hacker-terminal", + refreshFrequency: "5s", + showDetails: true, + }, + }, + }, + }; + + config.http.routers[key] = { + rule: `Host(\`${primaryDomain}\`) || Host(\`${fallbackDomain}\`)`, + entryPoints: ["web", "websecure"], + middlewares: [`sablier-${key}`], + service: key, + }; + + config.http.services[key] = { + loadBalancer: { + servers: [{ url: `http://${key}:5000` }], + }, + }; + + saveConfig(config); +} + +export function removeSablierEntry(slug: string): void { + const config = loadConfig(); + const key = `postiz-${slug}`; + + delete config.http.middlewares[`sablier-${key}`]; + delete config.http.routers[key]; + delete config.http.services[key]; + + saveConfig(config); +} diff --git a/api/src/services/secret-generator.ts b/api/src/services/secret-generator.ts new file mode 100644 index 0000000..5620218 --- /dev/null +++ b/api/src/services/secret-generator.ts @@ -0,0 +1,13 @@ +import { randomBytes } from "crypto"; + +export interface GeneratedSecrets { + jwtSecret: string; + postgresPassword: string; +} + +export function generateSecrets(): GeneratedSecrets { + return { + jwtSecret: randomBytes(32).toString("base64url"), + postgresPassword: randomBytes(24).toString("base64url"), + }; +} diff --git a/api/src/services/template-engine.ts b/api/src/services/template-engine.ts new file mode 100644 index 0000000..38e5989 --- /dev/null +++ b/api/src/services/template-engine.ts @@ -0,0 +1,77 @@ +import { readFileSync } from "fs"; +import type { SpaceConfig } from "../types.js"; + +const TEMPLATE_PATH = + process.env.TEMPLATE_PATH ?? "../docker-compose.template.yml"; + +export function generateComposeFile(config: SpaceConfig): string { + let template = readFileSync(TEMPLATE_PATH, "utf-8"); + + const infisicalSlug = config.infisicalSlug ?? `postiz-${config.slug}`; + const emailFromName = config.postiz?.emailFromName ?? "rSocials"; + const disableReg = config.postiz?.disableRegistration ?? false; + + // Build OAuth block + const oauthBlock = ` POSTIZ_GENERIC_OAUTH: 'true' + NEXT_PUBLIC_POSTIZ_OAUTH_DISPLAY_NAME: 'Pocket ID' + NEXT_PUBLIC_POSTIZ_OAUTH_LOGO_URL: 'https://raw.githubusercontent.com/pocket-id/pocket-id/refs/heads/main/frontend/static/img/static-logo.svg' + POSTIZ_OAUTH_URL: 'https://auth.jeffemmett.com' + POSTIZ_OAUTH_AUTH_URL: 'https://auth.jeffemmett.com/authorize' + POSTIZ_OAUTH_TOKEN_URL: 'https://auth.jeffemmett.com/api/oidc/token' + POSTIZ_OAUTH_USERINFO_URL: 'https://auth.jeffemmett.com/api/oidc/userinfo' + POSTIZ_OAUTH_SCOPE: 'openid profile email' + # POSTIZ_OAUTH_CLIENT_ID + CLIENT_SECRET from Infisical`; + + // Build Sablier labels + const traefikLabels = ` - "traefik.enable=false" + - "sablier.enable=true" + - "sablier.group=postiz-${config.slug}" + - "traefik.http.routers.postiz-${config.slug}.rule=Host(\`${config.primaryDomain}\`) || Host(\`${config.fallbackDomain}\`)" + - "traefik.http.routers.postiz-${config.slug}.entrypoints=web,websecure" + - "traefik.http.services.postiz-${config.slug}.loadbalancer.server.port=5000"`; + + const sablierDb = ` labels: + - "sablier.enable=true" + - "sablier.group=postiz-${config.slug}"`; + + const sablierRedis = sablierDb; + + // Substitutions + const replacements: Record = { + "{{SPACE_NAME}}": config.displayName, + "{{SPACE_SLUG}}": config.slug, + "{{PRIMARY_DOMAIN}}": config.primaryDomain, + "{{FALLBACK_DOMAIN}}": config.fallbackDomain, + "{{INFISICAL_SLUG}}": infisicalSlug, + "{{POSTIZ_IMAGE}}": "ghcr.io/gitroomhq/postiz-app:latest", + "{{POSTIZ_PORT}}": "5000", + "{{POSTGRES_IMAGE}}": "postgres:17-alpine", + "{{REDIS_IMAGE}}": "redis:7.2", + "{{TEMPORAL_IMAGE}}": "temporalio/auto-setup:1.28.1", + "{{TEMPORAL_PG_IMAGE}}": "postgres:16", + "{{EMAIL_PROVIDER}}": "nodemailer", + "{{EMAIL_FROM_NAME}}": emailFromName, + "{{EMAIL_FROM}}": config.emailFrom, + "{{EMAIL_HOST}}": "mailcowdockerized-postfix-mailcow-1", + "{{EMAIL_PORT}}": "587", + "{{EMAIL_SECURE}}": "false", + "{{EMAIL_USER}}": "noreply@rmail.online", + "{{STORAGE_PROVIDER}}": "local", + "{{UPLOAD_DIR}}": "/uploads", + "{{DISABLE_REG}}": String(disableReg), + "{{IS_GENERAL}}": "true", + "{{API_LIMIT}}": "30", + }; + + for (const [placeholder, value] of Object.entries(replacements)) { + template = template.replaceAll(placeholder, value); + } + + // Replace multi-line blocks + template = template.replace("{{OAUTH_BLOCK}}", oauthBlock); + template = template.replace("{{TRAEFIK_LABELS}}", traefikLabels); + template = template.replace("{{SABLIER_LABELS_DB}}", sablierDb); + template = template.replace("{{SABLIER_LABELS_REDIS}}", sablierRedis); + + return template; +} diff --git a/api/src/services/tunnel-config.ts b/api/src/services/tunnel-config.ts new file mode 100644 index 0000000..d532712 --- /dev/null +++ b/api/src/services/tunnel-config.ts @@ -0,0 +1,72 @@ +import { readFileSync, writeFileSync } from "fs"; +import yaml from "js-yaml"; + +const TUNNEL_CONFIG_PATH = + process.env.TUNNEL_CONFIG_PATH ?? "/root/cloudflared/config.yml"; + +interface TunnelConfig { + tunnel?: string; + "credentials-file"?: string; + ingress: Array<{ + hostname?: string; + service: string; + }>; +} + +function loadConfig(): TunnelConfig { + const content = readFileSync(TUNNEL_CONFIG_PATH, "utf-8"); + return yaml.load(content) as TunnelConfig; +} + +function saveConfig(config: TunnelConfig): void { + writeFileSync(TUNNEL_CONFIG_PATH, yaml.dump(config, { lineWidth: 120 })); +} + +export function addTunnelHostnames( + primaryDomain: string, + fallbackDomain: string +): void { + const config = loadConfig(); + + // Insert before the catch-all rule (last entry with no hostname) + const catchAllIdx = config.ingress.findIndex((e) => !e.hostname); + const insertIdx = catchAllIdx >= 0 ? catchAllIdx : config.ingress.length; + + // Check if already exists + const exists = config.ingress.some( + (e) => e.hostname === primaryDomain || e.hostname === fallbackDomain + ); + if (exists) return; + + config.ingress.splice( + insertIdx, + 0, + { hostname: primaryDomain, service: "http://localhost:80" }, + { hostname: fallbackDomain, service: "http://localhost:80" } + ); + + saveConfig(config); +} + +export function removeTunnelHostnames( + primaryDomain: string, + fallbackDomain: string +): void { + const config = loadConfig(); + config.ingress = config.ingress.filter( + (e) => e.hostname !== primaryDomain && e.hostname !== fallbackDomain + ); + saveConfig(config); +} + +export async function restartCloudflared(): Promise { + const proc = Bun.spawn(["docker", "restart", "cloudflared"], { + stdout: "pipe", + stderr: "pipe", + }); + const exitCode = await proc.exited; + if (exitCode !== 0) { + const stderr = await new Response(proc.stderr).text(); + throw new Error(`Failed to restart cloudflared: ${stderr}`); + } +} diff --git a/api/src/types.ts b/api/src/types.ts new file mode 100644 index 0000000..c048fba --- /dev/null +++ b/api/src/types.ts @@ -0,0 +1,56 @@ +export interface SpaceConfig { + slug: string; + displayName: string; + primaryDomain: string; + fallbackDomain: string; + emailFrom: string; + infisicalSlug?: string; + postiz?: { + disableRegistration?: boolean; + emailFromName?: string; + }; +} + +export interface Instance { + id: string; + slug: string; + displayName: string; + primaryDomain: string; + fallbackDomain: string; + owner: string; + status: InstanceStatus; + composePath: string | null; + createdAt: string; + updatedAt: string; +} + +export type InstanceStatus = + | "provisioning" + | "active" + | "failed" + | "teardown" + | "destroyed"; + +export interface ProvisionRequest { + slug: string; + displayName: string; + primaryDomain?: string; + emailFrom?: string; + disableRegistration?: boolean; +} + +export interface ProvisionLog { + id: number; + instanceId: string; + action: string; + detail: string | null; + createdAt: string; +} + +export interface ResourceCheck { + totalMemMB: number; + availMemMB: number; + instanceCount: number; + canProvision: boolean; + reason?: string; +} diff --git a/api/tsconfig.json b/api/tsconfig.json new file mode 100644 index 0000000..b10ebb4 --- /dev/null +++ b/api/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "outDir": "dist", + "rootDir": "src", + "types": ["@types/bun"] + }, + "include": ["src"] +} diff --git a/backlog/tasks/task-2 - Multi-tenant-provisioning-platform.md b/backlog/tasks/task-2 - Multi-tenant-provisioning-platform.md index fa04023..52c56af 100644 --- a/backlog/tasks/task-2 - Multi-tenant-provisioning-platform.md +++ b/backlog/tasks/task-2 - Multi-tenant-provisioning-platform.md @@ -1,9 +1,10 @@ --- id: TASK-2 title: Multi-tenant provisioning platform -status: To Do +status: In Progress assignee: [] created_date: '2026-02-24 03:54' +updated_date: '2026-02-25 05:12' labels: [] dependencies: [] priority: medium @@ -14,3 +15,9 @@ priority: medium Self-service API for communities to provision their own Postiz instance at .rsocials.online. CRDT token gating, x402 micropayment metering, Sablier hibernation. Full plan at ~/.claude/plans/greedy-skipping-dahl.md. Phases: 1) Provisioning API Core, 2) CRDT Token Gating, 3) Landing Page + Provision UI, 4) x402 Usage Metering, 5) Sablier + Hardening. + +## Implementation Notes + + +Starting Phase 1: Provisioning API Core (Hono/Bun) + diff --git a/backlog/tasks/task-6 - Remove-plaintext-.env-files-from-server.md b/backlog/tasks/task-6 - Remove-plaintext-.env-files-from-server.md index 1aa8966..a3bc973 100644 --- a/backlog/tasks/task-6 - Remove-plaintext-.env-files-from-server.md +++ b/backlog/tasks/task-6 - Remove-plaintext-.env-files-from-server.md @@ -1,10 +1,10 @@ --- id: TASK-6 title: Remove plaintext .env files from server -status: In Progress +status: Done assignee: [] created_date: '2026-02-25 05:02' -updated_date: '2026-02-25 05:04' +updated_date: '2026-02-25 05:11' labels: - security - infisical @@ -21,7 +21,19 @@ Now that all secrets are stored in Infisical, remove the plaintext .env files fr ## Acceptance Criteria -- [ ] #1 All Postiz spaces pull secrets from Infisical at container startup +- [x] #1 All Postiz spaces pull secrets from Infisical at container startup - [ ] #2 No plaintext .env files with secrets remain on server -- [ ] #3 Containers use entrypoint wrapper or infisical run for secret injection +- [x] #3 Containers use entrypoint wrapper or infisical run for secret injection + +## Implementation Notes + + +AC #2 (remove .env files from server) requires deploying the new compose files on netcup-full. The generated compose files and .env templates are ready in generated/. + + +## Final Summary + + +Template updated to use Infisical entrypoint wrapper. Compose files no longer contain secrets — only INFISICAL_CLIENT_ID, INFISICAL_CLIENT_SECRET, and POSTGRES_PASSWORD in .env (3 values). All other secrets (JWT_SECRET, EMAIL_PASS, OAuth creds, social API keys) injected at runtime from Infisical. Added missing EMAIL_PASS and POSTGRES_PASSWORD to all 3 Postiz Infisical projects. Server-side deployment: replace existing compose files with generated ones + create minimal .env per space. + diff --git a/backlog/tasks/task-7 - Clean-up-duplicate-rsocials-online-Infisical-project.md b/backlog/tasks/task-7 - Clean-up-duplicate-rsocials-online-Infisical-project.md index 9392cb0..09fbaf1 100644 --- a/backlog/tasks/task-7 - Clean-up-duplicate-rsocials-online-Infisical-project.md +++ b/backlog/tasks/task-7 - Clean-up-duplicate-rsocials-online-Infisical-project.md @@ -1,9 +1,10 @@ --- id: TASK-7 title: Clean up duplicate rsocials-online Infisical project -status: To Do +status: Done assignee: [] created_date: '2026-02-25 05:02' +updated_date: '2026-02-25 05:12' labels: - infisical - cleanup @@ -16,3 +17,9 @@ priority: low There's a pre-existing rsocials-online project in Infisical (slug: rsocials) that the app container points to, plus a newer rsocials-app project created during migration. Consolidate into one project and update container config to match. + +## Final Summary + + +Deleted duplicate rsocials-app project (30bb7fcf) from Infisical. The pre-existing rsocials project (slug: rsocials) contains the real app secrets and is what the container references. Total Infisical projects: 16 (was 17). +