// Runs the hub's API. Configuration (environment): // DATABASE_URL postgres://... (default: PGlite files in ./data/db, for development) // AUTH_SECRET at least 32 random characters (required unless NODE_ENV is development) // API_URL the API's address as the website and proxies reach it (default http://localhost:4101) // WEB_URL the website's address (default http://localhost:4100) // PORT default 4101 // REGISTRATION open or closed (new accounts wait for a moderator's approval), until an admin sets it in the console // AGE_PROVIDERS ways to verify age, comma-separated (default imvu): imvu, didit // DIDIT_API_KEY, DIDIT_WORKFLOW_ID, DIDIT_WEBHOOK_SECRET // Didit's ID document check (docs/design.md, "Age"); its webhook is API_URL/api/v1/age/webhook/didit // DIDIT_MONTHLY_LIMIT checks started a month at most (default 30: Didit's free plan has $10 of credit a month, and a // check with ID document, liveness and face match costs $0.30) import { openDatabase } from "@t5de/db"; import { AgeService, type Provider } from "./age"; import { diditAgeProvider } from "./age/didit"; import { imvuAgeProvider } from "./age/imvu"; import { createApp } from "./app"; import { createAuth } from "./auth"; import { Settings } from "./settings"; import { ImvuClient } from "./imvu"; const env = process.env; const dev = env.NODE_ENV !== "production"; const port = Number(env.PORT ?? 4101); const secret = env.AUTH_SECRET ?? (dev ? "development-secret-not-for-production-use" : ""); if (secret.length < 32) { console.error("AUTH_SECRET must be at least 32 characters"); process.exit(1); } const apiURL = env.API_URL ?? `http://localhost:${port}`; const webURL = env.WEB_URL ?? "http://localhost:4100"; const { db } = await openDatabase(env.DATABASE_URL ?? "./data/db"); const settings = new Settings(db, { registration: env.REGISTRATION }); const auth = createAuth(db, { apiURL, webURL, secret, settings }); const imvu = new ImvuClient(); function required(name: string): string { const v = env[name]; if (!v) { console.error(`${name} must be set`); process.exit(1); } return v; } const ageProviders: Record Provider> = { imvu: () => imvuAgeProvider(imvu), didit: () => diditAgeProvider({ apiKey: required("DIDIT_API_KEY"), workflowId: required("DIDIT_WORKFLOW_ID"), webhookSecret: required("DIDIT_WEBHOOK_SECRET"), monthlyLimit: Number(env.DIDIT_MONTHLY_LIMIT ?? 30), }), }; const providers = (env.AGE_PROVIDERS ?? "imvu").split(",").map((id) => id.trim()).filter(Boolean); if (!providers.includes("imvu")) providers.unshift("imvu"); // linking records IMVU's answer for (const id of providers) { if (!ageProviders[id]) { console.error(`AGE_PROVIDERS: no provider ${id}`); process.exit(1); } } const ageService = new AgeService(db, providers.map((id) => ageProviders[id]!())); const { app, age, rooms, products } = createApp({ db, auth, imvu, age: ageService, apiURL, webURL, settings }); app.listen({ port, hostname: env.HOST ?? "0.0.0.0" }); // Age answers that can change (IMVU's) are checked again when they expire, a few at a time; revisions missing their // parent's info (IMVU was unavailable, or they predate it) get it, starting now. const parentInfo = () => products.fillParentInfo(20).catch((e) => console.error("parent info:", e)); parentInfo(); setInterval(() => { age.recheckDue(50).catch((e) => console.error("age re-checks:", e)); rooms.recheckDue(20).catch((e) => console.error("room re-checks:", e)); parentInfo(); }, 10 * 60 * 1000); console.log(`t5de-hub API on ${apiURL} (website ${webURL})`);