diff --git a/apps/api/app/main.py b/apps/api/app/main.py index 07eade5..f361270 100644 --- a/apps/api/app/main.py +++ b/apps/api/app/main.py @@ -1417,8 +1417,12 @@ class ApiHandler(BaseHTTPRequestHandler): # approval. Discovery/ingest still validates the effective configuration. if config and not validation.valid:return self.send_json(400,{"error":"invalid_source_config","details":validation.errors}) cur=db.execute("INSERT INTO sources(organization_id,name,kind,source_code,display_name,enabled,approved,config_json,policy_json,quota_json) VALUES(?,?,?,?,?,?,?,?,?,?)",(user['organization_id'],name,kind,source_code,str(payload.get('display_name') or adapter_for(source_code).display_name),int(bool(payload.get('enabled',False))),int(bool(payload.get('approved',config.get('approved',False)))),json.dumps(config,sort_keys=True),json.dumps(payload.get('policy',{}),sort_keys=True),json.dumps(payload.get('quota',{}),sort_keys=True))) - except sqlite3.IntegrityError:return self.send_json(409,{"error":"duplicate_source"}) - self.audit(db,user,'source.created',str(cur.lastrowid));db.commit();return self.send_json(201,row_json(db.execute("SELECT id,organization_id,name,kind,source_code,display_name,enabled,approved,policy_json,quota_json,health_status,consecutive_failures,circuit_open,last_success_at,last_failure_at,last_error,created_at,updated_at FROM sources WHERE id=?",(cur.lastrowid,)).fetchone())) + except sqlite3.IntegrityError: + existing=db.execute("SELECT id,organization_id,name,kind,source_code,display_name,enabled,approved,policy_json,quota_json,health_status,consecutive_failures,circuit_open,last_success_at,last_failure_at,last_error,created_at,updated_at FROM sources WHERE organization_id=? AND name=?",(user['organization_id'],name)).fetchone() + if existing: + body=row_json(existing); body['created']=False; self.audit(db,user,'source.registration_reused',str(existing['id'])); db.commit(); return self.send_json(200,body) + return self.send_json(409,{"error":"duplicate_source"}) + self.audit(db,user,'source.created',str(cur.lastrowid));db.commit(); body=row_json(db.execute("SELECT id,organization_id,name,kind,source_code,display_name,enabled,approved,policy_json,quota_json,health_status,consecutive_failures,circuit_open,last_success_at,last_failure_at,last_error,created_at,updated_at FROM sources WHERE id=?",(cur.lastrowid,)).fetchone()); body['created']=True; return self.send_json(201,body) def update_source(self,sid,payload,db,user): source=db.execute("SELECT * FROM sources WHERE id=? AND organization_id=?",(sid,user['organization_id'])).fetchone() if not source:return self.send_json(404,{"error":"not_found"}) diff --git a/apps/api/tests/test_sources_phase5.py b/apps/api/tests/test_sources_phase5.py index 84a4bcc..1101319 100644 --- a/apps/api/tests/test_sources_phase5.py +++ b/apps/api/tests/test_sources_phase5.py @@ -79,6 +79,11 @@ class SourceApiTests(unittest.TestCase): self.assertEqual(status, 201) self.assertEqual(self.req('PATCH', f"/api/v1/sources/{website['id']}", {'enabled': True})[0], 409) + def test_duplicate_source_registration_is_idempotent(self): + payload={'name':'OpenStreetMap / Overpass · plumbers','kind':'openstreetmap','config':{'provider':'openstreetmap','query':'plumbers','location':'Cape Town','approved':True,'public_access':True,'terms_accepted':True,'rate_limit':1}} + status, created=self.req('POST','/api/v1/sources',payload); self.assertEqual(status,201); self.assertTrue(created['created']) + status, reused=self.req('POST','/api/v1/sources',payload); self.assertEqual(status,200); self.assertFalse(reused['created']); self.assertEqual(reused['id'],created['id']) + def test_source_configuration_can_be_saved_before_enablement(self): status, source = self.req('POST', '/api/v1/sources', {'name': 'DNS', 'kind': 'dns', 'config': {}}) self.assertEqual(status, 201) diff --git a/apps/web/app.js b/apps/web/app.js index 2bfd21b..49e9b41 100644 --- a/apps/web/app.js +++ b/apps/web/app.js @@ -296,7 +296,7 @@ if (!value || !value.trim()) return; const text = value.trim(); const config = type === 'public_website' ? {urls:[text]} : type === 'dns' ? {domains:[text]} : type === 'rdap' || type === 'ct_logs' ? {domain:text} : {provider:type,query:text,location:(window.prompt('Enter the location (optional):','South Africa') || 'South Africa').trim(),approved:true,public_access:true,terms_accepted:true,rate_limit:1}; - try { await jsonRequest('/api/v1/sources', {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({name:`${sourceLabel(preview)} · ${text}`,kind:type,config})}); await loadSources(); sourceMessage('Source registered. Test it, then enable it once the health check succeeds.'); } + try { const registered=await jsonRequest('/api/v1/sources', {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({name:`${sourceLabel(preview)} · ${text}`,kind:type,config})}); await loadSources(); sourceMessage(registered.created === false ? 'Existing source reloaded. Test it, then enable it once the health check succeeds.' : 'Source registered. Test it, then enable it once the health check succeeds.'); } catch (error) { if (error.message !== 'unauthorized') sourceMessage(error.message || 'Unable to register source.', true); } } async function sourceAction(id, action) { if (action === 'setup') return registerAdapterPreview(id); const source = findRegisteredSource(id); if (!source || !source.id) { sourceMessage('This source is an adapter preview, not a registered source.', true); return; } if (action === 'review') { selectedSourceId=source.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 { let notice=''; if (action === 'test') { await jsonRequest(`/api/v1/sources/${encodeURIComponent(source.id)}/test`, {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({})}); notice='Source test completed.'; } else { const enabled = sourceState(source); await jsonRequest(`/api/v1/sources/${encodeURIComponent(source.id)}`, {method:'PATCH', headers:{'Content-Type':'application/json'}, body:JSON.stringify({enabled:!enabled})}); notice=`Source ${enabled ? 'disabled' : 'enabled'} by the workspace.`; } await loadSources(); sourceMessage(notice); } catch (error) { if (error.message !== 'unauthorized') sourceMessage(error.message || `Unable to ${action} source.`, true); } } diff --git a/apps/web/asset-manifest.json b/apps/web/asset-manifest.json index a80a686..1b5e31b 100644 --- a/apps/web/asset-manifest.json +++ b/apps/web/asset-manifest.json @@ -1,6 +1,6 @@ { "schema": 1, - "version": "phase-32", + "version": "phase-33", "entrypoints": [ "config.js", "app.js", @@ -13,10 +13,10 @@ "healthz" ], "integrity": { - "config.js": "sha256-418b33e62973a10cd7e5580c054db6db01e29e3ea7f292f49d799b155f424cf7", - "app.js": "sha256-f99db87b5011864e2e6055a90e74fa68710c87887a26ec1ac3ae4f860b5b8d1f", + "config.js": "sha256-8ba805b718f74e47a97f859fab817127ccc49f4b3a827c1ca324dc8370b8bf9e", + "app.js": "sha256-ee539f85c77a082f9bc97fa853ebc6ed0c45f0863d68ff6abf3c0e135e9cb152", "styles.css": "sha256-ba90290ab11e82a6b2639dfd70d1e74502c1cacb2b26cf1db92b45beb67ac03f", - "index.html": "sha256-fe529a79334e94d905235af62bab23d2c2fe6b2411fdde188b8cceaadca55a78", + "index.html": "sha256-ed6d261126818460a058ccc0c561b843d92c17e83faccc449648dd2ddc843159", "health.html": "sha256-c352a6f37aa24628cfc8d5709a70ff2d94181d192ed5fe00916cca9378a61d81", "error.html": "sha256-f3cc28d2dfc9e8af112d0257c6ee47b9e9146e5da8590a754dd589bf43702aaf", "healthz": "sha256-dc51b8c96c2d745df3bd5590d990230a482fd247123599548e0632fdbf97fc22" diff --git a/apps/web/config.js b/apps/web/config.js index c9a0efa..b13a3dd 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-32' + assetVersion: 'phase-33' }); diff --git a/apps/web/index.html b/apps/web/index.html index f7fd202..78c18e5 100644 --- a/apps/web/index.html +++ b/apps/web/index.html @@ -5,7 +5,7 @@ ProspectOS · Pipeline intelligence - +
@@ -138,7 +138,7 @@ - - + +