diff --git a/apps/web/README.md b/apps/web/README.md
index eb987db..f06fef8 100644
--- a/apps/web/README.md
+++ b/apps/web/README.md
@@ -12,7 +12,7 @@ window.__PROSPECT_CONFIG__ = Object.freeze({ apiBase: 'https://api.example.inval
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.
+`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 current release version query string; update those references and regenerate the manifest when changing the release version.
## Deployment readiness checks
@@ -51,9 +51,9 @@ The browser must not directly fetch arbitrary target URLs, follow redirects for
## Phase 5 source UI contract
-The web client may display registered source metadata, query mode, approval/terms state, rate-limit status, retention class, health, and circuit state returned by the API. It must label `dry_run` as a plan/validation result and distinguish operator-supplied CSV/manual references from independently verified evidence. It must not offer a live-source control unless the API reports explicit approval and operational enablement; client visibility is never an authorization control.
+The web client displays registered source metadata, adapter type, approval/terms state, configured/available/enabled status, credential state, rate-limit/quota status, health, and circuit state returned by the API. Optional adapters are shown as unavailable/configuration-gated until the API reports explicit approval and operational enablement; client visibility is never an authorization control. It labels `dry_run` as a plan/validation result and distinguishes operator-supplied CSV/manual references from independently verified evidence.
-CSV and manual reference workflows must show source attribution, adapter/version, observed time, and any retention/redaction status. Raw source payloads should be hidden or minimized in the UI and remain tenant-scoped. A circuit-open or rate-limited source must be presented as unavailable/deferred, not as an empty discovery result. The current static client has no network discovery implementation; these are display and contract requirements for a future approved integration.
+CSV and manual reference workflows must show source attribution, adapter/version, observed time, and any retention/redaction status. Raw source payloads should be hidden or minimized in the UI and remain tenant-scoped. A circuit-open or rate-limited source must be presented as unavailable/deferred, not as an empty discovery result. The current client provides governed CSV/manual setup, source registry status, and authenticated discovery-run controls while keeping optional network adapters fail-closed.
## Phase 4 job/live-log UI contract
diff --git a/apps/web/app.js b/apps/web/app.js
index 5714105..624b6d1 100644
--- a/apps/web/app.js
+++ b/apps/web/app.js
@@ -35,7 +35,7 @@
async function request(path, options = {}) { const response=await fetch(endpoint(path),{...options,credentials:'include'}); if(response.status===401){showLogin('Your session has expired. Please sign in again.');throw new Error('unauthorized');} if(response.status===403)throw new Error('Tenant/workspace access denied.'); return response; }
async function jsonRequest(path, options = {}) { const res=await request(path,options); const body=await res.json().catch(()=>({})); if(!res.ok)throw new Error(body.error||body.message||'Request failed'); return body; }
function showLogin(message=''){currentUser=null;$('dashboardShell').hidden=true;$('loginScreen').hidden=false;$('loginMessage').textContent=message;$('loginMessage').className=`form-message${message?' error':''}`;}
- function showDashboard(user){currentUser=user||{};const name=currentUser.name||currentUser.full_name||currentUser.email||'Workspace member';const role=currentUser.role||currentUser.roles?.[0]||'Member';$('userIdentity').textContent=`${name} · ${role}`;$('userAvatar').textContent=name.split(/\s+/).map(x=>x[0]).join('').slice(0,2).toUpperCase();$('loginScreen').hidden=true;$('dashboardShell').hidden=false;updateJobPermissions();}
+ function showDashboard(user){currentUser=user||{};const name=currentUser.display_name||currentUser.name||currentUser.full_name||currentUser.email||'Workspace member';const role=currentUser.role||currentUser.roles?.[0]||'Member';$('userIdentity').textContent=`${name} · ${role}`;$('userGreetingName').textContent=name.split(/\s+/)[0]||'there';$('userAvatar').textContent=name.split(/\s+/).map(x=>x[0]).join('').slice(0,2).toUpperCase();$('loginScreen').hidden=true;$('dashboardShell').hidden=false;updateJobPermissions();}
function renderMetrics(summary){const total=Number(summary?.businesses??summary?.total??prospects.length),high=Number(summary?.high_fit??summary?.high_fit_count??prospects.filter(p=>scoreFor(p)>=80).length),review=Number(summary?.needs_review??summary?.review_count??prospects.filter(p=>statusOf(p)==='review').length),suppressed=Number(summary?.suppressed??summary?.suppressed_count??prospects.filter(p=>statusOf(p)==='suppressed').length),fresh=prospects.length?Math.round(prospects.filter(p=>freshness(p).cls==='good').length/prospects.length*100):0;$('metricTotal').textContent=total;$('heroCount').textContent=`${total} prospects`;$('metricReview').textContent=review;$('metricHigh').textContent=high;$('metricFresh').textContent=`${summary?.freshness_under_7d??summary?.fresh_count??fresh}%`;if($('metricSuppressed'))$('metricSuppressed').textContent=suppressed;}
function filterValues(){return {q:$('searchInput').value.trim(),score:$('scoreFilter').value,status:$('statusFilter').value,website_class:$('websiteClassFilter').value,pipeline_stage:$('pipelineFilter').value,source:$('sourceFilter')?.value||'all',geography:$('geographyFilter')?.value||'all',category:$('categoryFilter')?.value||'all',contact_status:$('contactStatusFilter')?.value||'all',freshness:$('explorerState')?.dataset?.filter||'all'};}
function filtered(){const {q,score:sf,status:st,website_class:wc,pipeline_stage:ps,source,geography,category,contact_status,freshness}=filterValues();return prospects.filter(p=>{const s=scoreFor(p),text=`${p.name} ${p.website_domain||p.website||''} ${p.location||''}`.toLowerCase(),stage=p.pipeline_stage||p.pipeline?.stage||(Array.isArray(p.pipeline)?p.pipeline.at(-1)?.stage:'')||'new',contacts=p.contacts||p.contact_extractions||[], pSource=String(p.source_name||p.source||p.source_id||'all'), geo=String(p.city||p.province||p.country||p.location||'all'), cat=String(p.category||p.industry||p.categories?.[0]||'all'), contactState=contacts.length||p.email||p.phone?'present':statusOf(p)==='suppressed'?'suppressed':'missing';return(!q||text.includes(q.toLowerCase()))&&(source==='all'||pSource===source||String(p.source_id)===source)&&(geography==='all'||geo.toLowerCase().includes(geography.toLowerCase()))&&(category==='all'||cat===category)&&(contact_status==='all'||contactState===contact_status)&&(sf==='all'||(sf==='high'&&s>=80)||(sf==='medium'&&s>=60&&s<80)||(sf==='low'&&s<60))&&(st==='all'||statusOf(p)===st)&&(wc==='all'||(p.website_class||'missing')===wc)&&(ps==='all'||stage===ps)&&(freshness!=='under_7d'||freshnessValue(p));});}
@@ -272,15 +272,19 @@
let sources = [], selectedSourceId = null;
const sourceState = source => Boolean(source?.enabled ?? source?.active);
- const sourceStatus = source => sourceState(source) ? 'enabled' : 'disabled';
+ const sourceStatus = source => source.optional && !source.configured ? 'unavailable' : sourceState(source) ? 'enabled' : 'disabled';
const sourceItems = payload => Array.isArray(payload) ? payload : (payload?.sources || payload?.items || payload?.records || []);
+ const sourceJson = (value, fallback={}) => { if (value && typeof value === 'object') return value; try { return value ? JSON.parse(value) : fallback; } catch { return fallback; } };
+ const sourceLabel = source => source.display_name || source.name || source.source_code || source.kind || `Source ${source.id}`;
+ const sourceType = source => source.source_code || source.kind || source.type || 'unknown';
+ const sourceText = (source, keys, fallback='Not returned') => { for (const key of keys) if (source?.[key] !== undefined && source[key] !== null && source[key] !== '') return source[key]; return fallback; };
function sourceMessage(text, error = false) { const el = $('sourcesMessage'); if (el) { el.textContent = text || ''; el.className = `sources-message${error ? ' error' : ''}`; } }
- function renderSourceSelect() { const select = $('discoverySource'); if (select) select.innerHTML = `${sources.map(s => ``).join('')}`; const multi=$('directDiscoverySources'); if(multi) multi.innerHTML=sources.filter(sourceState).map(s=>``).join('') || ''; }
- function renderSources() { renderSourceSelect(); const list = $('sourcesList'); if (!sources.length) { list.innerHTML = '
'; try { const [sourcePayload, adapterPayload, recordPayload] = await Promise.all([jsonRequest('/api/v1/sources'), jsonRequest('/api/v1/sources/adapters'), jsonRequest('/api/v1/source-records?page_size=25')]); const configured=sourceItems(sourcePayload), registeredCodes=new Set(configured.map(sourceType)); const optional=sourceItems(adapterPayload).filter(adapter=>!registeredCodes.has(adapter.source_code)).map(adapter=>({...adapter,source_code:adapter.source_code,display_name:adapter.display_name,kind:adapter.source_code,optional:true,configured:false,available:false,enabled:false})); sources=[...configured,...optional]; renderSources(); renderSourceRecords(sourceItems(recordPayload)); $('sourcesUpdatedAt').textContent = `Updated ${new Date().toLocaleTimeString([], {hour:'2-digit', minute:'2-digit'})}`; sourceMessage(sources.some(sourceState) ? '' : 'No live source is enabled. Optional integrations remain unavailable until configured and approved.'); } catch (error) { sources = []; renderSources(); renderSourceRecords([]); if (error.message !== 'unauthorized') sourceMessage(error.message || 'Unable to load sources.', true); } }
async function saveSource(event) { event.preventDefault(); const form = event.currentTarget, fields = Object.fromEntries(new FormData(form).entries()); if (fields.source_type === 'csv' && !fields.csv_content.trim()) { message('sourceFormMessage', 'CSV content is required for a CSV source.', true); return; } const config = {url:fields.url, terms_url:fields.terms_url, owner:fields.owner, rate_limit:fields.rate_limit}; if (fields.source_type === 'csv') config.csv = fields.csv_content; else config.rows = []; try { await jsonRequest('/api/v1/sources', {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({name:fields.name, kind:fields.source_type, config, enabled:false})}); message('sourceFormMessage', 'Source saved. It remains disabled until explicitly enabled.'); form.reset(); $('sourceCsvField').hidden = true; await loadSources(); } catch (error) { if (error.message !== 'unauthorized') message('sourceFormMessage', error.message || 'Unable to save source.', true); } }
- async function sourceAction(id, action) { const source = sources.find(item => String(item.id) === String(id)); if (!source) return; try { if (action === 'test') { await jsonRequest(`/api/v1/sources/${encodeURIComponent(id)}/test`, {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({})}); sourceMessage('Source test completed.'); } else { const enabled = sourceState(source); await jsonRequest(`/api/v1/sources/${encodeURIComponent(id)}`, {method:'PATCH', headers:{'Content-Type':'application/json'}, body:JSON.stringify({enabled:!enabled})}); sourceMessage(`Source ${enabled ? 'disabled' : 'enabled'} by the workspace.`); } await loadSources(); } catch (error) { if (error.message !== 'unauthorized') sourceMessage(error.message || `Unable to ${action} source.`, true); } }
+ async function sourceAction(id, action) { const source = sources.find(item => String(item.id) === String(id)); if (!source || source.optional) return; if (action === 'review') { selectedSourceId=id; document.querySelector(`[data-source-id="${CSS.escape(String(id))}"]`)?.scrollIntoView({behavior:'smooth',block:'center'}); sourceMessage('Source details are shown below. Review terms, owner, limits, health, and circuit state before enabling.'); return; } try { if (action === 'test') { await jsonRequest(`/api/v1/sources/${encodeURIComponent(id)}/test`, {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({})}); sourceMessage('Source test completed.'); } else { const enabled = sourceState(source); await jsonRequest(`/api/v1/sources/${encodeURIComponent(id)}`, {method:'PATCH', headers:{'Content-Type':'application/json'}, body:JSON.stringify({enabled:!enabled})}); sourceMessage(`Source ${enabled ? 'disabled' : 'enabled'} by the workspace.`); } await loadSources(); } catch (error) { if (error.message !== 'unauthorized') sourceMessage(error.message || `Unable to ${action} source.`, true); } }
async function runDiscovery(dryRun) { const form = $('discoveryForm'), data = Object.fromEntries(new FormData(form).entries()); data.dry_run = Boolean(dryRun); if (!data.source_id || !data.query.trim()) { message('discoveryMessage', 'Select a source and enter a query.', true); return; } const source = sources.find(item => String(item.id) === String(data.source_id)); if (!dryRun && !sourceState(source)) { message('discoveryMessage', 'This source is disabled. Enable it only after review.', true); return; } const button = dryRun ? $('discoveryDryRunBtn') : $('discoveryRunBtn'); button.disabled = true; message('discoveryMessage', dryRun ? 'Validating query…' : 'Starting discovery…'); try { const query = await jsonRequest('/api/v1/discovery-queries', {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({source_id:Number(data.source_id), name:data.query.trim().slice(0,80), query:{text:data.query.trim()}, dry_run:data.dry_run})}); if (!dryRun) await jsonRequest(`/api/v1/discovery-queries/${encodeURIComponent(query.id)}/run`, {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({})}); message('discoveryMessage', dryRun ? 'Dry run completed; no discovery job was started.' : 'Discovery request accepted. Check Jobs for progress.'); } catch (error) { if (error.message !== 'unauthorized') message('discoveryMessage', error.message || 'Discovery request failed.', true); } finally { button.disabled = false; } }
let discoveryRuns = [], selectedDiscoveryRunId = null, discoveryPollTimer = null;
const discoveryStatus = run => String(run?.status || run?.state || 'queued').toLowerCase().replaceAll('_','-');
diff --git a/apps/web/asset-manifest.json b/apps/web/asset-manifest.json
index eb6c6ca..d78907f 100644
--- a/apps/web/asset-manifest.json
+++ b/apps/web/asset-manifest.json
@@ -1,6 +1,6 @@
{
"schema": 1,
- "version": "phase-19",
+ "version": "phase-20",
"entrypoints": [
"config.js",
"app.js",
@@ -13,10 +13,10 @@
"healthz"
],
"integrity": {
- "config.js": "sha256-f9c7b4db3eab4cf54146bd25891b5103b09ae75da93c57b548cf57ae93e4a3f6",
- "app.js": "sha256-ab3d9046b53fd87950bedeec66da479da8b935bc070b60490a1aab7b22fff2a8",
- "styles.css": "sha256-423d0f9489061aff6420bea3d854e49aaf1c35d94a8c0e0bca2f82434781a2b7",
- "index.html": "sha256-1979f265a29009f7bd5380401c2185c6473394eb2412b592c50ca90271cb6d3f",
+ "config.js": "sha256-12a10f772029a5ee6d813ed9fd61dfc7ff877aa356bf90f65987948cc3274f90",
+ "app.js": "sha256-de1bc9074e8f41d5a964f199e220ce5b48fabbfd0e0301000d3870db84696a43",
+ "styles.css": "sha256-ddbb75572a2e80e19a99834a8fb61540e0d94fffe4658429c99116242034183c",
+ "index.html": "sha256-2cba8095f33d9eaed8d73c33eecb08b82b223448240e9ef0d96de8c836122da8",
"health.html": "sha256-c352a6f37aa24628cfc8d5709a70ff2d94181d192ed5fe00916cca9378a61d81",
"error.html": "sha256-f3cc28d2dfc9e8af112d0257c6ee47b9e9146e5da8590a754dd589bf43702aaf",
"healthz": "sha256-dc51b8c96c2d745df3bd5590d990230a482fd247123599548e0632fdbf97fc22"
diff --git a/apps/web/config.js b/apps/web/config.js
index 88240ba..bccf855 100644
--- a/apps/web/config.js
+++ b/apps/web/config.js
@@ -1,5 +1,5 @@
/* Public, non-secret runtime configuration. Replace this file at deploy time if needed. */
window.__PROSPECT_CONFIG__ = Object.freeze({
apiBase: '',
- assetVersion: 'phase-17'
+ assetVersion: 'phase-20'
});
diff --git a/apps/web/index.html b/apps/web/index.html
index f08b7ce..6c6030b 100644
--- a/apps/web/index.html
+++ b/apps/web/index.html
@@ -5,7 +5,7 @@
ProspectOS · Pipeline intelligence
-
+
@@ -45,7 +45,7 @@
Workspace / Growth pipeline
● Connecting…
?
-
EVIDENCE-LED PROSPECTING
Good morning, Alex ✦
Your pipeline has 0 prospects ready for review.
+
EVIDENCE-LED PROSPECTING
Good morning, there✦
Your pipeline has 0 prospects ready for review.
SOURCE INTELLIGENCE
Discovery runs
Build a criteria-first run across approved sources, monitor it live, and review every result with provenance before it enters your pipeline.
Bounded · review first
@@ -90,14 +90,14 @@
-
GOVERNANCE
Sources
Review source ownership, terms, limits, and health before using discovery.
+
GOVERNANCE · SOURCE REGISTRY
Sources
One operational view of every registered integration. Configure, test, and enable sources only after their terms, owner, limits, and health are reviewable.
Discovery is disabled by default. No live source is enabled in this workspace. Enable a source only after its terms, owner, rate limit, and health have been reviewed.
-
CONFIGURATION
Add a source
Manual or CSV
+
SOURCE SETUP
Add a governed source
Manual or CSV only
Bring in operator-controlled records without activating an external integration. New sources start disabled and remain review-only until explicitly enabled.
DISCOVERY
Query a source
No automatic runs
-
REGISTRY
Configured sources
Not loaded
Sign in to load sources from the workspace.
+
REGISTRY STATUS
Configured & available integrations
Configured sources are shown alongside optional adapters so unavailable capability is never mistaken for an active source.
Not loaded
Sign in to load sources from the workspace.
RECENT OUTPUT
Recent source records
No records loaded.
INTAKE
Add a prospect
Manual entry
@@ -130,7 +130,7 @@
REVIEW REQUIRED
Confirm merge
This action is reversible. The merge will be recorded in history and can be reversed later.