add production readiness and recovery assets
This commit is contained in:
+12
-1
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
+10
-1
@@ -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():
|
||||
|
||||
@@ -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()
|
||||
@@ -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
|
||||
|
||||
+24
-5
@@ -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
|
||||
<script>window.API_BASE = 'http://127.0.0.1:8000';</script>
|
||||
<script src="app.js"></script>
|
||||
```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.
|
||||
|
||||
+2
-1
@@ -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();
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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'
|
||||
});
|
||||
@@ -0,0 +1,2 @@
|
||||
<!doctype html>
|
||||
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>ProspectOS · Temporarily unavailable</title><style>body{font:16px system-ui,sans-serif;margin:4rem auto;max-width:42rem;padding:0 1.5rem;color:#172033;background:#f7f8fb}main{background:#fff;border:1px solid #e7eaf1;border-radius:12px;padding:2rem}h1{color:#b84d55}a{color:#6756e8}</style></head><body><main><p>ProspectOS frontend</p><h1>Something went wrong</h1><p>This page could not be loaded. Please try again or return to the <a href="/">workspace</a>.</p></main></body></html>
|
||||
@@ -0,0 +1,2 @@
|
||||
<!doctype html>
|
||||
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>ProspectOS · Healthy</title><style>body{font:16px system-ui,sans-serif;margin:4rem auto;max-width:42rem;padding:0 1.5rem;color:#172033;background:#f7f8fb}main{background:#fff;border:1px solid #e7eaf1;border-radius:12px;padding:2rem}h1{color:#16845b}code{background:#e5f7ef;padding:.15rem .35rem;border-radius:4px}</style></head><body><main><p>ProspectOS frontend</p><h1>Ready</h1><p>Static asset delivery is available. API availability is checked separately by the application.</p><p><code>health.html</code></p></main></body></html>
|
||||
+3
-2
@@ -5,7 +5,7 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>ProspectOS · Pipeline intelligence</title>
|
||||
<meta name="description" content="Prospect discovery and review dashboard">
|
||||
<link rel="stylesheet" href="styles.css">
|
||||
<link rel="stylesheet" href="styles.css?v=phase-15">
|
||||
</head>
|
||||
<body>
|
||||
<section class="login-screen" id="loginScreen" aria-labelledby="loginTitle">
|
||||
@@ -117,6 +117,7 @@
|
||||
<div class="merge-dialog" id="mergeDialog" hidden role="dialog" aria-modal="true" aria-labelledby="mergeDialogTitle">
|
||||
<div class="merge-dialog-card"><div class="panel-heading"><div><p class="eyebrow">REVIEW REQUIRED</p><h2 id="mergeDialogTitle">Confirm merge</h2></div><button class="icon-button" id="cancelMergeBtn" type="button" aria-label="Close merge confirmation">×</button></div><p id="mergeDialogCopy"></p><div class="merge-warning"><strong>This action is reversible.</strong> The merge will be recorded in history and can be reversed later.</div><p id="mergeDialogMessage" class="form-message" role="alert" aria-live="polite"></p><div class="merge-dialog-actions"><button class="button ghost" id="cancelMergeBtnSecondary" type="button">Cancel</button><button class="button primary" id="confirmMergeBtn" type="button">Confirm merge</button></div></div>
|
||||
</div>
|
||||
<script src="app.js"></script>
|
||||
<script src="config.js?v=phase-15"></script>
|
||||
<script src="app.js?v=phase-15"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
#!/usr/bin/env node
|
||||
import { createHash } from 'node:crypto';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { resolve } from 'node:path';
|
||||
|
||||
const base = (process.argv[2] || 'http://127.0.0.1:8080').replace(/\/$/, '');
|
||||
const manifestPath = resolve(process.cwd(), 'asset-manifest.json');
|
||||
const manifest = JSON.parse(await readFile(manifestPath, 'utf8'));
|
||||
const assets = [...new Set([...(manifest.entrypoints || []), ...(manifest.publicAssets || [])])];
|
||||
const failures = [];
|
||||
const secretPattern = /(api[_-]?key|secret|token|password|private[_-]?key|authorization)\s*[:=]\s*["'][^"']+/i;
|
||||
|
||||
async function check(path, expected = {}) {
|
||||
const response = await fetch(`${base}/${path}`);
|
||||
const body = await response.text();
|
||||
if (!response.ok) failures.push(`${path}: HTTP ${response.status}`);
|
||||
if (expected.contentType && !response.headers.get('content-type')?.includes(expected.contentType)) failures.push(`${path}: unexpected content type`);
|
||||
if (expected.marker && !body.includes(expected.marker)) failures.push(`${path}: missing marker ${expected.marker}`);
|
||||
if (expected.integrity) {
|
||||
const digest = `sha256-${createHash('sha256').update(body).digest('hex')}`;
|
||||
if (digest !== expected.integrity) failures.push(`${path}: integrity mismatch`);
|
||||
}
|
||||
if (secretPattern.test(body)) failures.push(`${path}: possible hardcoded secret`);
|
||||
console.log(`${response.ok ? 'PASS' : 'FAIL'} ${path} (${response.status})`);
|
||||
}
|
||||
|
||||
const expectations = {
|
||||
'index.html': { contentType: 'text/html', marker: 'ProspectOS' },
|
||||
'health.html': { contentType: 'text/html', marker: 'Ready' },
|
||||
'error.html': { contentType: 'text/html', marker: 'Something went wrong' },
|
||||
healthz: { marker: 'ok' }
|
||||
};
|
||||
for (const asset of assets) await check(asset, { ...expectations[asset], integrity: manifest.integrity?.[asset] });
|
||||
if (failures.length) { console.error(`\n${failures.length} deployment smoke check(s) failed`); for (const failure of failures) console.error(`- ${failure}`); process.exitCode = 1; } else console.log(`\nDeployment smoke checks passed for ${base}`);
|
||||
Reference in New Issue
Block a user