Route cloud access through Caterium and vendor login SDK

This commit is contained in:
pavlov346346-source 2026-09-18 19:07:47 +03:00
parent ff7fe90ccf
commit 71bc56b0cd
18 changed files with 426 additions and 99 deletions

2
.gitattributes vendored Normal file
View File

@ -0,0 +1,2 @@
# Preserve the published SDK bytes, including whitespace inside template strings.
public/vendor/supabase-2.112.4.min.js -text -diff

View File

@ -16,5 +16,6 @@ jobs:
- run: npm ci - run: npm ci
- run: npm audit --audit-level=high - run: npm audit --audit-level=high
- run: npm run check:deploy - run: npm run check:deploy
- run: php -l public/api/index.php && php -l ops/timeweb/api-proxy.php
- run: npx playwright install --with-deps chromium webkit - run: npx playwright install --with-deps chromium webkit
- run: npm run test:e2e - run: npm run test:e2e

View File

@ -11,7 +11,7 @@
"serverReady": true, "serverReady": true,
"workspaceAutoDiscovery": true, "workspaceAutoDiscovery": true,
"invitesTemporarilyDisabled": false, "invitesTemporarilyDisabled": false,
"pwaCache": "v103-20260918-help-center", "pwaCache": "v104-20260918-russia-proxy",
"fullOfferDescriptions": true, "fullOfferDescriptions": true,
"dynamicOfferRows": true, "dynamicOfferRows": true,
"pdfOfferDescriptionFix": true, "pdfOfferDescriptionFix": true,

View File

@ -1,5 +1,35 @@
# Timeweb fallback auto-deploy # Timeweb fallback auto-deploy
## Backend access without external browser dependencies
The browser loads the pinned Supabase SDK from `public/vendor/`, then sends
Auth, REST, Storage and Functions traffic to the same-origin PHP entry point
`https://app.caterium.ru/api/index.php?__caterium_path=...`.
The query route is deliberate: nginx can serve static extensions before Apache,
so rewriting a storage URL ending in `.jpg` is insufficient on this hosting.
`public/api/index.php` must be identical to `ops/timeweb/api-proxy.php`.
The app copy is deployed with the release; the dedicated API host copy is
installed separately at `/home/c/ci503744/public_html/api-proxy/index.php`.
Only `app.caterium.ru` and `api.caterium.ru` are accepted, with a fixed Supabase
upstream and the existing allowlist of backend paths. Browser authorization is
forwarded unchanged; RLS remains enforced. Responses are private/no-store, and
the service worker never caches `/api/`.
Safe reads and password login may retry on `api.caterium.ru`. Direct Supabase
fallback is disabled. Writes and refresh-token exchanges are not replayed;
uncertain saves retain the existing read/revision recovery path. The Supabase
client still uses the project URL internally, preserving existing auth sessions.
Updates use HTTP: a revision-only request every 20 seconds while visible, and
chat refresh every 10 seconds. Unchanged bases are not downloaded again. Requests
from the previous account are discarded. Typing/online indicators require a
future WebSocket-capable proxy and are not advertised by the HTTP mode.
This covers application data and media. Optional map/geocoding providers remain
external and do not gate login or order loading. If the login page itself cannot
open, diagnose DNS/TLS/operator connectivity separately.
Use this only for the Year/shared-hosting fallback where system `crontab` is unavailable and scheduling is configured in the Timeweb panel. Use this only for the Year/shared-hosting fallback where system `crontab` is unavailable and scheduling is configured in the Timeweb panel.
## Production target: app.caterium.ru ## Production target: app.caterium.ru

View File

