// Server settings an admin can change on the site instead of in .env (Settings → Server settings). // Saved values are sealed in data/settings.json and override .env in every process (see connector/src/env.js). // Scopes: "all" = every workspace; "self" = hidden in hosted agency workspaces (we provide those); // "control" = only on the control centre (app.quorlasocial.com). Secrets are never sent back to the browser. import fs from "node:fs"; import path from "node:path"; import { SETTINGS_FILE, ENV_BASE, savedSettings, applySetting } from "../../connector/src/env.js"; import { seal } from "../../connector/src/sealed.js"; import * as control from "./control.js"; const bad = (message, status = 400) => Object.assign(new Error(message), { status }); const GROUPS = [ { name: "General", fields: [ { key: "PUBLIC_URL", label: "Public address", hint: "Where this dashboard is reached, e.g. https://social.youragency.com. Used in email links and for platforms that fetch media.", scope: "self", type: "url" }, { key: "DASHBOARD_SESSION_HOURS", label: "Stay signed in (hours)", type: "number", placeholder: "12", restart: true }, ] }, { name: "Publishing", fields: [ { key: "ALLOW_PUBLISH", label: "Allow publishing", hint: "Off: drafts can be prepared but nothing is posted. Every post still needs approval.", type: "bool", default: false }, { key: "SCHEDULER_ENABLED", label: "Run the scheduler", hint: "Posts approved posts at their time.", type: "bool", default: true, restart: true }, { key: "DASHBOARD_NIGHTLY_REFRESH", label: "Nightly numbers refresh and monthly reports", type: "bool", default: true, restart: true }, ] }, { name: "Meta", fields: [ { key: "META_MEDIA_PAGE_ID", label: "Agency Facebook Page ID", hint: "Needed to post images for Instagram-only clients." }, { key: "META_API_VERSION", label: "Graph API version", placeholder: "v23.0", scope: "self", restart: true }, ] }, { name: "AI runs", fields: [ { key: "ANTHROPIC_API_KEY", label: "Anthropic API key", hint: "For audits, weekly plans and reports.", secret: true }, { key: "DASHBOARD_AI_MODEL", label: "Model", placeholder: "claude-opus-5-5" }, { key: "DASHBOARD_AI_MAX_TURNS", label: "Max steps per run", type: "number", placeholder: "80" }, ] }, { name: "Email (SMTP)", scope: "self", fields: [ { key: "SMTP_HOST", label: "Host", placeholder: "mail.example.com" }, { key: "SMTP_PORT", label: "Port", type: "number", placeholder: "587" }, { key: "SMTP_USER", label: "Username" }, { key: "SMTP_PASS", label: "Password", secret: true }, { key: "SMTP_FROM", label: "From address", placeholder: "Quorla " }, ] }, { name: "Platforms", scope: "self", fields: [ { key: "YOUTUBE_API_KEY", label: "YouTube Data API key", hint: "Reads public comments for the Inbox.", secret: true }, { key: "LINKEDIN_API_VERSION", label: "LinkedIn API version", placeholder: "202606" }, { key: "PINTEREST_SANDBOX", label: "Pinterest sandbox (Trial access)", type: "bool", default: false }, { key: "REDDIT_USER_AGENT", label: "Reddit user agent", placeholder: "web:com.youragency.app:1.0 (by /u/yourname)" }, ] }, { name: "Billing & sign-ups", scope: "control", fields: [ { key: "STRIPE_SECRET_KEY", label: "Stripe secret key", secret: true }, { key: "STRIPE_PRICE_ID", label: "Stripe price ID", placeholder: "price_…" }, { key: "STRIPE_WEBHOOK_SECRET", label: "Stripe webhook secret", secret: true, hint: "Webhook URL: /control/stripe/webhook on this site." }, { key: "STRIPE_TRIAL_DAYS", label: "Free trial (days)", type: "number", placeholder: "0" }, { key: "TENANT_BASE_DOMAIN", label: "Agency workspace domain", placeholder: "quorlasocial.com" }, ] }, ]; const hosted = () => process.env.QUORLA_HOSTED_TENANT === "true"; const visible = (scope) => scope === "control" ? control.enabled() : scope === "self" ? !hosted() : true; const visibleFields = () => GROUPS.flatMap((g) => (visible(g.scope) ? g.fields.filter((f) => visible(f.scope)) : [])); /** Everything the Settings page needs. Secrets come back only as set / not set. */ export function list() { const saved = savedSettings(); return { hosted: hosted(), canRestart: fs.existsSync("/.dockerenv"), groups: GROUPS.filter((g) => visible(g.scope)).map((g) => ({ name: g.name, fields: g.fields.filter((f) => visible(f.scope)).map((f) => { const value = process.env[f.key]; const fromEnv = (f.key in ENV_BASE ? ENV_BASE[f.key] : value) !== undefined; return { key: f.key, label: f.label, hint: f.hint || "", placeholder: f.placeholder || "", type: f.type || "text", secret: Boolean(f.secret), restart: Boolean(f.restart), default: f.default ?? null, source: f.key in saved ? "site" : fromEnv ? "env" : null, set: Boolean(value), value: f.secret ? "" : value ?? "", }; }), })), }; } function clean(f, raw) { const v = String(raw ?? "").trim(); if (/[\r\n]/.test(v)) throw bad(`${f.label}: one line only.`); if (f.type === "number" && !/^\d+$/.test(v)) throw bad(`${f.label} must be a whole number.`); if (f.type === "bool" && !["true", "false"].includes(v)) throw bad(`${f.label} must be on or off.`); if (f.type === "url" && !/^https?:\/\/[^\s/]+/.test(v)) throw bad(`${f.label} must start with https://`); return f.type === "url" ? v.replace(/\/+$/, "") : v; } /** * values: { KEY: "new value" } (an empty secret keeps the stored one); clear: [KEY] goes back to .env. * Returns the fresh list plus which saved keys need a restart. */ export function save({ values = {}, clear = [] } = {}, onChange = () => {}) { const fields = new Map(visibleFields().map((f) => [f.key, f])); const next = savedSettings(); const changed = []; for (const k of [...Object.keys(values), ...clear]) if (!fields.has(k)) throw bad(`${k} can't be changed here.`); for (const [k, raw] of Object.entries(values)) { const f = fields.get(k); if (f.secret && !String(raw ?? "").trim()) continue; if (!f.secret && !String(raw ?? "").trim()) { if (k in next) { delete next[k]; changed.push(k); } continue; } const v = clean(f, raw); if (next[k] !== v) { next[k] = v; changed.push(k); } } for (const k of clear) if (k in next) { delete next[k]; changed.push(k); } if (changed.length) { fs.mkdirSync(path.dirname(SETTINGS_FILE), { recursive: true }); const tmp = `${SETTINGS_FILE}.tmp`; fs.writeFileSync(tmp, JSON.stringify({ sealed: seal(next), updatedAt: new Date().toISOString() }), { mode: 0o600 }); fs.renameSync(tmp, SETTINGS_FILE); for (const k of changed) applySetting(k, next[k] ?? null); onChange(changed); } return { ...list(), changed, restart: changed.filter((k) => fields.get(k).restart) }; }