From a420ff72bddb7cde4af79a496a740bf21fe1dce4 Mon Sep 17 00:00:00 2001 From: Coding-Dev-Tools Date: Fri, 24 Jul 2026 00:54:21 -0400 Subject: [PATCH 1/3] fix: preselect hosted Pro and Team plans --- engraphis/static/dashboard.js | 2 +- tests/e2e/commercial.spec.js | 12 ++++++------ 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/engraphis/static/dashboard.js b/engraphis/static/dashboard.js index 8de80a1..6c6d1f8 100644 --- a/engraphis/static/dashboard.js +++ b/engraphis/static/dashboard.js @@ -151,7 +151,7 @@ async function loadOverviewAnalytics(){ } /* ── shared hosted upgrade / trial CTA ── */ -function hostedPlanUrl(plan,trial){const raw=(LIC&&(plan==='team'?LIC.team_upgrade_url:LIC.pro_upgrade_url))||(LIC&&LIC.upgrade_url);const safe=safeUrl(raw);if(!safe||safe==='#')return '#';try{const url=new URL(safe,location.href);if(trial)url.searchParams.set('trial',plan);return url.href}catch(e){return safe}} +function hostedPlanUrl(plan,trial){const raw=(LIC&&(plan==='team'?LIC.team_upgrade_url:LIC.pro_upgrade_url))||(LIC&&LIC.upgrade_url);const safe=safeUrl(raw);if(!safe||safe==='#')return '#';try{const url=new URL(safe,location.href);if(plan==='pro'||plan==='team')url.searchParams.set('plan',plan);if(trial)url.searchParams.set('trial',plan);return url.href}catch(e){return safe}} function unlockHtml(feature,plan){const url=hostedPlanUrl(plan),trialUrl=hostedPlanUrl(plan,true);const used=LIC&&LIC.trial&&LIC.trial.used;const trial=plan==='team'?'Start hosted Team trial':'Start hosted Pro trial';const detail=used?'Your free trial has already been used.':`The email-confirmed, no-card trial lasts exactly ${TRIAL_DAYS} active days.`;return `
${esc(feature)} runs in Engraphis ${plan==='team'?'Team':'Pro'} Cloud
${detail} Local-only write grace is separate, capped at 24 hours, and never extends cloud access.
${used?'':`${trial}`}${plan==='team'?'View Team plans':'View Pro plans'}
`} function startTrialPlan(plan){const url=hostedPlanUrl(plan,true);if(url==='#'){toast('Hosted signup URL is not configured','err');return}const link=document.createElement('a');link.href=url;link.target='_blank';link.rel='noopener';link.click()} function startTrial(){return startTrialPlan('pro')} diff --git a/tests/e2e/commercial.spec.js b/tests/e2e/commercial.spec.js index 6ecea16..6345544 100644 --- a/tests/e2e/commercial.spec.js +++ b/tests/e2e/commercial.spec.js @@ -134,9 +134,9 @@ test('local dashboard exposes hosted Pro and Team CTAs without local commercial const team = page.locator('#team-body'); await expect(team.getByText('Engraphis Team Cloud', { exact: false })).toBeVisible(); await expect(team.getByRole('link', { name: 'Start hosted Team trial' })) - .toHaveAttribute('href', 'https://cloud.engraphis.test/team?trial=team'); + .toHaveAttribute('href', 'https://cloud.engraphis.test/team?plan=team&trial=team'); await expect(team.getByRole('link', { name: 'Open Team Cloud' })) - .toHaveAttribute('href', 'https://cloud.engraphis.test/team'); + .toHaveAttribute('href', 'https://cloud.engraphis.test/team?plan=team'); await expect(team).toContainText('exactly 3 active days'); await expect(team).toContainText( 'A separate local-only write grace is capped at 24 hours and never extends Team or other cloud access.', @@ -187,9 +187,9 @@ for (const cloudStatus of [401, 402, 501]) { 'Local-only write grace is separate, capped at 24 hours, and never extends cloud access.', ); await expect(analytics.getByRole('link', { name: 'Start hosted Pro trial' })) - .toHaveAttribute('href', 'https://cloud.engraphis.test/pro?trial=pro'); + .toHaveAttribute('href', 'https://cloud.engraphis.test/pro?plan=pro&trial=pro'); await expect(analytics.getByRole('link', { name: 'View Pro plans' })) - .toHaveAttribute('href', 'https://cloud.engraphis.test/pro'); + .toHaveAttribute('href', 'https://cloud.engraphis.test/pro?plan=pro'); await expect(page.locator('#an-lock')).toHaveText('PRO'); const automationBefore = calls.filter(call => call.path === '/automation').length; @@ -206,9 +206,9 @@ for (const cloudStatus of [401, 402, 501]) { 'Local-only write grace is separate, capped at 24 hours, and never extends cloud access.', ); await expect(automation.getByRole('link', { name: 'Start hosted Pro trial' })) - .toHaveAttribute('href', 'https://cloud.engraphis.test/pro?trial=pro'); + .toHaveAttribute('href', 'https://cloud.engraphis.test/pro?plan=pro&trial=pro'); await expect(automation.getByRole('link', { name: 'View Pro plans' })) - .toHaveAttribute('href', 'https://cloud.engraphis.test/pro'); + .toHaveAttribute('href', 'https://cloud.engraphis.test/pro?plan=pro'); await expect(page.locator('#au-lock')).toHaveText('PRO'); expect(calls.some(call => call.path === '/analytics' && call.method === 'GET')).toBe(true); From a7068b044800bb5f58f7a5b157d98136fdc4cfbf Mon Sep 17 00:00:00 2001 From: Coding-Dev-Tools Date: Fri, 24 Jul 2026 00:54:37 -0400 Subject: [PATCH 2/3] fix: route denied cloud sync to upgrade --- docs/SYNC.md | 4 +++- engraphis/routes/v2_api.py | 17 ++++++++++++----- engraphis/static/dashboard.js | 2 +- tests/e2e/commercial.spec.js | 35 ++++++++++++++++++++++++++++++++--- tests/test_sync_dashboard.py | 18 ++++++++++++++++++ 5 files changed, 66 insertions(+), 10 deletions(-) diff --git a/docs/SYNC.md b/docs/SYNC.md index 4d8ca74..7f02ace 100644 --- a/docs/SYNC.md +++ b/docs/SYNC.md @@ -65,7 +65,9 @@ python -m scripts.sync \ The dashboard's **Sync now** action invokes the same customer protocol. The public package does not run a local auto-sync loop or ship a cron/Task Scheduler wrapper. Hosted automation belongs -to the private service. +to the private service. If the relay denies every workspace because the session is expired, +revoked, or no longer entitled, the dashboard returns to the hosted Pro/Team recovery CTA +instead of reporting a successful empty sync. ### Local folder transport diff --git a/engraphis/routes/v2_api.py b/engraphis/routes/v2_api.py index d8b6543..e170afb 100644 --- a/engraphis/routes/v2_api.py +++ b/engraphis/routes/v2_api.py @@ -1917,11 +1917,18 @@ async def sync_run(): import asyncio summary = await asyncio.to_thread(_sync_all, svc) _SYNC_STATE["last"] = summary - # If cloud authorization failed for every workspace (nothing exported, a 402 seen), - # surface it as the button's upgrade/renew prompt rather than a silent partial success. - if summary["exported"] == 0 and any(e.get("status") == 402 for e in summary["errors"]): - first = next(e for e in summary["errors"] if e.get("status") == 402) - raise HTTPException(status_code=402, detail={ + # If the relay denied every workspace for authentication, authorization, or an + # inactive entitlement, surface that result to the dashboard. Returning a 200 + # here made a revoked/expired session look like a successful zero-item sync and + # left the user with only a retry prompt instead of the Cloud recovery CTA. + authorization_statuses = {401, 402, 403} + if summary["exported"] == 0 and any( + e.get("status") in authorization_statuses for e in summary["errors"] + ): + first = next( + e for e in summary["errors"] if e.get("status") in authorization_statuses + ) + raise HTTPException(status_code=first["status"], detail={ "error": first["error"], "upgrade_url": licensing.upgrade_url()}) return {"ok": True, "summary": summary} diff --git a/engraphis/static/dashboard.js b/engraphis/static/dashboard.js index 6c6d1f8..b062ce3 100644 --- a/engraphis/static/dashboard.js +++ b/engraphis/static/dashboard.js @@ -440,7 +440,7 @@ async function testLlm(){const r=document.getElementById('llm-test-result');if(r async function loadHostedAgentAccess(){const el=document.getElementById('tokens-body');if(!el)return;let url=hostedPlanUrl('team');try{const st=await api('/auth/state');if(url==='#'&&st&&st.cloud_url)url=safeUrl(st.cloud_url)}catch(e){}el.innerHTML=`
Per-member agent accounts, roles, named seats, and rotating device credentials are managed in Team Cloud, not by this local dashboard.
Open Team CloudAgent Connect guide
`} async function loadSyncStatus(){try{const d=await api('/sync/status');renderSync(d)}catch(e){const el=document.getElementById('sync-body');if(el)el.innerHTML=unlockHtml('Cloud Sync','pro')}} function renderSync(d){const el=document.getElementById('sync-body');if(!el)return;d=d||{};if(!d.available){el.innerHTML=unlockHtml('Cloud Sync','pro');return}const last=d.last;let status='Cloud session connected; no sync recorded on this installation.';if(last){const when=new Date((last.at||0)*1000).toLocaleString();status='Last synced '+when+' — pushed '+(last.exported||0)+', +'+(last.added||0)+' received'+((last.errors&&last.errors.length)?' · '+last.errors.length+' issue(s)':'')+'.'}el.innerHTML=`
Hosted relayCONNECTED
Relay storage and authorization run in Engraphis Cloud. This package contains only the customer client; it does not run a local relay or background scheduler.
${esc(status)}
`} -async function syncNow(){const b=document.getElementById('sync-btn');const s=document.getElementById('sync-status');if(b){b.disabled=true;b.textContent='Syncing…'}if(s)s.textContent='Contacting the cloud…';try{const d=await api('/sync/run',{method:'POST',headers:{'Content-Type':'application/json'},body:'{}'});const su=d.summary||{};toast('Synced — pushed '+(su.exported||0)+', '+(su.added||0)+' new from other devices','ok');await loadSyncStatus()}catch(e){toast('Sync failed: '+e.message,'err');if(b){b.disabled=false;b.textContent='Sync now'}if(s)s.textContent='Sync failed — try again.'}} +async function syncNow(){const b=document.getElementById('sync-btn');const s=document.getElementById('sync-status');if(b){b.disabled=true;b.textContent='Syncing…'}if(s)s.textContent='Contacting the cloud…';try{const d=await api('/sync/run',{method:'POST',headers:{'Content-Type':'application/json'},body:'{}'});const su=d.summary||{};toast('Synced — pushed '+(su.exported||0)+', '+(su.added||0)+' new from other devices','ok');await loadSyncStatus()}catch(e){if(e.status===401||e.status===402||e.status===403){const el=document.getElementById('sync-body');if(el)el.innerHTML=unlockHtml('Cloud Sync','pro');toast(e.status===402?'Cloud Sync requires an active Pro or Team entitlement — open Engraphis Cloud to upgrade or renew.':'Cloud Sync authorization is no longer active — reconnect in Engraphis Cloud.','err');return}toast('Sync failed: '+e.message,'err');if(b){b.disabled=false;b.textContent='Sync now'}if(s)s.textContent='Sync failed — try again.'}} /* ─── knowledge graph (force-graph + d3-force: compact defaults and selectable layouts) ─── */ let GRAPH=null, FG=null, GRESIZE=false, GRESIZEFRAME=0, GADJ={}, GCOMM_ADJ={}, GCOMPONENTS={}, GCOMPONENT_LAYOUT=null, GHILITE=null, GHOVERSET=null, GLABELRANK={}, GLABELBOXES=[], GDATA_CACHE=null, GACTIVE_DATA=null, GREDRAWFRAME=0, GPERF={large:false,dense:false}; diff --git a/tests/e2e/commercial.spec.js b/tests/e2e/commercial.spec.js index 6345544..c76f6ac 100644 --- a/tests/e2e/commercial.spec.js +++ b/tests/e2e/commercial.spec.js @@ -14,7 +14,7 @@ const hostedLicense = { trial: { used: false, trial_days: 3 }, }; -async function mockLocalClient(page, cloudStatus = 402) { +async function mockLocalClient(page, cloudStatus = 402, syncRunStatus = null) { const calls = []; await page.route('**/api/**', async route => { @@ -53,7 +53,17 @@ async function mockLocalClient(page, cloudStatus = 402) { cloud_url: 'https://cloud.engraphis.test/team', }; } else if (path === '/sync/status') { - body = { available: false }; + body = { available: syncRunStatus !== null }; + } else if (path === '/sync/run' && syncRunStatus !== null) { + status = syncRunStatus; + body = { + detail: { + error: syncRunStatus === 402 + ? 'Cloud Sync entitlement is inactive (upgrade or renew required)' + : 'cloud relay synchronization failed', + upgrade_url: 'https://cloud.engraphis.test/pro', + }, + }; } else if (path === '/llm/status') { body = { configured: false, @@ -88,6 +98,23 @@ async function mockLocalClient(page, cloudStatus = 402) { return calls; } +test('Cloud Sync denial returns an unlicensed installation to the hosted upgrade CTA', async ({ page }) => { + const errors = recordBrowserErrors(page); + const calls = await mockLocalClient(page, 402, 402); + await page.goto('/'); + await openView(page, 'settings'); + + await expect(page.getByRole('button', { name: 'Sync now' })).toBeVisible(); + await page.getByRole('button', { name: 'Sync now' }).click(); + + const sync = page.locator('#sync-body'); + await expect(sync).toContainText('Cloud Sync runs in Engraphis Pro Cloud'); + await expect(sync.getByRole('link', { name: 'Start hosted Pro trial' })) + .toHaveAttribute('href', 'https://cloud.engraphis.test/pro?trial=pro'); + await expect(calls.some(call => call.path === '/sync/run' && call.method === 'POST')).toBe(true); + expect(errors).toEqual([]); +}); + function recordBrowserErrors(page) { const errors = []; page.on('console', message => { @@ -95,7 +122,9 @@ function recordBrowserErrors(page) { const location = message.location(); const expectedCloudDenial = /\/api\/(analytics|automation)/.test(location.url || '') && /status of (401|402|501)/.test(message.text()); - if (expectedCloudDenial) return; + const expectedSyncDenial = /\/api\/sync\/run/.test(location.url || '') + && /status of (401|402|403)/.test(message.text()); + if (expectedCloudDenial || expectedSyncDenial) return; errors.push(message.text() + (location.url ? ` @ ${location.url}:${location.lineNumber}` : '')); diff --git a/tests/test_sync_dashboard.py b/tests/test_sync_dashboard.py index 44da806..2f43c9d 100644 --- a/tests/test_sync_dashboard.py +++ b/tests/test_sync_dashboard.py @@ -189,3 +189,21 @@ def fail_transport(*args, **kwargs): "status": 502, }] assert secret not in repr(errors) + + +@pytest.mark.parametrize("status", (401, 402, 403)) +def test_sync_run_surfaces_full_authorization_denial(monkeypatch, tmp_path, status): + """A denied session must not look like a successful zero-item dashboard sync.""" + from engraphis.backends.sync_relay import RelayError + + def fail_transport(*args, **kwargs): + raise RelayError("relay authorization denied", status=status) + + monkeypatch.setattr("engraphis.backends.sync_folder.get_transport", fail_transport) + with _client(monkeypatch, tmp_path, cloud=True) as c: + response = c.post("/api/sync/run", json={}) + + assert response.status_code == status + detail = response.json()["detail"] + assert detail["error"] == "cloud relay synchronization failed" + assert detail["upgrade_url"].startswith("https://api.engraphis.com/account") From f6b6775d6ac9fcfd3501096b4b00683be448f45b Mon Sep 17 00:00:00 2001 From: Coding-Dev-Tools Date: Fri, 24 Jul 2026 00:55:43 -0400 Subject: [PATCH 3/3] test: preserve plan selection in sync upgrade CTA --- tests/e2e/commercial.spec.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/e2e/commercial.spec.js b/tests/e2e/commercial.spec.js index c76f6ac..860414b 100644 --- a/tests/e2e/commercial.spec.js +++ b/tests/e2e/commercial.spec.js @@ -110,7 +110,7 @@ test('Cloud Sync denial returns an unlicensed installation to the hosted upgrade const sync = page.locator('#sync-body'); await expect(sync).toContainText('Cloud Sync runs in Engraphis Pro Cloud'); await expect(sync.getByRole('link', { name: 'Start hosted Pro trial' })) - .toHaveAttribute('href', 'https://cloud.engraphis.test/pro?trial=pro'); + .toHaveAttribute('href', 'https://cloud.engraphis.test/pro?plan=pro&trial=pro'); await expect(calls.some(call => call.path === '/sync/run' && call.method === 'POST')).toBe(true); expect(errors).toEqual([]); });