Recover login from empty API responses with one authenticated session

This commit is contained in:
pavlov346346-source 2026-09-18 02:39:30 +03:00
parent 1ae56fb57b
commit d4e02cab8b
14 changed files with 188 additions and 60 deletions

View File

@ -0,0 +1,9 @@
# Recover login when the API proxy returns no data
The API proxy intermittently returned HTTP 200 with an empty HTML response for authentication and workspace membership requests. The same Supabase backend returned a valid session and the existing workspace directly. The former membership loader treated malformed responses like missing workspaces, while an existing login gate kept its loading message. A second authentication client used by the fallback also shared the same session storage key.
The shared transport now validates JSON responses and uses the same backend directly for failed reads and password login. Authorization and the single SDK session are preserved. Only GET/HEAD and explicitly allowlisted read RPCs can be retried, plus password login. Database writes are not replayed. Both channels have bounded waits; permission and credential errors are returned without fallback.
Membership requests for the same account share one in-flight operation. Late responses from a different account are ignored. Missing RPC compatibility is used only for a missing function, not network errors. A failed membership request produces a retry/exit screen and cannot be mistaken for a developer account with no company. Successful login still opens the normal application.
Regression coverage includes empty/invalid proxy responses, identical request bodies and authorization on fallback, non-replayed writes, credential rejection, concurrent membership loads, recovery UI, and a real Supabase SDK login against controlled endpoint responses. The full flow is checked on desktop and mobile without changing production credentials or authentication requirements.

View File

@ -11,7 +11,7 @@
"serverReady": true, "serverReady": true,
"workspaceAutoDiscovery": true, "workspaceAutoDiscovery": true,
"invitesTemporarilyDisabled": false, "invitesTemporarilyDisabled": false,
"pwaCache": "v89-20260917-calendar-compact", "pwaCache": "v90-20260918-login-recovery",
"fullOfferDescriptions": true, "fullOfferDescriptions": true,
"dynamicOfferRows": true, "dynamicOfferRows": true,
"pdfOfferDescriptionFix": true, "pdfOfferDescriptionFix": true,

View File

