Restore Caterium schema and switch production to fresh Supabase

This commit is contained in:
pavlov346346-source 2026-09-17 17:30:57 +03:00
parent 2f7b8b9ea9
commit da8ff042f7
26 changed files with 5244 additions and 22 deletions

27
docs/RECOVERY-20260917.md Normal file
View File

@ -0,0 +1,27 @@
# Caterium recovery — 17 September 2026
Target: `caterium-fresh`, Supabase reference `usfjwhztqoopzzfmfbis`, region `eu-central-1`. The retired project was deleted; this recovery creates an empty application. No old users, company data, or passwords are imported. The `ai-staff` project is outside this recovery.
## Database
The supplied production archive and Git history did not contain the v17 normalized-state foundation or the complete promo/developer RPC layer. These were reconstructed from the current frontend contracts and the retained SQL. The v17.8 developer panel and v17.9 developer settings scripts were recovered from the local desktop source.
`supabase/migrations/20260917150000_fresh_caterium.sql` is the complete transaction applied to the empty target database. The migration rejects an already initialized Caterium schema. Its 25 inputs, in execution order, are listed in `tests/recovery/manifest.json`; `npm run build:recovery` reproduces the bundle. Further production changes must use a new migration rather than replaying this baseline.
Deployment verification found 26 public tables, RLS enabled on all 26, two Caterium cron jobs, zero workspaces and zero auth users. The migration is recorded in `supabase_migrations.schema_migrations`.
The recovered layer includes revision conflict detection, permission-filtered snapshots, normalized orders/catalog/clients, backup and automation RPCs, subscription onboarding, promo codes and developer aliases. Recovery also fixes ambiguous client upserts, enforces confirmed owner email, and avoids an administrator-only audit helper during ordinary owner onboarding. Developer operations enforce AAL2.
`npm run test:db` uses a disposable local PGlite database with mocked Supabase auth/storage/realtime/cron infrastructure. It exercises owner onboarding, save/read, optimistic conflicts, backups, employee membership, cross-company denial, viewer write denial, MFA restrictions and developer RPCs. It does not replace a real signed-in production acceptance test or verify Supabase email delivery.
## Application and hosting
The app and PHP proxy now target the new project. Only the public publishable key is present in the frontend. Existing browser configuration for the retired backend is archived locally and reset; old local workspace data remains isolated through the existing workspace-switch mechanism. A new service-worker cache and asset version deliver the change to installed clients.
GitHub `main` runs QA and promotes the tested commit to `production`. Timeweb sync deploys `public/` to `~/caterium-app/public_html`. The separately hosted PHP proxy is deployed to `~/public_html/api-proxy/index.php`; preserve a copy in `~/.caterium-deploy/manual-backups` before replacement.
Both Edge Functions are deployed to the new Supabase project: `caterium-create-employee` and `caterium-platform-auth-admin`. JWT verification remains enabled; the platform auth function additionally checks the MFA-protected developer dashboard RPC before privileged operations. Supabase Site URL is `https://app.caterium.ru`, with `https://app.caterium.ru/**` allowed for redirects. Email confirmation remains enabled.
## First use
All old accounts were deleted with the old database. A new owner account must register, confirm its email and set a new password. The first platform administrator and any onboarding promo must be provisioned for the user-confirmed owner identity; there is no universal recovery password or open administrator bootstrap. A real login, email delivery, and company onboarding must be checked once that identity is available.

View File

@ -11,7 +11,7 @@
"serverReady": true,
"workspaceAutoDiscovery": true,
"invitesTemporarilyDisabled": false,
"pwaCache": "v81-20260912-account-center-loader",
"pwaCache": "v82-20260917-caterium-fresh",
"fullOfferDescriptions": true,
"dynamicOfferRows": true,
"pdfOfferDescriptionFix": true,
@ -381,5 +381,7 @@
"clientServerSnapshotRpc": "sun_v17_clients_snapshot_v1773",
"clientLegacyFallback": true,
"clientOrderMetricsSource": "legacy orders verified locally",
"clientServerCache": "cateriumClientsServerV1773"
"clientServerCache": "cateriumClientsServerV1773",
"supabaseProjectRef": "usfjwhztqoopzzfmfbis",
"recoveryMigration": "20260917150000_fresh_caterium"
}

View File

