- 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>
213 lines
13 KiB
JavaScript
213 lines
13 KiB
JavaScript
// 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]);
|
||
}
|