@ -1,23 +1,26 @@
<?php <?php
/** /**
* api.caterium.ru -> Supabase managed backend reverse proxy. * Caterium same-origin /api and api.caterium.ru -> Supabase HTTP proxy.
* Forwards only REST/Auth/Storage/Edge Functions HTTP traffic. * Forwards only REST/Auth/Storage/Edge Functions HTTP traffic.
* Realtime/WebSocket is intentionally NOT proxied here (see ops/timeweb/README.md). * Browser updates use authenticated HTTP polling; this is not a WebSocket proxy.
* Upstream host is a fixed constant - never derived from request input (no open-proxy risk). * Upstream host is a fixed constant - never derived from request input (no open-proxy risk).
*/ */
const UPSTREAM = 'https://usfjwhztqoopzzfmfbis.supabase.co'; const UPSTREAM = 'https://usfjwhztqoopzzfmfbis.supabase.co';
const PUBLIC_BASE = 'https://api.caterium.ru';
const SERVE_HOST = 'api.caterium.ru';
const ALLOWED_PREFIXES = ['/rest/v1/', '/auth/v1/', '/storage/v1/', '/functions/v1/']; const ALLOWED_PREFIXES = ['/rest/v1/', '/auth/v1/', '/storage/v1/', '/functions/v1/'];
// Defense in depth: this script lives inside a document root shared with // The same source is deployed to the dedicated API host and app /api/index.php.
// caterium.ru (2-site plan limit). The root .htaccess only rewrites into // Never accept another Host or derive the upstream host from browser input.
// here for Host: api.caterium.ru, but a bare direct hit on this file's own
// path (under any host) must still refuse to act as a generic proxy.
$requestHost = strtolower(explode(':', $_SERVER['HTTP_HOST'] ?? '')[0]); $requestHost = strtolower(explode(':', $_SERVER['HTTP_HOST'] ?? '')[0]);
if ($requestHost !== SERVE_HOST) { $path = parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH) ?: '/';
$query = parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_QUERY);
if ($requestHost === 'app.caterium.ru' && $path === '/api/index.php' && is_string($_GET['__caterium_path'] ?? null)) {
$path = $_GET['__caterium_path'];
$query = implode('&', array_filter(explode('&', $query ?? ''), static function ($part) {
return urldecode(explode('=', $part, 2)[0]) !== '__caterium_path';
}));
} elseif ($requestHost !== 'api.caterium.ru') {
http_response_code(404); http_response_code(404);
exit; exit;
} }
@ -34,7 +37,7 @@ const FORWARD_REQUEST_HEADERS = [
]; ];
const STRIP_RESPONSE_HEADERS = [ const STRIP_RESPONSE_HEADERS = [
'transfer-encoding', 'connection', 'content-encoding', 'content-length', 'transfer-encoding', 'connection', 'content-encoding', 'content-length', 'cache-control', 'expires', 'pragma', 'set-cookie',
]; ];
function send_cors_headers(): void function send_cors_headers(): void
@ -72,15 +75,14 @@ function request_headers(): array
} }
send_cors_headers(); send_cors_headers();
header('Cache-Control: private, no-store');
header('X-Content-Type-Options: nosniff');
if (($_SERVER['REQUEST_METHOD'] ?? '') === 'OPTIONS') { if (($_SERVER['REQUEST_METHOD'] ?? '') === 'OPTIONS') {
http_response_code(204); http_response_code(204);
exit; exit;
} }
$path = parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH) ?: '/';
$query = parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_QUERY);
$allowed = false; $allowed = false;
foreach (ALLOWED_PREFIXES as $prefix) { foreach (ALLOWED_PREFIXES as $prefix) {
if (strpos($path, $prefix) === 0) { if (strpos($path, $prefix) === 0) {
@ -89,7 +91,7 @@ foreach (ALLOWED_PREFIXES as $prefix) {
} }
} }
if (!$allowed) { if (!$allowed || strpos(rawurldecode($path), '..') !== false || preg_match('/[\\\\?#\x00-\x20]/', $path)) {
http_response_code(404); http_response_code(404);
header('Content-Type: application/json'); header('Content-Type: application/json');
echo json_encode(['error' => 'not_found', 'message' => 'Path not proxied.']); echo json_encode(['error' => 'not_found', 'message' => 'Path not proxied.']);
@ -125,6 +127,7 @@ curl_setopt_array($ch, [
CURLOPT_TIMEOUT => 30, CURLOPT_TIMEOUT => 30,
CURLOPT_SSL_VERIFYPEER => true, CURLOPT_SSL_VERIFYPEER => true,
CURLOPT_SSL_VERIFYHOST => 2, CURLOPT_SSL_VERIFYHOST => 2,
CURLOPT_NOBODY => $method === 'HEAD',
]); ]);
if ($body !== null && $body !== '') { if ($body !== null && $body !== '') {
@ -165,6 +168,9 @@ foreach (preg_split('/\r\n/', $rawHeaders) as $line) {
if (in_array($headerName, STRIP_RESPONSE_HEADERS, true) || strpos($headerName, 'access-control-') === 0) { if (in_array($headerName, STRIP_RESPONSE_HEADERS, true) || strpos($headerName, 'access-control-') === 0) {
continue; continue;
} }
// Backend HTTP endpoints do not require external redirects. Keep any
// upstream redirect on the dedicated API host, including signed downloads.
if ($headerName === 'location') $line = str_replace(UPSTREAM, 'https://api.caterium.ru', $line);
header($line, false); header($line, false);
} }
@ -173,7 +179,7 @@ foreach (preg_split('/\r\n/', $rawHeaders) as $line) {
// request (an <img src>, a download link, ...) is proxied too, not sent to // request (an <img src>, a download link, ...) is proxied too, not sent to
// *.supabase.co directly. Only touch text/JSON bodies - never binary payloads. // *.supabase.co directly. Only touch text/JSON bodies - never binary payloads.
if (stripos($responseContentType, 'application/json') !== false || stripos($responseContentType, 'text/') !== false) { if (stripos($responseContentType, 'application/json') !== false || stripos($responseContentType, 'text/') !== false) {
$respBody = str_replace(UPSTREAM, PUBLIC_BASE, $respBody); $respBody = str_replace(UPSTREAM, 'https://api.caterium.ru', $respBody);
} }
echo $respBody; echo $respBody;

185
public/api/index.php Normal file
View File

@ -0,0 +1,185 @@
<?php
/**
* Caterium same-origin /api and api.caterium.ru -> Supabase HTTP proxy.
* Forwards only REST/Auth/Storage/Edge Functions HTTP traffic.
* Browser updates use authenticated HTTP polling; this is not a WebSocket proxy.
* Upstream host is a fixed constant - never derived from request input (no open-proxy risk).
*/
const UPSTREAM = 'https://usfjwhztqoopzzfmfbis.supabase.co';
const ALLOWED_PREFIXES = ['/rest/v1/', '/auth/v1/', '/storage/v1/', '/functions/v1/'];
// The same source is deployed to the dedicated API host and app /api/index.php.
// Never accept another Host or derive the upstream host from browser input.
$requestHost = strtolower(explode(':', $_SERVER['HTTP_HOST'] ?? '')[0]);
$path = parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH) ?: '/';
$query = parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_QUERY);
if ($requestHost === 'app.caterium.ru' && $path === '/api/index.php' && is_string($_GET['__caterium_path'] ?? null)) {
$path = $_GET['__caterium_path'];
$query = implode('&', array_filter(explode('&', $query ?? ''), static function ($part) {
return urldecode(explode('=', $part, 2)[0]) !== '__caterium_path';
}));
} elseif ($requestHost !== 'api.caterium.ru') {
http_response_code(404);
exit;
}
const ALLOWED_ORIGINS = [
'https://app.caterium.ru',
'https://caterium.ru',
'https://www.caterium.ru',
];
const FORWARD_REQUEST_HEADERS = [
'authorization', 'apikey', 'content-type', 'prefer', 'range',
'x-client-info', 'x-supabase-api-version', 'accept-profile', 'content-profile', 'x-upsert', 'cache-control',
];
const STRIP_RESPONSE_HEADERS = [
'transfer-encoding', 'connection', 'content-encoding', 'content-length', 'cache-control', 'expires', 'pragma', 'set-cookie',
];
function send_cors_headers(): void
{
$origin = $_SERVER['HTTP_ORIGIN'] ?? '';
if (in_array($origin, ALLOWED_ORIGINS, true)) {
header('Access-Control-Allow-Origin: ' . $origin);
header('Vary: Origin');
}
header('Access-Control-Allow-Methods: GET, POST, PATCH, PUT, DELETE, OPTIONS');
header('Access-Control-Allow-Headers: ' . implode(', ', FORWARD_REQUEST_HEADERS));
header('Access-Control-Max-Age: 86400');
}
function request_headers(): array
{
if (function_exists('getallheaders')) {
$raw = getallheaders();
if (is_array($raw)) {
return $raw;
}
}
// Fallback for environments without getallheaders().
$out = [];
foreach ($_SERVER as $key => $value) {
if (strpos($key, 'HTTP_') === 0) {
$name = str_replace(' ', '-', ucwords(strtolower(str_replace('_', ' ', substr($key, 5)))));
$out[$name] = $value;
}
}
if (isset($_SERVER['CONTENT_TYPE'])) {
$out['Content-Type'] = $_SERVER['CONTENT_TYPE'];
}
return $out;
}
send_cors_headers();
header('Cache-Control: private, no-store');
header('X-Content-Type-Options: nosniff');
if (($_SERVER['REQUEST_METHOD'] ?? '') === 'OPTIONS') {
http_response_code(204);
exit;
}
$allowed = false;
foreach (ALLOWED_PREFIXES as $prefix) {
if (strpos($path, $prefix) === 0) {
$allowed = true;
break;
}
}
if (!$allowed || strpos(rawurldecode($path), '..') !== false || preg_match('/[\\\\?#\x00-\x20]/', $path)) {
http_response_code(404);
header('Content-Type: application/json');
echo json_encode(['error' => 'not_found', 'message' => 'Path not proxied.']);
exit;
}
$upstreamUrl = UPSTREAM . $path . ($query !== null && $query !== '' ? '?' . $query : '');
$incoming = request_headers();
$incomingLower = [];
foreach ($incoming as $name => $value) {
$incomingLower[strtolower($name)] = $value;
}
$forwardHeaders = [];
foreach (FORWARD_REQUEST_HEADERS as $name) {
if (isset($incomingLower[$name]) && $incomingLower[$name] !== '') {
$forwardHeaders[] = $name . ': ' . $incomingLower[$name];
}
}
$method = $_SERVER['REQUEST_METHOD'] ?? 'GET';
$body = ($method === 'GET' || $method === 'HEAD') ? null : file_get_contents('php://input');
$ch = curl_init($upstreamUrl);
curl_setopt_array($ch, [
CURLOPT_CUSTOMREQUEST => $method,
CURLOPT_HTTPHEADER => $forwardHeaders,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HEADER => true,
CURLOPT_FOLLOWLOCATION => false,
CURLOPT_CONNECTTIMEOUT => 10,
CURLOPT_TIMEOUT => 30,
CURLOPT_SSL_VERIFYPEER => true,
CURLOPT_SSL_VERIFYHOST => 2,
CURLOPT_NOBODY => $method === 'HEAD',
]);
if ($body !== null && $body !== '') {
curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
}
$response = curl_exec($ch);
if ($response === false) {
http_response_code(502);
header('Content-Type: application/json');
echo json_encode(['error' => 'upstream_unreachable']);
curl_close($ch);
exit;
}
$headerSize = curl_getinfo($ch, CURLINFO_HEADER_SIZE);
$statusCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$rawHeaders = substr($response, 0, $headerSize);
$respBody = substr($response, $headerSize);
curl_close($ch);
http_response_code($statusCode);
$responseContentType = '';
foreach (preg_split('/\r\n/', $rawHeaders) as $line) {
if ($line === '' || stripos($line, 'HTTP/') === 0) {
continue;
}
$colon = strpos($line, ':');
if ($colon === false) {
continue;
}
$headerName = strtolower(trim(substr($line, 0, $colon)));
if ($headerName === 'content-type') {
$responseContentType = trim(substr($line, $colon + 1));
}
if (in_array($headerName, STRIP_RESPONSE_HEADERS, true) || strpos($headerName, 'access-control-') === 0) {
continue;
}
// Backend HTTP endpoints do not require external redirects. Keep any
// upstream redirect on the dedicated API host, including signed downloads.
if ($headerName === 'location') $line = str_replace(UPSTREAM, 'https://api.caterium.ru', $line);
header($line, false);
}
// Supabase returns absolute upstream URLs inside some JSON bodies (e.g. Storage
// createSignedUrl). Rewrite those to our public host so the browser's follow-up
// request (an <img src>, a download link, ...) is proxied too, not sent to
// *.supabase.co directly. Only touch text/JSON bodies - never binary payloads.
if (stripos($responseContentType, 'application/json') !== false || stripos($responseContentType, 'text/') !== false) {
$respBody = str_replace(UPSTREAM, 'https://api.caterium.ru', $respBody);
}
echo $respBody;

View File

@ -2158,7 +2158,7 @@ window.SUN_LEGACY_CATALOG_V175=[];
const DB_NAME = 'SunCloudV2'; const DB_NAME = 'SunCloudV2';
const DB_VERSION = 1; const DB_VERSION = 1;
const BUCKET = 'sun-media'; const BUCKET = 'sun-media';
const SUPABASE_JS = 'https://cdn.jsdelivr.net/npm/@supabase/supabase-js@2.112.4'; const SUPABASE_JS = '/vendor/supabase-2.112.4.min.js';
const MEDIA_PREFIX = '__sun_media__:'; const MEDIA_PREFIX = '__sun_media__:';
const SYNC_DEBOUNCE = 1800; const SYNC_DEBOUNCE = 1800;
@ -2175,7 +2175,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 = 12000; const PROXY_FETCH_TIMEOUT_MS = 12000;
const supabaseProxyFetch=window.CateriumCloudTransport.create({upstream:DEFAULT_SUPABASE_URL,proxy:SUPABASE_API_PROXY,timeout:PROXY_FETCH_TIMEOUT_MS}); const supabaseProxyFetch=window.CateriumCloudTransport.create({upstream:DEFAULT_SUPABASE_URL,proxy:location.origin+'/api/index.php',fallbackProxy:SUPABASE_API_PROXY,timeout:PROXY_FETCH_TIMEOUT_MS});
let config = loadConfig(); let config = loadConfig();
let client = null; let client = null;
@ -2189,7 +2189,8 @@ window.SUN_LEGACY_CATALOG_V175=[];
let membershipsLoaded = false; let membershipsLoaded = false;
let membershipError = ''; let membershipError = '';
let membershipLoad = null; let membershipLoad = null;
let realtimeChannel = null; let remotePollTimer = null;
let remotePollBusy = false;
let syncTimer = null; let syncTimer = null;
let networkRetryTimer = null; let networkRetryTimer = null;
let isSyncing = false; let isSyncing = false;
@ -3353,14 +3354,27 @@ window.SUN_LEGACY_CATALOG_V175=[];
catch(error){handleError(error,'Не удалось создать приглашение.');} catch(error){handleError(error,'Не удалось создать приглашение.');}
} }
function unsubscribeRealtime(){if(realtimeChannel&&client){try{client.removeChannel(realtimeChannel);}catch(_){}}realtimeChannel=null;} function unsubscribeRealtime(){clearInterval(remotePollTimer);remotePollTimer=null;}
async function pollRemoteChanges(){
if(remotePollBusy||document.hidden||!navigator.onLine||isSyncing||supportMode||!workspaceMigrated()||!session?.user||!workspace?.id)return;
const startedUser=session.user.id,startedWorkspace=workspace.id,startedClient=client;
const current=()=>!signOutInProgress&&client===startedClient&&session?.user?.id===startedUser&&workspace?.id===startedWorkspace;
remotePollBusy=true;
try{
// Poll only the revision; download/merge the full base only after a change.
// The same RLS and authorization apply as to the previous socket channel.
const {data,error}=await client.from('sun_app_state').select('revision').eq('workspace_id',startedWorkspace).maybeSingle();
if(error)throw error;
const baseline=await getBaseline();
if(current()&&data&&Number(data.revision)!==Number(baseline?.revision))await syncNow({quiet:true});
}catch(_){/* A later poll retries; normal saves retain their visible error/retry UI. */}
finally{remotePollBusy=false;}
}
function subscribeRealtime(){ function subscribeRealtime(){
unsubscribeRealtime();if(!client||!session||!workspace?.id)return; unsubscribeRealtime();if(!client||!session||!workspace?.id)return;
realtimeChannel=client.channel(`sun-state-${workspace.id}-${clientId()}`).on('postgres_changes',{event:'INSERT',schema:'public',table:'sun_sync_events',filter:`workspace_id=eq.${workspace.id}`},async payload=>{ remotePollTimer=setInterval(pollRemoteChanges,20000);
if(payload?.new?.client_id===clientId())return;
try{const row=await fetchRemoteRow();if(row)await handleRealtimeRow(row);}catch(error){handleError(error,'Не удалось получить изменения с другого устройства.');}
}).subscribe(status=>{if(status==='CHANNEL_ERROR')setStatus('error','Realtime не подключился. Обычная синхронизация продолжит работать.');});
} }
document.addEventListener('visibilitychange',()=>{if(remotePollTimer&&!document.hidden)pollRemoteChanges();});
function handleError(error, message) { function handleError(error, message) {
if(signOutInProgress)return; if(signOutInProgress)return;
@ -5513,15 +5527,12 @@ window.SUN_LEGACY_CATALOG_V175=[];
let messages = []; let messages = [];
let activeThread = null; let activeThread = null;
let workspaceChannel = null; let workspaceChannel = null;
let threadChannel = null;
let workspaceChannelId = ''; let workspaceChannelId = '';
let threadChannelId = ''; let threadChannelId = '';
let online = new Set(); let online = new Set();
let pendingFiles = []; let pendingFiles = [];
let loadingThreads = false; let loadingThreads = false;
let loadingMessages = false; let loadingMessages = false;
let typingStopTimer = null;
let lastTypingSent = 0;
let typingUsers = new Map(); let typingUsers = new Map();
let sidebarMode = 'general'; let sidebarMode = 'general';
let chatDock = 'global'; let chatDock = 'global';
@ -5643,7 +5654,7 @@ window.SUN_LEGACY_CATALOG_V175=[];
function activeSubtitle(t){ function activeSubtitle(t){
if(!t)return ''; if(!t)return '';
if(t.kind==='direct'){const m=memberById(t.other_user_id);return online.has(String(t.other_user_id))?'<span class="sun-chat-online-text">онлайн</span>':esc(ROLE_LABELS[m?.role]||'сотрудник');} if(t.kind==='direct'){const m=memberById(t.other_user_id);return online.has(String(t.other_user_id))?'<span class="sun-chat-online-text">онлайн</span>':esc(ROLE_LABELS[m?.role]||'сотрудник');}
if(t.kind==='company'){const count=members.length,on=members.filter(m=>online.has(String(m.user_id))).length;return `${count} сотрудников · ${on} онлайн`;} if(t.kind==='company')return `${members.length} сотрудников`;
return 'Обсуждение заказа'; return 'Обсуждение заказа';
} }
@ -5676,7 +5687,7 @@ window.SUN_LEGACY_CATALOG_V175=[];
async function hydrateAttachments(){ async function hydrateAttachments(){
const c=client();if(!c)return; const c=client();if(!c)return;
const links=qa('[data-chat-attachment]',$('sunChatBodyV29')); const links=qa('[data-chat-attachment]',$('sunChatBodyV29'));
for(const a of links){const path=a.dataset.chatAttachment;if(!path)continue;try{const r=await c.storage.from('sun-chat').createSignedUrl(path,3600);if(r.error)throw r.error;const url=r.data?.signedUrl;if(!url)continue;a.href=url;const type=a.dataset.chatType||'';if(type.startsWith('image/')){const p=a.querySelector('.sun-chat-file-preview');if(p)p.innerHTML=`<img class="sun-chat-file-img" alt="Вложение" src="${esc(url)}">`;}}catch(e){a.onclick=ev=>{ev.preventDefault();toast('Не удалось открыть вложение.','error')}}} for(const a of links){const path=a.dataset.chatAttachment;if(!path)continue;try{const r=await c.storage.from('sun-chat').createSignedUrl(path,3600);if(r.error)throw r.error;const url=window.CateriumCloudTransport.mediaUrl(r.data?.signedUrl);if(!url)continue;a.href=url;const type=a.dataset.chatType||'';if(type.startsWith('image/')){const p=a.querySelector('.sun-chat-file-preview');if(p)p.innerHTML=`<img class="sun-chat-file-img" alt="Вложение" src="${esc(url)}">`;}}catch(e){a.onclick=ev=>{ev.preventDefault();toast('Не удалось открыть вложение.','error')}}}
} }
function renderTyping(){const root=$('sunChatTypingV29');if(!root)return;const names=[...typingUsers.values()].filter(x=>x&&x.user_id!==me()&&Date.now()-x.at<3500).map(x=>x.name||'Сотрудник');root.textContent=names.length?`${names.slice(0,2).join(', ')} ${names.length>1?'печатают':'печатает'}`:'';} function renderTyping(){const root=$('sunChatTypingV29');if(!root)return;const names=[...typingUsers.values()].filter(x=>x&&x.user_id!==me()&&Date.now()-x.at<3500).map(x=>x.name||'Сотрудник');root.textContent=names.length?`${names.slice(0,2).join(', ')} ${names.length>1?'печатают':'печатает'}`:'';}
@ -5685,10 +5696,14 @@ window.SUN_LEGACY_CATALOG_V175=[];
async function loadThreads({quiet=false}={}){ async function loadThreads({quiet=false}={}){
if(!available()||loadingThreads)return; if(!available()||loadingThreads)return;
const startedWorkspace=workspace().id,startedUser=me();
const current=()=>available()&&workspace().id===startedWorkspace&&me()===startedUser;
loadingThreads=true; loadingThreads=true;
try{ try{
const ws=workspace();await rpc('sun_chat_get_company_thread_v29',{p_workspace:ws.id}); const ws=workspace();await rpc('sun_chat_get_company_thread_v29',{p_workspace:ws.id});
if(!current())return;
const [t,m]=await Promise.all([rpc('sun_chat_list_threads_v29',{p_workspace:ws.id}),rpc('sun_chat_list_members_v29',{p_workspace:ws.id})]); const [t,m]=await Promise.all([rpc('sun_chat_list_threads_v29',{p_workspace:ws.id}),rpc('sun_chat_list_members_v29',{p_workspace:ws.id})]);
if(!current())return;
threads=Array.isArray(t)?t:[];members=Array.isArray(m)?m:[]; threads=Array.isArray(t)?t:[];members=Array.isArray(m)?m:[];
if(activeThread){const fresh=threadById(activeThread.thread_id);if(fresh)activeThread={...activeThread,...fresh};} if(activeThread){const fresh=threadById(activeThread.thread_id);if(fresh)activeThread={...activeThread,...fresh};}
renderSidebar();renderHead();updateUnreadBadges(); renderSidebar();renderHead();updateUnreadBadges();
@ -5697,11 +5712,16 @@ window.SUN_LEGACY_CATALOG_V175=[];
async function loadMessages({mark=true,quiet=false}={}){ async function loadMessages({mark=true,quiet=false}={}){
if(!activeThread||!available()||loadingMessages)return; if(!activeThread||!available()||loadingMessages)return;
loadingMessages=true;renderMessages(); const startedThread=activeThread.thread_id,startedWorkspace=workspace().id,startedUser=me();
const current=()=>available()&&workspace().id===startedWorkspace&&me()===startedUser&&activeThread?.thread_id===startedThread;
let changed=!quiet;
loadingMessages=true;if(!quiet)renderMessages();
try{ try{
const data=await rpc('sun_chat_list_messages_v29',{p_thread:activeThread.thread_id,p_limit:120,p_before:null});messages=Array.isArray(data)?data:[]; const data=await rpc('sun_chat_list_messages_v29',{p_thread:startedThread,p_limit:120,p_before:null});
if(!current())return;
const next=Array.isArray(data)?data:[];changed=changed||JSON.stringify(next)!==JSON.stringify(messages);messages=next;
if(mark){const t=threadById(activeThread.thread_id);if(Number(t?.unread_count||0)>0){await rpc('sun_chat_mark_read_v29',{p_thread:activeThread.thread_id});await loadThreads({quiet:true});}} if(mark){const t=threadById(activeThread.thread_id);if(Number(t?.unread_count||0)>0){await rpc('sun_chat_mark_read_v29',{p_thread:activeThread.thread_id});await loadThreads({quiet:true});}}
}catch(e){if(!quiet)toast(e.message||String(e),'error',6500)}finally{loadingMessages=false;renderMessages();} }catch(e){if(!quiet&&current())toast(e.message||String(e),'error',6500)}finally{loadingMessages=false;if(current()&&changed)renderMessages();}
} }
async function openThread(t){ async function openThread(t){
@ -5776,34 +5796,26 @@ window.SUN_LEGACY_CATALOG_V175=[];
} }
async function ensureWorkspaceChannel(){ async function ensureWorkspaceChannel(){
if(!available())return unsubscribeWorkspace();const c=client(),ws=workspace(),ss=session();if(!c||!ws||!ss)return; if(!available())return unsubscribeWorkspace();
if(workspaceChannel&&workspaceChannelId===ws.id)return; const identity=workspace().id+':'+me();
unsubscribeWorkspace();workspaceChannelId=ws.id; if(workspaceChannel&&workspaceChannelId===identity)return;
try{if(c.realtime?.setAuth)await c.realtime.setAuth(ss.access_token);}catch(_){} if(workspaceChannelId&&workspaceChannelId!==identity){activeThread=null;threads=[];members=[];messages=[];pendingFiles=[];renderMessages();renderPending();}
const ch=c.channel(`sun-chat-workspace:${ws.id}`,{config:{private:true,presence:{key:me()},broadcast:{ack:true}}});workspaceChannel=ch; unsubscribeWorkspace();workspaceChannelId=identity;
ch.on('broadcast',{event:'chat_changed'},async()=>{await loadThreads({quiet:true});if(activeThread){const fresh=threadById(activeThread.thread_id);if(fresh&&Number(fresh.unread_count||0)>0)await loadMessages({mark:true,quiet:true});else renderMessages();}}) workspaceChannel=setInterval(async()=>{
.on('presence',{event:'sync'},()=>{online=new Set(Object.keys(ch.presenceState()||{}));renderSidebar();renderHead();}) if(document.hidden||!navigator.onLine||!available()||workspaceChannelId!==identity)return;
.on('presence',{event:'join'},()=>{online=new Set(Object.keys(ch.presenceState()||{}));renderSidebar();renderHead();}) await loadThreads({quiet:true});
.on('presence',{event:'leave'},()=>{online=new Set(Object.keys(ch.presenceState()||{}));renderSidebar();renderHead();}) if(workspaceChannelId!==identity)return;
.subscribe(async(status)=>{if(status==='SUBSCRIBED'){try{await ch.track({user_id:me(),name:meName(),at:new Date().toISOString()})}catch(_){}await loadThreads({quiet:true});}}); const main=$('sunChatMainV29');
if(activeThread&&main?.getClientRects().length)await loadMessages({mark:true,quiet:true});
},10000);
} }
function unsubscribeWorkspace(){if(workspaceChannel){try{client()?.removeChannel?.(workspaceChannel)}catch(_){}workspaceChannel=null;}workspaceChannelId='';online=new Set();unsubscribeThread();} function unsubscribeWorkspace(){clearInterval(workspaceChannel);workspaceChannel=null;workspaceChannelId='';online=new Set();unsubscribeThread();}
async function subscribeThread(id){ async function subscribeThread(id){
if(!available()||!id)return;const c=client(),ss=session();if(threadChannel&&threadChannelId===id)return;unsubscribeThread();threadChannelId=id; if(!available()||!id)return;unsubscribeThread();threadChannelId=id;
try{if(c.realtime?.setAuth)await c.realtime.setAuth(ss.access_token);}catch(_){}
const ch=c.channel(`sun-chat-thread:${id}`,{config:{private:true,broadcast:{ack:false,self:false}}});threadChannel=ch;
ch.on('broadcast',{event:'message'},()=>loadMessages({mark:true,quiet:true}))
.on('broadcast',{event:'read'},()=>loadMessages({mark:false,quiet:true}))
.on('broadcast',{event:'typing'},payload=>{const p=payload?.payload||{};if(!p.user_id||String(p.user_id)===String(me()))return;if(p.typing)typingUsers.set(String(p.user_id),{user_id:p.user_id,name:p.name||'Сотрудник',at:Date.now()});else typingUsers.delete(String(p.user_id));renderTyping();setTimeout(()=>renderTyping(),3600)})
.subscribe();
}
function unsubscribeThread(){if(threadChannel){try{client()?.removeChannel?.(threadChannel)}catch(_){}threadChannel=null;}threadChannelId='';typingUsers.clear();renderTyping();}
async function sendTyping(flag){
if(!threadChannel||!activeThread)return;const now=Date.now();if(flag&&now-lastTypingSent<700){clearTimeout(typingStopTimer);typingStopTimer=setTimeout(()=>sendTyping(false),1800);return}lastTypingSent=now;
try{await threadChannel.send({type:'broadcast',event:'typing',payload:{user_id:me(),name:meName(),typing:Boolean(flag)}})}catch(_){}
clearTimeout(typingStopTimer);if(flag)typingStopTimer=setTimeout(()=>sendTyping(false),1800);
} }
function unsubscribeThread(){threadChannelId='';typingUsers.clear();renderTyping();}
function sendTyping(){/* Typing/presence require a WebSocket-capable proxy. */}
function updateOrderChatBadge(){ function updateOrderChatBadge(){
const b=$('sunOrderChatTabV31');if(!b)return;const id=currentOrderId(),t=id?orderThreadByOrder(id):null,n=Number(t?.unread_count||0);let badge=b.querySelector('.sun-chat-mini-badge');if(n>0){if(!badge){badge=document.createElement('span');badge.className='sun-chat-mini-badge';b.appendChild(badge);}badge.textContent=n>99?'99+':String(n);}else badge?.remove(); const b=$('sunOrderChatTabV31');if(!b)return;const id=currentOrderId(),t=id?orderThreadByOrder(id):null,n=Number(t?.unread_count||0);let badge=b.querySelector('.sun-chat-mini-badge');if(n>0){if(!badge){badge=document.createElement('span');badge.className='sun-chat-mini-badge';b.appendChild(badge);}badge.textContent=n>99?'99+':String(n);}else badge?.remove();
@ -5851,7 +5863,7 @@ window.SUN_LEGACY_CATALOG_V175=[];
window.addEventListener('sun:cloud-permissions-changed',()=>setTimeout(safeStateChanged,60)); window.addEventListener('sun:cloud-permissions-changed',()=>setTimeout(safeStateChanged,60));
window.addEventListener('sun:cloud-state-applied',()=>setTimeout(safeStateChanged,60)); window.addEventListener('sun:cloud-state-applied',()=>setTimeout(safeStateChanged,60));
window.addEventListener('online',()=>{if(available())ensureWorkspaceChannel()}); window.addEventListener('online',()=>{if(available())ensureWorkspaceChannel()});
window.addEventListener('beforeunload',()=>{try{workspaceChannel?.untrack?.()}catch(_){}}); window.addEventListener('beforeunload',unsubscribeWorkspace);
let tries=0;bootRetryTimer=setInterval(()=>{tries++;safeStateChanged();if(available()||tries>=24){clearInterval(bootRetryTimer);bootRetryTimer=null;}},750);setTimeout(safeStateChanged,250); let tries=0;bootRetryTimer=setInterval(()=>{tries++;safeStateChanged();if(available()||tries>=24){clearInterval(bootRetryTimer);bootRetryTimer=null;}},750);setTimeout(safeStateChanged,250);
} }

View File

@ -3,9 +3,21 @@
const READ_RPCS=new Set(['sun_my_workspaces','sun_fetch_app_state','sun_is_platform_admin','caterium_trial_demo_status']); const READ_RPCS=new Set(['sun_my_workspaces','sun_fetch_app_state','sun_is_platform_admin','caterium_trial_demo_status']);
const isTransient=error=>/TimeoutError|AbortError|CATERIUM_TIMEOUT|Failed to fetch|fetch failed|NetworkError|Load failed|network request failed|превышено время ожидания/i.test(String(error?.message||error||'')); const isTransient=error=>/TimeoutError|AbortError|CATERIUM_TIMEOUT|Failed to fetch|fetch failed|NetworkError|Load failed|network request failed|превышено время ожидания/i.test(String(error?.message||error||''));
const errorMessage=error=>isTransient(error)?'Сервер временно не отвечает. Изменения остаются на этом устройстве. Проверьте соединение и повторите загрузку.':String(error?.message||error||'Неизвестная ошибка'); const errorMessage=error=>isTransient(error)?'Сервер временно не отвечает. Изменения остаются на этом устройстве. Проверьте соединение и повторите загрузку.':String(error?.message||error||'Неизвестная ошибка');
function create({upstream,proxy,timeout=12000,fallbackTimeout=15000,writeTimeout=35000,cooldown=60000}){ function routeUrl(base,url){
// Explicit PHP entry point also works for storage paths ending in .jpg:
// Timeweb serves static extensions before Apache rewrite rules.
return base.endsWith('.php')?base+'?__caterium_path='+encodeURIComponent(url.pathname)+(url.search?'&'+url.search.slice(1):''):base+url.pathname+url.search;
}
function mediaUrl(value){
if(!value)return value;
const url=new URL(value,location.href);
if(['https://usfjwhztqoopzzfmfbis.supabase.co','https://api.caterium.ru'].includes(url.origin)&&url.pathname.startsWith('/storage/v1/'))return routeUrl(location.origin+'/api/index.php',url);
return value;
}
function create({upstream,proxy,fallbackProxy=proxy,timeout=12000,fallbackTimeout=15000,writeTimeout=35000,cooldown=60000}){
const origin=new URL(upstream).origin; const origin=new URL(upstream).origin;
let directUntil=0; proxy=proxy.replace(/\/$/,'');fallbackProxy=fallbackProxy.replace(/\/$/,'');
let fallbackUntil=0;
return async function(input,init={}){ return async function(input,init={}){
const original=new Request(input,init),url=new URL(original.url),isBackend=url.origin===origin; const original=new Request(input,init),url=new URL(original.url),isBackend=url.origin===origin;
// A Request used as RequestInit exposes its ReadableStream body. Safari // A Request used as RequestInit exposes its ReadableStream body. Safari
@ -14,7 +26,7 @@
const requestInit={method:original.method,headers:original.headers,body,credentials:original.credentials,mode:original.mode,cache:original.cache,redirect:original.redirect,referrer:original.referrer,referrerPolicy:original.referrerPolicy,integrity:original.integrity,keepalive:original.keepalive}; const requestInit={method:original.method,headers:original.headers,body,credentials:original.credentials,mode:original.mode,cache:original.cache,redirect:original.redirect,referrer:original.referrer,referrerPolicy:original.referrerPolicy,integrity:original.integrity,keepalive:original.keepalive};
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 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 passwordLogin=original.method==='POST'&&url.pathname==='/auth/v1/token'&&url.searchParams.get('grant_type')==='password';
const safeFallback=isBackend&&(read||passwordLogin); const safeFallback=isBackend&&proxy!==fallbackProxy&&(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/')))); 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){ async function attempt(target,limit){
const controller=new AbortController(),abort=()=>controller.abort(original.signal.reason); const controller=new AbortController(),abort=()=>controller.abort(original.signal.reason);
@ -31,18 +43,18 @@
return response; return response;
}finally{clearTimeout(timer);original.signal.removeEventListener('abort',abort)} }finally{clearTimeout(timer);original.signal.removeEventListener('abort',abort)}
} }
const directFirst=isBackend&&Date.now()<directUntil; const fallbackFirst=isBackend&&Date.now()<fallbackUntil;
const first=directFirst?original.url:isBackend?proxy+url.pathname+url.search:original.url; const first=isBackend?routeUrl(fallbackFirst?fallbackProxy:proxy,url):original.url;
const second=directFirst?proxy+url.pathname+url.search:original.url; const second=routeUrl(fallbackFirst?proxy:fallbackProxy,url);
try{ try{
const response=await attempt(first,safeFallback?(directFirst?fallbackTimeout:timeout):writeTimeout); const response=await attempt(first,safeFallback?(fallbackFirst?fallbackTimeout:timeout):writeTimeout);
if(!safeFallback||response.status<500)return response; if(!safeFallback||response.status<500)return response;
}catch(error){if(!safeFallback||original.signal.aborted)throw error} }catch(error){if(!safeFallback||original.signal.aborted)throw error}
// Same backend, same authorization, one SDK session. Never replay writes. // Same backend, same authorization, one SDK session. Never replay writes.
const response=await attempt(second,directFirst?timeout:fallbackTimeout); const response=await attempt(second,fallbackFirst?timeout:fallbackTimeout);
if(response.ok)directUntil=directFirst?0:Date.now()+cooldown; if(response.ok)fallbackUntil=fallbackFirst?0:Date.now()+cooldown;
return response; return response;
}; };
} }
window.CateriumCloudTransport=Object.freeze({create,isTransient,errorMessage}); window.CateriumCloudTransport=Object.freeze({create,isTransient,errorMessage,mediaUrl});
})(); })();