@ -0,0 +1,52 @@
-- Explicit API grants, with RLS on every application table.
do $$ declare f record;begin
for f in select p.oid::regprocedure as signature from pg_proc p join pg_namespace n on n.oid=p.pronamespace
where n.nspname='public' and (p.proname like 'sun_%' or p.proname like 'caterium_%') loop
execute format('revoke execute on function %s from public,anon',f.signature);
end loop;
end $$;
grant execute on function public.caterium_trial_promo_preview(text,text),public.sun_invite_preview_v27(uuid) to anon,authenticated;
grant execute on function public.sun_v17_log_error(uuid,text,text,text,text,text,jsonb) to authenticated;
-- No direct full-state writes/reads: retain the subscription/permission checks.
revoke all on public.sun_app_state from anon,authenticated;
-- Enforce MFA even if a caller uses an older public platform RPC name.
do $$ declare f record;definition text;begin
for f in select p.oid from pg_proc p join pg_namespace n on n.oid=p.pronamespace
where n.nspname='public' and p.proname like 'sun_platform_%' and p.prosrc like '%if not public.sun_is_platform_admin() then raise exception ''Platform administrator required''; end if;%' loop
definition:=replace(pg_get_functiondef(f.oid),'if not public.sun_is_platform_admin() then raise exception ''Platform administrator required''; end if;','perform public.sun_require_platform_admin_aal2();');
execute definition;
end loop;
end $$;
create or replace function public.sun_v17_entity_snapshot(p_workspace uuid)
returns jsonb language plpgsql stable security definer set search_path=public as $$
declare is_admin boolean:=public.sun_is_platform_admin();
begin
if is_admin then perform public.sun_require_platform_admin_aal2();
elsif public.sun_member_role(p_workspace) is null then raise exception 'Access denied'; end if;
if not is_admin and public.sun_subscription_access_mode(p_workspace)='blocked' then raise exception 'Подписка закончилась'; end if;
return jsonb_build_object(
'orders',case when is_admin or (public.sun_has_permission(p_workspace,'orders.view') and public.sun_workspace_has_feature(p_workspace,'orders')) then coalesce((select jsonb_agg(jsonb_build_object('id',order_id,'version',version,'data',data,'updated_at',updated_at) order by order_id) from public.sun_v17_orders where workspace_id=p_workspace),'[]') else '[]'::jsonb end,
'catalog',case when is_admin or (public.sun_has_permission(p_workspace,'catalog.view') and public.sun_workspace_has_feature(p_workspace,'catalog_view')) then coalesce((select jsonb_agg(jsonb_build_object('id',item_id,'version',version,'data',data,'updated_at',updated_at) order by item_id) from public.sun_v17_catalog_items where workspace_id=p_workspace),'[]') else '[]'::jsonb end,
'meta',coalesce((select to_jsonb(m) from public.sun_v17_workspace_meta m where workspace_id=p_workspace),'{}'));
end $$;
-- Authenticated roles can invoke public API guards but cannot invoke snapshot internals.
grant execute on function public.sun_v17_entity_snapshot(uuid) to authenticated;
revoke all on function public.sun_v17_build_snapshot(uuid),public.sun_require_platform_admin_aal2() from public,anon,authenticated;
-- Keep owner-registration metadata in the same typed format as the sync client.
do $$declare definition text;begin
definition:=pg_get_functiondef('public.sun_create_workspace(text)'::regprocedure);
definition:=replace(definition,'''sunCompanyProfileV1'',v_profile::text','''sunCompanyProfileV1'',jsonb_build_object(''t'',''j'',''v'',v_profile)');
execute definition;
end $$;
-- Match client visibility to the same granular permission as state reads.
do $$declare definition text;begin
definition:=pg_get_functiondef('public.sun_v17_clients_snapshot_v1773(uuid)'::regprocedure);
definition:=replace(definition,'if not public.sun_workspace_has_feature(p_workspace,''clients'') then','if not public.sun_has_permission(p_workspace,''clients.view'') or public.sun_subscription_access_mode(p_workspace)=''blocked'' or not public.sun_workspace_has_feature(p_workspace,''clients'') then');
execute definition;
end $$;

View File

@ -0,0 +1,112 @@
-- Missing production RPC contracts reconstructed for the empty Caterium project.
create table public.caterium_trial_promos (
id uuid primary key default gen_random_uuid(),code text not null unique,
client_email text,trial_days integer not null default 14 check(trial_days between 1 and 365),
plan_id text not null default 'full' references public.sun_plans(id),
max_uses integer not null default 1 check(max_uses between 1 and 10000),use_count integer not null default 0,
is_active boolean not null default true,valid_until timestamptz,note text,
created_by uuid references auth.users(id) on delete set null,created_at timestamptz not null default now(),updated_at timestamptz not null default now()
);
create table public.caterium_trial_redemptions (
id uuid primary key default gen_random_uuid(),promo_id uuid not null references public.caterium_trial_promos(id),
workspace_id uuid not null unique references public.sun_workspaces(id) on delete cascade,
user_id uuid not null unique references auth.users(id) on delete cascade,email text,trial_ends_at timestamptz,
created_at timestamptz not null default now()
);
alter table public.caterium_trial_promos enable row level security;
alter table public.caterium_trial_redemptions enable row level security;
revoke all on public.caterium_trial_promos,public.caterium_trial_redemptions from anon,authenticated;
create function public.caterium_normalize_trial_code(p_code text) returns text language sql immutable set search_path=public as $$select upper(regexp_replace(trim(coalesce(p_code,'')),'[[:space:]]','','g'))$$;
create function public.caterium_trial_promo_preview(p_code text,p_email text default null)
returns jsonb language plpgsql stable security definer set search_path=public as $$
declare p public.caterium_trial_promos%rowtype;
begin
select * into p from public.caterium_trial_promos where code=public.caterium_normalize_trial_code(p_code);
if not found or not p.is_active or (p.valid_until is not null and p.valid_until<=now()) or p.use_count>=p.max_uses
or (p.client_email is not null and p.client_email<>lower(trim(coalesce(p_email,'')))) then
return jsonb_build_object('valid',false,'reason','Промокод недействителен для этого email или срок его действия истёк');
end if;
return jsonb_build_object('valid',true,'trial_days',p.trial_days,'plan',p.plan_id);
end $$;
create function public.sun_dev_create_trial_promo(p_code text default null,p_email text default null,p_trial_days integer default 14,p_valid_days integer default 7,p_max_uses integer default 1,p_plan text default 'full',p_note text default null)
returns jsonb language plpgsql security definer set search_path=public as $$
declare p public.caterium_trial_promos%rowtype; c text;
begin
perform public.sun_require_platform_admin_aal2();
c:=coalesce(nullif(public.caterium_normalize_trial_code(p_code),''),'CTM-'||upper(replace(gen_random_uuid()::text,'-',''))::varchar(16));
if c !~ '^[A-Z0-9-]{3,32}$' then raise exception 'Некорректный промокод'; end if;
if p_valid_days not between 1 and 365 then raise exception 'Некорректный срок'; end if;
insert into public.caterium_trial_promos(code,client_email,trial_days,valid_until,max_uses,plan_id,note,created_by)
values(c,nullif(lower(trim(p_email)),''),p_trial_days,now()+make_interval(days=>p_valid_days),p_max_uses,p_plan,p_note,auth.uid()) returning * into p;
perform public.sun_platform_log_event('trial_promo.create',null,null,jsonb_build_object('promo_id',p.id));
return jsonb_build_object('promo_id',p.id,'code',p.code,'trial_days',p.trial_days,'valid_until',p.valid_until);
end $$;
create function public.sun_dev_list_trial_promos(p_limit integer default 300)
returns table(promo_id uuid,code text,client_email text,trial_days integer,valid_until timestamptz,max_uses integer,use_count integer,is_active boolean,last_redeemed_at timestamptz,last_workspace_name text,last_redeemed_email text)
language plpgsql stable security definer set search_path=public as $$
begin
perform public.sun_require_platform_admin_aal2();
return query select p.id,p.code,p.client_email,p.trial_days,p.valid_until,p.max_uses,p.use_count,p.is_active,r.created_at,w.name,r.email
from public.caterium_trial_promos p left join lateral (select x.* from public.caterium_trial_redemptions x where x.promo_id=p.id order by x.created_at desc limit 1) r on true
left join public.sun_workspaces w on w.id=r.workspace_id order by p.created_at desc limit greatest(1,least(coalesce(p_limit,300),1000));
end $$;
create function public.sun_dev_set_trial_promo_active(p_promo uuid,p_active boolean)
returns void language plpgsql security definer set search_path=public as $$
begin
perform public.sun_require_platform_admin_aal2();
update public.caterium_trial_promos set is_active=p_active,updated_at=now() where id=p_promo;
if not found then raise exception 'Промокод не найден'; end if;
perform public.sun_platform_log_event('trial_promo.set_active',null,null,jsonb_build_object('promo_id',p_promo,'active',p_active));
end $$;
create function public.caterium_platform_create_company(p_name text,p_owner_email text default null,p_plan text default 'full',p_days integer default 30,p_mode text default 'empty')
returns jsonb language plpgsql security definer set search_path=public,auth as $$
declare v_ws uuid; v_owner uuid; v_email text:=nullif(lower(trim(p_owner_email)),''); v_token uuid;
begin
perform public.sun_require_platform_admin_aal2();
if p_days not between 1 and 3650 then raise exception 'Invalid subscription duration'; end if;
select id into v_owner from auth.users where lower(email)=v_email and email_confirmed_at is not null;
insert into public.sun_workspaces(name,created_by) values(coalesce(nullif(trim(p_name),''),'Новая компания'),coalesce(v_owner,auth.uid())) returning id into v_ws;
if v_owner is not null then
insert into public.sun_workspace_members(workspace_id,user_id,role,is_active,permissions,display_name)
values(v_ws,v_owner,'admin',true,public.sun_role_default_permissions('admin'),split_part(v_email,'@',1));
elsif v_email is not null then
insert into public.caterium_company_owner_invites(workspace_id,email) values(v_ws,v_email) returning token into v_token;
end if;
insert into public.sun_app_state(workspace_id,client_id) values(v_ws,'platform-bootstrap');
insert into public.sun_workspace_subscriptions(workspace_id,plan_id,status,current_period_start,current_period_end,grace_until,source)
values(v_ws,p_plan,'active',now(),now()+make_interval(days=>p_days),now()+make_interval(days=>p_days+7),'platform');
return jsonb_build_object('workspace_id',v_ws,'owner_user_id',v_owner,'owner_email',v_email,'owner_invite_token',v_token);
end $$;
-- Recover the sun_dev_* names used by the client, preserving the retained
-- server implementation and requiring AAL2 at every platform boundary.
do $recovery$
declare entry record; f record; call_args text; command text;
begin
for entry in select * from (values
('sun_dev_dashboard','sun_platform_dashboard'),('sun_dev_list_activity','sun_platform_list_activity'),
('sun_dev_list_companies','sun_platform_list_companies_v22'),('sun_dev_list_users','sun_platform_list_users_v22'),
('sun_dev_list_errors','sun_platform_list_errors_v22'),('sun_dev_support_snapshot','sun_platform_support_snapshot'),
('sun_dev_workspace_diagnostics','sun_platform_workspace_diagnostics'),('sun_dev_list_workspace_features','sun_platform_list_workspace_features'),
('sun_dev_log_event','sun_platform_log_event'),('sun_dev_set_plan_feature','sun_platform_set_plan_feature'),
('sun_dev_set_plan_max_members','sun_platform_set_plan_max_members'),('sun_dev_seed_workspace_catalog','sun_platform_seed_workspace_catalog'),
('sun_dev_reset_feature_override','sun_platform_reset_feature_override'),('sun_dev_create_company','sun_platform_create_company_v22'),
('sun_dev_set_subscription','sun_platform_set_subscription'),('sun_dev_set_feature_override','sun_platform_set_feature_override')
) names(alias_name,source_name) loop
select p.*,pg_get_function_arguments(p.oid) as args,pg_get_function_result(p.oid) as result into strict f
from pg_proc p join pg_namespace n on n.oid=p.pronamespace where n.nspname='public' and p.proname=entry.source_name;
select coalesce(string_agg('$'||i,',' order by i),'') into call_args from generate_series(1,f.pronargs) i;
command:=case when f.proretset then 'return query select * from' when f.prorettype='void'::regtype then 'perform' else 'return' end;
execute format('create function public.%I(%s) returns %s language plpgsql security definer set search_path=public,auth as $body$ begin perform public.sun_require_platform_admin_aal2(); %s public.%I(%s); end $body$',entry.alias_name,f.args,f.result,command,entry.source_name,call_args);
end loop;
end $recovery$;
revoke all on function public.caterium_normalize_trial_code(text),public.caterium_trial_promo_preview(text,text),public.caterium_platform_create_company(text,text,text,integer,text) from public,anon,authenticated;
grant execute on function public.caterium_trial_promo_preview(text,text) to anon,authenticated;
do $$declare f record;begin
for f in select p.oid::regprocedure as signature from pg_proc p join pg_namespace n on n.oid=p.pronamespace where n.nspname='public' and p.proname like 'sun_dev_%' loop
execute format('revoke all on function %s from public,anon',f.signature);
execute format('grant execute on function %s to authenticated',f.signature);
end loop;
end $$;

View File

@ -0,0 +1,136 @@
-- Reconstructed from the production client and retained SQL, 2026-09-17.
-- Empty-project recovery only; original v17 foundation was not in Git history.
create table public.sun_v17_orders (
workspace_id uuid not null references public.sun_workspaces(id) on delete cascade,
order_id text not null, data jsonb not null, version bigint not null default 1,
created_at timestamptz not null default now(), updated_at timestamptz not null default now(),
updated_by uuid references auth.users(id) on delete set null, primary key(workspace_id,order_id)
);
create table public.sun_v17_catalog_items (
workspace_id uuid not null references public.sun_workspaces(id) on delete cascade,
item_id text not null, data jsonb not null, version bigint not null default 1,
created_at timestamptz not null default now(), updated_at timestamptz not null default now(),
updated_by uuid references auth.users(id) on delete set null, primary key(workspace_id,item_id)
);
create table public.sun_v17_clients (
workspace_id uuid not null references public.sun_workspaces(id) on delete cascade,
client_key text not null,name text,phone text,latest_address text,data jsonb not null default '{}',
version bigint not null default 1,created_at timestamptz not null default now(),updated_at timestamptz not null default now(),
primary key(workspace_id,client_key)
);
create table public.sun_v17_settings (
workspace_id uuid not null references public.sun_workspaces(id) on delete cascade,
key text not null,value jsonb,version bigint not null default 1,updated_at timestamptz not null default now(),
primary key(workspace_id,key)
);
create table public.sun_v17_workspace_meta (
workspace_id uuid primary key references public.sun_workspaces(id) on delete cascade,
schema_version integer not null default 17,last_backup_on date,legacy_revision bigint not null default 0,
migrated_at timestamptz not null default now(),updated_at timestamptz not null default now()
);
create table public.sun_v17_change_events (
id bigint generated by default as identity primary key,
workspace_id uuid not null references public.sun_workspaces(id) on delete cascade,
entity text not null,entity_key text,operation text not null,version bigint,client_id text,
created_by uuid references auth.users(id) on delete set null,created_at timestamptz not null default now()
);
create index sun_v17_changes_workspace_id_idx on public.sun_v17_change_events(workspace_id,id);
create table public.sun_v17_backups (
id uuid primary key default gen_random_uuid(),workspace_id uuid not null references public.sun_workspaces(id) on delete cascade,
kind text not null,label text not null default '',snapshot jsonb not null,
created_by uuid references auth.users(id) on delete set null,created_at timestamptz not null default now()
);
create index sun_v17_backups_workspace_created_idx on public.sun_v17_backups(workspace_id,created_at desc);
create table public.sun_v17_error_events (
id uuid primary key default gen_random_uuid(),workspace_id uuid references public.sun_workspaces(id) on delete cascade,
user_id uuid references auth.users(id) on delete set null,client_id text,app_version text,level text,message text,
stack text,context jsonb not null default '{}',created_at timestamptz not null default now()
);
create index sun_v17_errors_workspace_created_idx on public.sun_v17_error_events(workspace_id,created_at desc);
create table public.caterium_company_owner_invites (
token uuid primary key default gen_random_uuid(),workspace_id uuid not null references public.sun_workspaces(id) on delete cascade,
email text not null,created_at timestamptz not null default now(),expires_at timestamptz not null default now()+interval '7 days',
used_at timestamptz,used_by uuid references auth.users(id) on delete set null
);
do $$ declare t text; begin
foreach t in array array['sun_v17_orders','sun_v17_catalog_items','sun_v17_clients','sun_v17_settings','sun_v17_workspace_meta','sun_v17_change_events','sun_v17_backups','sun_v17_error_events','caterium_company_owner_invites'] loop
execute format('alter table public.%I enable row level security',t);
execute format('revoke all on public.%I from anon,authenticated',t);
end loop;
end $$;
-- Browser data access uses the permission-checked RPCs. Realtime only reveals an event.
grant select on public.sun_v17_change_events to authenticated;
create policy sun_v17_change_read on public.sun_v17_change_events for select to authenticated using(public.sun_member_role(workspace_id) is not null);
alter publication supabase_realtime add table public.sun_v17_change_events;
create function public.sun_my_workspaces()
returns table(id uuid,name text,role text,display_name text,is_active boolean,permissions jsonb)
language sql stable security definer set search_path=public as $$
select w.id,w.name,m.role,m.display_name,m.is_active,public.sun_role_default_permissions(m.role)||coalesce(m.permissions,'{}')
from public.sun_workspace_members m join public.sun_workspaces w on w.id=m.workspace_id
where m.user_id=auth.uid() and m.is_active order by w.created_at,w.id
$$;
create function public.sun_v17_mirror_legacy(p_workspace uuid,p_payload jsonb,p_client_id text default null)
returns void language plpgsql security definer set search_path=public as $$
declare canonical jsonb; r record; v_data jsonb; v_ids text[]; v_key text; v_rev bigint;
begin
if public.sun_member_role(p_workspace) is null and not public.sun_is_platform_admin() then raise exception 'Access denied'; end if;
perform 1 from public.sun_workspaces where id=p_workspace for update;
select payload,revision into canonical,v_rev from public.sun_app_state where workspace_id=p_workspace;
if canonical is null then return; end if;
-- Always mirror the validated server row. The supplied legacy argument is never trusted.
for r in select * from (values ('sunOrders','sun_v17_orders','order_id','order'),('sunBoxes','sun_v17_catalog_items','item_id','catalog')) as x(storage_key,table_name,id_column,entity) loop
v_data:=coalesce(canonical#>array['storage',r.storage_key,'v'],'[]'::jsonb);
if jsonb_typeof(v_data)<>'array' then raise exception 'Invalid entity array: %',r.storage_key; end if;
if exists(select 1 from jsonb_array_elements(v_data) e where nullif(e->>'id','') is null) then raise exception 'Entity ID is required'; end if;
if (select count(*) from jsonb_array_elements(v_data))<>(select count(distinct e->>'id') from jsonb_array_elements(v_data) e) then raise exception 'Duplicate entity ID'; end if;
select coalesce(array_agg(e->>'id'),'{}') into v_ids from jsonb_array_elements(v_data) e;
execute format('with gone as (delete from public.%I where workspace_id=$1 and not (%I=any($2)) returning %I,version) insert into public.sun_v17_change_events(workspace_id,entity,entity_key,operation,version,client_id,created_by) select $1,$3,%I,''delete'',version+1,$4,auth.uid() from gone',r.table_name,r.id_column,r.id_column,r.id_column) using p_workspace,v_ids,r.entity,p_client_id;
execute format('with saved as (insert into public.%I as dest(workspace_id,%I,data,updated_by) select $1,e->>''id'',e,auth.uid() from jsonb_array_elements($2) e on conflict(workspace_id,%I) do update set data=excluded.data,version=dest.version+1,updated_at=now(),updated_by=auth.uid() where dest.data is distinct from excluded.data returning %I,version) insert into public.sun_v17_change_events(workspace_id,entity,entity_key,operation,version,client_id,created_by) select $1,$3,%I,''upsert'',version,$4,auth.uid() from saved',r.table_name,r.id_column,r.id_column,r.id_column,r.id_column) using p_workspace,v_data,r.entity,p_client_id;
end loop;
delete from public.sun_v17_settings where workspace_id=p_workspace and not (canonical->'storage' ? key);
insert into public.sun_v17_settings as dest(workspace_id,key,value)
select p_workspace,key,value from jsonb_each(coalesce(canonical->'storage','{}')) where key not in ('sunOrders','sunBoxes')
on conflict(workspace_id,key) do update set value=excluded.value,version=dest.version+1,updated_at=now() where dest.value is distinct from excluded.value;
insert into public.sun_v17_workspace_meta(workspace_id,legacy_revision) values(p_workspace,v_rev)
on conflict(workspace_id) do update set legacy_revision=excluded.legacy_revision,updated_at=now();
end $$;
create function public.sun_save_app_state_v17(p_workspace uuid,p_payload jsonb,p_client_id text,p_expected_revision bigint default null)
returns table(workspace_id uuid,payload jsonb,revision bigint,updated_at timestamptz,client_id text)
language plpgsql security definer set search_path=public as $$
declare current_revision bigint;
begin
if public.sun_member_role(p_workspace) is null then raise exception 'Access denied'; end if;
perform 1 from public.sun_workspaces w where w.id=p_workspace for update;
select s.revision into current_revision from public.sun_app_state s where s.workspace_id=p_workspace;
if p_expected_revision is not null and coalesce(current_revision,0)<>p_expected_revision then
raise exception using errcode='40001',message=format('SUN_CONFLICT expected=%s actual=%s',p_expected_revision,coalesce(current_revision,0));
end if;
perform public.sun_save_app_state(p_workspace,p_payload,p_client_id);
perform public.sun_v17_mirror_legacy(p_workspace,p_payload,p_client_id);
return query select * from public.sun_fetch_app_state(p_workspace);
end $$;
create function public.sun_v17_build_snapshot(p_workspace uuid)
returns jsonb language sql stable security definer set search_path=public as $$
select jsonb_build_object('version',17,'legacy',payload,'revision',revision,'created_at',now()) from public.sun_app_state where workspace_id=p_workspace
$$;
create function public.sun_v17_prune_backups(p_workspace uuid,p_keep integer default 30)
returns integer language plpgsql security definer set search_path=public as $$
declare n integer;
begin
if public.sun_member_role(p_workspace) is null and not public.sun_is_platform_admin() then raise exception 'Access denied'; end if;
delete from public.sun_v17_backups where workspace_id=p_workspace and kind='daily' and id in
(select id from public.sun_v17_backups where workspace_id=p_workspace and kind='daily' order by created_at desc offset greatest(30,coalesce(p_keep,30)));
get diagnostics n=row_count; return n;
end $$;
create function public.sun_workspace_access_mode_internal_v28(p_workspace uuid) returns text language sql stable security definer set search_path=public as $$select public.sun_subscription_access_mode(p_workspace)$$;
create function public.sun_workspace_feature_internal_v28(p_workspace uuid,p_feature text) returns boolean language sql stable security definer set search_path=public as $$select public.sun_workspace_has_feature(p_workspace,p_feature)$$;
revoke all on function public.sun_v17_build_snapshot(uuid),public.sun_workspace_access_mode_internal_v28(uuid),public.sun_workspace_feature_internal_v28(uuid,text) from public,anon,authenticated;
revoke all on function public.sun_my_workspaces(),public.sun_save_app_state_v17(uuid,jsonb,text,bigint),public.sun_v17_mirror_legacy(uuid,jsonb,text),public.sun_v17_prune_backups(uuid,integer) from public,anon;
grant execute on function public.sun_my_workspaces(),public.sun_save_app_state_v17(uuid,jsonb,text,bigint),public.sun_v17_mirror_legacy(uuid,jsonb,text),public.sun_v17_prune_backups(uuid,integer) to authenticated;

View File

@ -52,7 +52,7 @@ begin
insert into public.sun_v17_clients(workspace_id,client_key,name,phone,latest_address,data,version,created_at,updated_at)
values(p_workspace,p_client_key,next_name,next_phone,next_address,p_profile,1,now(),now())
on conflict(workspace_id,client_key) do update set
on conflict on constraint sun_v17_clients_pkey do update set
name=coalesce(excluded.name,sun_v17_clients.name),
phone=coalesce(excluded.phone,sun_v17_clients.phone),
latest_address=coalesce(excluded.latest_address,sun_v17_clients.latest_address),

View File

@ -0,0 +1,118 @@
-- Sun Catering v17.8 developer-panel additions.
-- Applied to the current project on 2026-09-01.
create or replace function public.sun_platform_list_users()
returns table(
user_id uuid,
email text,
last_sign_in_at timestamptz,
created_at timestamptz,
workspace_id uuid,
workspace_name text,
role text,
display_name text,
is_active boolean,
is_platform_admin boolean
)
language plpgsql
stable
security definer
set search_path='public'
as $$
begin
if not public.sun_is_platform_admin() then raise exception 'Platform administrator required'; end if;
return query
select u.id,u.email::text,u.last_sign_in_at,u.created_at,m.workspace_id,w.name,m.role,m.display_name,m.is_active,
exists(select 1 from public.sun_platform_admins p where p.user_id=u.id)
from auth.users u
left join public.sun_workspace_members m on m.user_id=u.id
left join public.sun_workspaces w on w.id=m.workspace_id
order by coalesce(w.name,''),coalesce(m.display_name,u.email),u.email;
end;
$$;
revoke all on function public.sun_platform_list_users() from public;
grant execute on function public.sun_platform_list_users() to authenticated;
create or replace function public.sun_v17_create_backup(p_workspace uuid, p_kind text default 'manual'::text, p_label text default ''::text)
returns uuid
language plpgsql
security definer
set search_path='public'
as $$
declare v_id uuid; v_kind text:=lower(coalesce(p_kind,'manual'));
begin
if public.sun_member_role(p_workspace) is null and not public.sun_is_platform_admin() then raise exception 'Access denied'; end if;
if v_kind not in ('daily','manual','pre_restore','migration') then raise exception 'Invalid backup kind'; end if;
if v_kind<>'daily' and not public.sun_is_platform_admin() then raise exception 'Platform administrator required'; end if;
if v_kind='daily' and exists(select 1 from public.sun_v17_backups where workspace_id=p_workspace and kind='daily' and created_at::date=current_date) then
select id into v_id from public.sun_v17_backups where workspace_id=p_workspace and kind='daily' and created_at::date=current_date order by created_at desc limit 1;
return v_id;
end if;
insert into public.sun_v17_backups(workspace_id,kind,label,snapshot,created_by)
values(p_workspace,v_kind,left(coalesce(p_label,''),240),public.sun_v17_build_snapshot(p_workspace),auth.uid()) returning id into v_id;
insert into public.sun_v17_workspace_meta(workspace_id,last_backup_on,updated_at) values(p_workspace,current_date,now())
on conflict(workspace_id) do update set last_backup_on=current_date,updated_at=now();
return v_id;
end;
$$;
create or replace function public.sun_v17_restore_backup(p_workspace uuid, p_backup uuid)
returns void
language plpgsql
security definer
set search_path='public'
as $$
declare s jsonb; legacy jsonb;
begin
if not public.sun_is_platform_admin() then raise exception 'Platform administrator required'; end if;
perform public.sun_v17_create_backup(p_workspace,'pre_restore','Автоматически перед восстановлением');
select snapshot into s from public.sun_v17_backups where id=p_backup and workspace_id=p_workspace;
if s is null then raise exception 'Backup not found'; end if;
legacy:=s->'legacy';
if legacy is null or jsonb_typeof(legacy)<>'object' then raise exception 'Backup has no legacy state'; end if;
perform public.sun_save_app_state(p_workspace,legacy,'backup-restore');
perform public.sun_v17_mirror_legacy(p_workspace,legacy,'backup-restore');
insert into public.sun_v17_change_events(workspace_id,entity,entity_key,operation,client_id,created_by)
values(p_workspace,'workspace','backup','restore','backup-restore',auth.uid());
end;
$$;
revoke all on function public.sun_v17_create_backup(uuid,text,text) from public;
revoke all on function public.sun_v17_restore_backup(uuid,uuid) from public;
grant execute on function public.sun_v17_create_backup(uuid,text,text) to authenticated;
grant execute on function public.sun_v17_restore_backup(uuid,uuid) to authenticated;
-- Company owner can attach an already registered account by email without invite codes.
create or replace function public.sun_owner_add_existing_member(p_workspace uuid,p_email text,p_role text default 'manager')
returns uuid
language plpgsql
security definer
set search_path='public'
as $$
declare
v_user uuid;
v_role text:=lower(coalesce(p_role,'manager'));
v_display text;
v_max integer;
v_count integer;
begin
if auth.uid() is null then raise exception 'Authentication required'; end if;
if public.sun_member_role(p_workspace)<>'admin' and not public.sun_has_permission(p_workspace,'users.manage') then raise exception 'Нет права управлять сотрудниками'; end if;
if public.sun_subscription_access_mode(p_workspace)<>'full' then raise exception 'Подписка не позволяет изменять пользователей'; end if;
if not public.sun_workspace_has_feature(p_workspace,'users_manage') then raise exception 'Сотрудники недоступны на текущем тарифе'; end if;
if v_role not in ('manager','kitchen','courier','viewer') then raise exception 'Для сотрудника выберите рабочую роль'; end if;
select id,coalesce(nullif(raw_user_meta_data->>'name',''),split_part(email,'@',1)) into v_user,v_display
from auth.users where lower(email)=lower(trim(p_email)) limit 1;
if v_user is null then raise exception 'Аккаунт с таким email ещё не зарегистрирован'; end if;
select p.max_members into v_max from public.sun_workspace_subscriptions s join public.sun_plans p on p.id=s.plan_id where s.workspace_id=p_workspace;
select count(*)::int into v_count from public.sun_workspace_members where workspace_id=p_workspace and is_active=true;
if not exists(select 1 from public.sun_workspace_members where workspace_id=p_workspace and user_id=v_user and is_active=true) then
if v_max is not null and v_count>=v_max then raise exception 'Достигнут лимит сотрудников тарифа (%)',v_max; end if;
end if;
insert into public.sun_workspace_members(workspace_id,user_id,role,display_name,is_active,permissions,updated_at)
values(p_workspace,v_user,v_role,v_display,true,public.sun_role_default_permissions(v_role),now())
on conflict(workspace_id,user_id) do update set role=excluded.role,display_name=coalesce(public.sun_workspace_members.display_name,excluded.display_name),is_active=true,permissions=excluded.permissions,updated_at=now();
return v_user;
end;
$$;
revoke all on function public.sun_owner_add_existing_member(uuid,text,text) from public;
grant execute on function public.sun_owner_add_existing_member(uuid,text,text) to authenticated;

View File

@ -22,6 +22,7 @@ declare
v_trial_end timestamptz;
begin
if v_user is null then raise exception 'Authentication required'; end if;
if not exists(select 1 from auth.users where id=v_user and email_confirmed_at is not null) then raise exception 'Подтвердите email'; end if;
if exists(select 1 from public.sun_workspace_members where user_id=v_user and is_active=true) then
raise exception 'Аккаунт уже относится к компании';
end if;
@ -56,7 +57,8 @@ begin
values(v_promo.id,v_workspace,v_user,v_email,v_trial_end);
update public.caterium_trial_promos set use_count=use_count+1,updated_at=now() where id=v_promo.id;
update auth.users set raw_user_meta_data=coalesce(raw_user_meta_data,'{}'::jsonb)-'promo_code'-'company_name' where id=v_user;
perform public.sun_platform_log_event('trial_promo.redeem',v_workspace,v_user,jsonb_build_object('promo_id',v_promo.id,'code',v_promo.code,'trial_days',v_promo.trial_days,'plan',v_promo.plan_id));
insert into public.sun_platform_audit_events(actor_user_id,action,target_workspace_id,target_user_id,details)
values(v_user,'trial_promo.redeem',v_workspace,v_user,jsonb_build_object('promo_id',v_promo.id,'code',v_promo.code,'trial_days',v_promo.trial_days,'plan',v_promo.plan_id));
return v_workspace;
end;
$function$;

View File

@ -0,0 +1,97 @@
-- Sun Catering v17.9 developer settings.
-- Safe, additive migration for the existing v17 schema.
begin;
-- The platform administrator can inspect normalized health for any company.
-- Ordinary users remain limited to companies where they are active members.
create or replace function public.sun_v17_entity_snapshot(p_workspace uuid)
returns jsonb
language plpgsql
stable
security definer
set search_path='public'
as $$
begin
if public.sun_member_role(p_workspace) is null and not public.sun_is_platform_admin() then
raise exception 'Access denied';
end if;
return jsonb_build_object(
'orders',coalesce((
select jsonb_agg(jsonb_build_object('id',order_id,'version',version,'data',data,'updated_at',updated_at) order by order_id)
from public.sun_v17_orders where workspace_id=p_workspace
),'[]'::jsonb),
'catalog',coalesce((
select jsonb_agg(jsonb_build_object('id',item_id,'version',version,'data',data,'updated_at',updated_at) order by item_id)
from public.sun_v17_catalog_items where workspace_id=p_workspace
),'[]'::jsonb),
'meta',coalesce((
select to_jsonb(meta) from public.sun_v17_workspace_meta meta where workspace_id=p_workspace
),'{}'::jsonb)
);
end;
$$;
-- The platform administrator can list backups for any company from the
-- protected developer panel. Members can still list their own company's rows.
create or replace function public.sun_v17_list_backups(p_workspace uuid, p_limit integer default 30)
returns table(id uuid, kind text, label text, created_at timestamptz, created_by uuid)
language plpgsql
stable
security definer
set search_path='public'
as $$
begin
if public.sun_member_role(p_workspace) is null and not public.sun_is_platform_admin() then
raise exception 'Access denied';
end if;
return query
select backup.id,backup.kind,backup.label,backup.created_at,backup.created_by
from public.sun_v17_backups backup
where backup.workspace_id=p_workspace
order by backup.created_at desc
limit greatest(1,least(coalesce(p_limit,30),100));
end;
$$;
-- Global technical error directory. It intentionally excludes stack/context
-- from the browser result; those fields can contain sensitive implementation data.
create or replace function public.sun_platform_list_errors(p_workspace uuid default null, p_limit integer default 80)
returns table(
id uuid,
workspace_id uuid,
workspace_name text,
user_id uuid,
client_id text,
app_version text,
level text,
message text,
created_at timestamptz
)
language plpgsql
stable
security definer
set search_path='public'
as $$
begin
if not public.sun_is_platform_admin() then
raise exception 'Platform administrator required';
end if;
return query
select err.id,err.workspace_id,company.name,err.user_id,err.client_id,err.app_version,err.level,err.message,err.created_at
from public.sun_v17_error_events err
left join public.sun_workspaces company on company.id=err.workspace_id
where p_workspace is null or err.workspace_id=p_workspace
order by err.created_at desc
limit greatest(1,least(coalesce(p_limit,80),200));
end;
$$;
revoke all on function public.sun_v17_entity_snapshot(uuid) from public, anon;
revoke all on function public.sun_v17_list_backups(uuid,integer) from public, anon;
revoke all on function public.sun_platform_list_errors(uuid,integer) from public, anon;
grant execute on function public.sun_v17_entity_snapshot(uuid) to authenticated;
grant execute on function public.sun_v17_list_backups(uuid,integer) to authenticated;
grant execute on function public.sun_platform_list_errors(uuid,integer) to authenticated;
commit;

View File

@ -6,7 +6,7 @@
* Upstream host is a fixed constant - never derived from request input (no open-proxy risk).
*/
const UPSTREAM = 'https://cksuehzcimitsxmeloes.supabase.co';
const UPSTREAM = 'https://usfjwhztqoopzzfmfbis.supabase.co';
const PUBLIC_BASE = 'https://api.caterium.ru';
const SERVE_HOST = 'api.caterium.ru';

8
package-lock.json generated
View File

@ -8,6 +8,7 @@
"name": "caterium-app",
"version": "17.7.3",
"devDependencies": {
"@electric-sql/pglite": "0.5.8",
"@playwright/test": "^1.51.0",
"http-server": "^14.1.1",
"wrangler": "4.131.0"
@ -137,6 +138,13 @@
"node": ">=12"
}
},
"node_modules/@electric-sql/pglite": {
"version": "0.5.8",
"resolved": "https://registry.npmjs.org/@electric-sql/pglite/-/pglite-0.5.8.tgz",
"integrity": "sha512-n9tsbUOhwx2epK1V0ZG9Ar4SHWUju04dhmzZXiSBXwBoleOvIfals33NAaWgagQVAL4Rbvx/Ptsu3P+pA09f6Q==",
"dev": true,
"license": "Apache-2.0"
},
"node_modules/@emnapi/runtime": {
"version": "1.11.3",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz",

View File

@ -7,12 +7,15 @@
"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",
"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:deploy": "npm run check:syntax && npm run test:static && npm run check:release",
"check:deploy": "npm run check:syntax && npm run test:static && npm run check:release && node tests/backend-cutover.mjs && npm run test:db",
"test:db": "node tests/recovery/validate.mjs --smoke",
"build:recovery": "node tests/recovery/bundle.mjs",
"test:e2e": "playwright test --config=tests/playwright.config.mjs",
"test": "npm run check:deploy",
"deploy:cloudflare-backup": "wrangler deploy"
},
"devDependencies": {
"@electric-sql/pglite": "0.5.8",
"@playwright/test": "^1.51.0",
"http-server": "^14.1.1",
"wrangler": "4.131.0"

View File

@ -2115,8 +2115,8 @@ window.SUN_LEGACY_CATALOG_V175=[{"id":"1","name":"Фуршетный бокс
console[type === 'error' ? 'error' : 'log']('[SunCloud]', text);
};
const DEFAULT_SUPABASE_URL = 'https://cksuehzcimitsxmeloes.supabase.co';
const DEFAULT_SUPABASE_KEY = 'sb_publishable_v8Z3hEBnu7zsDwAb5KCWcg_T96IejsM';
const DEFAULT_SUPABASE_URL = 'https://usfjwhztqoopzzfmfbis.supabase.co';
const DEFAULT_SUPABASE_KEY = 'sb_publishable_CAxfhMKrduJjuk_5ybCQLg_TqSGWGoy';
const SUPABASE_API_PROXY = 'https://api.caterium.ru';
const PROXY_FETCH_TIMEOUT_MS = 7000;
function supabaseProxyFetch(input, init) {
@ -2180,6 +2180,18 @@ window.SUN_LEGACY_CATALOG_V175=[{"id":"1","name":"Фуршетный бокс
value.key ||= String(cloud.anonKey || '').trim();
} catch (_) {}
}
// Move the retired Caterium backend to the fresh project once. Preserve the
// old workspace identity so switchTenantLocal archives its data separately.
if (/^https:\/\/(?:cksuehzcimitsxmeloes\.supabase\.co|api\.caterium\.ru)\/?$/i.test(String(value.url||'')) || value.key==='sb_publishable_v8Z3hEBnu7zsDwAb5KCWcg_T96IejsM') {
try { localStorage.setItem('cateriumRetiredBackend20260917',JSON.stringify(value)); } catch (_) {}
value.legacyLocalWorkspaceId ||= value.localWorkspaceId || value.workspaceId || 'retired-caterium';
value.url=DEFAULT_SUPABASE_URL;
value.key=DEFAULT_SUPABASE_KEY;
value.workspaceId='';
value.migrated={};
value.lastSync='';
try { localStorage.setItem(CONFIG_KEY,JSON.stringify(value)); } catch (_) {}
}
return {
url: String(value.url || DEFAULT_SUPABASE_URL || '').trim().replace(/\/$/, ''),
key: String(value.key || DEFAULT_SUPABASE_KEY || '').trim(),

View File

@ -3,8 +3,8 @@
const VERSION='17.8.4-auth-proxy-fallback';
const PENDING_REGISTRATION_KEY='sunPendingRegistrationV23';
const DIRECT_SUPABASE_URL='https://cksuehzcimitsxmeloes.supabase.co';
const DIRECT_SUPABASE_KEY='sb_publishable_v8Z3hEBnu7zsDwAb5KCWcg_T96IejsM';
const DIRECT_SUPABASE_URL='https://usfjwhztqoopzzfmfbis.supabase.co';
const DIRECT_SUPABASE_KEY='sb_publishable_CAxfhMKrduJjuk_5ybCQLg_TqSGWGoy';
let busy=false,directClient=null;
const $=id=>document.getElementById(id);

File diff suppressed because one or more lines are too long

View File

@ -1,5 +1,5 @@
const CACHE='sun-catering-pwa-v81-20260912-account-center-loader';
const VERSION='20260914-safe-navigation';
const CACHE='sun-catering-pwa-v82-20260917-caterium-fresh';
const VERSION='20260917-caterium-fresh';
const CORE=[
'./','./index.html',
`./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}`,

7
supabase/config.toml Normal file
View File

@ -0,0 +1,7 @@
project_id = "caterium-fresh"
[functions.caterium-create-employee]
verify_jwt = true
[functions.caterium-platform-auth-admin]
verify_jwt = true

View File

@ -42,6 +42,8 @@ Deno.serve(async (req) => {
if (userError || !user) return json(req, {error:'Unauthorized'},401)
const {data:isAdmin,error:adminError} = await userClient.rpc('sun_is_platform_admin')
if (adminError || isAdmin !== true) return json(req, {error:'Platform administrator required'},403)
const {error:mfaError} = await userClient.rpc('sun_dev_dashboard')
if (mfaError) return json(req, {error:'Developer MFA AAL2 required'},403)
const body = await req.json().catch(()=>({})) as Record<string,unknown>
const action = String(body.action || 'list')

File diff suppressed because it is too large Load Diff

11
tests/backend-cutover.mjs Normal file
View File

@ -0,0 +1,11 @@
import fs from 'node:fs';
import vm from 'node:vm';
import assert from 'node:assert/strict';
const source=fs.readFileSync('public/app-runtime.js','utf8');
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 run=config=>{const storage=new Map([['sunCloudV2Config',JSON.stringify(config)],['sunOrders','[{"id":"old-order"}]']]);const ctx={DEFAULT_SUPABASE_URL:url,DEFAULT_SUPABASE_KEY:key,CONFIG_KEY:'sunCloudV2Config',localStorage:{getItem:k=>storage.get(k),setItem:(k,v)=>storage.set(k,v)}};vm.createContext(ctx);const result=vm.runInContext(code+';loadConfig()',ctx);return{result,storage};};
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');
assert.equal(run({}).result.url,url);
console.log('PASS backend cutover: retired config upgraded, old local data preserved separately, fresh config stable');

11
tests/recovery/bundle.mjs Normal file
View File

@ -0,0 +1,11 @@
import fs from 'node:fs';
import path from 'node:path';
import {fileURLToPath} from 'node:url';
const root=fileURLToPath(new URL('../../',import.meta.url));
const files=JSON.parse(fs.readFileSync(new URL('manifest.json',import.meta.url),'utf8'));
let sql=`-- Caterium fresh recovery; target usfjwhztqoopzzfmfbis only.\n-- Do not run against an existing database.\nbegin;\nset local lock_timeout='10s';\nset local statement_timeout='120s';\ndo $$begin if to_regclass('public.sun_workspaces') is not null then raise exception 'Fresh recovery requires an empty Caterium schema'; end if; end $$;\ncreate extension if not exists pg_cron;\n`;
for(const file of files)sql+=`\n-- Source: ${file}\n`+fs.readFileSync(path.join(root,file),'utf8').replace(/^\s*(begin|commit);\s*$/gim,'')+'\n';
sql+=`\nnotify pgrst,'reload schema';\ncommit;\nselect (select count(*) from pg_tables where schemaname='public') as tables,(select count(*) from public.sun_workspaces) as workspaces,(select count(*) from auth.users) as users;\n`;
fs.mkdirSync(path.join(root,'supabase/migrations'),{recursive:true});
fs.writeFileSync(path.join(root,'supabase/migrations/20260917150000_fresh_caterium.sql'),sql);
console.log(JSON.stringify({bytes:Buffer.byteLength(sql),sources:files.length}));

View File

@ -0,0 +1,27 @@
[
"ops/sql/SUPABASE-SETUP.sql",
"ops/sql/SUPABASE-RBAC-V3.sql",
"ops/sql/SUPABASE-SAAS-V16.sql",
"ops/sql/SUPABASE-SAAS-V16-FINALIZE.sql",
"ops/sql/SUPABASE-FRESH-V17-FOUNDATION.sql",
"ops/sql/SUPABASE-V17.8-DEVELOPER-PANEL.sql",
"ops/sql/SUPABASE-V17.9-DEVELOPER-SETTINGS.sql",
"ops/sql/SUPABASE-DEVELOPER-V22.sql",
"ops/sql/SUPABASE-DEVELOPER-V22-AAL2.sql",
"ops/sql/SUPABASE-REGISTRATION-USERS-V27.sql",
"ops/sql/SUPABASE-EMPLOYEE-MANAGEMENT-V28.sql",
"ops/sql/SUPABASE-CHAT-V29.sql",
"ops/sql/SUPABASE-ADMIN-RIGHTS-V30.sql",
"ops/sql/SUPABASE-V17.6.1-CHAT-STORAGE-GUARD.sql",
"ops/sql/SUPABASE-V17.6.8-DEVELOPER-CONSOLE-UX.sql",
"ops/sql/CATERIUM-OWNER-ACCOUNTS-V31.sql",
"ops/sql/SUPABASE-V17.7.0-SERVER-ORDER-AUTOMATION.sql",
"ops/sql/SUPABASE-V17.7.0-ERROR-TELEMETRY-HYGIENE.sql",
"ops/sql/SUPABASE-V17.7.2-CLIENTS-FOUNDATION.sql",
"ops/sql/SUPABASE-V17.7.3-CLIENTS-SERVER-READ.sql",
"ops/sql/SUPABASE-V17.7.4-AUTH-EMAIL-VERIFICATION.sql",
"ops/sql/SUPABASE-V17.7.4-ADVISOR-HARDENING.sql",
"ops/sql/SUPABASE-FRESH-PROMOS-AND-ADMIN.sql",
"ops/sql/SUPABASE-V17.8.2-OWNER-WORKSPACE-ONBOARDING.sql",
"ops/sql/SUPABASE-FRESH-FINAL-GUARDS.sql"
]

47
tests/recovery/smoke.mjs Normal file
View File

@ -0,0 +1,47 @@
import assert from 'node:assert/strict';
export async function run(db){
const q=(s,p=[])=>db.query(s,p);const one=async(s,p)=>(await q(s,p)).rows[0];
const ids=['00000000-0000-4000-8000-000000000001','00000000-0000-4000-8000-000000000002','00000000-0000-4000-8000-000000000003','00000000-0000-4000-8000-000000000004'];
async function actor(id,aal='aal1'){await db.exec('reset role');await q("select set_config('request.jwt.claim.sub',$1,false),set_config('request.jwt.claims',$2,false)",[id,JSON.stringify({sub:id,aal,role:'authenticated'})]);await db.exec('set role authenticated');}
async function denied(s,p,re){await assert.rejects(q(s,p),re);}
await db.exec(`create function realtime.send(jsonb,text,text,boolean) returns void language plpgsql as $$begin end$$;`);
for(let i=0;i<ids.length;i++)await q("insert into auth.users(id,email,email_confirmed_at) values($1,$2,now())",[ids[i],`test${i}@example.invalid`]);
await q('insert into public.sun_platform_admins(user_id) values($1)',[ids[0]]);
await actor(ids[0]);await denied('select public.sun_dev_dashboard()',[],/AAL2/);
await actor(ids[0],'aal2');
for(let i=1;i<=2;i++)await q('select public.sun_dev_create_trial_promo($1,$2,14,7,1,\'full\',null)',[`RECOVERY-TEST-${i}`,`test${i}@example.invalid`]);
await db.exec('reset role');
for(let i=1;i<=2;i++)await q("update auth.users set raw_user_meta_data=jsonb_build_object('promo_code',$2::text) where id=$1",[ids[i],`RECOVERY-TEST-${i}`]);
await actor(ids[1]);const ws=(await one('select public.sun_create_workspace() as id')).id;
assert.equal((await q('select * from public.sun_my_workspaces()')).rows.length,1);
await denied('select public.sun_create_workspace()',[],/уже относится/);
let row=await one('select * from public.sun_fetch_app_state($1)',[ws]);
let payload=row.payload;payload.storage.sunOrders={t:'j',v:[{id:1,client:'Test customer',date:'2099-01-01',time:'12:00',total:5000,status:'Новый'}]};payload.storage.sunBoxes={t:'j',v:[{id:'box-1',name:'Test box',price:500}]};
row=await one('select * from public.sun_save_app_state_v17($1,$2,$3,$4)',[ws,payload,'recovery-test',row.revision]);
await denied('select * from public.sun_save_app_state_v17($1,$2,$3,$4)',[ws,payload,'stale-client',row.revision-1],/SUN_CONFLICT/);
const snap=(await one('select public.sun_v17_entity_snapshot($1) as s',[ws])).s;
assert.equal(snap.orders.length,1);assert.equal(snap.catalog.length,1);
const client=await one('select * from public.sun_v17_save_client_v1772($1,$2,$3,null,$4)',[ws,'p:79990000000',{identity:{name:'Test client',phone:'+79990000000'}},'recovery-test']);
assert.equal(client.version,1);
await q('select * from public.sun_v17_clients_snapshot_v1773($1)',[ws]);
await q("select public.sun_v17_create_backup($1,'daily','test')",[ws]);
assert.equal((await q('select * from public.sun_v17_list_backups($1)',[ws])).rows.length,1);
await q('select public.sun_v17_prune_backups($1)',[ws]);
await q('select public.sun_run_order_automation($1)',[ws]);
await q('select public.caterium_account_snapshot($1)',[ws]);
await q('select * from public.sun_list_workspace_members($1)',[ws]);
await q("select public.sun_employee_prepare_v28($1,'test3@example.invalid','Viewer','viewer')",[ws]);
await q("select public.sun_employee_finalize_v28($1,$2,'Viewer','viewer')",[ws,ids[3]]);
await actor(ids[2]);const foreign=(await one('select public.sun_create_workspace() as id')).id;
await denied('select * from public.sun_fetch_app_state($1)',[ws],/Access denied/);
await denied('select * from public.sun_v17_clients_snapshot_v1773($1)',[ws],/Access denied/);
await denied('select public.sun_dev_dashboard()',[],/administrator/);
await actor(ids[3]);
await denied('select * from public.sun_save_app_state_v17($1,$2,$3,$4)',[ws,{...payload,storage:{...payload.storage,sunOrders:{t:'j',v:[{id:2}]}}},'viewer',row.revision],/Нет права/);
await actor(ids[0],'aal2');
for(const f of ['sun_dev_dashboard','sun_dev_list_companies','sun_dev_list_companies_v1768','sun_dev_list_users','sun_dev_list_errors','sun_dev_list_activity','sun_dev_list_trial_promos'])await q(`select * from public.${f}()`);
for(const f of ['sun_dev_workspace_diagnostics','sun_dev_list_workspace_features','sun_dev_support_snapshot'])await q(`select * from public.${f}($1)`,[ws]);
await q("select public.sun_dev_create_company('Recovery test company','test3@example.invalid','full',30,'[]','test')");
console.log('PASS smoke: owner onboarding, save/read, optimistic conflict, normalized catalog/orders/clients, backups, employees, cross-tenant isolation, viewer write denial, AAL2, developer RPCs');
await db.close();
}

View File

@ -0,0 +1,29 @@
import fs from 'node:fs';
const root = new URL('../../', import.meta.url);
import {PGlite} from '@electric-sql/pglite';
import {pgcrypto} from '@electric-sql/pglite/contrib/pgcrypto';
export const db=new PGlite({extensions:{pgcrypto}});
await db.exec(`create role anon; create role authenticated; create role service_role bypassrls;
create schema auth; create schema storage; create schema cron; create schema realtime;
create table realtime.messages(id bigint,extension text,topic text);
create function realtime.topic() returns text language sql as $$select ''::text$$;
create table auth.users(id uuid primary key,email varchar,raw_user_meta_data jsonb default '{}',raw_app_meta_data jsonb default '{}',created_at timestamptz default now(),last_sign_in_at timestamptz,email_confirmed_at timestamptz,encrypted_password varchar);
create table auth.identities(id uuid primary key,user_id uuid,identity_data jsonb,provider text);
create function auth.uid() returns uuid language sql stable as $$select nullif(current_setting('request.jwt.claim.sub',true),'')::uuid$$;
create function auth.jwt() returns jsonb language sql stable as $$select coalesce(nullif(current_setting('request.jwt.claims',true),''),'{}')::jsonb$$;
create function auth.role() returns text language sql stable as $$select coalesce(auth.jwt()->>'role','authenticated')$$;
create table storage.buckets(id text primary key,name text,public boolean,file_size_limit bigint,allowed_mime_types text[]);
create table storage.objects(id uuid primary key default gen_random_uuid(),bucket_id text,name text,owner uuid,metadata jsonb);
alter table storage.objects enable row level security;
create function storage.foldername(text) returns text[] language sql immutable as $$select string_to_array($1,'/')$$;
create table cron.job(jobid bigserial,jobname text);
create function cron.unschedule(bigint) returns boolean language sql as $$select true$$;
create function cron.schedule(text,text,text) returns bigint language sql as $$select 1::bigint$$;
create publication supabase_realtime;
grant usage on schema public,auth,storage to anon,authenticated,service_role;
grant execute on all functions in schema auth to anon,authenticated,service_role;`);
const files=JSON.parse(fs.readFileSync(new URL('manifest.json',import.meta.url),'utf8'));
for(const file of files){try{await db.exec(fs.readFileSync(new URL(file,root),'utf8'));console.log('PASS '+file);}catch(e){console.error(JSON.stringify({file,message:e.message,detail:e.detail,where:e.where,position:e.position},null,2));process.exit(1);}}
console.log((await db.query(`select count(*) as tables from pg_tables where schemaname='public'`)).rows);
if(process.argv.includes('--smoke')){try{await (await import('./smoke.mjs')).run(db);}catch(e){console.error(JSON.stringify({message:e.message,detail:e.detail,where:e.where,query:e.query,internalQuery:e.internalQuery},null,2));process.exit(1);}}
else await db.close();

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(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(sw.includes('v81-20260912-account-center-loader')&&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('20260916-v18-1-2-banquet-menu-tab')&&index.includes('classic-offer-pdf-v1767.js')&&!index.includes('20260907-v17-6-0-stability-security'),'index cache-busting points to v17.7.3');
check(sw.includes('v82-20260917-caterium-fresh')&&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-caterium-fresh')&&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("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');
@ -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(releaseManifest.version===`v${pkg.version}`,'release manifest version matches package.json');
check(releaseManifest.channel==='production','release manifest channel is production');
check(String(releaseManifest.pwaCache||'').includes('v81-20260912-account-center-loader'),'release manifest points to current PWA cache');
check(String(releaseManifest.pwaCache||'').includes('v82-20260917-caterium-fresh'),'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(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');
@ -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(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(index.includes('20260916-v18-1-2-banquet-menu-tab'),'index cache bust is v17.7.3');
check(sw.includes('v81-20260912-account-center-loader')&&sw.includes('data-layer-v1773.js')&&sw.includes('server-automation-v1770.js'),'PWA caches v17.7.3 client foundation modules');
check(index.includes('20260917-caterium-fresh'),'index cache bust is v17.7.3');
check(sw.includes('v82-20260917-caterium-fresh')&&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(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');

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');
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(!sw.includes('20260912-account-center-loader')||!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('20260916-v18-1-2-banquet-menu-tab')||!html.includes('classic-offer-pdf-v1767.js'))fail('index still serves stale core asset version');else ok('index cache-busting is current');
if(!sw.includes('20260917-caterium-fresh')||!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-caterium-fresh')||!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("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');