@ -4,7 +4,7 @@
"version": "17.7.3", "version": "17.7.3",
"type": "module", "type": "module",
"scripts": { "scripts": {
"check:syntax": "node --check public/app-runtime.js && node --check public/service-worker.js && node --check public/legacy/bootstrap.js && node --check public/core/sun-safe.js && node --check public/core/account-center-v1780.js && node --check public/core/performance.js && node --check public/core/auth-security-v1774.js && node --check public/core/trial-promo-developer-v181.js && node --check public/core/order-enhancements-v1775.js && node --check public/core/data-layer-v1773.js && node --check public/core/server-automation-v1770.js && node --check public/core/hotfix-v1763.js && node --check public/core/ops-ux-v1762.js && node --check public/core/ux-fixes-v1764.js && node --check public/core/pdf-engine.js && node --check public/core/classic-offer-pdf-v1767.js && node --check public/core/signature-offer-pdf-v18.js && node --check public/core/developer-console-v1768.js && node --check public/core/offer-workspace-v1769.js && node --check public/core/brand-theme.js && node --check public/core/company-branding.js && node --check public/core/import-archive.js && node --check public/core/access-policy.js && node --check public/core/banquet-menu.js", "check:syntax": "node --check public/app-runtime.js && node --check public/service-worker.js && node --check public/legacy/bootstrap.js && node --check public/core/sun-safe.js && node --check public/core/account-center-v1780.js && node --check public/core/performance.js && node --check public/core/auth-security-v1774.js && node --check public/core/trial-promo-developer-v181.js && node --check public/core/order-enhancements-v1775.js && node --check public/core/data-layer-v1773.js && node --check public/core/server-automation-v1770.js && node --check public/core/hotfix-v1763.js && node --check public/core/ops-ux-v1762.js && node --check public/core/ux-fixes-v1764.js && node --check public/core/pdf-engine.js && node --check public/core/classic-offer-pdf-v1767.js && node --check public/core/signature-offer-pdf-v18.js && node --check public/core/developer-console-v1768.js && node --check public/core/offer-workspace-v1769.js && node --check public/core/brand-theme.js && node --check public/core/company-branding.js && node --check public/core/import-archive.js && node --check public/core/access-policy.js && node --check public/core/banquet-menu.js && node --check public/core/cloud-transport.js",
"test:static": "node tests/static-security.mjs && node tests/auth-security-v1774.mjs && node tests/employee-create-v1774.mjs && node tests/html-integrity-v1774.mjs && node tests/edge-security-v1774.mjs && node tests/branding-v1774.mjs && node tests/order-enhancements-v1775.mjs", "test:static": "node tests/static-security.mjs && node tests/auth-security-v1774.mjs && node tests/employee-create-v1774.mjs && node tests/html-integrity-v1774.mjs && node tests/edge-security-v1774.mjs && node tests/branding-v1774.mjs && node tests/order-enhancements-v1775.mjs",
"check:release": "node tests/release-check.mjs", "check:release": "node tests/release-check.mjs",
"check:deploy": "npm run check:syntax && npm run test:static && npm run check:release && node tests/backend-cutover.mjs && npm run test:db", "check:deploy": "npm run check:syntax && npm run test:static && npm run check:release && node tests/backend-cutover.mjs && npm run test:db",

View File

@ -2113,23 +2113,7 @@ window.SUN_LEGACY_CATALOG_V175=[];
const DEFAULT_SUPABASE_KEY = 'sb_publishable_CAxfhMKrduJjuk_5ybCQLg_TqSGWGoy'; const DEFAULT_SUPABASE_KEY = 'sb_publishable_CAxfhMKrduJjuk_5ybCQLg_TqSGWGoy';
const SUPABASE_API_PROXY = 'https://api.caterium.ru'; const SUPABASE_API_PROXY = 'https://api.caterium.ru';
const PROXY_FETCH_TIMEOUT_MS = 7000; const PROXY_FETCH_TIMEOUT_MS = 7000;
function supabaseProxyFetch(input, init) { const supabaseProxyFetch=window.CateriumCloudTransport.create({upstream:DEFAULT_SUPABASE_URL,proxy:SUPABASE_API_PROXY,timeout:PROXY_FETCH_TIMEOUT_MS});
try {
const url = typeof input === 'string' ? input : input?.url;
if (url && url.indexOf(DEFAULT_SUPABASE_URL) === 0) {
const proxied = SUPABASE_API_PROXY + url.slice(DEFAULT_SUPABASE_URL.length);
input = typeof input === 'string' ? proxied : new Request(proxied, input);
}
} catch (_) {}
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), PROXY_FETCH_TIMEOUT_MS);
const externalSignal = init?.signal;
if (externalSignal) {
if (externalSignal.aborted) controller.abort();
else externalSignal.addEventListener('abort', () => controller.abort(), {once:true});
}
return fetch(input, {...init, signal: controller.signal}).finally(() => clearTimeout(timer));
}
let config = loadConfig(); let config = loadConfig();
let client = null; let client = null;
@ -2140,6 +2124,8 @@ window.SUN_LEGACY_CATALOG_V175=[];
let supportMode = null; let supportMode = null;
let membershipsLoading = false; let membershipsLoading = false;
let membershipsLoaded = false; let membershipsLoaded = false;
let membershipError = '';
let membershipLoad = null;
let realtimeChannel = null; let realtimeChannel = null;
let syncTimer = null; let syncTimer = null;
let isSyncing = false; let isSyncing = false;
@ -3123,7 +3109,7 @@ window.SUN_LEGACY_CATALOG_V175=[];
const signature=`${config.url}|${config.key}`; const signature=`${config.url}|${config.key}`;
if(!client||signature!==clientSignature){ if(!client||signature!==clientSignature){
client=window.supabase.createClient(config.url,config.key,{auth:{persistSession:true,autoRefreshToken:true,detectSessionInUrl:true},global:{fetch:supabaseProxyFetch}});clientSignature=signature; client=window.supabase.createClient(config.url,config.key,{auth:{persistSession:true,autoRefreshToken:true,detectSessionInUrl:true},global:{fetch:supabaseProxyFetch}});clientSignature=signature;
client.auth.onAuthStateChange((_event,next)=>{if(session?.user?.id!==next?.user?.id)window.CateriumBranding?.resetSidebar();session=next;membershipsLoading=Boolean(next?.user);membershipsLoaded=!next?.user;setTimeout(async()=>{try{if(!next?.user){const sw=await switchTenantLocal('');if(sw.changed){location.reload();return;}}else{const sw=await loadMemberships();if(sw){location.reload();return;}}}finally{renderCloudUI();updatePill();}},0);}); client.auth.onAuthStateChange((_event,next)=>{const changed=session?.user?.id!==next?.user?.id;if(changed)window.CateriumBranding?.resetSidebar();session=next;if(!changed&&(membershipsLoading||(membershipsLoaded&&!membershipError)))return;membershipsLoading=Boolean(next?.user);membershipsLoaded=!next?.user;setTimeout(async()=>{try{if(!next?.user){const sw=await switchTenantLocal('');if(sw.changed){location.reload();return;}}else{const sw=await loadMemberships();if(sw){location.reload();return;}}}finally{renderCloudUI();updatePill();}},0);});
} }
const {data,error}=await client.auth.getSession();if(error)throw error;session=data.session||null; const {data,error}=await client.auth.getSession();if(error)throw error;session=data.session||null;
const tenantSwitched=session?await loadMemberships():(await switchTenantLocal('')).changed; const tenantSwitched=session?await loadMemberships():(await switchTenantLocal('')).changed;
@ -3158,18 +3144,26 @@ window.SUN_LEGACY_CATALOG_V175=[];
}catch(error){handleError(error,signUp?'Не удалось создать аккаунт.':'Не удалось войти.');} }catch(error){handleError(error,signUp?'Не удалось создать аккаунт.':'Не удалось войти.');}
} }
async function loadMemberships() { function loadMemberships() {
memberships=[];workspace=null;membershipsLoaded=false; const userId=session?.user?.id||'';
if(!client||!session?.user)return false; if(membershipLoad?.userId===userId)return membershipLoad.promise;
if(!client||!userId){memberships=[];workspace=null;membershipsLoading=false;membershipsLoaded=true;membershipError='';return Promise.resolve(false)}
const task={userId,promise:null};membershipLoad=task;
task.promise=runMembershipLoad(task).finally(()=>{if(membershipLoad!==task)return;membershipLoad=null;membershipsLoading=false;membershipsLoaded=true;try{window.dispatchEvent(new CustomEvent('sun:cloud-permissions-changed',{detail:{workspace:clone(workspace)}}))}catch(_){}});
return task.promise;
}
async function runMembershipLoad(task) {
const activeClient=client,current=()=>membershipLoad===task&&client===activeClient&&session?.user?.id===task.userId;
memberships=[];workspace=null;membershipsLoaded=false;membershipError='';
membershipsLoading=true; membershipsLoading=true;
try{ try{
let rows=[]; let rows=[];
const rpc=await sunCloudAwait(client.rpc('sun_my_workspaces'),'Загрузка рабочей базы'); const rpc=await sunCloudAwait(client.rpc('sun_my_workspaces'),'Загрузка рабочей базы');
if(!rpc.error&&Array.isArray(rpc.data)){ if(!rpc.error&&Array.isArray(rpc.data)){
rows=rpc.data.map(x=>({workspace_id:x.id,name:x.name,role:x.role,display_name:x.display_name,is_active:x.is_active,permissions:x.permissions})); rows=rpc.data.map(x=>({workspace_id:x.id,name:x.name,role:x.role,display_name:x.display_name,is_active:x.is_active,permissions:x.permissions}));
}else{ }else if(rpc.error&&['PGRST202','42883'].includes(rpc.error.code)){
// Compatibility fallback for an older server schema. // Compatibility fallback for an older server schema.
const direct=await sunCloudAwait(client.from('sun_workspace_members').select('workspace_id,role,display_name,is_active,permissions').eq('user_id',session.user.id),'Загрузка рабочей базы'); const direct=await sunCloudAwait(activeClient.from('sun_workspace_members').select('workspace_id,role,display_name,is_active,permissions').eq('user_id',task.userId),'Загрузка рабочей базы');
if(direct.error)throw rpc.error||direct.error; if(direct.error)throw rpc.error||direct.error;
const active=(direct.data||[]).filter(x=>x.is_active!==false); const active=(direct.data||[]).filter(x=>x.is_active!==false);
if(active.length){ if(active.length){
@ -3177,21 +3171,24 @@ window.SUN_LEGACY_CATALOG_V175=[];
const w=await sunCloudAwait(client.from('sun_workspaces').select('id,name').in('id',ids),'Загрузка рабочей базы');if(w.error)throw w.error;(w.data||[]).forEach(x=>names[x.id]=x.name); const w=await sunCloudAwait(client.from('sun_workspaces').select('id,name').in('id',ids),'Загрузка рабочей базы');if(w.error)throw w.error;(w.data||[]).forEach(x=>names[x.id]=x.name);
rows=active.map(x=>({...x,name:names[x.workspace_id]||'Рабочая база'})); rows=active.map(x=>({...x,name:names[x.workspace_id]||'Рабочая база'}));
} }
} }else throw rpc.error||new Error('Не удалось прочитать список компаний. Повторите загрузку.');
if(!current())return false;
memberships=rows.filter(x=>x.is_active!==false).map(x=>({id:x.workspace_id,name:x.name||'Рабочая база',role:x.role,display_name:x.display_name||'',is_active:x.is_active!==false,permissions:x.permissions||{}})); memberships=rows.filter(x=>x.is_active!==false).map(x=>({id:x.workspace_id,name:x.name||'Рабочая база',role:x.role,display_name:x.display_name||'',is_active:x.is_active!==false,permissions:x.permissions||{}}));
if(memberships.length&&!memberships.some(x=>x.id===config.workspaceId))config.workspaceId=memberships[0].id; if(memberships.length&&!memberships.some(x=>x.id===config.workspaceId))config.workspaceId=memberships[0].id;
if(!memberships.length)config.workspaceId=''; if(!memberships.length)config.workspaceId='';
saveConfig();selectWorkspace(); saveConfig();selectWorkspace();
const switched=await switchTenantLocal(workspace?.id||''); const switched=await switchTenantLocal(workspace?.id||'');
if(!current())return false;
membershipsLoaded=true; membershipsLoaded=true;
if(switched.changed)return true; if(switched.changed)return true;
try{window.dispatchEvent(new CustomEvent('sun:cloud-permissions-changed',{detail:{workspace:clone(workspace),user:clone(session.user)}}));}catch(_){} try{window.dispatchEvent(new CustomEvent('sun:cloud-permissions-changed',{detail:{workspace:clone(workspace),user:clone(session.user)}}));}catch(_){}
return false; return false;
}catch(error){ }catch(error){
membershipsLoaded=true; if(!current())return false;
setStatus('error',error?.message||'Не удалось загрузить рабочую базу.'); membershipError=error?.message||'Не удалось загрузить рабочую базу.';
setStatus('error',membershipError);
return false; return false;
}finally{membershipsLoading=false;} }
} }
function selectWorkspace(){workspace=memberships.find(x=>x.id===config.workspaceId)||memberships[0]||null;if(workspace&&config.workspaceId!==workspace.id){config.workspaceId=workspace.id;saveConfig();}} function selectWorkspace(){workspace=memberships.find(x=>x.id===config.workspaceId)||memberships[0]||null;if(workspace&&config.workspaceId!==workspace.id){config.workspaceId=workspace.id;saveConfig();}}
@ -3360,7 +3357,7 @@ window.SUN_LEGACY_CATALOG_V175=[];
setAutoSyncDeveloper, setAutoSyncDeveloper,
snapshotLocal:()=>collectLocalPayload(), snapshotLocal:()=>collectLocalPayload(),
reloadMemberships:async()=>{await loadMemberships();renderCloudUI();return workspace?clone(workspace):null;}, reloadMemberships:async()=>{await loadMemberships();renderCloudUI();return workspace?clone(workspace):null;},
status:()=>({connected:Boolean(client),signedIn:Boolean(session),supportMode:supportMode?clone(supportMode):null,workspace:workspace?clone(workspace):null,membershipsLoading,membershipsLoaded,membershipCount:memberships.length,dirty,lastStatus,lastError}) status:()=>({connected:Boolean(client),signedIn:Boolean(session),supportMode:supportMode?clone(supportMode):null,workspace:workspace?clone(workspace):null,membershipsLoading,membershipsLoaded,membershipError,membershipCount:memberships.length,dirty,lastStatus,lastError})
}; };
async function boot(){ async function boot(){
@ -3761,9 +3758,17 @@ window.SUN_LEGACY_CATALOG_V175=[];
} }
if(st.signedIn&&ws){$('sunCloudAuthGateV3')?.remove();document.body.classList.remove('sun-cloud-auth-required');try{localStorage.removeItem(LOCAL_ONLY_KEY)}catch(_){}return;} if(st.signedIn&&ws){$('sunCloudAuthGateV3')?.remove();document.body.classList.remove('sun-cloud-auth-required');try{localStorage.removeItem(LOCAL_ONLY_KEY)}catch(_){}return;}
if(!st.signedIn&&window.CateriumAccessPolicy?.emergencyLocalActive()){$('sunCloudAuthGateV3')?.remove();document.body.classList.remove('sun-cloud-auth-required');return;} if(!st.signedIn&&window.CateriumAccessPolicy?.emergencyLocalActive()){$('sunCloudAuthGateV3')?.remove();document.body.classList.remove('sun-cloud-auth-required');return;}
if($('sunCloudAuthGateV3')){if(st.signedIn&&st.membershipsLoading){const e=$('sunGateErrorV3');if(e)e.textContent='Загружаю рабочую базу…';}return;} const gateState=st.signedIn?(st.membershipsLoading?'loading':st.membershipError?'error':'missing'):'login';
const currentGate=$('sunCloudAuthGateV3');
if(currentGate){if(st.signedIn&&currentGate.dataset.authState!==gateState)currentGate.remove();else{if(st.signedIn){const e=$('sunGateErrorV3');if(e)e.textContent=st.membershipsLoading?'Загружаю рабочую базу…':st.membershipError||e.textContent;}return;}}
document.body.classList.add('sun-cloud-auth-required'); document.body.classList.add('sun-cloud-auth-required');
const gate=document.createElement('div');gate.id='sunCloudAuthGateV3';gate.className='sun-cloud-auth-gate'; const gate=document.createElement('div');gate.id='sunCloudAuthGateV3';gate.className='sun-cloud-auth-gate';gate.dataset.authState=gateState;
if(st.signedIn&&st.membershipError){
gate.innerHTML=`<div class="sun-cloud-auth-card"><h2>Не удалось загрузить рабочую базу</h2><p class="hint">${esc(ss?.user?.email||'Аккаунт авторизован')}</p><p class="hint">Вход выполнен, но сервер не вернул данные компании. Повторите загрузку.</p><div class="sun-cloud-auth-error" id="sunGateErrorV3">${esc(st.membershipError)}</div><div class="sun-cloud-auth-actions"><button class="primary" id="sunGateRetryWorkspaceV3" type="button">Повторить загрузку</button><button class="outline" id="sunGateSignOutV3" type="button">Выйти</button></div></div>`;
document.body.appendChild(gate);
gate.querySelector('#sunGateRetryWorkspaceV3').onclick=async()=>{const button=gate.querySelector('#sunGateRetryWorkspaceV3');button.disabled=true;try{const found=await cloud()?.reloadMemberships?.();if(found){location.reload();return;}}finally{gate.remove();ensureAuthGate();}};
gate.querySelector('#sunGateSignOutV3').onclick=()=>cloud()?.signOut?.();return;
}
if(st.signedIn&&ss?.user&&!ws){ if(st.signedIn&&ss?.user&&!ws){
const pending=getPendingRegistration(ss.user.email||''); const pending=getPendingRegistration(ss.user.email||'');
gate.innerHTML=`<div class="sun-cloud-auth-card"><div class="sun-cloud-auth-brand"><img src="caterium-login-logo.png" alt="Caterium"><div><h2>${pending?'Создаю компанию':st.membershipsLoading?'Загружаю рабочую базу':'Аккаунт готов'}</h2><div class="hint">${esc(ss.user.email||'Аккаунт авторизован')}</div></div></div><p class="hint">${pending?'Регистрация завершена. Сейчас подготовим вашу рабочую компанию.':st.membershipsLoading?'Проверяю доступы этого аккаунта на сервере.':'У этого аккаунта пока нет компании. Укажите название, чтобы создать её.'}</p>${!pending&&!st.membershipsLoading?'<label>Название компании<input id="sunGateRecoveryCompanyV25" autocomplete="organization" value="Моя компания"></label>':''}<div class="sun-cloud-auth-actions"><button class="primary" id="sunGateRetryWorkspaceV3" type="button">${pending?'Продолжить':st.membershipsLoading?'Проверить ещё раз':'Создать компанию'}</button><button class="outline" id="sunGateSignOutV3" type="button">Выйти</button></div><div class="sun-cloud-auth-error" id="sunGateErrorV3">${pending?'Подготавливаю компанию…':st.membershipsLoading?'Загружаю рабочую базу…':''}</div></div>`; gate.innerHTML=`<div class="sun-cloud-auth-card"><div class="sun-cloud-auth-brand"><img src="caterium-login-logo.png" alt="Caterium"><div><h2>${pending?'Создаю компанию':st.membershipsLoading?'Загружаю рабочую базу':'Аккаунт готов'}</h2><div class="hint">${esc(ss.user.email||'Аккаунт авторизован')}</div></div></div><p class="hint">${pending?'Регистрация завершена. Сейчас подготовим вашу рабочую компанию.':st.membershipsLoading?'Проверяю доступы этого аккаунта на сервере.':'У этого аккаунта пока нет компании. Укажите название, чтобы создать её.'}</p>${!pending&&!st.membershipsLoading?'<label>Название компании<input id="sunGateRecoveryCompanyV25" autocomplete="organization" value="Моя компания"></label>':''}<div class="sun-cloud-auth-actions"><button class="primary" id="sunGateRetryWorkspaceV3" type="button">${pending?'Продолжить':st.membershipsLoading?'Проверить ещё раз':'Создать компанию'}</button><button class="outline" id="sunGateSignOutV3" type="button">Выйти</button></div><div class="sun-cloud-auth-error" id="sunGateErrorV3">${pending?'Подготавливаю компанию…':st.membershipsLoading?'Загружаю рабочую базу…':''}</div></div>`;
@ -4688,7 +4693,7 @@ window.SUN_LEGACY_CATALOG_V175=[];
function seedWorkspace(){clearWorkspaceLocalData();localStorage.setItem('sunBoxes','[]');localStorage.setItem('sunOrders','[]');} function seedWorkspace(){clearWorkspaceLocalData();localStorage.setItem('sunBoxes','[]');localStorage.setItem('sunOrders','[]');}
function enhanceOnboardingGate(){ function enhanceOnboardingGate(){
const gate=$('sunCloudAuthGateV3'),ss=session(),ws=workspace(),st=cloud()?.status?.();if(window.SunDeveloperV22?.isPlatformAdmin?.())return;if(!gate||!ss?.user||ws||st?.membershipsLoading||st?.membershipsLoaded===false||gate.dataset.saasEnhanced==='1')return; const gate=$('sunCloudAuthGateV3'),ss=session(),ws=workspace(),st=cloud()?.status?.();if(window.SunDeveloperV22?.isPlatformAdmin?.())return;if(!gate||!ss?.user||ws||st?.membershipsLoading||st?.membershipError||st?.membershipsLoaded===false||gate.dataset.saasEnhanced==='1')return;
gate.dataset.saasEnhanced='1';const card=gate.querySelector('.sun-cloud-auth-card');if(!card)return; gate.dataset.saasEnhanced='1';const card=gate.querySelector('.sun-cloud-auth-card');if(!card)return;
card.innerHTML=`<div class="sun-cloud-auth-brand"><img src="caterium-mark-light.svg" alt=""><div><h2>Создайте компанию</h2><div class="hint">${esc(ss.user.email||'Аккаунт авторизован')} · 14 дней Полного тарифа бесплатно</div></div></div><p class="hint">У этого аккаунта пока нет рабочей базы. Приглашения временно отключены на период локального тестирования.</p><label>Название компании<input id="sunSaaSCompanyNameV16" value="Моя кейтеринговая компания" autocomplete="organization"></label><div class="sun-saas-onboarding-tabs"><button class="sun-saas-onboarding-choice" type="button" data-create="empty"><b>Новая компания</b><small>Пустой каталог, без заказов и клиентов. Добавьте свои блюда, боксы и фотографии.</small></button></div><div class="sun-cloud-auth-actions" style="margin-top:14px"><button class="outline" type="button" data-signout>Выйти</button></div><div class="sun-cloud-auth-error" id="sunSaaSGateErrorV16"></div>`; card.innerHTML=`<div class="sun-cloud-auth-brand"><img src="caterium-mark-light.svg" alt=""><div><h2>Создайте компанию</h2><div class="hint">${esc(ss.user.email||'Аккаунт авторизован')} · 14 дней Полного тарифа бесплатно</div></div></div><p class="hint">У этого аккаунта пока нет рабочей базы. Приглашения временно отключены на период локального тестирования.</p><label>Название компании<input id="sunSaaSCompanyNameV16" value="Моя кейтеринговая компания" autocomplete="organization"></label><div class="sun-saas-onboarding-tabs"><button class="sun-saas-onboarding-choice" type="button" data-create="empty"><b>Новая компания</b><small>Пустой каталог, без заказов и клиентов. Добавьте свои блюда, боксы и фотографии.</small></button></div><div class="sun-cloud-auth-actions" style="margin-top:14px"><button class="outline" type="button" data-signout>Выйти</button></div><div class="sun-cloud-auth-error" id="sunSaaSGateErrorV16"></div>`;
const err=$('sunSaaSGateErrorV16'); const err=$('sunSaaSGateErrorV16');
@ -5359,7 +5364,7 @@ window.SUN_LEGACY_CATALOG_V175=[];
async function renderJournal(body){const [activity,errors]=await Promise.all([rpc('sun_dev_list_activity',{p_limit:150}),rpc('sun_dev_list_errors',{p_workspace:null,p_limit:100})]);body.innerHTML=`<div class="sun-dev-section-grid"><div class="sun-dev-card" style="overflow:auto"><h2>Действия разработчика</h2><div class="sun-dev-table-wrap"><table class="sun-dev-table"><thead><tr><th>Дата</th><th>Действие</th><th>Компания / аккаунт</th><th>Детали</th></tr></thead><tbody>${(activity||[]).map(a=>`<tr><td>${fmtDate(a.created_at)}</td><td><b>${esc(a.action)}</b><div class="sun-dev-muted">${esc(a.actor_email||'')}</div></td><td>${esc(a.workspace_name||a.target_email||'—')}</td><td class="sun-dev-muted">${esc(JSON.stringify(a.details||{}))}</td></tr>`).join('')||'<tr><td colspan="4">Записей пока нет.</td></tr>'}</tbody></table></div></div><div class="sun-dev-card" style="overflow:auto"><h2>Ошибки приложения</h2><div class="sun-dev-table-wrap"><table class="sun-dev-table"><thead><tr><th>Дата</th><th>Компания</th><th>Версия</th><th>Ошибка</th></tr></thead><tbody>${(errors||[]).map(e=>`<tr><td>${fmtDate(e.created_at)}</td><td>${esc(e.workspace_name||'—')}</td><td>${esc(e.app_version||'—')}</td><td><b>${esc(e.level||'error')}</b><div>${esc(e.message||'')}</div></td></tr>`).join('')||'<tr><td colspan="4">Ошибок нет.</td></tr>'}</tbody></table></div></div></div>`} async function renderJournal(body){const [activity,errors]=await Promise.all([rpc('sun_dev_list_activity',{p_limit:150}),rpc('sun_dev_list_errors',{p_workspace:null,p_limit:100})]);body.innerHTML=`<div class="sun-dev-section-grid"><div class="sun-dev-card" style="overflow:auto"><h2>Действия разработчика</h2><div class="sun-dev-table-wrap"><table class="sun-dev-table"><thead><tr><th>Дата</th><th>Действие</th><th>Компания / аккаунт</th><th>Детали</th></tr></thead><tbody>${(activity||[]).map(a=>`<tr><td>${fmtDate(a.created_at)}</td><td><b>${esc(a.action)}</b><div class="sun-dev-muted">${esc(a.actor_email||'')}</div></td><td>${esc(a.workspace_name||a.target_email||'—')}</td><td class="sun-dev-muted">${esc(JSON.stringify(a.details||{}))}</td></tr>`).join('')||'<tr><td colspan="4">Записей пока нет.</td></tr>'}</tbody></table></div></div><div class="sun-dev-card" style="overflow:auto"><h2>Ошибки приложения</h2><div class="sun-dev-table-wrap"><table class="sun-dev-table"><thead><tr><th>Дата</th><th>Компания</th><th>Версия</th><th>Ошибка</th></tr></thead><tbody>${(errors||[]).map(e=>`<tr><td>${fmtDate(e.created_at)}</td><td>${esc(e.workspace_name||'—')}</td><td>${esc(e.app_version||'—')}</td><td><b>${esc(e.level||'error')}</b><div>${esc(e.message||'')}</div></td></tr>`).join('')||'<tr><td colspan="4">Ошибок нет.</td></tr>'}</tbody></table></div></div></div>`}
function enhanceDeveloperGate(){const gate=$('sunCloudAuthGateV3'),ss=session(),ws=cloud()?.getWorkspace?.(),st=cloud()?.status?.();if(!platformAdmin||!gate||!ss?.user||ws||st?.membershipsLoading||st?.membershipsLoaded===false)return;const card=gate.querySelector('.sun-cloud-auth-card');if(!card||card.dataset.devEnhanced==='1')return;card.dataset.devEnhanced='1';gate.dataset.saasEnhanced='1';card.innerHTML=`<div class="sun-cloud-auth-brand"><img src="caterium-login-logo.png" alt="Caterium"><div><h2>Аккаунт разработчика</h2><div class="hint">${esc(ss.user.email||'')}</div></div></div><p class="hint">Этот аккаунт управляет платформой и не обязан иметь собственную рабочую компанию.</p><div class="sun-cloud-auth-actions"><button class="primary" type="button" data-open-dev>Открыть кабинет разработчика</button><button class="outline" type="button" data-signout>Выйти</button></div>`;card.querySelector('[data-open-dev]').onclick=()=>open();card.querySelector('[data-signout]').onclick=()=>cloud()?.signOut?.()} function enhanceDeveloperGate(){const gate=$('sunCloudAuthGateV3'),ss=session(),ws=cloud()?.getWorkspace?.(),st=cloud()?.status?.();if(!platformAdmin||!gate||!ss?.user||ws||st?.membershipsLoading||st?.membershipError||st?.membershipsLoaded===false)return;const card=gate.querySelector('.sun-cloud-auth-card');if(!card||card.dataset.devEnhanced==='1')return;card.dataset.devEnhanced='1';gate.dataset.saasEnhanced='1';card.innerHTML=`<div class="sun-cloud-auth-brand"><img src="caterium-login-logo.png" alt="Caterium"><div><h2>Аккаунт разработчика</h2><div class="hint">${esc(ss.user.email||'')}</div></div></div><p class="hint">Этот аккаунт управляет платформой и не обязан иметь собственную рабочую компанию.</p><div class="sun-cloud-auth-actions"><button class="primary" type="button" data-open-dev>Открыть кабинет разработчика</button><button class="outline" type="button" data-signout>Выйти</button></div>`;card.querySelector('[data-open-dev]').onclick=()=>open();card.querySelector('[data-signout]').onclick=()=>cloud()?.signOut?.()}
function syncChrome(){if(platformAdmin){ensureNav();enhanceDeveloperGate()}else{$('sunDeveloperNavV22')?.remove()}ensureSupportBanner()} function syncChrome(){if(platformAdmin){ensureNav();enhanceDeveloperGate()}else{$('sunDeveloperNavV22')?.remove()}ensureSupportBanner()}
function boot(){installStyle();let tries=0;const timer=setInterval(async()=>{tries++;if(document.hidden)return;if(client()&&session()?.user){await checkPlatformAdmin(false);enhanceDeveloperGate();ensureSupportBanner()}else if(!session()?.user){platformAdmin=false;syncChrome()}if(tries>40)clearInterval(timer)},3000);window.addEventListener('sun:cloud-permissions-changed',()=>setTimeout(()=>checkPlatformAdmin(true),120));window.addEventListener('sun:cloud-state-applied',()=>ensureSupportBanner());document.addEventListener('click',e=>{const b=e.target.closest('header nav button');if(!b)return;if(String(b.dataset.navLabel||b.textContent||'').trim()==='Настройки'&&platformAdmin)setTimeout(()=>{$('sunSaaSSettingsCardV16')?.querySelector('[data-saas-admin]')?.remove()},120)},true);setTimeout(()=>checkPlatformAdmin(true),900)} function boot(){installStyle();let tries=0;const timer=setInterval(async()=>{tries++;if(document.hidden)return;if(client()&&session()?.user){await checkPlatformAdmin(false);enhanceDeveloperGate();ensureSupportBanner()}else if(!session()?.user){platformAdmin=false;syncChrome()}if(tries>40)clearInterval(timer)},3000);window.addEventListener('sun:cloud-permissions-changed',()=>setTimeout(()=>checkPlatformAdmin(true),120));window.addEventListener('sun:cloud-state-applied',()=>ensureSupportBanner());document.addEventListener('click',e=>{const b=e.target.closest('header nav button');if(!b)return;if(String(b.dataset.navLabel||b.textContent||'').trim()==='Настройки'&&platformAdmin)setTimeout(()=>{$('sunSaaSSettingsCardV16')?.querySelector('[data-saas-admin]')?.remove()},120)},true);setTimeout(()=>checkPlatformAdmin(true),900)}

View File

@ -1,20 +1,15 @@
(()=>{ (()=>{
'use strict'; 'use strict';
const VERSION='17.8.4-auth-proxy-fallback'; const VERSION='17.8.5-single-session-login';
const PENDING_REGISTRATION_KEY='sunPendingRegistrationV23'; const PENDING_REGISTRATION_KEY='sunPendingRegistrationV23';
const DIRECT_SUPABASE_URL='https://usfjwhztqoopzzfmfbis.supabase.co'; let busy=false;
const DIRECT_SUPABASE_KEY='sb_publishable_CAxfhMKrduJjuk_5ybCQLg_TqSGWGoy';
let busy=false,directClient=null;
const $=id=>document.getElementById(id); const $=id=>document.getElementById(id);
const cloud=()=>window.SunCloudV2||null; const cloud=()=>window.SunCloudV2||null;
const client=()=>cloud()?.getClient?.()||null; const client=()=>cloud()?.getClient?.()||null;
const esc=value=>window.SunSafe?.escapeHTML?window.SunSafe.escapeHTML(String(value??'')):String(value??''); const esc=value=>window.SunSafe?.escapeHTML?window.SunSafe.escapeHTML(String(value??'')):String(value??'');
function directAuthClient(){if(directClient)return directClient;try{if(!window.supabase?.createClient)return null;directClient=window.supabase.createClient(DIRECT_SUPABASE_URL,DIRECT_SUPABASE_KEY,{auth:{persistSession:true,autoRefreshToken:true,detectSessionInUrl:true}});return directClient}catch(_){return null}}
function isProxyServerError(error){const text=`${error?.message||error||''} ${error?.status||''} ${error?.statusCode||''}`;return /(?:http\s*)?500|internal server error|failed to fetch|networkerror|load failed/i.test(text)}
async function signInWithFallback(c,email,password){let first=null;try{first=await c.auth.signInWithPassword({email,password})}catch(error){if(!isProxyServerError(error))throw error;first={error}}if(first?.data?.session)return{authClient:c,result:first};if(first?.error&&!isProxyServerError(first.error))throw first.error;const fallback=directAuthClient();if(!fallback)throw first?.error||new Error('Сервис входа временно недоступен.');const second=await fallback.auth.signInWithPassword({email,password});if(second.error)throw second.error;return{authClient:fallback,result:second}}
function redirectUrl(){try{const u=new URL(location.href);u.hash='';return u.toString();}catch(_){return location.href.split('#')[0];}} function redirectUrl(){try{const u=new URL(location.href);u.hash='';return u.toString();}catch(_){return location.href.split('#')[0];}}
function savePendingRegistration(email,promoCode){try{localStorage.setItem(PENDING_REGISTRATION_KEY,JSON.stringify({email:String(email||'').trim().toLowerCase(),companyName:'Новая компания',promoCode:String(promoCode||'').trim().toUpperCase(),createdAt:new Date().toISOString()}));}catch(_){}} function savePendingRegistration(email,promoCode){try{localStorage.setItem(PENDING_REGISTRATION_KEY,JSON.stringify({email:String(email||'').trim().toLowerCase(),companyName:'Новая компания',promoCode:String(promoCode||'').trim().toUpperCase(),createdAt:new Date().toISOString()}));}catch(_){}}
@ -46,10 +41,10 @@
if(!email||password.length<6){setError(gate,'Введите email и пароль минимум из 6 символов.');return;} if(!email||password.length<6){setError(gate,'Введите email и пароль минимум из 6 символов.');return;}
busy=true;const button=$('sunGateSubmitV3');if(button)button.disabled=true;setError(gate,'Выполняю вход…'); busy=true;const button=$('sunGateSubmitV3');if(button)button.disabled=true;setError(gate,'Выполняю вход…');
try{ try{
const signed=await signInWithFallback(c,email,password),result=signed.result,authClient=signed.authClient; const result=await c.auth.signInWithPassword({email,password});if(result.error)throw result.error;
const authUser=result.data?.user||result.data?.session?.user||null;const userId=authUser?.id||'';if(userId)try{sessionStorage.setItem(`sunCloudQuickPinUnlockedV3:${userId}`,'1')}catch(_){} const authUser=result.data?.user||result.data?.session?.user||null;const userId=authUser?.id||'';if(userId)try{sessionStorage.setItem(`sunCloudQuickPinUnlockedV3:${userId}`,'1')}catch(_){}
const persisted=await authClient.auth.getSession();if(persisted.error)throw persisted.error;if(!persisted.data?.session?.user)throw new Error('Сессия входа не сохранилась. Повторите вход.'); const persisted=await c.auth.getSession();if(persisted.error)throw persisted.error;if(!persisted.data?.session?.user)throw new Error('Сессия входа не сохранилась. Повторите вход.');
setError(gate,authClient===c?'Вход выполнен. Открываю Caterium…':'Вход выполнен через резервный канал. Открываю Caterium…'); setError(gate,'Вход выполнен. Открываю Caterium…');
setTimeout(()=>location.reload(),120); setTimeout(()=>location.reload(),120);
}catch(error){setError(gate,String(error?.message||error||'Не удалось войти.'));if(button)button.disabled=false;busy=false;} }catch(error){setError(gate,String(error?.message||error||'Не удалось войти.'));if(button)button.disabled=false;busy=false;}
} }
@ -110,4 +105,4 @@
const init=()=>{neutralizeLegacyCompanyUI();setTimeout(()=>{const gate=$('sunCloudAuthGateV3');if(gate&&pendingRegistration())finishOwnerOnboarding(gate);},500)}; const init=()=>{neutralizeLegacyCompanyUI();setTimeout(()=>{const gate=$('sunCloudAuthGateV3');if(gate&&pendingRegistration())finishOwnerOnboarding(gate);},500)};
if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init,{once:true});else init(); if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init,{once:true});else init();
window.CateriumAuthSecurityV1774=Object.freeze({VERSION,redirectUrl,ensureRegistrationFields}); window.CateriumAuthSecurityV1774=Object.freeze({VERSION,redirectUrl,ensureRegistrationFields});
})(); })();

