/** * Two-layer fail-closed guard for `rp ai-test`. An AI agent must never drive * production data, and there are TWO distinct things to defend: * * 1. The dashboard HOST (`assertAllowedDashboardHost`) — reject typos, unknown, * and stale-stack hosts before we ever point a live session at them. * 2. The org MODE (`assertSandboxModeEnabled`) — the real production guard. * * Why mode, not host: a dashboard host serves BOTH production and sandbox data for * an org — the difference is the in-app Sandbox toggle, persisted as * localStorage["${orgId}_sandbox"]. (The sandbox.* hosts are API hosts; they have * no login form.) So the hostname can NOT distinguish sandbox from production — the * allowlist only stops us hitting a wrong/unknown host, and the sandbox flag is what * actually keeps the run out of production. login.ts forces the flag to "true" after * login and calls assertSandboxModeEnabled to verify it stuck before any scenario * runs; if it can't, the run aborts rather than touching production. */ // Fail-closed: a host whose domain is NOT one of these is refused. This is the // anti-typo / anti-stale-trace layer, NOT the production guard — every host here can // serve production data, so assertSandboxModeEnabled is what keeps a run out of // production. We allow the dashboard domain of every Root stack (multi-tenant SA + // UK, staging, embed, and the private stacks); resolveDashboardUrl's api.*→app.* // swap maps each stack's API host onto its own dashboard host, so one suffix per // domain covers every present and future stack on that domain. export const ALLOWED_DASHBOARD_HOST_SUFFIXES = [ 'rootplatform.com', // app.rootplatform.com, app.uk.rootplatform.com 'rootprivatestack.com', // app..rootprivatestack.com (rcs, cim, momentum, abacus, …) 'embedroot.com', // app.embedroot.com, app..embedroot.com 'root.co.za', // app...root.co.za (e.g. the Sanlam Indie private stack) 'alfred.fun', // staging.app.alfred.fun (staging dashboard) + the Alfred hosts ]; // Exact-match hosts (local dev) that have no domain suffix to match on. export const ALLOWED_DASHBOARD_HOSTS_EXACT = ['localhost', '127.0.0.1']; export const isAllowedDashboardHost = (host: string): boolean => { if (ALLOWED_DASHBOARD_HOSTS_EXACT.includes(host)) return true; // api.*/sandbox.* are API hosts with no login form — reject them even on an allowed // domain, so a config whose api.*→app.* swap didn't fire fails with a clear error // instead of a confusing "no login form" hang. if (host.startsWith('api.') || host.startsWith('sandbox.')) return false; return ALLOWED_DASHBOARD_HOST_SUFFIXES.some((suffix) => host === suffix || host.endsWith(`.${suffix}`)); }; export const resolveDashboardUrl = (rawHost: string, override?: string): string => { if (override) return override.replace(/\/$/, ''); // .root-config.json stores a BARE hostname (e.g. "api.rootplatform.com"), but an // explicit override may already carry a scheme. Normalise to an absolute URL // before parsing — `new URL("api.rootplatform.com")` throws "Invalid URL". const withScheme = /^[a-z][a-z0-9+.-]*:\/\//i.test(rawHost) ? rawHost : `https://${rawHost}`; // The dashboard lives on the matching app.* host, so swap an `api.` prefix to // `app.`. Anything that already looks like a dashboard host is used as-is (then // host-allowlisted). const url = new URL(withScheme); if (url.hostname.startsWith('api.')) { url.hostname = 'app.' + url.hostname.slice('api.'.length); } return url.toString().replace(/\/$/, ''); }; export const assertAllowedDashboardHost = (dashboardUrl: string): void => { const host = new URL(dashboardUrl).hostname; if (!isAllowedDashboardHost(host)) { throw new Error( `rp ai-test refuses to run against host "${host}" — only known Root dashboard domains are allowed (${[ ...ALLOWED_DASHBOARD_HOST_SUFFIXES, ...ALLOWED_DASHBOARD_HOSTS_EXACT, ].join(', ')}). Point --dashboard-url at a supported dashboard if your .root-config.json host resolved wrong.`, ); } }; // The localStorage key the dashboard reads to decide sandbox vs production mode. export const SANDBOX_FLAG_VALUE = 'true'; export const sandboxFlagKey = (organizationId: string): string => `${organizationId}_sandbox`; /** * Thrown when sandbox mode could not be confirmed. Kept distinct from generic * login failures so callers (login.ts) can re-throw it verbatim instead of * masking the production-guard breach behind a "could not log in" message. */ export class ProductionGuardError extends Error { constructor(message: string) { super(message); this.name = 'ProductionGuardError'; } } /** * The real production guard: assert the org's sandbox flag is "true". Called with * the value read back from the page's localStorage after login.ts sets it. Absent * or "false" means the dashboard would serve PRODUCTION data — abort hard. */ export const assertSandboxModeEnabled = (organizationId: string, flagValue: string | null): void => { if (flagValue !== SANDBOX_FLAG_VALUE) { throw new ProductionGuardError( `rp ai-test aborted: sandbox mode is not enabled for org ${organizationId} ` + `(localStorage["${sandboxFlagKey(organizationId)}"] = ${JSON.stringify(flagValue)}). ` + `app.rootplatform.com serves PRODUCTION data unless this flag is "${SANDBOX_FLAG_VALUE}", and an AI agent must never drive production.`, ); } };