// Control centre for hosted Quorla (only when QUORLA_CONTROL=true, i.e. on app.quorlasocial.com). // One Quorla per agency: its own container, data and address. This module keeps the list of agencies ("tenants"), // takes sign-ups through Stripe Checkout, follows the subscription through Stripe webhooks, and tells the // provisioning script on the server what each agency's container should be (running / stopped / removed). // The web app never runs Docker itself; deploy/provision-tenants.sh does, using PROVISION_TOKEN. import fs from "node:fs"; import path from "node:path"; import crypto from "node:crypto"; import { PROJECT_ROOT } from "../../connector/src/env.js"; import * as notify from "./notify.js"; const DIR = process.env.QUORLA_CONTROL_DIR || path.join(PROJECT_ROOT, "data", "control"); // override for tests const FILE = path.join(DIR, "tenants.json"); fs.mkdirSync(DIR, { recursive: true }); const read = () => { try { return JSON.parse(fs.readFileSync(FILE, "utf8")); } catch { return {}; } }; const write = (db) => { const tmp = FILE + ".tmp"; fs.writeFileSync(tmp, JSON.stringify(db, null, 1)); fs.renameSync(tmp, FILE); }; const err = (msg, status = 400) => Object.assign(new Error(msg), { status }); const now = () => new Date().toISOString(); export const enabled = () => process.env.QUORLA_CONTROL === "true"; const BASE_DOMAIN = () => process.env.TENANT_BASE_DOMAIN || "quorlasocial.com"; const RESERVED = new Set(["app", "www", "api", "mail", "webmail", "admin", "status", "help", "docs", "blog", "billing", "control", "quorla", "test", "staging"]); const PUBLIC = (t) => { const { setupToken, ...rest } = t; return { ...rest, setupPending: Boolean(setupToken) }; }; export function list() { return Object.values(read()).sort((a, b) => b.createdAt.localeCompare(a.createdAt)).map(PUBLIC); } // ---------- Stripe (REST, no SDK) ---------- async function stripe(pathname, form) { const key = process.env.STRIPE_SECRET_KEY; if (!key) throw err("Stripe isn't set up (STRIPE_SECRET_KEY).", 503); const body = new URLSearchParams(); const add = (k, v) => { if (v === undefined || v === null) return; if (typeof v === "object") for (const [kk, vv] of Object.entries(v)) add(`${k}[${kk}]`, vv); else body.append(k, String(v)); }; for (const [k, v] of Object.entries(form || {})) add(k, v); const r = await fetch(`https://api.stripe.com/v1/${pathname}`, { method: "POST", headers: { Authorization: `Bearer ${key}`, "Content-Type": "application/x-www-form-urlencoded" }, body }); const j = await r.json().catch(() => ({})); if (!r.ok) throw err(`Stripe: ${j.error?.message || r.status}`, 502); return j; } /** Verify a Stripe webhook: Stripe-Signature "t=…,v1=…" over `${t}.${rawBody}`, within 5 minutes. */ export function verifyWebhook(rawBody, header) { const secret = process.env.STRIPE_WEBHOOK_SECRET; if (!secret) throw err("Webhook secret missing.", 503); const parts = Object.fromEntries(String(header || "").split(",").map((p) => p.split("=")).filter((p) => p.length === 2).map(([k, v]) => [k.trim(), v.trim()])); const sigs = String(header || "").split(",").filter((p) => p.trim().startsWith("v1=")).map((p) => p.trim().slice(3)); const t = Number(parts.t); if (!t || !sigs.length) throw err("Bad signature header.", 400); if (Math.abs(Date.now() / 1000 - t) > 300) throw err("Webhook too old.", 400); const expected = crypto.createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex"); const ok = sigs.some((s) => s.length === expected.length && crypto.timingSafeEqual(Buffer.from(s), Buffer.from(expected))); if (!ok) throw err("Signature doesn't match.", 400); return JSON.parse(rawBody); } // ---------- sign-up ---------- function validate({ agencyName, ownerName, ownerEmail, slug }) { const s = String(slug || "").toLowerCase().trim(); if (!String(agencyName || "").trim()) throw err("Enter your agency's name."); if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(String(ownerEmail || "").trim())) throw err("Enter a valid work email."); if (!/^[a-z][a-z0-9-]{2,30}$/.test(s) || RESERVED.has(s)) throw err("Workspace address: 3–31 lowercase letters, numbers or dashes, starting with a letter."); const db = read(); if (db[s] && !["pending_payment", "cancelled"].includes(db[s].status)) throw err("That workspace address is taken. Try another."); return { slug: s, agencyName: String(agencyName).trim().slice(0, 60), ownerName: String(ownerName || "").trim().slice(0, 60), ownerEmail: String(ownerEmail).trim().toLowerCase().slice(0, 120) }; } /** Public sign-up: creates the tenant record and returns where to send the person (Stripe Checkout or a thank-you). */ export async function signup(input, origin) { const t = validate(input); const db = read(); db[t.slug] = { ...t, plan: "hosted", status: "pending_payment", desired: "none", actual: "none", port: null, url: `https://${t.slug}.${BASE_DOMAIN()}`, setupToken: crypto.randomBytes(18).toString("base64url"), createdAt: now(), updatedAt: now(), stripe: {}, history: [{ at: now(), event: "signed up" }], }; write(db); if (!process.env.STRIPE_SECRET_KEY || !process.env.STRIPE_PRICE_ID) { // No Stripe yet: an admin approves the agency by hand on the Agencies page. await notify.notify({ type: "tenant_signup", title: `New agency sign-up: ${t.agencyName}`, body: `${t.ownerName} <${t.ownerEmail}> · ${t.slug}`, link: "/admin/agencies", to: "admins" }); return { next: "manual" }; } const trial = Number(process.env.STRIPE_TRIAL_DAYS || 0); const session = await stripe("checkout/sessions", { mode: "subscription", customer_email: t.ownerEmail, client_reference_id: t.slug, "line_items[0][price]": process.env.STRIPE_PRICE_ID, "line_items[0][quantity]": 1, success_url: `${origin}/signup/done?w=${t.slug}`, cancel_url: `${origin}/signup?cancelled=1`, metadata: { slug: t.slug }, subscription_data: { metadata: { slug: t.slug }, ...(trial > 0 ? { trial_period_days: trial } : {}) }, allow_promotion_codes: "true", }); return { next: "stripe", url: session.url }; } // ---------- subscription lifecycle ---------- function update(slug, fn, event) { const db = read(); const t = db[slug]; if (!t) return null; fn(t); t.updatedAt = now(); if (event) (t.history ||= []).push({ at: now(), event }); t.history = t.history.slice(-100); write(db); return t; } const slugFromObj = (o) => o?.metadata?.slug || o?.client_reference_id || o?.subscription_details?.metadata?.slug || null; const bySubscription = (id) => Object.values(read()).find((t) => t.stripe?.subscription === id)?.slug || null; /** Apply one Stripe event. Returns a short description for the log. */ export async function onStripeEvent(evt) { const o = evt.data?.object || {}; switch (evt.type) { case "checkout.session.completed": { const slug = slugFromObj(o); if (!slug) return "no slug"; update(slug, (t) => { t.status = "provisioning"; t.desired = "running"; t.stripe = { customer: o.customer, subscription: o.subscription }; }, "paid; workspace queued"); await notify.notify({ type: "tenant_paid", level: "ok", title: `New paying agency: ${read()[slug]?.agencyName}`, link: "/admin/agencies", to: "admins", email: true }); return `provisioning ${slug}`; } case "customer.subscription.updated": { const slug = slugFromObj(o) || bySubscription(o.id); if (!slug) return "unknown subscription"; update(slug, (t) => { if (["active", "trialing"].includes(o.status)) { if (t.status === "suspended" && t.suspendedFor === "billing") { t.desired = "running"; delete t.suspendedFor; } if (t.actual === "running") t.status = "active"; } else if (o.status === "past_due") t.status = "past_due"; else if (["unpaid", "canceled", "incomplete_expired"].includes(o.status)) { t.status = "suspended"; t.suspendedFor = "billing"; t.desired = "stopped"; } }, `subscription ${o.status}`); return `${slug} ${o.status}`; } case "customer.subscription.deleted": { const slug = slugFromObj(o) || bySubscription(o.id); if (!slug) return "unknown subscription"; update(slug, (t) => { t.status = "cancelled"; t.desired = "stopped"; }, "subscription cancelled; workspace stopped (data kept)"); return `${slug} cancelled`; } case "invoice.payment_failed": { const slug = bySubscription(o.subscription); if (slug) update(slug, (t) => { if (t.status === "active") t.status = "past_due"; }, "payment failed"); return `payment failed ${slug || ""}`; } default: return `ignored ${evt.type}`; } } // ---------- admin actions ---------- export function approve(slug) { return PUBLIC(update(slug, (t) => { t.status = "provisioning"; t.desired = "running"; }, "approved by admin") || (() => { throw err("Unknown agency.", 404); })()); } export function suspend(slug) { return PUBLIC(update(slug, (t) => { t.status = "suspended"; t.suspendedFor = "admin"; t.desired = "stopped"; }, "suspended by admin") || (() => { throw err("Unknown agency.", 404); })()); } export function resume(slug) { return PUBLIC(update(slug, (t) => { t.desired = "running"; delete t.suspendedFor; t.status = t.actual === "running" ? "active" : "provisioning"; }, "resumed by admin") || (() => { throw err("Unknown agency.", 404); })()); } /** Remove the container. Data stays on disk (the script archives it) until someone deletes it by hand. */ export function remove(slug) { return PUBLIC(update(slug, (t) => { t.status = "removed"; t.desired = "removed"; }, "removal requested by admin") || (() => { throw err("Unknown agency.", 404); })()); } export async function billingPortal(slug, returnUrl) { const t = read()[slug]; if (!t?.stripe?.customer) throw err("This agency has no Stripe customer yet."); return (await stripe("billing_portal/sessions", { customer: t.stripe.customer, return_url: returnUrl })).url; } // ---------- provisioning script API ---------- export function checkProvisionToken(header) { const need = process.env.PROVISION_TOKEN || ""; const got = String(header || "").replace(/^Bearer\s+/i, ""); if (!need || got.length !== need.length || !crypto.timingSafeEqual(Buffer.from(got), Buffer.from(need))) throw err("Not allowed.", 401); } /** What the script should do: every tenant whose container isn't in its desired state. Assigns ports. */ export function tasks() { const db = read(); const used = new Set(Object.values(db).map((t) => t.port).filter(Boolean)); let changed = false; const out = []; for (const t of Object.values(db)) { if (t.desired === "none" || t.desired === t.actual) continue; if (t.desired === "running" && !t.port) { let p = 4400; while (used.has(p)) p++; t.port = p; used.add(p); changed = true; } out.push({ slug: t.slug, desired: t.desired, port: t.port, agencyName: t.agencyName, url: t.url, setupToken: t.setupToken || null }); } if (changed) write(db); return out; } /** The script reports a container's state. First time a workspace runs: email the owner their setup link. */ export async function report(slug, { actual, error }) { const before = read()[slug]; if (!before) throw err("Unknown agency.", 404); const t = update(slug, (x) => { if (error) { x.lastError = String(error).slice(0, 500); return; } x.actual = actual; delete x.lastError; if (actual === "running" && ["provisioning", "past_due"].includes(x.status)) x.status = "active"; }, error ? `provisioning error: ${String(error).slice(0, 120)}` : `container ${actual}`); if (!error && actual === "running" && before.actual !== "running" && t.setupToken && !t.welcomedAt) { const sent = await notify.sendEmail({ to: t.ownerEmail, subject: `Your Quorla workspace is ready`, heading: `Welcome to Quorla, ${t.ownerName || t.agencyName}`, lines: [`Your workspace for ${t.agencyName} is ready at ${t.url}.`, "Use the button to create your admin account. The link works once; after that, sign in with your email and password.", "Then follow the Getting started list: connect your clients' accounts, invite your team and schedule your first post."], button: { label: "Create my account", url: `${t.url}/?setup=${t.setupToken}` }, fromName: "Quorla", }); update(slug, (x) => { if (sent.sent) x.welcomedAt = now(); }, sent.sent ? "welcome email sent" : `welcome email not sent: ${sent.reason}`); } return PUBLIC(read()[slug]); } /** Send the welcome email again (the setup link only works until the first account exists). */ export async function resendWelcome(slug) { const t = read()[slug]; if (!t) throw err("Unknown agency.", 404); if (t.actual !== "running") throw err("The workspace isn't running yet."); update(slug, (x) => { delete x.welcomedAt; }); await report(slug, { actual: "running" }); update(slug, () => {}, "welcome email re-sent"); return PUBLIC(read()[slug]); }