metatroncubeswdev 075b79f6b2 Hosted Quorla: sign-up, Stripe billing, one workspace per agency
- Control centre (QUORLA_CONTROL=true on app.quorlasocial.com): public /signup → Stripe Checkout,
  verified Stripe webhook (paid, updated, cancelled, payment failed), Admin → Agencies
  (approve without Stripe, suspend, resume, billing portal, resend welcome, remove)
- deploy/provision-tenants.sh (cron): one container per agency with its own .env and data,
  nginx map for <agency>.quorlasocial.com, upgrades agencies after a rebuild, archives removed data
- New workspaces: first admin only via the emailed one-time setup link (SETUP_TOKEN)
- Meta system user token can be saved in Settings (encrypted) for workspaces that can't edit .env
- docs/multi-tenant.md: DNS, wildcard cert, nginx, sudoers, cron, Stripe setup; tests for the control centre

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 10:35:23 -04:00

208 lines
13 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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]);
}