View File

@ -0,0 +1,35 @@
(()=>{
'use strict';
const READ_RPCS=new Set(['sun_my_workspaces','sun_fetch_app_state','sun_is_platform_admin']);
function create({upstream,proxy,timeout=7000,fallbackTimeout=4000}){
const origin=new URL(upstream).origin;
return async function(input,init={}){
const original=new Request(input,init),url=new URL(original.url),isBackend=url.origin===origin;
const read=original.method==='GET'||original.method==='HEAD'||(original.method==='POST'&&url.pathname.startsWith('/rest/v1/rpc/')&&READ_RPCS.has(url.pathname.slice('/rest/v1/rpc/'.length)));
const passwordLogin=original.method==='POST'&&url.pathname==='/auth/v1/token'&&url.searchParams.get('grant_type')==='password';
const safeFallback=isBackend&&(read||passwordLogin);
const expectJson=original.method!=='HEAD'&&(passwordLogin||url.pathname.startsWith('/rest/v1/rpc/')||(read&&(url.pathname.startsWith('/rest/v1/')||url.pathname.startsWith('/auth/v1/'))));
async function attempt(target,limit){
const controller=new AbortController(),abort=()=>controller.abort(original.signal.reason);
if(original.signal.aborted)abort();else original.signal.addEventListener('abort',abort,{once:true});
const timer=setTimeout(()=>controller.abort(),limit);
try{
const response=await fetch(new Request(target,original.clone()),{signal:controller.signal});
if(expectJson&&response.ok&&response.status!==204){
const text=await response.clone().text();
try{if(!text.trim()||!response.headers.get('content-type')?.includes('json'))throw new Error();JSON.parse(text)}
catch(_){throw new Error('Сервис вернул пустой или некорректный ответ. Повторите загрузку.')}
}
return response;
}finally{clearTimeout(timer);original.signal.removeEventListener('abort',abort)}
}
try{
const response=await attempt(isBackend?proxy+url.pathname+url.search:original.url,timeout);
if(!safeFallback||response.status<500)return response;
}catch(error){if(!safeFallback||original.signal.aborted)throw error}
// Same backend, same authorization, one SDK session. Never replay writes.
return attempt(original.url,fallbackTimeout);
};
}
window.CateriumCloudTransport=Object.freeze({create});
})();