View File

@ -1,7 +1,7 @@
(()=>{ (()=>{
'use strict'; 'use strict';
const VERSION='17.7.3'; const VERSION='17.7.3';
const RELEASE='20260918-help-center'; const RELEASE='20260918-russia-proxy';
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,6 +1,7 @@
const CACHE='sun-catering-pwa-v103-20260918-help-center'; const CACHE='sun-catering-pwa-v104-20260918-russia-proxy';
const VERSION='20260918-help-center'; const VERSION='20260918-russia-proxy';
const CORE=[ const CORE=[
'./vendor/supabase-2.112.4.min.js',
`./core/help-center.js?v=${VERSION}`,`./core/help-center.css?v=${VERSION}`,`./help/knowledge-v1.json?v=${VERSION}`, `./core/help-center.js?v=${VERSION}`,`./core/help-center.css?v=${VERSION}`,`./help/knowledge-v1.json?v=${VERSION}`,
'./','./index.html',`./core/mobile-order.js?v=${VERSION}`,`./core/proposal-layout.js?v=${VERSION}`,'./fonts/Manrope.ttf','./fonts/PlayfairDisplay.ttf','./fonts/PlayfairDisplay-Italic.ttf',`./core/trial-demo.js?v=${VERSION}`,`./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}`, './','./index.html',`./core/mobile-order.js?v=${VERSION}`,`./core/proposal-layout.js?v=${VERSION}`,'./fonts/Manrope.ttf','./fonts/PlayfairDisplay.ttf','./fonts/PlayfairDisplay-Italic.ttf',`./core/trial-demo.js?v=${VERSION}`,`./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/login-signature-v1776.css?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/login-signature-v1776.css?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}`,

12
public/vendor/README.md vendored Normal file
View File

@ -0,0 +1,12 @@
# Supabase browser SDK
`supabase-2.112.4.min.js` is the unmodified `dist/umd/supabase.js` from
the official npm package `@supabase/supabase-js@2.112.4`.
Source: https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.112.4.tgz
SHA-256: `f8ce7fab799af1916019cbd0b485b39bb80dbdbc6dc062909a751c9e5198e04c`
MIT license: `supabase-LICENSE.txt`, from the upstream Supabase repository.
Hosting the SDK here keeps login independent of external CDNs. Keep this
version aligned with the runtime loader, PWA assets and browser tests.

BIN
public/vendor/supabase-2.112.4.min.js vendored Normal file

Binary file not shown.

21
public/vendor/supabase-LICENSE.txt vendored Normal file
View File

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2020 Supabase
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@ -1,6 +1,7 @@
import fs from 'node:fs'; import fs from 'node:fs';
import vm from 'node:vm'; import vm from 'node:vm';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import {createHash} from 'node:crypto';
const source=fs.readFileSync('public/app-runtime.js','utf8'); const source=fs.readFileSync('public/app-runtime.js','utf8');
const code=source.slice(source.indexOf(' function loadConfig() {'),source.indexOf(' function saveConfig() {')); const code=source.slice(source.indexOf(' function loadConfig() {'),source.indexOf(' function saveConfig() {'));
const url='https://usfjwhztqoopzzfmfbis.supabase.co',key='sb_publishable_CAxfhMKrduJjuk_5ybCQLg_TqSGWGoy'; const url='https://usfjwhztqoopzzfmfbis.supabase.co',key='sb_publishable_CAxfhMKrduJjuk_5ybCQLg_TqSGWGoy';
@ -8,4 +9,6 @@ const run=config=>{const storage=new Map([['sunCloudV2Config',JSON.stringify(con
for(const retired of ['https://cksuehzcimitsxmeloes.supabase.co','https://api.caterium.ru']){const {result,storage}=run({url:retired,key:'old-key',workspaceId:'old-company',autoSync:false});assert.equal(result.url,url);assert.equal(result.key,key);assert.equal(result.workspaceId,'');assert.equal(result.legacyLocalWorkspaceId,'old-company');assert.equal(result.autoSync,false);assert.equal(storage.get('sunOrders'),'[{"id":"old-order"}]');assert.ok(storage.has('cateriumRetiredBackend20260917'));} for(const retired of ['https://cksuehzcimitsxmeloes.supabase.co','https://api.caterium.ru']){const {result,storage}=run({url:retired,key:'old-key',workspaceId:'old-company',autoSync:false});assert.equal(result.url,url);assert.equal(result.key,key);assert.equal(result.workspaceId,'');assert.equal(result.legacyLocalWorkspaceId,'old-company');assert.equal(result.autoSync,false);assert.equal(storage.get('sunOrders'),'[{"id":"old-order"}]');assert.ok(storage.has('cateriumRetiredBackend20260917'));}
const {result}=run({url,key,workspaceId:'new-company',localWorkspaceId:'new-company',tenantStorageReady:true});assert.equal(result.workspaceId,'new-company');assert.equal(result.localWorkspaceId,'new-company'); const {result}=run({url,key,workspaceId:'new-company',localWorkspaceId:'new-company',tenantStorageReady:true});assert.equal(result.workspaceId,'new-company');assert.equal(result.localWorkspaceId,'new-company');
assert.equal(run({}).result.url,url); assert.equal(run({}).result.url,url);
assert.equal(fs.readFileSync('public/api/index.php','utf8').replaceAll('\r\n','\n'),fs.readFileSync('ops/timeweb/api-proxy.php','utf8').replaceAll('\r\n','\n'),'both deployed proxy entry points use the same reviewed source');
assert.equal(createHash('sha256').update(fs.readFileSync('public/vendor/supabase-2.112.4.min.js','utf8').replaceAll('\r\n','\n')).digest('hex'),'f8ce7fab799af1916019cbd0b485b39bb80dbdbc6dc062909a751c9e5198e04c','vendored SDK matches the published package');
console.log('PASS backend cutover: retired config upgraded, old local data preserved separately, fresh config stable'); console.log('PASS backend cutover: retired config upgraded, old local data preserved separately, fresh config stable');

View File

@ -1,6 +1,42 @@
import fs from 'node:fs'; import fs from 'node:fs';
import {test,expect} from '@playwright/test'; import {test,expect} from '@playwright/test';
test('same-origin proxy preserves encoded storage paths, tokens, filters and never falls back to Supabase',async({page})=>{
await page.route('**/index.html',r=>r.fulfill({contentType:'text/html',body:'<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://usfjwhztqoopzzfmfbis.supabase.co',proxy=location.origin+'/api/index.php';
const send=CateriumCloudTransport.create({upstream,proxy,fallbackProxy:'https://api.caterium.ru'});
window.fetch=async request=>{calls.push(request.url);return new Response('unavailable',{status:503})};
await send(upstream+'/rest/v1/sun_app_state?select=revision&workspace_id=eq.company');
const path='/storage/v1/object/sign/sun-chat/company/photo%20one.jpg',query='?token=a%2Bb%2Fc%3D&download=photo.jpg';
const media=CateriumCloudTransport.mediaUrl(upstream+path+query),url=new URL(media);
return {calls,media,pathname:url.pathname,path:url.searchParams.get('__caterium_path'),token:url.searchParams.get('token'),download:url.searchParams.get('download'),external:CateriumCloudTransport.mediaUrl('https://example.invalid/image.jpg')};
});
expect(result.calls).toHaveLength(2);expect(result.calls.every(u=>!u.includes('.supabase.co'))).toBe(true);
expect(new URL(result.calls[0]).searchParams.get('workspace_id')).toBe('eq.company');
expect(result.pathname).toBe('/api/index.php');expect(result.path).toBe('/storage/v1/object/sign/sun-chat/company/photo%20one.jpg');expect(result.token).toBe('a+b/c=');expect(result.download).toBe('photo.jpg');
expect(result.external).toBe('https://example.invalid/image.jpg');
});
test('revision polling reads only changed bases and ignores the previous account response',async({page})=>{
await syncHarness(page);
const result=await page.evaluate(async()=>{
let revision=1,readCount=0,resolveRevision;const calls=[];
const payload={format:'sun-cloud-v2',version:2,storage:{sunOrders:{t:'j',v:[]}}};
const query={select(fields){calls.push(fields);return this},eq(key,value){calls.push([key,value]);return this},async maybeSingle(){readCount++;return revision===99?new Promise(r=>resolveRevision=r):{data:{revision}}}};
const c={from(table){calls.push(table);return query},async rpc(name){calls.push(name);return {data:[{revision,payload}]}}};
SunCloudV2.testInit(c);await SunCloudV2.testBaseline({revision:1,payload});
await SunCloudV2.testPoll();const unchanged=calls.splice(0);
revision=2;await SunCloudV2.testPoll();const changed=calls.splice(0);
revision=99;const pending=SunCloudV2.testPoll();await SunCloudV2.testPoll();SunCloudV2.testLeave();resolveRevision({data:{revision:99}});await pending;
return {unchanged,changed,late:calls,readCount};
});
expect(result.unchanged).toEqual(['sun_app_state','revision',['workspace_id','company']]);
expect(result.changed).toContain('sun_fetch_app_state');
expect(result.late).not.toContain('sun_fetch_app_state');expect(result.readCount).toBe(3);
});
test('Safari without streaming uploads can send login and binary bodies through fallback',async({page})=>{ test('Safari without streaming uploads can send login and binary bodies through fallback',async({page})=>{
await page.route('**/index.html',r=>r.fulfill({contentType:'text/html',body:'<html><body></body></html>'})); await page.route('**/index.html',r=>r.fulfill({contentType:'text/html',body:'<html><body></body></html>'}));
await page.goto('/index.html');await page.addScriptTag({url:'/core/cloud-transport.js'}); await page.goto('/index.html');await page.addScriptTag({url:'/core/cloud-transport.js'});
@ -9,7 +45,7 @@ test('Safari without streaming uploads can send login and binary bodies through
window.Request=new Proxy(NativeRequest,{construct(Target,args){if(args[1]?.body instanceof ReadableStream)throw new TypeError('ReadableStream uploading is not supported');return new Target(...args)}}); window.Request=new Proxy(NativeRequest,{construct(Target,args){if(args[1]?.body instanceof ReadableStream)throw new TypeError('ReadableStream uploading is not supported');return new Target(...args)}});
const calls=[]; const calls=[];
window.fetch=async request=>{calls.push({url:request.url,body:[...new Uint8Array(await request.arrayBuffer())],type:request.headers.get('content-type')});return request.url.includes('proxy.example')?new Response('',{status:503}):new Response('{}',{headers:{'content-type':'application/json'}})}; window.fetch=async request=>{calls.push({url:request.url,body:[...new Uint8Array(await request.arrayBuffer())],type:request.headers.get('content-type')});return request.url.includes('proxy.example')?new Response('',{status:503}):new Response('{}',{headers:{'content-type':'application/json'}})};
const send=CateriumCloudTransport.create({upstream:'https://backend.example',proxy:'https://proxy.example',cooldown:0}); const send=CateriumCloudTransport.create({upstream:'https://backend.example',proxy:'https://proxy.example',fallbackProxy:'https://secondary.example',cooldown:0});
const body=JSON.stringify({email:'test@example.invalid',password:'test-password'}); const body=JSON.stringify({email:'test@example.invalid',password:'test-password'});
await send('https://backend.example/auth/v1/token?grant_type=password',{method:'POST',headers:{'content-type':'application/json'},body}); await send('https://backend.example/auth/v1/token?grant_type=password',{method:'POST',headers:{'content-type':'application/json'},body});
await send('https://backend.example/storage/v1/object/test/image',{method:'POST',headers:{'content-type':'image/png'},body:new Uint8Array([0,255,137,80]).buffer}); await send('https://backend.example/storage/v1/object/test/image',{method:'POST',headers:{'content-type':'image/png'},body:new Uint8Array([0,255,137,80]).buffer});
@ -69,20 +105,20 @@ test('slow connections use a healthy route, allow longer saves and preserve call
const upstream='https://backend.example.invalid',proxy='https://proxy.example.invalid',calls=[]; const upstream='https://backend.example.invalid',proxy='https://proxy.example.invalid',calls=[];
const ok=()=>new Response('{}',{headers:{'content-type':'application/json'}}); const ok=()=>new Response('{}',{headers:{'content-type':'application/json'}});
window.fetch=(request,{signal})=>{calls.push(request.url);return request.url.startsWith(proxy)?new Promise((_,reject)=>signal.addEventListener('abort',()=>reject(signal.reason),{once:true})):Promise.resolve(ok())}; window.fetch=(request,{signal})=>{calls.push(request.url);return request.url.startsWith(proxy)?new Promise((_,reject)=>signal.addEventListener('abort',()=>reject(signal.reason),{once:true})):Promise.resolve(ok())};
const send=CateriumCloudTransport.create({upstream,proxy,timeout:10,fallbackTimeout:100,writeTimeout:100}); const send=CateriumCloudTransport.create({upstream,proxy,fallbackProxy:'https://secondary.example.invalid',timeout:10,fallbackTimeout:100,writeTimeout:100});
await send(upstream+'/rest/v1/rpc/sun_my_workspaces',{method:'POST',body:'{}'}); await send(upstream+'/rest/v1/rpc/sun_my_workspaces',{method:'POST',body:'{}'});
await send(upstream+'/rest/v1/rpc/sun_fetch_app_state',{method:'POST',body:'{}'}); await send(upstream+'/rest/v1/rpc/sun_fetch_app_state',{method:'POST',body:'{}'});
const routes=calls.splice(0); const routes=calls.splice(0);
window.fetch=(request,{signal})=>{calls.push(request.url);return new Promise((resolve,reject)=>{const timer=setTimeout(()=>resolve(ok()),35);signal.addEventListener('abort',()=>{clearTimeout(timer);reject(signal.reason)},{once:true})})}; window.fetch=(request,{signal})=>{calls.push(request.url);return new Promise((resolve,reject)=>{const timer=setTimeout(()=>resolve(ok()),35);signal.addEventListener('abort',()=>{clearTimeout(timer);reject(signal.reason)},{once:true})})};
const slowSave=CateriumCloudTransport.create({upstream,proxy,timeout:10,writeTimeout:100}); const slowSave=CateriumCloudTransport.create({upstream,proxy,fallbackProxy:'https://secondary.example.invalid',timeout:10,writeTimeout:100});
const saved=(await slowSave(upstream+'/rest/v1/rpc/sun_save_app_state_v17',{method:'POST',body:'{}'})).status;const saveCalls=calls.splice(0); const saved=(await slowSave(upstream+'/rest/v1/rpc/sun_save_app_state_v17',{method:'POST',body:'{}'})).status;const saveCalls=calls.splice(0);
const timeoutSend=CateriumCloudTransport.create({upstream,proxy,writeTimeout:5});let timeoutName=''; const timeoutSend=CateriumCloudTransport.create({upstream,proxy,fallbackProxy:'https://secondary.example.invalid',writeTimeout:5});let timeoutName='';
try{await timeoutSend(upstream+'/rest/v1/rpc/sun_save_app_state_v17',{method:'POST',body:'{}'})}catch(e){timeoutName=e.name}const timeoutCalls=calls.splice(0); try{await timeoutSend(upstream+'/rest/v1/rpc/sun_save_app_state_v17',{method:'POST',body:'{}'})}catch(e){timeoutName=e.name}const timeoutCalls=calls.splice(0);
const controller=new AbortController();controller.abort(new DOMException('Account changed','AbortError'));let cancel=''; const controller=new AbortController();controller.abort(new DOMException('Account changed','AbortError'));let cancel='';
try{await send(upstream+'/rest/v1/rpc/sun_my_workspaces',{method:'POST',body:'{}',signal:controller.signal})}catch(e){cancel=e.message} try{await send(upstream+'/rest/v1/rpc/sun_my_workspaces',{method:'POST',body:'{}',signal:controller.signal})}catch(e){cancel=e.message}
return {routes,saved,saveCalls,timeoutName,timeoutCalls,cancel,cancelCalls:calls}; return {routes,saved,saveCalls,timeoutName,timeoutCalls,cancel,cancelCalls:calls};
}); });
expect(result.routes.map(u=>new URL(u).host)).toEqual(['proxy.example.invalid','backend.example.invalid','backend.example.invalid']); expect(result.routes.map(u=>new URL(u).host)).toEqual(['proxy.example.invalid','secondary.example.invalid','secondary.example.invalid']);
expect(result.saved).toBe(200);expect(result.saveCalls).toHaveLength(1);expect(result.timeoutName).toBe('TimeoutError');expect(result.timeoutCalls).toHaveLength(1); expect(result.saved).toBe(200);expect(result.saveCalls).toHaveLength(1);expect(result.timeoutName).toBe('TimeoutError');expect(result.timeoutCalls).toHaveLength(1);
expect(result.cancel).toBe('Account changed');expect(result.cancelCalls).toHaveLength(0); expect(result.cancel).toBe('Account changed');expect(result.cancelCalls).toHaveLength(0);
}); });
@ -91,7 +127,7 @@ async function syncHarness(page){
await page.route('**/index.html',r=>r.fulfill({contentType:'text/html',body:'<html><body></body></html>'}));await page.goto('/index.html'); await page.route('**/index.html',r=>r.fulfill({contentType:'text/html',body:'<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'}); 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 source=runtime.slice(runtime.indexOf('/* ===== MODULE: cloud-sync-v2.js'),runtime.indexOf('/* ===== MODULE: admin-rbac-v3.js')); const runtime=fs.readFileSync('public/app-runtime.js','utf8');let source=runtime.slice(runtime.indexOf('/* ===== MODULE: cloud-sync-v2.js'),runtime.indexOf('/* ===== MODULE: admin-rbac-v3.js'));
source=source.replace('async function boot(){','async function boot(){return;').replace('window.SunCloudV2={',`window.SunCloudV2={testUploadDataUrl:uploadDataUrl,testInit:c=>{client=c;session={user:{id:'test-user'}};workspace={id:'company',role:'admin'};config.migrated.company=true;config.tenantStorageReady=true;config.localWorkspaceId='company';config.workspaceId='company'},testBaseline:setBaseline,testLeave:()=>{session=null;workspace=null},`); source=source.replace('async function boot(){','async function boot(){return;').replace('window.SunCloudV2={',`window.SunCloudV2={testPoll:pollRemoteChanges,testUploadDataUrl:uploadDataUrl,testInit:c=>{client=c;session={user:{id:'test-user'}};workspace={id:'company',role:'admin'};config.migrated.company=true;config.tenantStorageReady=true;config.localWorkspaceId='company';config.workspaceId='company'},testBaseline:setBaseline,testLeave:()=>{session=null;workspace=null},`);
await page.addScriptTag({content:'var orders=[],boxes=[];'+source}); await page.addScriptTag({content:'var orders=[],boxes=[];'+source});
} }
@ -139,7 +175,7 @@ test('cloud reads and password login recover from empty proxy responses without
const result=await page.evaluate(async()=>{ const result=await page.evaluate(async()=>{
const calls=[],upstream='https://backend.example.invalid',proxy='https://proxy.example.invalid';let scenario='read'; 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'}})}; 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,cooldown:0}); const send=window.CateriumCloudTransport.create({upstream,proxy,fallbackProxy:'https://secondary.example.invalid',cooldown:0});
const read=await (await send(upstream+'/rest/v1/rpc/sun_my_workspaces',{method:'POST',headers:{Authorization:'Bearer test-token'},body:'{}'})).json(); 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); 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); await send(upstream+'/auth/v1/token?grant_type=password',{method:'POST',body:'{"email":"test@example.invalid","password":"test"}'});const authCalls=calls.splice(0);
@ -148,7 +184,7 @@ test('cloud reads and password login recover from empty proxy responses without
scenario='denied';const denied=await send(upstream+'/auth/v1/token?grant_type=password',{method:'POST',body:'{}'});const deniedCalls=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}; 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.read).toEqual([{id:'company'}]);expect(result.readCalls.map(c=>c.url)).toEqual(['https://proxy.example.invalid/rest/v1/rpc/sun_my_workspaces','https://secondary.example.invalid/rest/v1/rpc/sun_my_workspaces']);
expect(result.readCalls.every(c=>c.auth==='Bearer test-token'&&c.body==='{}')).toBe(true); 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.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.write).toBe(503);expect(result.writeCalls).toHaveLength(1);expect(result.writeError).toContain('пустой');expect(result.emptyWriteCalls).toHaveLength(1);
@ -186,29 +222,35 @@ test('a failed company load replaces the stale login form with an actionable ret
await expect(page.getByRole('button',{name:'Повторить загрузку',exact:true})).toBeEnabled();await expect(page.locator('body > header')).toBeHidden(); 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})=>{ for(const primaryFails of [false,true])test('real SDK loads orders with foreign services blocked, primary failure='+primaryFails,async({page})=>{
const userId='11111111-1111-4111-8111-111111111111',workspaceId='22222222-2222-4222-8222-222222222222',expires=Math.floor(Date.now()/1000)+3600; 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 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 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 seen=[],foreign=[],sockets=[],warnings=[];
page.on('console',m=>{if(m.type()==='warning')warnings.push(m.text())});page.on('websocket',ws=>sockets.push(ws.url()));
await page.route('https://**',r=>{foreign.push(r.request().url());return r.abort()});
const handle=async route=>{ const handle=async route=>{
const request=route.request(),url=new URL(request.url());seen.push(url.host+url.pathname); const request=route.request(),url=new URL(request.url()),path=url.searchParams.get('__caterium_path')||url.pathname;seen.push({host:url.host,path,method:request.method()});
const headers={'access-control-allow-origin':'*'}; const headers={'access-control-allow-origin':'*'};
if(request.method()==='OPTIONS')return route.fulfill({status:204,headers}); 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}); if(primaryFails&&url.pathname==='/api/index.php')return route.fulfill({status:200,contentType:'text/html',body:'',headers});
let body=null; 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}; if(path==='/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(path==='/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(path.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; else if(path.endsWith('/sun_is_platform_admin'))body=false;
else if(path.endsWith('/sun_fetch_app_state'))body=[{revision:1,payload:{format:'sun-cloud-v2',version:2,storage:{sunOrders:{t:'j',v:[{id:'proxy-order',event:'Заказ без VPN',contact:'Тестовый клиент',phone:'79990000000',address:'Тестовый адрес',total:2400,lines:[]}]},sunBoxes:{t:'j',v:[]}}}}];
else if(path.endsWith('/sun_app_state'))body={revision:1};
return route.fulfill({status:200,contentType:'application/json',body:JSON.stringify(body),headers}); 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.route('**://api.caterium.ru/**',handle);await page.route('**/api/index.php?**',handle);
await page.goto('/index.html',{waitUntil:'domcontentloaded'});await page.waitForFunction(()=>window.CateriumAuthSecurityV1774&&window.SunCloudV2?.getClient()); 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 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(); 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(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); await expect.poll(()=>page.evaluate(()=>JSON.parse(localStorage.getItem('sunOrders')||'[]').map(o=>o.id))).toContain('proxy-order');
expect(seen.some(s=>s.endsWith('.supabase.co/rest/v1/rpc/sun_my_workspaces'))).toBe(true); expect(seen.some(s=>s.path==='/auth/v1/token'&&s.host!=='api.caterium.ru')).toBe(true);
expect(seen.some(s=>s.host==='api.caterium.ru')).toBe(primaryFails);
expect(foreign).toEqual([]);expect(sockets).toEqual([]);
expect(warnings.some(s=>s.includes('Multiple GoTrueClient'))).toBe(false); expect(warnings.some(s=>s.includes('Multiple GoTrueClient'))).toBe(false);
}); });

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('v103-20260918-help-center')&&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('v104-20260918-russia-proxy')&&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('20260918-help-center')&&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-russia-proxy')&&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('v103-20260918-help-center'),'release manifest points to current PWA cache'); check(String(releaseManifest.pwaCache||'').includes('v104-20260918-russia-proxy'),'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=45000')&&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=45000')&&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('20260918-help-center'),'index cache bust is v17.7.3'); check(index.includes('20260918-russia-proxy'),'index cache bust is v17.7.3');
check(sw.includes('v103-20260918-help-center')&&sw.includes('data-layer-v1773.js')&&sw.includes('server-automation-v1770.js'),'PWA caches v17.7.3 client foundation modules'); check(sw.includes('v104-20260918-russia-proxy')&&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('20260918-help-center')||!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-russia-proxy')||!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('20260918-help-center')||!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-russia-proxy')||!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');