// Postgres manifest store (optional). When DATABASE_URL is set, project manifests live in a `projects` // table (the whole manifest as a jsonb column, owner_id mirrored out for fast per-user filtering) // instead of JSON files on disk. Media files (assets/audio) still live on disk for now — object // storage (R2) is a later step. When DATABASE_URL is NOT set, projects.mjs falls back to file storage. // // editor/.env: DATABASE_URL=postgresql://user:pass@host:5432/dbname?sslmode=require (Neon / Supabase / local) import pg from "pg"; const { Pool } = pg; let pool = null; export const pgEnabled = () => !!process.env.DATABASE_URL; // TLS: required for hosted Postgres, skipped for local. Use a localhost/private-IP DENYLIST (default // SSL on) rather than a vendor allowlist, so Azure/DO/GCP/etc. work without being individually listed. const needsSSL = () => { const u = process.env.DATABASE_URL || ""; if (/sslmode=disable/i.test(u)) return false; if (/sslmode=require|ssl=true|ssl=1/i.test(u)) return true; const host = (u.match(/@([^:/?]+)/) || [])[1] || ""; const local = /^(localhost|127\.0\.0\.1|\[?::1\]?)$/i.test(host) || /^(10\.|192\.168\.|172\.(1[6-9]|2\d|3[01])\.)/.test(host); return !!host && !local; }; function getPool() { if (!pool) { pool = new Pool({ connectionString: process.env.DATABASE_URL, max: 8, ssl: needsSSL() ? { rejectUnauthorized: false } : undefined, connectionTimeoutMillis: 5000, // a dead/paused host fails in 5s, not the ~2min kernel TCP timeout idleTimeoutMillis: 30000, statement_timeout: 15000, // a runaway query can't pin a pool slot forever }); // CRITICAL: without this listener a dropped idle connection (hosted PG suspends idle sockets) // emits 'error' with no handler → Node re-throws it as an uncaught exception and kills the process. pool.on("error", (err) => console.error("[pg] idle client error:", err.message)); } return pool; } // graceful shutdown — close the pool so dev hot-reloads / restarts don't leak server-side connections let _shutHooked = false; function hookShutdown() { if (_shutHooked) return; _shutHooked = true; const close = () => { const p = pool; pool = null; if (p) p.end().catch(() => {}); }; process.once("SIGINT", () => { close(); process.exit(0); }); process.once("SIGTERM", () => { close(); process.exit(0); }); } let schemaReady = null; export function ensureSchema() { if (!schemaReady) { hookShutdown(); schemaReady = getPool().query(` CREATE TABLE IF NOT EXISTS projects ( id text PRIMARY KEY, owner_id text, manifest jsonb NOT NULL, created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now() ); CREATE INDEX IF NOT EXISTS projects_owner_idx ON projects (owner_id); -- abuse prevention: hashed identity signals per user/device (never raw PII) ... CREATE TABLE IF NOT EXISTS usage_identity_signals ( id bigserial PRIMARY KEY, user_id text NOT NULL, visitor_id text, ip_hash text, fingerprint_hash text, email_hash text, phone_hash text, device_key text, created_at timestamptz NOT NULL DEFAULT now(), last_seen_at timestamptz NOT NULL DEFAULT now(), UNIQUE (user_id, device_key) ); CREATE INDEX IF NOT EXISTS uis_fp_idx ON usage_identity_signals (fingerprint_hash); CREATE INDEX IF NOT EXISTS uis_vid_idx ON usage_identity_signals (visitor_id); CREATE INDEX IF NOT EXISTS uis_ip_idx ON usage_identity_signals (ip_hash); -- ... and the per-device free-quota bucket (a shared key across the device's accounts) CREATE TABLE IF NOT EXISTS quota_usage ( id bigserial PRIMARY KEY, identity_key text UNIQUE NOT NULL, usage_count integer NOT NULL DEFAULT 0, quota_limit integer NOT NULL DEFAULT 100, reset_at timestamptz, created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now() ); -- AI request + cost log (Bundle 3) CREATE TABLE IF NOT EXISTS ai_request_log ( id bigserial PRIMARY KEY, user_id text, route text, model text, in_tokens integer, out_tokens integer, est_cost numeric(12,6), duration_ms integer, ok boolean, created_at timestamptz NOT NULL DEFAULT now() ); CREATE INDEX IF NOT EXISTS ai_log_user_idx ON ai_request_log (user_id); CREATE INDEX IF NOT EXISTS ai_log_time_idx ON ai_request_log (created_at); -- priority support tickets (paid customers escalate here; the owner sees them in the dashboard) CREATE TABLE IF NOT EXISTS support_tickets ( id text PRIMARY KEY, user_id text, tier text, email text, subject text, message text, transcript jsonb, status text NOT NULL DEFAULT 'open', created_at timestamptz NOT NULL DEFAULT now() ); CREATE INDEX IF NOT EXISTS tickets_time_idx ON support_tickets (created_at); `).catch((e) => { schemaReady = null; throw e; }); // let a transient failure retry next call } return schemaReady; } // generic query helper for the abuse / logging services (manifest CRUD has its own functions above) export async function pgQuery(text, params) { await ensureSchema(); return getPool().query(text, params); } // --- manifest CRUD (the manifest is the whole project JSON; owner_id is mirrored to a column) --- export async function pgRead(id) { await ensureSchema(); const { rows } = await getPool().query("SELECT manifest FROM projects WHERE id = $1", [id]); return rows[0]?.manifest ?? null; // jsonb → JS object (node-pg parses it) } export async function pgExists(id) { await ensureSchema(); const { rows } = await getPool().query("SELECT 1 FROM projects WHERE id = $1", [id]); return rows.length > 0; } export async function pgWrite(m) { await ensureSchema(); await getPool().query( `INSERT INTO projects (id, owner_id, manifest, created_at, updated_at) VALUES ($1, $2, $3::jsonb, COALESCE($4::timestamptz, now()), now()) ON CONFLICT (id) DO UPDATE SET owner_id = EXCLUDED.owner_id, manifest = EXCLUDED.manifest, updated_at = now()`, [m.id, m.ownerId ?? null, JSON.stringify(m), m.createdAt || null], ); return m; } export async function pgList(userId) { await ensureSchema(); // userId null → everything (dev/admin); else this user's rows PLUS shared/ownerless ones const { rows } = await getPool().query( `SELECT manifest FROM projects WHERE $1::text IS NULL OR owner_id IS NULL OR owner_id = $1 ORDER BY updated_at DESC`, [userId ?? null], ); return rows.map((r) => r.manifest); } export async function pgRemove(id) { await ensureSchema(); const { rowCount } = await getPool().query("DELETE FROM projects WHERE id = $1", [id]); return rowCount > 0; }