View File

@ -1,7 +1,7 @@
(()=>{ (()=>{
'use strict'; 'use strict';
const VERSION='17.7.3'; const VERSION='17.7.3';
const RELEASE='20260917-calendar-compact'; const RELEASE='20260918-login-recovery';
const hasStoredSession=()=>{try{return Object.keys(localStorage).some(k=>/^sb-.*-auth-token$/i.test(k)&&String(localStorage.getItem(k)||'').length>20)}catch(_){return false}}; const hasStoredSession=()=>{try{return Object.keys(localStorage).some(k=>/^sb-.*-auth-token$/i.test(k)&&String(localStorage.getItem(k)||'').length>20)}catch(_){return false}};
function installAuthBoot(){ function installAuthBoot(){

File diff suppressed because one or more lines are too long

View File

@ -1,7 +1,7 @@
const CACHE='sun-catering-pwa-v89-20260917-calendar-compact'; const CACHE='sun-catering-pwa-v90-20260918-login-recovery';
const VERSION='20260917-calendar-compact'; const VERSION='20260918-login-recovery';
const CORE=[ const CORE=[
'./','./index.html',`./core/banquet-menu.js?v=${VERSION}`,`./core/access-policy.js?v=${VERSION}`,`./core/import-archive.js?v=${VERSION}`,`./core/company-branding.js?v=${VERSION}`,`./core/signature-offer-pdf-v18.js?v=${VERSION}`,`./core/brand-theme.js?v=${VERSION}`, './','./index.html',`./core/cloud-transport.js?v=${VERSION}`,`./core/banquet-menu.js?v=${VERSION}`,`./core/access-policy.js?v=${VERSION}`,`./core/import-archive.js?v=${VERSION}`,`./core/company-branding.js?v=${VERSION}`,`./core/signature-offer-pdf-v18.js?v=${VERSION}`,`./core/brand-theme.js?v=${VERSION}`,
`./core/sun-safe.js?v=${VERSION}`,`./core/performance.js?v=${VERSION}`,`./core/account-center-v1780.js?v=${VERSION}`,`./core/login-signature-v1776.js?v=${VERSION}`,`./core/data-layer-v1773.js?v=${VERSION}`,`./core/server-automation-v1770.js?v=${VERSION}`,`./core/hotfix-v1763.js?v=${VERSION}`,`./core/ops-ux-v1762.js?v=${VERSION}`,`./core/ux-fixes-v1764.js?v=${VERSION}`,`./core/pdf-engine.js?v=${VERSION}`,`./core/classic-offer-pdf-v1767.js?v=${VERSION}`,`./core/developer-console-v1768.js?v=${VERSION}`,`./core/offer-workspace-v1769.js?v=${VERSION}`,`./core/auth-security-v1774.js?v=${VERSION}`,`./core/order-enhancements-v1775.js?v=${VERSION}`,`./legacy/bootstrap.js?v=${VERSION}`,`./app-runtime.js?v=${VERSION}`, `./core/sun-safe.js?v=${VERSION}`,`./core/performance.js?v=${VERSION}`,`./core/account-center-v1780.js?v=${VERSION}`,`./core/login-signature-v1776.js?v=${VERSION}`,`./core/data-layer-v1773.js?v=${VERSION}`,`./core/server-automation-v1770.js?v=${VERSION}`,`./core/hotfix-v1763.js?v=${VERSION}`,`./core/ops-ux-v1762.js?v=${VERSION}`,`./core/ux-fixes-v1764.js?v=${VERSION}`,`./core/pdf-engine.js?v=${VERSION}`,`./core/classic-offer-pdf-v1767.js?v=${VERSION}`,`./core/developer-console-v1768.js?v=${VERSION}`,`./core/offer-workspace-v1769.js?v=${VERSION}`,`./core/auth-security-v1774.js?v=${VERSION}`,`./core/order-enhancements-v1775.js?v=${VERSION}`,`./legacy/bootstrap.js?v=${VERSION}`,`./app-runtime.js?v=${VERSION}`,
'./offer-gallery/001.jpg','./offer-gallery/002.jpg', './offer-gallery/001.jpg','./offer-gallery/002.jpg',
'./catalog/001.jpg','./catalog/002.jpg','./catalog/003.jpg', './catalog/001.jpg','./catalog/002.jpg','./catalog/003.jpg',
@ -9,6 +9,7 @@ const CORE=[
'./offer-templates/thumb-light.jpg','./offer-templates/thumb-editorial-grid.jpg','./offer-templates/thumb-midnight-glass.jpg','./offer-templates/thumb-emerald-gold.jpg' './offer-templates/thumb-light.jpg','./offer-templates/thumb-editorial-grid.jpg','./offer-templates/thumb-midnight-glass.jpg','./offer-templates/thumb-emerald-gold.jpg'
]; ];
const CRITICAL_FRESH=new Set([ const CRITICAL_FRESH=new Set([
'/core/cloud-transport.js',
'/core/banquet-menu.js','/core/access-policy.js','/core/import-archive.js','/core/company-branding.js','/core/brand-theme.js','/core/sun-safe.js','/core/performance.js','/core/account-center-v1780.js','/core/login-signature-v1776.js','/core/auth-security-v1774.js','/legacy/bootstrap.js','/app-runtime.js' '/core/banquet-menu.js','/core/access-policy.js','/core/import-archive.js','/core/company-branding.js','/core/brand-theme.js','/core/sun-safe.js','/core/performance.js','/core/account-center-v1780.js','/core/login-signature-v1776.js','/core/auth-security-v1774.js','/legacy/bootstrap.js','/app-runtime.js'
]); ]);
self.addEventListener('install',event=>{ self.addEventListener('install',event=>{

View File

@ -18,6 +18,7 @@ test('workspace changes isolate client caches and ignore delayed responses from
await page.route('**/index.html',r=>r.fulfill({contentType:'text/html',body:'<!doctype html><html><body></body></html>'})); await page.route('**/index.html',r=>r.fulfill({contentType:'text/html',body:'<!doctype html><html><body></body></html>'}));
await page.goto('/index.html'); await page.goto('/index.html');
await page.addScriptTag({url:'/core/sun-safe.js'}); await page.addScriptTag({url:'/core/sun-safe.js'});
await page.addScriptTag({url:'/core/cloud-transport.js'});
const runtime=fs.readFileSync('public/app-runtime.js','utf8'); const runtime=fs.readFileSync('public/app-runtime.js','utf8');
let cloud=runtime.slice(runtime.indexOf('/* ===== MODULE: cloud-sync-v2.js'),runtime.indexOf('/* ===== MODULE: admin-rbac-v3.js')); let cloud=runtime.slice(runtime.indexOf('/* ===== MODULE: cloud-sync-v2.js'),runtime.indexOf('/* ===== MODULE: admin-rbac-v3.js'));
cloud=cloud.replace('async function boot(){','async function boot(){return;'); cloud=cloud.replace('async function boot(){','async function boot(){return;');

View File

@ -0,0 +1,82 @@
import fs from 'node:fs';
import {test,expect} from '@playwright/test';
test('cloud reads and password login recover from empty proxy responses without replaying writes',async({page})=>{
await page.route('**/index.html',r=>r.fulfill({contentType:'text/html',body:'<!doctype html><html><body></body></html>'}));
await page.goto('/index.html');await page.addScriptTag({url:'/core/cloud-transport.js'});
const result=await page.evaluate(async()=>{
const calls=[],upstream='https://backend.example.invalid',proxy='https://proxy.example.invalid';let scenario='read';
window.fetch=async request=>{calls.push({url:request.url,body:await request.text(),auth:request.headers.get('authorization')});if(scenario==='denied')return new Response('{"error":"denied"}',{status:401});if(scenario==='write')return new Response('',{status:503});return request.url.startsWith(proxy)?new Response('',{headers:{'content-type':'text/html'}}):new Response('[{"id":"company"}]',{headers:{'content-type':'application/json'}})};
const send=window.CateriumCloudTransport.create({upstream,proxy});
const read=await (await send(upstream+'/rest/v1/rpc/sun_my_workspaces',{method:'POST',headers:{Authorization:'Bearer test-token'},body:'{}'})).json();
const readCalls=calls.splice(0);
await send(upstream+'/auth/v1/token?grant_type=password',{method:'POST',body:'{"email":"test@example.invalid","password":"test"}'});const authCalls=calls.splice(0);
scenario='write';const write=await send(upstream+'/rest/v1/rpc/sun_save_app_state_v17',{method:'POST',body:'{}'});const writeCalls=calls.splice(0);
scenario='empty-write';let writeError='';try{await send(upstream+'/rest/v1/rpc/sun_save_app_state_v17',{method:'POST',body:'{}'})}catch(e){writeError=e.message}const emptyWriteCalls=calls.splice(0);
scenario='denied';const denied=await send(upstream+'/auth/v1/token?grant_type=password',{method:'POST',body:'{}'});const deniedCalls=calls.splice(0);
return {read,readCalls,authCalls,write:write.status,writeCalls,writeError,emptyWriteCalls,denied:denied.status,deniedCalls};
});
expect(result.read).toEqual([{id:'company'}]);expect(result.readCalls.map(c=>c.url)).toEqual(['https://proxy.example.invalid/rest/v1/rpc/sun_my_workspaces','https://backend.example.invalid/rest/v1/rpc/sun_my_workspaces']);
expect(result.readCalls.every(c=>c.auth==='Bearer test-token'&&c.body==='{}')).toBe(true);
expect(result.authCalls).toHaveLength(2);expect(result.authCalls[0].body).toBe(result.authCalls[1].body);
expect(result.write).toBe(503);expect(result.writeCalls).toHaveLength(1);expect(result.writeError).toContain('пустой');expect(result.emptyWriteCalls).toHaveLength(1);
expect(result.denied).toBe(401);expect(result.deniedCalls).toHaveLength(1);
});
test('membership loading is shared and an invalid response remains an error until retry succeeds',async({page})=>{
await page.route('**/index.html',r=>r.fulfill({contentType:'text/html',body:'<!doctype html><html><body></body></html>'}));await page.goto('/index.html');
await page.addScriptTag({url:'/core/sun-safe.js'});await page.addScriptTag({url:'/core/cloud-transport.js'});
const runtime=fs.readFileSync('public/app-runtime.js','utf8');let cloud=runtime.slice(runtime.indexOf('/* ===== MODULE: cloud-sync-v2.js'),runtime.indexOf('/* ===== MODULE: admin-rbac-v3.js'));
cloud=cloud.replace('async function boot(){','async function boot(){return;').replace('window.SunCloudV2={',`window.SunCloudV2={testLoad:loadMemberships,testInit:c=>{client=c;session={user:{id:'test-user'}};config.tenantStorageReady=true;config.localWorkspaceId='company';config.workspaceId='company'},`);
await page.addScriptTag({content:'var orders=[];var boxes=[];'+cloud});
const result=await page.evaluate(async()=>{
let calls=0,resolve;const c=window.SunCloudV2;c.testInit({rpc:()=>{calls++;return new Promise(r=>resolve=r)}});
const a=c.testLoad(),b=c.testLoad();resolve({data:'',error:null});await Promise.all([a,b]);const failed=c.status();
const retry=c.testLoad();resolve({data:[{id:'company',name:'Test',role:'admin',is_active:true}],error:null});await retry;
return {calls,failed,ready:c.status()};
});
expect(result.calls).toBe(2);expect(result.failed.membershipsLoading).toBe(false);expect(result.failed.membershipError).toContain('список компаний');expect(result.failed.workspace).toBeNull();
expect(result.ready.membershipError).toBe('');expect(result.ready.workspace.id).toBe('company');expect(result.ready.membershipsLoading).toBe(false);
});
test('a failed company load replaces the stale login form with an actionable retry screen',async({page})=>{
await page.route('**://api.caterium.ru/**',r=>r.abort());await page.route('**://*.supabase.co/**',r=>r.abort());
await page.goto('/index.html',{waitUntil:'domcontentloaded'});await expect(page.locator('#sunGateEmailV3')).toBeVisible();
await page.evaluate(()=>{
window.loginState={connected:true,signedIn:true,membershipsLoading:true,membershipsLoaded:false};window.retryCount=0;
window.SunCloudV2={getSession:()=>({user:{id:'test',email:'test@example.invalid'}}),getWorkspace:()=>null,getClient:()=>null,status:()=>window.loginState,hasPermission:()=>false,reloadMemberships:async()=>{window.retryCount++;return null}};
window.dispatchEvent(new Event('sun:cloud-permissions-changed'));
});
await expect(page.locator('#sunGateEmailV3')).toHaveCount(0);
await page.evaluate(()=>{window.loginState={...window.loginState,membershipsLoading:false,membershipsLoaded:true,membershipError:'Ответ сервера не получен'};window.dispatchEvent(new Event('sun:cloud-permissions-changed'))});
await expect(page.getByRole('heading',{name:'Не удалось загрузить рабочую базу'})).toBeVisible();await expect(page.locator('#sunGateErrorV3')).toHaveText('Ответ сервера не получен');
await page.getByRole('button',{name:'Повторить загрузку',exact:true}).click();expect(await page.evaluate(()=>window.retryCount)).toBe(1);
await expect(page.getByRole('button',{name:'Повторить загрузку',exact:true})).toBeEnabled();await expect(page.locator('body > header')).toBeHidden();
});
test('real SDK login opens the ordinary app when the proxy returns empty successful responses',async({page})=>{
const userId='11111111-1111-4111-8111-111111111111',workspaceId='22222222-2222-4222-8222-222222222222',expires=Math.floor(Date.now()/1000)+3600;
const user={id:userId,aud:'authenticated',role:'authenticated',email:'test@example.invalid',email_confirmed_at:new Date().toISOString(),app_metadata:{provider:'email'},user_metadata:{}};
const token=[{alg:'HS256',typ:'JWT'},{sub:userId,role:'authenticated',aud:'authenticated',exp:expires,iat:expires-3600,aal:'aal1'},'test'].map(x=>typeof x==='string'?x:Buffer.from(JSON.stringify(x)).toString('base64url')).join('.');
const seen=[],warnings=[];page.on('console',m=>{if(m.type()==='warning')warnings.push(m.text())});
const handle=async route=>{
const request=route.request(),url=new URL(request.url());seen.push(url.host+url.pathname);
const headers={'access-control-allow-origin':'*'};
if(request.method()==='OPTIONS')return route.fulfill({status:204,headers});
if(url.host==='api.caterium.ru')return route.fulfill({status:200,contentType:'text/html',body:'',headers});
let body=null;
if(url.pathname==='/auth/v1/token')body={access_token:token,refresh_token:'test-refresh',token_type:'bearer',expires_in:3600,expires_at:expires,user};
else if(url.pathname==='/auth/v1/user')body=user;
else if(url.pathname.endsWith('/sun_my_workspaces'))body=[{id:workspaceId,name:'Test workspace',role:'admin',is_active:true,permissions:{}}];
else if(url.pathname.endsWith('/sun_is_platform_admin'))body=true;
return route.fulfill({status:200,contentType:'application/json',body:JSON.stringify(body),headers});
};
await page.route('**://api.caterium.ru/**',handle);await page.route('**://*.supabase.co/**',handle);
await page.goto('/index.html',{waitUntil:'domcontentloaded'});await page.waitForFunction(()=>window.CateriumAuthSecurityV1774&&window.SunCloudV2?.getClient());
await page.locator('#sunGateEmailV3').fill(user.email);await page.locator('#sunGatePasswordV3').fill('test-password');await page.locator('#sunGateSubmitV3').click();
await expect(page.locator('#sunCloudAuthGateV3')).toHaveCount(0,{timeout:20000});await expect(page.locator('body > header')).toBeVisible();
expect(await page.evaluate(()=>window.SunCloudV2.getWorkspace()?.id)).toBe(workspaceId);
expect(seen.some(s=>s==='api.caterium.ru/rest/v1/rpc/sun_my_workspaces')).toBe(true);
expect(seen.some(s=>s.endsWith('.supabase.co/rest/v1/rpc/sun_my_workspaces'))).toBe(true);
expect(warnings.some(s=>s.includes('Multiple GoTrueClient'))).toBe(false);
});

View File

@ -2,7 +2,7 @@ import { defineConfig, devices } from '@playwright/test';
import {fileURLToPath} from 'node:url'; import {fileURLToPath} from 'node:url';
export default defineConfig({ export default defineConfig({
testDir:'.', testDir:'.',
testMatch:['app.spec.mjs','theme-startup.spec.mjs','company-branding.spec.mjs','order-import.spec.mjs','account-access.spec.mjs','banquet-menu.spec.mjs','calendar-print.spec.mjs'], testMatch:['app.spec.mjs','theme-startup.spec.mjs','company-branding.spec.mjs','order-import.spec.mjs','account-access.spec.mjs','banquet-menu.spec.mjs','calendar-print.spec.mjs','login-recovery.spec.mjs'],
timeout:30000, timeout:30000,
use:{baseURL:'http://127.0.0.1:4173'}, use:{baseURL:'http://127.0.0.1:4173'},
webServer:{command:'npx http-server public -p 4173 -c-1',cwd:fileURLToPath(new URL('../',import.meta.url)),port:4173,reuseExistingServer:true}, webServer:{command:'npx http-server public -p 4173 -c-1',cwd:fileURLToPath(new URL('../',import.meta.url)),port:4173,reuseExistingServer:true},

View File

@ -14,8 +14,8 @@ check(!index.includes('offer-gallery-data.js'),'blocking Base64 gallery absent')
check((runtime.match(/\/Type \/Catalog/g)||[]).length===0,'runtime contains no PDF binary writer'); check((runtime.match(/\/Type \/Catalog/g)||[]).length===0,'runtime contains no PDF binary writer');
check(read('core/pdf-engine.js').includes('595.28')&&read('core/pdf-engine.js').includes('841.89'),'PDF engine uses A4 MediaBox'); check(read('core/pdf-engine.js').includes('595.28')&&read('core/pdf-engine.js').includes('841.89'),'PDF engine uses A4 MediaBox');
check([...index.matchAll(/@page\{([^}]*)\}/g)].every(m=>/size:A4/i.test(m[1])),'compact @page rules use A4'); check([...index.matchAll(/@page\{([^}]*)\}/g)].every(m=>/size:A4/i.test(m[1])),'compact @page rules use A4');
check(sw.includes('v89-20260917-calendar-compact')&&sw.includes('data-layer-v1773.js')&&sw.includes('server-automation-v1770.js')&&sw.includes('offer-workspace-v1769.js'),'service worker cache is v17.7.3'); check(sw.includes('v90-20260918-login-recovery')&&sw.includes('data-layer-v1773.js')&&sw.includes('server-automation-v1770.js')&&sw.includes('offer-workspace-v1769.js'),'service worker cache is v17.7.3');
check(index.includes('20260917-calendar-compact')&&index.includes('classic-offer-pdf-v1767.js')&&!index.includes('20260907-v17-6-0-stability-security'),'index cache-busting points to v17.7.3'); check(index.includes('20260918-login-recovery')&&index.includes('classic-offer-pdf-v1767.js')&&!index.includes('20260907-v17-6-0-stability-security'),'index cache-busting points to v17.7.3');
check(performance.includes('SunAttachmentGuard')&&performance.includes('TARGET=2*1024*1024'),'chat photo auto-compression is versioned'); check(performance.includes('SunAttachmentGuard')&&performance.includes('TARGET=2*1024*1024'),'chat photo auto-compression is versioned');
check(performance.includes("rpc('sun_dev_dashboard')")&&performance.includes('server_size')&&performance.includes('storage_size'),'Developer Console server/storage counters are versioned'); check(performance.includes("rpc('sun_dev_dashboard')")&&performance.includes('server_size')&&performance.includes('storage_size'),'Developer Console server/storage counters are versioned');
check(performance.includes('MEMORY_REFRESH_MS=30000')&&performance.includes('MEMORY_TIMEOUT_MS=8000')&&performance.includes('memoryPromise'),'Developer Console memory refresh is bounded'); check(performance.includes('MEMORY_REFRESH_MS=30000')&&performance.includes('MEMORY_TIMEOUT_MS=8000')&&performance.includes('memoryPromise'),'Developer Console memory refresh is bounded');
@ -39,7 +39,7 @@ check(!/sb_secret_[A-Za-z0-9_-]{20,}|service_role\s*[:=]\s*["'][A-Za-z0-9._-]{30
check(lock.version===pkg.version&&lock.packages?.['']?.version===pkg.version,'package.json and package-lock.json versions match'); check(lock.version===pkg.version&&lock.packages?.['']?.version===pkg.version,'package.json and package-lock.json versions match');
check(releaseManifest.version===`v${pkg.version}`,'release manifest version matches package.json'); check(releaseManifest.version===`v${pkg.version}`,'release manifest version matches package.json');
check(releaseManifest.channel==='production','release manifest channel is production'); check(releaseManifest.channel==='production','release manifest channel is production');
check(String(releaseManifest.pwaCache||'').includes('v89-20260917-calendar-compact'),'release manifest points to current PWA cache'); check(String(releaseManifest.pwaCache||'').includes('v90-20260918-login-recovery'),'release manifest points to current PWA cache');
check(['17.6.2','17.6.3','17.6.4','17.6.5','17.6.6','17.6.7','17.6.8','17.6.9','17.7.0','17.7.1','17.7.2','17.7.3'].every(v=>fs.existsSync(path.join(root,`docs/releases/V${v}-CHANGES.txt`))),'release notes exist through v17.7.3'); check(['17.6.2','17.6.3','17.6.4','17.6.5','17.6.6','17.6.7','17.6.8','17.6.9','17.7.0','17.7.1','17.7.2','17.7.3'].every(v=>fs.existsSync(path.join(root,`docs/releases/V${v}-CHANGES.txt`))),'release notes exist through v17.7.3');
check(runtime.includes('CLOUD_RPC_TIMEOUT_MS=12000')&&runtime.includes('CLOUD_CONFLICT_MAX_RETRIES=4')&&runtime.includes('retryCount'),'cloud sync has timeout and capped exponential conflict retries'); check(runtime.includes('CLOUD_RPC_TIMEOUT_MS=12000')&&runtime.includes('CLOUD_CONFLICT_MAX_RETRIES=4')&&runtime.includes('retryCount'),'cloud sync has timeout and capped exponential conflict retries');
check(runtime.includes("const VERSION = '17.7.3'")&&runtime.includes('ERROR_DEDUPE_MS=5*60*1000')&&runtime.includes('mirrorBusy=false')&&runtime.includes('backupBusy=false'),'stability logger uses current version, dedupe and single-flight guards'); check(runtime.includes("const VERSION = '17.7.3'")&&runtime.includes('ERROR_DEDUPE_MS=5*60*1000')&&runtime.includes('mirrorBusy=false')&&runtime.includes('backupBusy=false'),'stability logger uses current version, dedupe and single-flight guards');
@ -65,8 +65,8 @@ check(offerWorkspace.includes('PDF и предпросмотр')&&offerWorkspace
check(offerWorkspace.includes('SunClassicOfferPDFV1767')&&offerWorkspace.includes('finalGallery=galleryFor'),'custom gallery is injected into PDF renderer'); check(offerWorkspace.includes('SunClassicOfferPDFV1767')&&offerWorkspace.includes('finalGallery=galleryFor'),'custom gallery is injected into PDF renderer');
check(releaseManifest.offerWorkspaceTabs===true&&releaseManifest.offerTemplatesSeparateTab===true&&releaseManifest.offerTwoCustomGalleryPhotos===true,'release manifest records offer workspace changes'); check(releaseManifest.offerWorkspaceTabs===true&&releaseManifest.offerTemplatesSeparateTab===true&&releaseManifest.offerTwoCustomGalleryPhotos===true,'release manifest records offer workspace changes');
check(pkg.version==='17.7.3','package version is v17.7.3'); check(pkg.version==='17.7.3','package version is v17.7.3');
check(index.includes('20260917-calendar-compact'),'index cache bust is v17.7.3'); check(index.includes('20260918-login-recovery'),'index cache bust is v17.7.3');
check(sw.includes('v89-20260917-calendar-compact')&&sw.includes('data-layer-v1773.js')&&sw.includes('server-automation-v1770.js'),'PWA caches v17.7.3 client foundation modules'); check(sw.includes('v90-20260918-login-recovery')&&sw.includes('data-layer-v1773.js')&&sw.includes('server-automation-v1770.js'),'PWA caches v17.7.3 client foundation modules');
check(fs.existsSync(path.join(root,'public/core/data-layer-v1773.js'))&&fs.existsSync(path.join(root,'public/core/server-automation-v1770.js')),'data layer and server automation modules exist'); check(fs.existsSync(path.join(root,'public/core/data-layer-v1773.js'))&&fs.existsSync(path.join(root,'public/core/server-automation-v1770.js')),'data layer and server automation modules exist');
check(ux.includes('CateriumServerAutomationV1770?.enabled'),'cloud browser auto completion is disabled when server automation is active'); check(ux.includes('CateriumServerAutomationV1770?.enabled'),'cloud browser auto completion is disabled when server automation is active');
check(runtime.includes("const VERSION = '17.7.3'")&&runtime.includes("v17.7.3 Clients Server Read"),'stability logger reports v17.7.3'); check(runtime.includes("const VERSION = '17.7.3'")&&runtime.includes("v17.7.3 Clients Server Read"),'stability logger reports v17.7.3');

View File

@ -28,8 +28,8 @@ if(current!==113)fail(`current catalog photo count ${current}, expected 113`);el
if(legacyCount!==60)fail(`legacy catalog photo count ${legacyCount}, expected 60`);else ok('60 legacy catalog photos'); if(legacyCount!==60)fail(`legacy catalog photo count ${legacyCount}, expected 60`);else ok('60 legacy catalog photos');
const gallery=fs.readdirSync(path.join(pub,'offer-gallery')).filter(x=>/\.jpg$/i.test(x)); const gallery=fs.readdirSync(path.join(pub,'offer-gallery')).filter(x=>/\.jpg$/i.test(x));
if(gallery.length!==2)fail(`offer gallery contains ${gallery.length} jpg files, expected 2`);else ok('offer gallery trimmed'); if(gallery.length!==2)fail(`offer gallery contains ${gallery.length} jpg files, expected 2`);else ok('offer gallery trimmed');
if(!sw.includes('20260917-calendar-compact')||!sw.includes('login-signature-v1776.js')||!sw.includes('data-layer-v1773.js')||!sw.includes('server-automation-v1770.js')||!sw.includes('offer-workspace-v1769.js')||sw.includes('offer-gallery-data.js'))fail('service worker cache is stale');else ok('PWA cache updated for login refresh'); if(!sw.includes('20260918-login-recovery')||!sw.includes('login-signature-v1776.js')||!sw.includes('data-layer-v1773.js')||!sw.includes('server-automation-v1770.js')||!sw.includes('offer-workspace-v1769.js')||sw.includes('offer-gallery-data.js'))fail('service worker cache is stale');else ok('PWA cache updated for login refresh');
if(html.includes('20260907-v17-6-0-stability-security')||html.includes('20260909-v17-7-3-clients-server-read')||!html.includes('20260917-calendar-compact')||!html.includes('classic-offer-pdf-v1767.js'))fail('index still serves stale core asset version');else ok('index cache-busting is current'); if(html.includes('20260907-v17-6-0-stability-security')||html.includes('20260909-v17-7-3-clients-server-read')||!html.includes('20260918-login-recovery')||!html.includes('classic-offer-pdf-v1767.js'))fail('index still serves stale core asset version');else ok('index cache-busting is current');
if(!performance.includes('SunAttachmentGuard')||!performance.includes('MAX_SIDE=2048'))fail('chat photo compression guard missing');else ok('chat photo compression guard present'); if(!performance.includes('SunAttachmentGuard')||!performance.includes('MAX_SIDE=2048'))fail('chat photo compression guard missing');else ok('chat photo compression guard present');
if(!performance.includes("rpc('sun_dev_dashboard')")||!performance.includes('storage_size')||!performance.includes('server_size'))fail('Developer Console memory counters missing');else ok('Developer Console memory counters present'); if(!performance.includes("rpc('sun_dev_dashboard')")||!performance.includes('storage_size')||!performance.includes('server_size'))fail('Developer Console memory counters missing');else ok('Developer Console memory counters present');
if(performance.includes('records.forEach(r=>r.addedNodes.forEach(n=>{if(n.nodeType===1)scan(n)}));enhanceDeveloperMemory()'))fail('Developer Console memory refresh is still coupled to MutationObserver');else ok('Developer Console memory refresh loop removed'); if(performance.includes('records.forEach(r=>r.addedNodes.forEach(n=>{if(n.nodeType===1)scan(n)}));enhanceDeveloperMemory()'))fail('Developer Console memory refresh is still coupled to MutationObserver');else ok('Developer Console memory refresh loop removed');