metatroncubeswdev 1e97bbd31f Control centre: don't block the provisioning report on email; fix resend
- The welcome email is sent in the background, so a slow mail server no
  longer makes provision-tenants.sh time out.
- SMTP connection/greeting/socket timeouts, so a bad host fails with a
  reason in the agency log instead of hanging.
- "Resend welcome" never sent anything (report() only emails on the
  change to running); it now sends directly and shows the error if any.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 13:15:45 -04:00

213 lines
13 KiB
JavaScript
Raw Permalink 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) {
// Don't make the script wait for the mail server; the outcome lands in the agency's log on Admin → Agencies.
sendWelcome(slug, t).catch(() => {});
}
return PUBLIC(read()[slug]);
}
async function sendWelcome(slug, t) {
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 sent;
}
/** 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.");
const sent = await sendWelcome(slug, t);
if (!sent.sent) throw err(`Welcome email not sent: ${sent.reason}`, 502);
return PUBLIC(read()[slug]);
}