ProspectOS frontend
Something went wrong
This page could not be loaded. Please try again or return to the workspace.
diff --git a/.env.example b/.env.example index c833ae4..0005541 100644 --- a/.env.example +++ b/.env.example @@ -1,12 +1,17 @@ -# Local, non-secret runtime configuration. Copy to .env; never commit real credentials. -APP_ENV=development +# Portable deployment example. Keep this file secret-free; supply SESSION_SECRET via a secret manager. +APP_ENV=production LOG_LEVEL=INFO API_PORT=8000 WEB_PORT=8080 -CORS_ORIGINS=http://localhost:8080 -# Optional first-run admin bootstrap. Set both only for a fresh deployment; -# remove them after the admin account has been created. Never commit credentials. +CORS_ORIGINS=https://your-approved-web-origin.example +DATA_DIR=/data +# Required in production; generate at least 32 random characters outside this file. +SESSION_SECRET= +# Optional first-run admin bootstrap. Remove both immediately after provisioning. BOOTSTRAP_ADMIN_EMAIL= BOOTSTRAP_ADMIN_PASSWORD= -# Deliberately fixed to false in compose for this MVP. +# Hard safety default; this release has no delivery capability. AUTOMATED_OUTREACH_ENABLED=false +# Backup operations (host-side, never mounted into the web container). +BACKUP_DIR=/var/backups/prospect-platform +BACKUP_RETENTION=30 diff --git a/README.md b/README.md index 9cea2a8..97b0fe5 100644 --- a/README.md +++ b/README.md @@ -217,4 +217,14 @@ Any future side-effecting operation must require a tenant-scoped idempotency key Phase 14 is preparation only and remains pilot-grade. The current Compose stack has no outreach provider, draft/send API, consent ledger, legal-policy engine, durable approval workflow, production secret manager, durable queue/worker, delivery telemetry, bounce/complaint handling, or compliance-grade audit/retention service. Before production, obtain jurisdiction-specific legal review and documented consent/legal-basis policy, implement provider contracts/DPA and secret isolation, durable transactional idempotency and audit, suppression synchronization, approval expiry/rollback, rate/cost controls, delivery feedback, incident kill switch, retention/deletion/legal-hold workflows, and end-to-end tenant-isolation and no-send tests. -See `apps/api/README.md`, `apps/web/README.md`, `docs/SECURITY.md`, and `docs/OPERATIONS.md` for details. +See `docs/DEPLOYMENT.md` for Phase 15 portable backup/restore, readiness, rollback, monitoring, and systemd/Virtualmin operations. No deployment is performed by this repository change. + +## Phase 15 production-readiness boundary + +Phase 15 documents a portable deployment pattern for a Virtualmin-managed VPS; it does not claim that this repository provisions Virtualmin, TLS, DNS, backups, or a production database. The supported baseline is Docker Engine plus the Compose v2 plugin on a Linux host, with Virtualmin (or another reverse proxy) terminating HTTPS and forwarding only to the published web/API ports. The operator owns firewalling, DNS, certificates, Docker access, host patching, resource capacity, and an off-host backup destination. + +Before deployment, verify the prerequisites in `docs/RELEASE_CHECKLIST.md`: a reviewed commit, Docker/Compose, DNS and TLS, a protected deployment directory, secret injection, backup destination, monitoring, and a tested rollback owner. Copy `.env.example` to an untracked `.env` only for non-secret defaults. Production secrets and bootstrap credentials must come from a secret manager or protected Virtualmin deployment environment; remove bootstrap values after first-run provisioning and never commit or print them. + +The API liveness endpoint (`/api/v1/health/live`) and web `/healthz` are public process checks; API `/api/v1/health/ready` additionally verifies SQLite readiness. They are suitable for Docker and monitoring but do not prove backups, tenant authorization, or external dependencies. Production ingress must not route traffic until both Compose services report `healthy` and the deployment smoke tests pass. The current image initializes SQLite from `schema.sql` and has no standalone migration runner. Treat schema changes as a versioned, backup-first migration: validate on a restored copy, record the schema/data checks, and keep the previous image/config available for rollback. + +The named Docker volume is not a backup. Stop or quiesce writes, create an encrypted off-host backup, verify it, and perform a restore drill before calling a deployment protected. Define retention for the database, audit/source lineage, logs, and backups; apply legal holds and deletion rules deliberately. Do not run `docker compose down -v` on a data-bearing host. Outbound traffic is deny-by-default for product behavior: `AUTOMATED_OUTREACH_ENABLED=false` is fixed in Compose and this release has no send/provider/worker path. Unexpected egress is an incident. SQLite, the in-process worker, HTTP-only local Compose, lack of durable migrations/PITR, and the limited SQLite-only readiness check are explicit limitations, not hidden guarantees. diff --git a/apps/api/README.md b/apps/api/README.md index f612d84..7bc5af9 100644 --- a/apps/api/README.md +++ b/apps/api/README.md @@ -57,6 +57,7 @@ Implementations should expose source/query/job state without leaking raw payload ### Health and workspace - `GET /api/v1/health/live` — unauthenticated liveness check. +- `GET /api/v1/health/ready` — unauthenticated readiness check; verifies SQLite connectivity and returns 503 when unavailable. - `GET /api/v1/auth/me` — current authenticated user and tenant. - `GET /api/v1/dashboard/summary` — tenant-scoped counts and score summary. @@ -188,7 +189,17 @@ Every draft must retain recipient/channel, bounded redacted content or a safe co Any future send or other side effect must require an `Idempotency-Key` scoped to tenant, operation, draft version, recipient, provider, and policy fingerprint. Exact retries return the original result; a different request under the same key is rejected. Enforce rate/message/cost caps before attempts and across retries, fallbacks, and workers; use bounded retry/backoff and circuit breaking. Audit draft/gate/approval/provider/cap/suppression events with safe per-item outcomes and redacted payloads. No route may infer completion from request acceptance. -The Phase 14 implementation remains documentation-only/pilot preparation: there is no draft persistence/API, consent ledger, legal-policy evaluator, configured provider, durable approval queue, delivery adapter, bounce/complaint feedback, secret manager, or production-grade audit/retention workflow in the current runtime. Production requires those components plus DPA/provider and jurisdictional legal review, suppression synchronization, kill switch, rollback/revocation, deletion/legal-hold verification, and integration tests proving no-send default, citation/hash binding, stale/uncertain gate failure, cap enforcement, idempotent replay/conflict rejection, approval expiry, and cross-tenant isolation. +The Phase 13 implementation remains documentation-only/pilot preparation: there is no draft persistence/API, consent ledger, legal-policy evaluator, configured provider, durable approval queue, delivery adapter, bounce/complaint feedback, secret manager, or production-grade audit/retention workflow in the current runtime. Production requires those components plus DPA/provider and jurisdictional legal review, suppression synchronization, kill switch, rollback/revocation, deletion/legal-hold verification, and integration tests proving no-send default, citation/hash binding, stale/uncertain gate failure, cap enforcement, idempotent replay/conflict rejection, approval expiry, and cross-tenant isolation. + +## Phase 15 deployment contract + +The API image is portable but intentionally small: it runs as a non-root user, writes only `/data`, and initializes the SQLite schema from the image-bundled `schema.sql`. It does not provision Virtualmin, DNS, TLS, a reverse proxy, a secret manager, a durable queue, or a production database. A Virtualmin deployment must provide Docker Engine with Compose v2, a protected checkout, host firewalling, HTTPS termination, a private deployment network, sufficient CPU/RAM/disk, and an encrypted off-host backup destination. Publish the API only where the reverse proxy requires it; preferably expose the web entry point publicly and keep the API port private to the host/network. + +Configuration is environment-only. `APP_ENV`, `LOG_LEVEL`, `CORS_ORIGINS`, and port values may be non-secret deployment settings. `BOOTSTRAP_ADMIN_EMAIL` and `BOOTSTRAP_ADMIN_PASSWORD` are one-time provisioning inputs, must be supplied together through a protected environment/secret store, and must be removed and rotated immediately after bootstrap. Never accept provider credentials from clients or store secrets in source, images, Compose, committed `.env`, logs, traces, metrics, or audit payloads. `AUTOMATED_OUTREACH_ENABLED=false` is the enforced Compose default; no outbound provider or send route exists in this release. + +`GET /api/v1/health/live` is an unauthenticated liveness check and `GET /api/v1/health/ready` is an unauthenticated SQLite readiness check. Operators must gate traffic on Compose `healthy` status plus an authenticated smoke test and migration validation; do not interpret either response as proof that backups, tenant authorization, workers, or external dependencies are ready. + +The current runtime has no standalone migration, backup, restore, retention, or rollback command. Before changing an image or schema, quiesce writes, take and verify an encrypted backup, restore it into an isolated copy, run the API test suite and schema/data invariants, then deploy. Keep the previous image digest and configuration revision available; rollback must restore the application image/config first and only restore data when compatibility and operator approval are established. SQLite remains pilot-scale: no HA, PITR, durable worker lease/recovery, or online migration is implied. See `docs/OPERATIONS.md` and `docs/RELEASE_CHECKLIST.md` for the operator procedure. ## Remaining limitations and production migration work diff --git a/apps/api/app/config.py b/apps/api/app/config.py new file mode 100644 index 0000000..f3f7db7 --- /dev/null +++ b/apps/api/app/config.py @@ -0,0 +1,43 @@ +"""Environment configuration validation for portable deployments.""" +from dataclasses import dataclass +import os +from pathlib import Path + + +class ConfigError(ValueError): + """Raised when deployment configuration is unsafe or malformed.""" + + +@dataclass(frozen=True) +class Config: + app_env: str + data_dir: Path + session_secret: str + outreach_enabled: bool + log_level: str + + +def _env(values, key, default=""): + return str(values.get(key, default) or "").strip() + + +def load_config(values=None): + values = os.environ if values is None else values + app_env = _env(values, "APP_ENV", "development").lower() + if app_env not in {"development", "test", "staging", "production"}: + raise ConfigError("APP_ENV must be development, test, staging, or production") + data_dir = Path(_env(values, "DATA_DIR", ".") or ".").expanduser() + if not data_dir.is_absolute(): + data_dir = (Path.cwd() / data_dir).resolve() + if data_dir.exists() and not data_dir.is_dir(): + raise ConfigError("DATA_DIR must be a directory") + secret = _env(values, "SESSION_SECRET") + if app_env == "production" and (len(secret) < 32 or secret.lower() in {"change-me", "development", "dev"}): + raise ConfigError("SESSION_SECRET must be at least 32 characters in production") + outreach = _env(values, "AUTOMATED_OUTREACH_ENABLED", "false").lower() + if outreach not in {"", "0", "false", "no", "off"}: + raise ConfigError("automated outreach is disabled in this release") + log_level = _env(values, "LOG_LEVEL", "INFO").upper() + if log_level not in {"QUIET", "ERROR", "WARNING", "INFO", "DEBUG"}: + raise ConfigError("LOG_LEVEL is invalid") + return Config(app_env, data_dir, secret, False, log_level) diff --git a/apps/api/app/main.py b/apps/api/app/main.py index b8c5b3d..f7fb7a1 100644 --- a/apps/api/app/main.py +++ b/apps/api/app/main.py @@ -14,6 +14,7 @@ if __package__ in (None, ""): from app.contact_extractor import extract_contacts, MAX_HTML_BYTES, MAX_RESULTS from app.scoring import DEFAULT_RULES, signals_for_business, evaluate_score, SCORE_VERSION from app.ai_assistance import generate as generate_ai, MAX_INPUT_ITEMS, MAX_OUTPUT_CHARS + from app.config import load_config else: from .domain import deduplication_key, deduplicate_businesses, is_suppressed, normalize_business, score_business, normalize_domain, normalize_phone, match_businesses from .sources import adapter_for, contains_secret @@ -22,6 +23,7 @@ else: from .contact_extractor import extract_contacts, MAX_HTML_BYTES, MAX_RESULTS from .scoring import DEFAULT_RULES, signals_for_business, evaluate_score, SCORE_VERSION from .ai_assistance import generate as generate_ai, MAX_INPUT_ITEMS, MAX_OUTPUT_CHARS +from .config import load_config ORGANIZATION_ID = "demo-tenant" SCHEMA = Path(__file__).resolve().parents[1] / "schema.sql" SESSION_DAYS = 7 @@ -521,7 +523,13 @@ class ApiHandler(BaseHTTPRequestHandler): def do_GET(self): parsed=urlparse(self.path); path=parsed.path.rstrip("/") - if path=="/api/v1/health/live": return self.send_json(200,{"status":"ok","organization_id":ORGANIZATION_ID}) + if path=="/api/v1/health/live": return self.send_json(200,{"status":"ok","organization_id":ORGANIZATION_ID,"outreach_enabled":False}) + if path=="/api/v1/health/ready": + try: + check=connect(self.server.db_path); check.execute("SELECT 1"); check.close() + return self.send_json(200,{"status":"ok","ready":True,"database":"ok","outreach_enabled":False}) + except (sqlite3.Error, OSError): + return self.send_json(503,{"status":"not_ready","ready":False,"database":"error","outreach_enabled":False}) db=self.db() try: user=self.require_auth(db) @@ -1284,6 +1292,7 @@ def _job_worker(server): finally: db.close() def create_server(host="127.0.0.1",port=8000,db_path="prospects.db"): + load_config() server=ThreadingHTTPServer((host,port),ApiHandler);server.db_path=db_path;connect(db_path).close();server.job_stop=threading.Event();server.job_wakeup=threading.Event();server.job_thread=threading.Thread(target=_job_worker,args=(server,),daemon=True);server.job_thread.start() original_close=server.server_close def close(): diff --git a/apps/api/tests/test_phase15_ops.py b/apps/api/tests/test_phase15_ops.py new file mode 100644 index 0000000..e20d423 --- /dev/null +++ b/apps/api/tests/test_phase15_ops.py @@ -0,0 +1,73 @@ +import hashlib +import json +import os +import sqlite3 +import subprocess +import sys +import threading +import unittest +from http.client import HTTPConnection +from pathlib import Path +from tempfile import TemporaryDirectory + +from app.main import create_server +from app.config import ConfigError, load_config + +ROOT = Path(__file__).resolve().parents[3] +SCRIPTS = ROOT / "scripts" + + +class Phase15OpsTests(unittest.TestCase): + def test_config_defaults_are_safe_and_production_requires_explicit_secret(self): + with TemporaryDirectory() as tmp: + cfg = load_config({"DATA_DIR": tmp}) + self.assertFalse(cfg.outreach_enabled) + self.assertEqual(cfg.app_env, "development") + with self.assertRaises(ConfigError): + load_config({"APP_ENV": "production", "DATA_DIR": tmp}) + cfg = load_config({"APP_ENV": "production", "DATA_DIR": tmp, "SESSION_SECRET": "a" * 32}) + self.assertEqual(cfg.session_secret, "a" * 32) + + def test_health_and_readiness_report_database_state(self): + with TemporaryDirectory() as tmp: + db_path = str(Path(tmp) / "state.db") + server = create_server("127.0.0.1", 0, db_path) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + try: + conn = HTTPConnection("127.0.0.1", server.server_port, timeout=3) + for path in ("/api/v1/health/live", "/api/v1/health/ready"): + conn.request("GET", path) + response = conn.getresponse() + payload = json.loads(response.read()) + self.assertEqual(response.status, 200) + self.assertEqual(payload["status"], "ok") + self.assertFalse(payload["outreach_enabled"]) + finally: + server.shutdown(); server.server_close(); thread.join(timeout=2) + + def test_backup_restore_integrity_and_checksum(self): + with TemporaryDirectory() as tmp: + root = Path(tmp); db = root / "prospects.db"; backups = root / "backups" + conn = sqlite3.connect(db); conn.execute("CREATE TABLE facts (value TEXT)"); conn.execute("INSERT INTO facts VALUES ('before')"); conn.commit(); conn.close() + result = subprocess.run([str(SCRIPTS / "backup_sqlite.sh"), str(db), str(backups), "2"], text=True, capture_output=True, check=True) + backup = Path(result.stdout.strip().splitlines()[-1]) + self.assertTrue(backup.exists()); self.assertTrue(Path(str(backup) + ".sha256").exists()) + self.assertEqual(hashlib.sha256(backup.read_bytes()).hexdigest(), Path(str(backup) + ".sha256").read_text().split()[0]) + conn = sqlite3.connect(db); conn.execute("UPDATE facts SET value='after'"); conn.commit(); conn.close() + subprocess.run([str(SCRIPTS / "restore_sqlite.sh"), str(backup), str(db), "--confirm-restore"], text=True, capture_output=True, check=True) + conn = sqlite3.connect(db); self.assertEqual(conn.execute("SELECT value FROM facts").fetchone()[0], "before"); conn.close() + self.assertTrue(any(p.name.startswith("pre-restore-") for p in backups.glob("*.db"))) + + def test_backup_output_does_not_contain_secret_values(self): + with TemporaryDirectory() as tmp: + root = Path(tmp); db = root / "prospects.db"; backups = root / "backups" + conn = sqlite3.connect(db); conn.execute("CREATE TABLE logs (message TEXT)"); conn.execute("INSERT INTO logs VALUES ('password=should-not-be-logged')"); conn.commit(); conn.close() + result = subprocess.run([str(SCRIPTS / "backup_sqlite.sh"), str(db), str(backups), "1"], text=True, capture_output=True, check=True) + combined = result.stdout + result.stderr + self.assertNotIn("should-not-be-logged", combined) + self.assertNotIn("password=", combined) + + +if __name__ == "__main__": + unittest.main() diff --git a/apps/web/Dockerfile b/apps/web/Dockerfile index 8e6cce0..9b719ff 100644 --- a/apps/web/Dockerfile +++ b/apps/web/Dockerfile @@ -3,8 +3,12 @@ RUN addgroup -S app && adduser -S -G app app WORKDIR /srv COPY index.html /srv/index.html COPY styles.css /srv/styles.css +COPY config.js /srv/config.js +COPY asset-manifest.json /srv/asset-manifest.json COPY app.js /srv/app.js COPY healthz /srv/healthz +COPY health.html /srv/health.html +COPY error.html /srv/error.html RUN chown -R app:app /srv USER app EXPOSE 8080 diff --git a/apps/web/README.md b/apps/web/README.md index acf08cd..126470d 100644 --- a/apps/web/README.md +++ b/apps/web/README.md @@ -4,14 +4,25 @@ Self-contained static frontend for the Prospect Platform API. There is no bundle ## Configure and run -The API base is configurable before `app.js` runs: +The public runtime configuration is loaded from `config.js` before `app.js`. It contains no credentials and may safely be replaced during deployment: -```html - - +```js +window.__PROSPECT_CONFIG__ = Object.freeze({ apiBase: 'https://api.example.invalid', assetVersion: 'phase-15' }); ``` -If not set, the UI uses `localStorage.prospect_api_base` when present; otherwise it targets the same origin. The dashboard requires an authenticated API session and shows the login screen until `/api/v1/auth/me` succeeds. +If `apiBase` is empty, the UI uses `window.API_BASE`, then `localStorage.prospect_api_base` when present, and otherwise targets the same origin. The dashboard requires an authenticated API session and shows the login screen until `/api/v1/auth/me` succeeds. Do not put tokens, passwords, or private keys in `config.js`. + +`asset-manifest.json` records the public entrypoints, cache-busting version, and SHA-256 digests for release verification. The HTML references the static assets with the `phase-15` version query string; update those references and regenerate the manifest when changing the release version. + +## Deployment readiness checks + +Serve this directory from the intended static-server root, then run: + +```sh +node scripts/smoke-deployment.mjs http://127.0.0.1:8080 +``` + +The smoke script checks HTTP delivery for the manifest-listed assets, health/error pages, `healthz`, expected markers, and common hardcoded-secret patterns. It validates static readiness only; it does not deploy the app or prove API/production availability. ## Phase 3 UI contract @@ -133,3 +144,11 @@ Every suggestion must display its evidence citations, tenant-scoped evidence IDs AI output must be visibly labeled **AI suggestion — human review required** and remain read-only until an authorized human explicitly approves it. Approval must show the proposed change, citations/hash, freshness, tenant scope, and safe reason; rejection and expiry must be available. The UI must require re-review when the evidence hash or policy version changes and must display partial/failed approval rather than implying persistence. Approval does not authorize contact or verification. No Phase 13 control may send email/SMS, probe SMTP, create a campaign, schedule follow-up, alter pipeline/interactions/outcomes as if communication occurred, merge records, acquire a domain, or perform autonomous CRM/outreach actions. The browser must not hide or export suppressed data as eligible, and exports/reports must retain safe AI provenance and redaction labels where applicable. Production remains limited until browser/API tests cover citations and hash mismatch, redaction, fallback boundaries, approval/rejection, stale/conflicting evidence, suppression precedence, tenant non-disclosure, and no-autonomy controls; the current Compose stack has no configured AI provider. + +## Phase 15 deployment and readiness + +The web image is a portable static server: it runs as a non-root user, serves only the files copied into `/srv`, and exposes `/healthz`. Virtualmin is responsible for DNS, HTTPS certificates, reverse-proxy routing, firewall rules, and any access control around the site. Set the API base deliberately for the deployed origin; do not put credentials or provider secrets in HTML, JavaScript, local storage, image layers, or `.env` files. `CORS_ORIGINS` must exactly match the approved HTTPS origin rather than a broad wildcard. + +`/healthz` is an unauthenticated process/liveness check. API `/api/v1/health/ready` checks SQLite readiness, but neither endpoint proves tenant authorization, backup validity, or external dependencies. Route traffic only after the web and API containers report `healthy`, the HTTPS proxy reaches the intended containers, and an authenticated browser/API smoke test succeeds. The browser must never be used to test or initiate outbound prospect/provider traffic; `AUTOMATED_OUTREACH_ENABLED=false` remains visible as the no-send default. + +For releases, validate the exact static image and API image together, capture image digests and configuration revision, and retain the prior pair for rollback. If a schema/data migration is involved, the API owner must complete backup/restore and migration validation before the web image is promoted. The current client has no service-worker cache or migration logic; stale browser tabs must be refreshed after a release, and Virtualmin/CDN caching must not serve an old API contract indefinitely. SQLite, HTTP-only local Compose, lack of a readiness endpoint, and lack of a production asset/CDN pipeline are explicit limitations. diff --git a/apps/web/app.js b/apps/web/app.js index 23f92e9..92497c0 100644 --- a/apps/web/app.js +++ b/apps/web/app.js @@ -1,7 +1,8 @@ /* ProspectOS frontend MVP. Configure before loading with window.API_BASE = 'http://127.0.0.1:8000'; */ (() => { 'use strict'; - const API_BASE = (window.API_BASE || localStorage.getItem('prospect_api_base') || '').replace(/\/$/, ''); + const publicConfig = window.__PROSPECT_CONFIG__ || {}; + const API_BASE = (publicConfig.apiBase || window.API_BASE || localStorage.getItem('prospect_api_base') || '').replace(/\/$/, ''); const endpoint = (path) => `${API_BASE}${path}`; let prospects = [], selectedId = null, selectedDetail = null, currentUser = null; let page = 1, pageSize = 10, hasNextPage = false, savedFilters = [], reviewQueue = [], selectedReviewIds = new Set(); diff --git a/apps/web/asset-manifest.json b/apps/web/asset-manifest.json new file mode 100644 index 0000000..07def99 --- /dev/null +++ b/apps/web/asset-manifest.json @@ -0,0 +1,15 @@ +{ + "schema": 1, + "version": "phase-15", + "entrypoints": ["config.js", "app.js", "styles.css"], + "publicAssets": ["index.html", "health.html", "error.html", "healthz"], + "integrity": { + "config.js": "sha256-20f3020432436dcccdbfc86fd56a6a6a49b71fc512a1e434187a5ddc134fda1c", + "app.js": "sha256-fc12f49012bb1329ffdd7bdcf655e9ab9097eaf1e0cc73cd0442b19fd57a0374", + "styles.css": "sha256-7340dccc648fa917fb497ceeba7286d9c4712b6552960de54cef759cdbda6de4", + "index.html": "sha256-37a9d5e2a81941c3c9f9bd3f42edf33a40565bf197cf675389d250dd60eb69db", + "health.html": "sha256-c352a6f37aa24628cfc8d5709a70ff2d94181d192ed5fe00916cca9378a61d81", + "error.html": "sha256-f3cc28d2dfc9e8af112d0257c6ee47b9e9146e5da8590a754dd589bf43702aaf", + "healthz": "sha256-dc51b8c96c2d745df3bd5590d990230a482fd247123599548e0632fdbf97fc22" + } +} diff --git a/apps/web/config.js b/apps/web/config.js new file mode 100644 index 0000000..a83ef1d --- /dev/null +++ b/apps/web/config.js @@ -0,0 +1,5 @@ +/* Public, non-secret runtime configuration. Replace this file at deploy time if needed. */ +window.__PROSPECT_CONFIG__ = Object.freeze({ + apiBase: '', + assetVersion: 'phase-15' +}); diff --git a/apps/web/error.html b/apps/web/error.html new file mode 100644 index 0000000..524b726 --- /dev/null +++ b/apps/web/error.html @@ -0,0 +1,2 @@ + +
ProspectOS frontend
This page could not be loaded. Please try again or return to the workspace.
ProspectOS frontend
Static asset delivery is available. API availability is checked separately by the application.
health.html
REVIEW REQUIRED