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 8de80a1..b062ce3 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.
`}
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')}
@@ -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.
`}
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 6ecea16..860414b 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?plan=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}`
: ''));
@@ -134,9 +163,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 +216,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 +235,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);
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")