letopis 2.0.0 → 3.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENT-CHEATSHEET.md +484 -414
- package/CHANGELOG.md +321 -0
- package/LICENSE +75 -21
- package/LICENSE-COMMERCIAL.md +49 -0
- package/README.md +786 -183
- package/api-contract.json +381 -0
- package/dist/acl.d.ts +3 -0
- package/dist/acl.js +19 -2
- package/dist/admin.d.ts +2 -1
- package/dist/admin.js +6 -4
- package/dist/auth.d.ts +18 -3
- package/dist/auth.js +75 -6
- package/dist/chain.d.ts +38 -17
- package/dist/chain.js +434 -63
- package/dist/cli.js +28 -6
- package/dist/errors.d.ts +19 -1
- package/dist/errors.js +46 -6
- package/dist/index.d.ts +10 -4
- package/dist/index.js +136 -46
- package/dist/internal.d.ts +2 -0
- package/dist/load.d.ts +9 -5
- package/dist/load.js +30 -11
- package/dist/log.d.ts +121 -0
- package/dist/log.js +633 -0
- package/dist/mcp.js +26 -11
- package/dist/messages/api.d.ts +66 -0
- package/dist/messages/api.js +90 -1
- package/dist/messages/boot.js +4 -4
- package/dist/messages/codes.js +4 -4
- package/dist/messages/index.d.ts +5 -2
- package/dist/messages/index.js +5 -2
- package/dist/messages/log.d.ts +229 -0
- package/dist/messages/log.js +102 -0
- package/dist/messages/model.d.ts +24 -1
- package/dist/messages/model.js +34 -3
- package/dist/messages/query.d.ts +10 -0
- package/dist/messages/query.js +13 -0
- package/dist/messages/sql-core.d.ts +15 -1
- package/dist/messages/sql-core.js +20 -3
- package/dist/messages/sql-time.d.ts +15 -0
- package/dist/messages/sql-time.js +23 -0
- package/dist/messages/write.d.ts +47 -0
- package/dist/messages/write.js +63 -0
- package/dist/model.d.ts +4 -0
- package/dist/model.js +23 -5
- package/dist/sql.d.ts +2 -0
- package/dist/sql.js +77 -25
- package/dist/sync.d.ts +1 -1
- package/dist/sync.js +50 -0
- package/dist/tx.d.ts +10 -1
- package/dist/tx.js +44 -2
- package/dist/typed.d.ts +14 -10
- package/dist/types.d.ts +82 -4
- package/dist/types.js +6 -1
- package/dist/up.d.ts +3 -3
- package/dist/up.js +57 -17
- package/dist/uuid.d.ts +8 -0
- package/dist/uuid.js +9 -1
- package/dist/validate.js +24 -7
- package/dist/watch.d.ts +5 -0
- package/dist/watch.js +13 -0
- package/dist/write.d.ts +78 -1
- package/dist/write.js +357 -39
- package/llms.txt +37 -17
- package/package.json +12 -6
- package/sql/20-context.sql +11 -3
- package/sql/30-validate.sql +51 -10
- package/sql/40-class.sql +21 -1
- package/sql/80-auth.sql +88 -17
- package/sql/90-time.sql +34 -6
- package/sql/99-revision.sql +5 -1
- package/sql/migrate/0009-restore-rev.tenants-name.sql +10 -0
- package/sql/migrate/0010-impersonation-no-account.sql +5 -0
- package/sql/migrate/0011-key-lock.otp-enabled.sql +7 -0
- package/sql/migrate/README.md +5 -1
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "letopis",
|
|
3
|
-
"version": "
|
|
4
|
-
"description": "Letopis (летопись):
|
|
3
|
+
"version": "3.0.0",
|
|
4
|
+
"description": "Letopis (летопись): хранилище версионированных сущностей на PostgreSQL 18 — целостность, история, арендаторы и права проверяет сама база, поверх — цепочки через точку. ИИ-агенту — AGENT-CHEATSHEET.md и llms.txt в пакете; исходный код доступен (PolyForm Noncommercial 1.0.0)",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"postgres",
|
|
7
7
|
"postgresql",
|
|
@@ -14,9 +14,12 @@
|
|
|
14
14
|
"jsonb",
|
|
15
15
|
"multi-tenant",
|
|
16
16
|
"acl",
|
|
17
|
-
"uuidv5"
|
|
17
|
+
"uuidv5",
|
|
18
|
+
"llms-txt",
|
|
19
|
+
"ai-agents",
|
|
20
|
+
"agent-cheatsheet"
|
|
18
21
|
],
|
|
19
|
-
"license": "
|
|
22
|
+
"license": "PolyForm-Noncommercial-1.0.0",
|
|
20
23
|
"repository": {
|
|
21
24
|
"type": "git",
|
|
22
25
|
"url": "git+https://github.com/alepri51/letopis.git",
|
|
@@ -53,12 +56,15 @@
|
|
|
53
56
|
"README.md",
|
|
54
57
|
"AGENT-CHEATSHEET.md",
|
|
55
58
|
"llms.txt",
|
|
59
|
+
"api-contract.json",
|
|
56
60
|
"CHANGELOG.md",
|
|
57
|
-
"LICENSE"
|
|
61
|
+
"LICENSE",
|
|
62
|
+
"LICENSE-COMMERCIAL.md"
|
|
58
63
|
],
|
|
59
64
|
"scripts": {
|
|
60
65
|
"build": "tsc -p tsconfig.build.json",
|
|
61
|
-
"typecheck": "tsc --noEmit",
|
|
66
|
+
"typecheck": "tsc -p tsconfig.build.json --noEmit",
|
|
67
|
+
"typecheck:all": "tsc --noEmit",
|
|
62
68
|
"test": "tsx --test --test-concurrency=1 --test-force-exit test/*.test.ts",
|
|
63
69
|
"check:docs": "node scripts/check-docs.mjs",
|
|
64
70
|
"check:package": "node scripts/check-package.mjs",
|
package/sql/20-context.sql
CHANGED
|
@@ -59,8 +59,13 @@ begin
|
|
|
59
59
|
perform @SCHEMA@.lt_fail('sql-core.no_impersonate_right');
|
|
60
60
|
end if;
|
|
61
61
|
select e.id, e.data into a from @SCHEMA@.entity e where e.id = imp::uuid and e.class = 'Account' and e.tenant = '@SYSTEM@'::uuid;
|
|
62
|
+
-- аккаунта нет (не заводили или удалён) — «не найден», а не «нельзя» (3.0): сервис с правом имперсонации отличит
|
|
63
|
+
-- опечатку в id от запрета
|
|
64
|
+
if a.id is null then
|
|
65
|
+
perform @SCHEMA@.lt_fail('sql-core.impersonation_no_account', jsonb_build_object('id', imp));
|
|
66
|
+
end if;
|
|
62
67
|
-- от имени System, сервисов, системных администраторов и отключённых аккаунтов действовать нельзя
|
|
63
|
-
if
|
|
68
|
+
if not coalesce((a.data->>'enabled')::boolean, true) or a.id = '@SYSTEM@'::uuid
|
|
64
69
|
or coalesce(a.data->'categories' ?| array['System', 'Service'], false)
|
|
65
70
|
or exists (select 1 from @SCHEMA@.entity x
|
|
66
71
|
where x.id = @SCHEMA@.v5_id('@SYSTEM@'::uuid, 'member', array[a.id::text, '@SYSTEM@']) and x.class = 'member') then
|
|
@@ -119,7 +124,7 @@ begin
|
|
|
119
124
|
end $$;
|
|
120
125
|
|
|
121
126
|
-- Вход по API-ключу (APIKEY): префикс ищется в ident, ключ сверяется по sha256. Сессия помнит ключ
|
|
122
|
-
-- (meta.cred): отзыв ключа гасит только его сессии.
|
|
127
|
+
-- (meta.cred): отзыв ключа гасит только его сессии. Вход и отзыв синхронизированы по строке ключа (for share).
|
|
123
128
|
create or replace function @SCHEMA@.login_apikey(p_key text) returns text
|
|
124
129
|
language plpgsql volatile security definer
|
|
125
130
|
set search_path = pg_catalog, @SCHEMA@, pg_temp
|
|
@@ -135,7 +140,10 @@ begin
|
|
|
135
140
|
and s.material = sha256(convert_to(p_key, 'UTF8'))
|
|
136
141
|
and (s.expires_at is null or s.expires_at > clock_timestamp())
|
|
137
142
|
and coalesce((s.meta->>'enabled')::boolean, true)
|
|
138
|
-
and coalesce((a.data->>'enabled')::boolean, true)
|
|
143
|
+
and coalesce((a.data->>'enabled')::boolean, true)
|
|
144
|
+
-- строка ключа — под блокировкой до конца транзакции входа: отзыв (auth_revoke_credential) ждёт её
|
|
145
|
+
-- фиксации и гасит открытую здесь сессию, а вход после отзыва ключа не находит
|
|
146
|
+
for share of s;
|
|
139
147
|
if k.account is null then
|
|
140
148
|
-- DETAIL не было и до каталога (raise без detail): p_detail => 'null' (15-errors.sql)
|
|
141
149
|
perform @SCHEMA@.lt_fail('sql-core.bad_api_key', p_detail => 'null');
|
package/sql/30-validate.sql
CHANGED
|
@@ -351,6 +351,14 @@ begin
|
|
|
351
351
|
validator := ('strict ' || expr)::jsonpath;
|
|
352
352
|
end $$;
|
|
353
353
|
|
|
354
|
+
-- Допустимые значения перечисления для сообщения нарушения (план 2.1, A8): JSON через запятую, первые 20 — как у
|
|
355
|
+
-- валидатора на TypeScript (validate.ts, enumValues).
|
|
356
|
+
create or replace function @SCHEMA@.issue_values(p_values jsonb) returns text
|
|
357
|
+
language sql immutable
|
|
358
|
+
set search_path = pg_catalog, @SCHEMA@, pg_temp
|
|
359
|
+
return (select string_agg(t.y::text, ', ' order by t.o) || case when jsonb_array_length(p_values) > 20 then ', …' else '' end
|
|
360
|
+
from jsonb_array_elements(p_values) with ordinality t(y, o) where t.o <= 20);
|
|
361
|
+
|
|
354
362
|
-- Проверка данных: общий вызов jsonb_path_match; при отказе — список нарушений по правилам.
|
|
355
363
|
-- Результат сравнивается через is true: ошибка внутри выражения даёт NULL, а не «да» (этап 1, п. 5).
|
|
356
364
|
-- Нарушение — { path, keyword, message, key, params }: message — русский текст по ключу каталога, рядом его key и
|
|
@@ -364,6 +372,7 @@ declare
|
|
|
364
372
|
ok boolean;
|
|
365
373
|
x jsonb;
|
|
366
374
|
p jsonb;
|
|
375
|
+
t text;
|
|
367
376
|
begin
|
|
368
377
|
ok := jsonb_path_match(p_data, p_validator, p_vars, true) is true;
|
|
369
378
|
if ok and not exists (select 1 from jsonb_array_elements(p_rules) r where r ? 'extra') then
|
|
@@ -373,10 +382,18 @@ begin
|
|
|
373
382
|
if e ? 'extra' then
|
|
374
383
|
for x in select * from jsonb_path_query(p_data, ('strict ' || (e->>'path_expr'))::jsonpath, '{}'::jsonb, true) loop
|
|
375
384
|
if (e->'extra'->>'kind') = 'length' and jsonb_typeof(x) = 'string' then
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
385
|
+
-- текст называет границу (letopis 2.1, второй раунд, A6) — как у валидатора на TypeScript (validate.ts)
|
|
386
|
+
t := null;
|
|
387
|
+
if char_length(x #>> '{}') < coalesce((e->'extra'->>'min')::int, 0) then
|
|
388
|
+
t := 'sql-core.issue_string_too_short';
|
|
389
|
+
p := jsonb_build_object('limit', (e->'extra'->>'min')::int);
|
|
390
|
+
elsif char_length(x #>> '{}') > coalesce((e->'extra'->>'max')::int, 2147483647) then
|
|
391
|
+
t := 'sql-core.issue_string_too_long';
|
|
392
|
+
p := jsonb_build_object('limit', (e->'extra'->>'max')::int);
|
|
393
|
+
end if;
|
|
394
|
+
if t is not null then
|
|
395
|
+
bad := bad || jsonb_build_object('path', e->'path', 'keyword', e->>'keyword', 'message', @SCHEMA@.lt_text(t, p),
|
|
396
|
+
'key', t, 'params', p);
|
|
380
397
|
end if;
|
|
381
398
|
elsif (e->'extra'->>'kind') = 'unique' and jsonb_typeof(x) = 'array' then
|
|
382
399
|
if (select count(*) from jsonb_array_elements(x)) <> (select count(distinct y) from jsonb_array_elements(x) y) then
|
|
@@ -397,16 +414,40 @@ begin
|
|
|
397
414
|
end if;
|
|
398
415
|
elsif (e->'extra'->>'kind') = 'enum' then
|
|
399
416
|
if not exists (select 1 from jsonb_array_elements(e->'extra'->'values') y where y = x) then
|
|
400
|
-
|
|
401
|
-
|
|
417
|
+
p := jsonb_build_object('values', @SCHEMA@.issue_values(e->'extra'->'values'));
|
|
418
|
+
bad := bad || jsonb_build_object('path', e->'path', 'keyword', e->>'keyword', 'message', @SCHEMA@.lt_text('sql-core.issue_enum', p),
|
|
419
|
+
'key', 'sql-core.issue_enum', 'params', p);
|
|
402
420
|
end if;
|
|
403
421
|
end if;
|
|
404
422
|
end loop;
|
|
405
423
|
elsif not ok then
|
|
406
424
|
if jsonb_path_match(p_data, ('strict !exists(' || (e->>'path_expr') || ' ? (!(' || (e->>'cond') || ')))')::jsonpath, e->'vars', true) is not true then
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
425
|
+
t := null;
|
|
426
|
+
if e->>'keyword' in ('enum', 'const') then
|
|
427
|
+
-- перечисление скаляров: значения — переменные правила v<N> (план 2.1, A8)
|
|
428
|
+
p := jsonb_build_object('values', @SCHEMA@.issue_values(
|
|
429
|
+
coalesce((select jsonb_agg(v.value order by (substr(v.key, 2))::int) from jsonb_each(e->'vars') v), '[]'::jsonb)));
|
|
430
|
+
bad := bad || jsonb_build_object('path', e->'path', 'keyword', e->>'keyword', 'message', @SCHEMA@.lt_text('sql-core.issue_enum', p),
|
|
431
|
+
'key', 'sql-core.issue_enum', 'params', p);
|
|
432
|
+
continue;
|
|
433
|
+
elsif e->>'keyword' = 'additionalProperties' then
|
|
434
|
+
-- лишние ключи: ключ, которого условие правила не принимает и в одиночку (план 2.1, A8)
|
|
435
|
+
select string_agg(z.k, ', ' order by z.k) into t from (
|
|
436
|
+
select distinct kv.key as k
|
|
437
|
+
from jsonb_path_query(p_data, ('strict ' || (e->>'path_expr'))::jsonpath, '{}'::jsonb, true) n(v)
|
|
438
|
+
cross join lateral jsonb_each(case when jsonb_typeof(n.v) = 'object' then n.v else '{}'::jsonb end) kv
|
|
439
|
+
where jsonb_path_exists(jsonb_build_object(kv.key, null), ('strict $ ? (' || (e->>'cond') || ')')::jsonpath, e->'vars', true) is not true
|
|
440
|
+
order by 1 limit 20) z;
|
|
441
|
+
end if;
|
|
442
|
+
if t is not null then
|
|
443
|
+
p := jsonb_build_object('keys', t);
|
|
444
|
+
bad := bad || jsonb_build_object('path', e->'path', 'keyword', e->>'keyword', 'message', @SCHEMA@.lt_text('sql-core.issue_additional', p),
|
|
445
|
+
'key', 'sql-core.issue_additional', 'params', p);
|
|
446
|
+
else
|
|
447
|
+
p := jsonb_build_object('keyword', e->>'keyword');
|
|
448
|
+
bad := bad || jsonb_build_object('path', e->'path', 'keyword', e->>'keyword', 'message', @SCHEMA@.lt_text('sql-core.issue_rule', p),
|
|
449
|
+
'key', 'sql-core.issue_rule', 'params', p);
|
|
450
|
+
end if;
|
|
410
451
|
end if;
|
|
411
452
|
end if;
|
|
412
453
|
end loop;
|
|
@@ -423,7 +464,7 @@ begin
|
|
|
423
464
|
foreach f in array array[
|
|
424
465
|
'uuid_v5(uuid, text)', 'v5_name(text, text[])', 'v5_id(uuid, text, text[])', 'jp_str(text)', 'jp_pattern(text)',
|
|
425
466
|
'jp_type_cond(text)', 'jp_prop(text, text)', 'jp_items(text)', 'jp_compile(jsonb, text, jsonb, boolean, int)', 'compile_validator(jsonb)',
|
|
426
|
-
'validate_data(jsonb, jsonpath, jsonb, jsonb)'] loop
|
|
467
|
+
'issue_values(jsonb)', 'validate_data(jsonb, jsonpath, jsonb, jsonb)'] loop
|
|
427
468
|
execute format('alter function @SCHEMA@.%s owner to @OWNER@', f);
|
|
428
469
|
execute format('revoke all on function @SCHEMA@.%s from public', f);
|
|
429
470
|
end loop;
|
package/sql/40-class.sql
CHANGED
|
@@ -209,12 +209,32 @@ begin
|
|
|
209
209
|
validator := c.validator;
|
|
210
210
|
end $$;
|
|
211
211
|
|
|
212
|
+
-- Нарушения данных строки класса по его описанию в базе, без записи (план 2.1, A8): валидатор на TypeScript не исполняет
|
|
213
|
+
-- шаблоны pattern (ревью, ReDoS), и у класса с ними библиотека берёт полный список нарушений здесь. Класс — свой
|
|
214
|
+
-- арендатора сессии или System, как у записи; умолчания подставляются, как перед проверкой при записи. Описание класса
|
|
215
|
+
-- сессия и так видит (реестр), поэтому ответ ничего нового не открывает. Нет класса — null.
|
|
216
|
+
create or replace function @SCHEMA@.validate_row(p_class text, p_data jsonb) returns jsonb
|
|
217
|
+
language plpgsql stable security definer
|
|
218
|
+
set search_path = pg_catalog, @SCHEMA@, pg_temp
|
|
219
|
+
as $$
|
|
220
|
+
declare
|
|
221
|
+
cls @SCHEMA@.entity;
|
|
222
|
+
begin
|
|
223
|
+
cls := @SCHEMA@.class_row(p_class, @SCHEMA@.ctx_tenant());
|
|
224
|
+
if cls.id is null or cls.validator is null then
|
|
225
|
+
return null;
|
|
226
|
+
end if;
|
|
227
|
+
return @SCHEMA@.validate_data(cls.data->'resolved'->'rules', cls.validator, cls.data->'resolved'->'vars',
|
|
228
|
+
@SCHEMA@.apply_defaults(cls.data->'resolved'->'schema', p_data));
|
|
229
|
+
end $$;
|
|
230
|
+
|
|
212
231
|
do $$
|
|
213
232
|
declare f text;
|
|
214
233
|
begin
|
|
215
234
|
foreach f in array array['class_metaschema()', 'class_meta_validator()', 'class_row(text, uuid)',
|
|
216
|
-
'class_resolve(jsonb, uuid)', 'class_prepare(jsonb, uuid)'] loop
|
|
235
|
+
'class_resolve(jsonb, uuid)', 'class_prepare(jsonb, uuid)', 'validate_row(text, jsonb)'] loop
|
|
217
236
|
execute format('alter function @SCHEMA@.%s owner to @OWNER@', f);
|
|
218
237
|
execute format('revoke all on function @SCHEMA@.%s from public', f);
|
|
219
238
|
end loop;
|
|
220
239
|
end $$;
|
|
240
|
+
grant execute on function @SCHEMA@.validate_row(text, jsonb) to @APP@;
|
package/sql/80-auth.sql
CHANGED
|
@@ -52,7 +52,8 @@ begin
|
|
|
52
52
|
if login_at > clock_timestamp() - @SCHEMA@.auth_recent()
|
|
53
53
|
and exists (select 1 from jsonb_array_elements_text(methods) m where m not in ('APIKEY', 'KEYSECRET', 'SERVICE'))
|
|
54
54
|
and (methods ? 'TOTP' or not exists (select 1 from @SCHEMA@.secret t where t.account = p_account and t.kind = 'TOTP'
|
|
55
|
-
and t.deleted_at is null and coalesce((t.meta->>'confirmed')::boolean, false)
|
|
55
|
+
and t.deleted_at is null and coalesce((t.meta->>'confirmed')::boolean, false)
|
|
56
|
+
and coalesce((t.meta->>'enabled')::boolean, true))) then
|
|
56
57
|
return;
|
|
57
58
|
end if;
|
|
58
59
|
end if;
|
|
@@ -326,7 +327,9 @@ begin
|
|
|
326
327
|
and s.material = sha256(convert_to(coalesce(p_key, ''), 'UTF8'))
|
|
327
328
|
and (s.expires_at is null or s.expires_at > clock_timestamp())
|
|
328
329
|
and coalesce((s.meta->>'enabled')::boolean, true)
|
|
329
|
-
and @SCHEMA@.auth_enabled(s.account)
|
|
330
|
+
and @SCHEMA@.auth_enabled(s.account)
|
|
331
|
+
-- строка ключа — под блокировкой до фиксации входа: отзыв ждёт её и гасит открытую здесь сессию
|
|
332
|
+
for share of s;
|
|
330
333
|
if k.account is null then
|
|
331
334
|
return null;
|
|
332
335
|
end if;
|
|
@@ -370,7 +373,9 @@ begin
|
|
|
370
373
|
and casefold(s.ident) collate pg_unicode_fast = casefold(p_key) collate pg_unicode_fast
|
|
371
374
|
and s.material = sha256(convert_to(coalesce(p_secret, ''), 'UTF8'))
|
|
372
375
|
and coalesce((s.meta->>'enabled')::boolean, true)
|
|
373
|
-
and @SCHEMA@.auth_enabled(s.account)
|
|
376
|
+
and @SCHEMA@.auth_enabled(s.account)
|
|
377
|
+
-- строка ключа — под блокировкой до фиксации входа: отзыв ждёт её и гасит открытую здесь сессию
|
|
378
|
+
for share of s;
|
|
374
379
|
if k.account is null then
|
|
375
380
|
return null;
|
|
376
381
|
end if;
|
|
@@ -428,6 +433,7 @@ begin
|
|
|
428
433
|
from @SCHEMA@.secret x
|
|
429
434
|
where x.kind = p_kind and x.kind ~ '^OTP' and x.deleted_at is null
|
|
430
435
|
and casefold(x.ident) collate pg_unicode_fast = casefold(p_ident) collate pg_unicode_fast
|
|
436
|
+
and coalesce((x.meta->>'enabled')::boolean, true) -- отключённый код (setFlags) не принимается
|
|
431
437
|
for update;
|
|
432
438
|
if s.id is null then
|
|
433
439
|
return null;
|
|
@@ -507,6 +513,7 @@ begin
|
|
|
507
513
|
coalesce((x.meta->>'fails')::int, 0) as fails, (x.meta->>'locked_until')::timestamptz as locked_until into s
|
|
508
514
|
from @SCHEMA@.secret x
|
|
509
515
|
where x.account = p_account and x.kind = 'TOTP' and x.deleted_at is null
|
|
516
|
+
and coalesce((x.meta->>'enabled')::boolean, true) -- отключённый фактор (setFlags) не принимается
|
|
510
517
|
for update;
|
|
511
518
|
if s.id is null or s.locked_until > clock_timestamp() then
|
|
512
519
|
return false;
|
|
@@ -547,7 +554,7 @@ begin
|
|
|
547
554
|
perform @SCHEMA@.auth_guard(p_account);
|
|
548
555
|
end if;
|
|
549
556
|
return exists (select 1 from @SCHEMA@.secret s where s.account = p_account and s.kind = 'TOTP' and s.deleted_at is null
|
|
550
|
-
and coalesce((s.meta->>'confirmed')::boolean, false));
|
|
557
|
+
and coalesce((s.meta->>'confirmed')::boolean, false) and coalesce((s.meta->>'enabled')::boolean, true));
|
|
551
558
|
end $$;
|
|
552
559
|
|
|
553
560
|
-- Внешняя identity (TELEGRAM, GOOGLE, SSO-провайдер): токен проверяет сервис, здесь — связка.
|
|
@@ -639,7 +646,10 @@ begin
|
|
|
639
646
|
perform @SCHEMA@.auth_session_required(c.account);
|
|
640
647
|
select s.account, s.kind,
|
|
641
648
|
(s.expires_at is null or s.expires_at > clock_timestamp()) and coalesce((s.meta->>'enabled')::boolean, true) as live
|
|
642
|
-
into cr from @SCHEMA@.secret s where s.id = p_id and s.kind not in ('SESSION', 'KEY', 'LOCK') and s.deleted_at is null
|
|
649
|
+
into cr from @SCHEMA@.secret s where s.id = p_id and s.kind not in ('SESSION', 'KEY', 'LOCK') and s.deleted_at is null
|
|
650
|
+
-- строка креда — под блокировкой: вход по этому ключу, начатый раньше, отзыв дожидается (его сессию гасит
|
|
651
|
+
-- auth_kill_cred ниже), начатый позже — ждёт отзыва и ключа уже не находит (login_apikey, login_keysecret)
|
|
652
|
+
for update;
|
|
643
653
|
if cr.account is null then
|
|
644
654
|
return false;
|
|
645
655
|
end if;
|
|
@@ -677,17 +687,47 @@ set search_path = pg_catalog, @SCHEMA@, pg_temp
|
|
|
677
687
|
as $$
|
|
678
688
|
declare
|
|
679
689
|
acc uuid;
|
|
690
|
+
knd text;
|
|
691
|
+
live boolean;
|
|
680
692
|
begin
|
|
681
693
|
perform @SCHEMA@.acl_require('auth.credential');
|
|
682
|
-
|
|
694
|
+
-- строка креда — под блокировкой, как при отзыве (auth_revoke_credential): вход по этому ключу, начатый раньше,
|
|
695
|
+
-- отключение дожидается и гасит его сессию ниже; начатый позже — ждёт и видит enabled = false (3.0.0, ревизия 11)
|
|
696
|
+
select s.account, s.kind,
|
|
697
|
+
(s.expires_at is null or s.expires_at > clock_timestamp()) and coalesce((s.meta->>'enabled')::boolean, true)
|
|
698
|
+
into acc, knd, live
|
|
699
|
+
from @SCHEMA@.secret s where s.id = p_id and s.kind not in ('SESSION', 'KEY', 'LOCK') and s.deleted_at is null
|
|
700
|
+
for update;
|
|
683
701
|
if acc is null then
|
|
684
702
|
return false;
|
|
685
703
|
end if;
|
|
686
704
|
perform @SCHEMA@.auth_guard(acc);
|
|
705
|
+
if p_enabled is false and live and knd in ('APIKEY', 'KEYSECRET') and @SCHEMA@.auth_is_service(acc) then
|
|
706
|
+
-- последний действующий ключ сервиса не отключается, как и не отзывается
|
|
707
|
+
perform 1 from @SCHEMA@.secret s
|
|
708
|
+
where s.account = acc and s.kind in ('APIKEY', 'KEYSECRET') and s.deleted_at is null
|
|
709
|
+
for update;
|
|
710
|
+
if not exists (select 1 from @SCHEMA@.secret s
|
|
711
|
+
where s.account = acc and s.kind in ('APIKEY', 'KEYSECRET') and s.deleted_at is null and s.id <> p_id
|
|
712
|
+
and (s.expires_at is null or s.expires_at > clock_timestamp()) and coalesce((s.meta->>'enabled')::boolean, true)) then
|
|
713
|
+
perform @SCHEMA@.lt_fail('sql-access.last_service_key');
|
|
714
|
+
end if;
|
|
715
|
+
end if;
|
|
687
716
|
update @SCHEMA@.secret s
|
|
688
717
|
set meta = s.meta || jsonb_strip_nulls(jsonb_build_object('confirmed', p_confirmed, 'enabled', p_enabled))
|
|
689
718
|
where s.id = p_id;
|
|
690
|
-
|
|
719
|
+
if not found then
|
|
720
|
+
return false;
|
|
721
|
+
end if;
|
|
722
|
+
-- отключённый кред не должен оставлять действующих сессий: ключ — свои (auth_kill_cred), прочие — сессии аккаунта
|
|
723
|
+
if p_enabled is false then
|
|
724
|
+
if knd in ('APIKEY', 'KEYSECRET') then
|
|
725
|
+
perform @SCHEMA@.auth_kill_cred(acc, p_id, knd, null);
|
|
726
|
+
else
|
|
727
|
+
perform @SCHEMA@.auth_kill(acc, null);
|
|
728
|
+
end if;
|
|
729
|
+
end if;
|
|
730
|
+
return true;
|
|
691
731
|
end $$;
|
|
692
732
|
|
|
693
733
|
-- Продление текущей сессии (библиотека — не чаще раза в минуту, §2.9). Новый срок — от сейчас.
|
|
@@ -725,6 +765,31 @@ begin
|
|
|
725
765
|
return m is not null;
|
|
726
766
|
end $$;
|
|
727
767
|
|
|
768
|
+
-- Выход пользователя по токену его сессии (db.auth.revoke(token), план 2.1, A3): сервис, у которого есть токен
|
|
769
|
+
-- пользователя (например, из cookie), гасит эту сессию, не подключаясь по ней. Право auth.session, как у
|
|
770
|
+
-- auth_session_info. Сессия ищется только среди SESSION по sha256 токена; погашенная — сигнал session:… кэшу, как у
|
|
771
|
+
-- auth_logout. Неверный, погашенный или истёкший токен — false: причина не различается.
|
|
772
|
+
create or replace function @SCHEMA@.auth_logout_token(p_token text) returns boolean
|
|
773
|
+
language plpgsql volatile security definer
|
|
774
|
+
set search_path = pg_catalog, @SCHEMA@, pg_temp
|
|
775
|
+
as $$
|
|
776
|
+
declare
|
|
777
|
+
c record;
|
|
778
|
+
m bytea;
|
|
779
|
+
begin
|
|
780
|
+
select * into c from @SCHEMA@.ctx();
|
|
781
|
+
perform @SCHEMA@.auth_session_required(c.account);
|
|
782
|
+
perform @SCHEMA@.acl_require('auth.session');
|
|
783
|
+
update @SCHEMA@.secret s set deleted_at = clock_timestamp()
|
|
784
|
+
where s.kind = 'SESSION' and s.deleted_at is null and s.material = sha256(convert_to(coalesce(p_token, ''), 'UTF8'))
|
|
785
|
+
and (s.expires_at is null or s.expires_at > clock_timestamp())
|
|
786
|
+
returning s.material into m;
|
|
787
|
+
if m is not null then
|
|
788
|
+
perform @SCHEMA@.signal_raw(array['session:' || left(encode(m, 'hex'), 16)]);
|
|
789
|
+
end if;
|
|
790
|
+
return m is not null;
|
|
791
|
+
end $$;
|
|
792
|
+
|
|
728
793
|
-- Выход везде: все сессии аккаунта (своего — сам; чужого — с правом auth.session).
|
|
729
794
|
create or replace function @SCHEMA@.auth_logout_all(p_account uuid default null) returns int
|
|
730
795
|
language plpgsql volatile security definer
|
|
@@ -777,8 +842,10 @@ begin
|
|
|
777
842
|
return p_tenant;
|
|
778
843
|
end $$;
|
|
779
844
|
|
|
780
|
-
-- Арендаторы, доступные аккаунту сессии: собственный и по членству (с ролями).
|
|
781
|
-
|
|
845
|
+
-- Арендаторы, доступные аккаунту сессии: собственный и по членству (с ролями). С ревизии 9 (план 2.1, A5) — ещё имя
|
|
846
|
+
-- аккаунта арендатора (строка Account в System: createTenant({ name }), у личного — имя пользователя) и признак
|
|
847
|
+
-- собственного арендатора personal; прежняя форма (tenant, roles) снимается миграцией 0009.
|
|
848
|
+
create or replace function @SCHEMA@.auth_tenants() returns table (tenant uuid, roles jsonb, name text, personal boolean)
|
|
782
849
|
language plpgsql stable security definer
|
|
783
850
|
set search_path = pg_catalog, @SCHEMA@, pg_temp
|
|
784
851
|
as $$
|
|
@@ -789,11 +856,14 @@ begin
|
|
|
789
856
|
select * into c from @SCHEMA@.ctx();
|
|
790
857
|
perform @SCHEMA@.auth_session_required(c.account);
|
|
791
858
|
return query
|
|
792
|
-
select
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
859
|
+
select t.tenant, t.roles, a.data->>'name', t.personal
|
|
860
|
+
from (select c.account as tenant, '["owner"]'::jsonb as roles, true as personal, 0 as ord
|
|
861
|
+
union all
|
|
862
|
+
select m.tenant, coalesce(m.data->'roles', '[]'::jsonb), false, 1
|
|
863
|
+
from @SCHEMA@.entity m
|
|
864
|
+
where m.class = 'member' and m.links->>'User' = c.account::text and @SCHEMA@.auth_enabled(m.tenant)) t
|
|
865
|
+
left join @SCHEMA@.entity a on a.id = t.tenant and a.class = 'Account' and a.tenant = '@SYSTEM@'::uuid
|
|
866
|
+
order by t.ord, t.tenant;
|
|
797
867
|
end $$;
|
|
798
868
|
|
|
799
869
|
-- Сведения о сессии по токену (db.auth.session): сервис, у которого есть токен пользователя (например, из
|
|
@@ -845,7 +915,8 @@ begin
|
|
|
845
915
|
'login_otp(text, text, text)', 'totp_code(bytea, bigint)', 'auth_enroll_totp(uuid)', 'auth_verify_totp(uuid, text, int)',
|
|
846
916
|
'auth_totp_enabled(uuid)', 'auth_link(uuid, text, text, jsonb, boolean)', 'auth_lookup(text, text, boolean)',
|
|
847
917
|
'auth_credentials(uuid)', 'auth_revoke_credential(uuid, boolean)', 'auth_set_flags(uuid, boolean, boolean)',
|
|
848
|
-
'auth_refresh(interval)', 'auth_logout()', '
|
|
918
|
+
'auth_refresh(interval)', 'auth_logout()', 'auth_logout_token(text)', 'auth_logout_all(uuid)', 'auth_switch(uuid)', 'auth_tenants()',
|
|
919
|
+
'auth_session_info(text)'] loop
|
|
849
920
|
execute format('alter function @SCHEMA@.%s owner to @OWNER@', f);
|
|
850
921
|
execute format('revoke all on function @SCHEMA@.%s from public', f);
|
|
851
922
|
end loop;
|
|
@@ -857,5 +928,5 @@ grant execute on function
|
|
|
857
928
|
@SCHEMA@.login_otp(text, text, text), @SCHEMA@.auth_enroll_totp(uuid), @SCHEMA@.auth_verify_totp(uuid, text, int),
|
|
858
929
|
@SCHEMA@.auth_totp_enabled(uuid), @SCHEMA@.auth_link(uuid, text, text, jsonb, boolean), @SCHEMA@.auth_lookup(text, text, boolean),
|
|
859
930
|
@SCHEMA@.auth_credentials(uuid), @SCHEMA@.auth_revoke_credential(uuid, boolean), @SCHEMA@.auth_set_flags(uuid, boolean, boolean),
|
|
860
|
-
@SCHEMA@.auth_refresh(interval), @SCHEMA@.auth_logout(), @SCHEMA@.
|
|
861
|
-
@SCHEMA@.auth_tenants(), @SCHEMA@.auth_session_info(text) to @APP@;
|
|
931
|
+
@SCHEMA@.auth_refresh(interval), @SCHEMA@.auth_logout(), @SCHEMA@.auth_logout_token(text), @SCHEMA@.auth_logout_all(uuid),
|
|
932
|
+
@SCHEMA@.auth_switch(uuid), @SCHEMA@.auth_tenants(), @SCHEMA@.auth_session_info(text) to @APP@;
|
package/sql/90-time.sql
CHANGED
|
@@ -109,8 +109,13 @@ end $$;
|
|
|
109
109
|
|
|
110
110
|
-- Восстановление удалённого объекта (этап 6, п. 2): последний снимок новой версией с видом restore.
|
|
111
111
|
-- Снимок перепроверяется по текущему описанию класса (данные, концы с видимостью целей, ключ), право
|
|
112
|
-
-- WRITE — на восстановленные значения. id прежний, в том числе у класса без ключа.
|
|
113
|
-
|
|
112
|
+
-- WRITE — на восстановленные значения. id прежний, в том числе у класса без ключа. p_rev (ревизия 9, план 2.1, A1) —
|
|
113
|
+
-- ожидаемый номер последней записи журнала (надгробия из ответа delete или withDeleted): другой — отказ restore_stale,
|
|
114
|
+
-- проверка под той же блокировкой записи журнала, что у параллельного restore и воскрешения. Прежняя restore(uuid)
|
|
115
|
+
-- снимается миграцией 0009: вызов restore(id) попадает сюда с p_rev = null. Отказ — по записи журнала за последним
|
|
116
|
+
-- надгробием (второй раунд 2.1, A2): restore — restore_already, create — restore_id_taken (объект живой); иначе с p_rev —
|
|
117
|
+
-- restore_stale с after (вид записи сразу за p_rev), deleted и live, без p_rev — restore_not_deleted.
|
|
118
|
+
create or replace function @SCHEMA@.restore(p_id uuid, p_rev int default null) returns uuid
|
|
114
119
|
language plpgsql volatile security definer
|
|
115
120
|
set search_path = pg_catalog, @SCHEMA@, pg_temp
|
|
116
121
|
as $$
|
|
@@ -127,6 +132,9 @@ declare
|
|
|
127
132
|
own uuid;
|
|
128
133
|
ts timestamptz;
|
|
129
134
|
n @SCHEMA@.entity;
|
|
135
|
+
v_live boolean;
|
|
136
|
+
v_tomb int;
|
|
137
|
+
v_next text;
|
|
130
138
|
begin
|
|
131
139
|
select * into c from @SCHEMA@.ctx();
|
|
132
140
|
if c.account is null then
|
|
@@ -134,8 +142,28 @@ begin
|
|
|
134
142
|
end if;
|
|
135
143
|
rd := @SCHEMA@.acl_actor('READ');
|
|
136
144
|
l := @SCHEMA@.last_version_visible(p_id, c.tenant, rd);
|
|
137
|
-
|
|
138
|
-
|
|
145
|
+
-- проигравший гонку: блокировка дождалась чужой фиксации, а запись l прочитана до неё. Новая версия видна следующему
|
|
146
|
+
-- оператору (read committed) — тогда последняя запись берётся заново, под той же блокировкой и проверкой видимости
|
|
147
|
+
if exists (select 1 from @SCHEMA@.log x where x.id = p_id and x.tenant = c.tenant and x.rev > l.rev) then
|
|
148
|
+
l := @SCHEMA@.last_version_visible(p_id, c.tenant, rd);
|
|
149
|
+
end if;
|
|
150
|
+
v_live := exists (select 1 from @SCHEMA@.entity e where e.id = p_id);
|
|
151
|
+
if l.op <> 'delete' or v_live or (p_rev is not null and l.rev <> p_rev) then
|
|
152
|
+
-- что за последним надгробием: restore — уже восстановлен, create — id занял новый объект с тем же ключом
|
|
153
|
+
select max(x.rev) into v_tomb from @SCHEMA@.log x where x.id = p_id and x.tenant = c.tenant and x.op = 'delete';
|
|
154
|
+
select x.op into v_next from @SCHEMA@.log x where x.id = p_id and x.tenant = c.tenant and x.rev > v_tomb order by x.rev limit 1;
|
|
155
|
+
if v_live and v_next = 'restore' then
|
|
156
|
+
perform @SCHEMA@.lt_fail('sql-time.restore_already', jsonb_build_object('id', p_id), jsonb_build_object('id', p_id, 'rev', l.rev));
|
|
157
|
+
elsif v_live and v_next = 'create' then
|
|
158
|
+
perform @SCHEMA@.lt_fail('sql-time.restore_id_taken', jsonb_build_object('id', p_id), jsonb_build_object('id', p_id, 'rev', l.rev));
|
|
159
|
+
elsif p_rev is null then
|
|
160
|
+
perform @SCHEMA@.lt_fail('sql-time.restore_not_deleted');
|
|
161
|
+
end if;
|
|
162
|
+
-- удалён снова, не удалялся, гонка: что было сразу после показанного удаления
|
|
163
|
+
select x.op into v_next from @SCHEMA@.log x where x.id = p_id and x.tenant = c.tenant and x.rev > p_rev order by x.rev limit 1;
|
|
164
|
+
perform @SCHEMA@.lt_fail('sql-time.restore_stale',
|
|
165
|
+
jsonb_build_object('id', p_id, 'rev', l.rev, 'expected', p_rev, 'after', coalesce(v_next, '—')),
|
|
166
|
+
jsonb_build_object('id', p_id, 'rev', l.rev, 'expected', p_rev, 'after', v_next, 'deleted', l.op = 'delete', 'live', v_live));
|
|
139
167
|
end if;
|
|
140
168
|
if l.class = 'Class' then
|
|
141
169
|
perform @SCHEMA@.lt_fail('sql-time.restore_class_row');
|
|
@@ -370,13 +398,13 @@ declare f text;
|
|
|
370
398
|
begin
|
|
371
399
|
foreach f in array array[
|
|
372
400
|
'session_role()', 'admin_role()', 'letopis_allowed(text)', 'session_expires()', 'signal_mode()', 'signal_key()', 'class_stamp()',
|
|
373
|
-
'last_version_visible(uuid, uuid, jsonb)', 'restore(uuid)', 'account_purge(uuid)', 'maintain()',
|
|
401
|
+
'last_version_visible(uuid, uuid, jsonb)', 'restore(uuid, int)', 'account_purge(uuid)', 'maintain()',
|
|
374
402
|
'watch_read(xid8, bigint, text[], int, pg_snapshot)', 'watch_head()'] loop
|
|
375
403
|
execute format('alter function @SCHEMA@.%s owner to @OWNER@', f);
|
|
376
404
|
execute format('revoke all on function @SCHEMA@.%s from public', f);
|
|
377
405
|
end loop;
|
|
378
406
|
end $$;
|
|
379
407
|
-- приложению: функции владельца этапа 6 (проверяют права сами) и подписка (под RLS)
|
|
380
|
-
grant execute on function @SCHEMA@.restore(uuid), @SCHEMA@.account_purge(uuid), @SCHEMA@.maintain(),
|
|
408
|
+
grant execute on function @SCHEMA@.restore(uuid, int), @SCHEMA@.account_purge(uuid), @SCHEMA@.maintain(),
|
|
381
409
|
@SCHEMA@.session_expires(), @SCHEMA@.watch_read(xid8, bigint, text[], int, pg_snapshot), @SCHEMA@.watch_head(),
|
|
382
410
|
@SCHEMA@.signal_mode(), @SCHEMA@.signal_key(), @SCHEMA@.class_stamp() to @APP@;
|
package/sql/99-revision.sql
CHANGED
|
@@ -2,4 +2,8 @@
|
|
|
2
2
|
-- Номер совпадает с DDL_REVISION в lib/src/types.ts (проверяет check-docs). С 1.4 в штампе ещё версия пакета и хэш
|
|
3
3
|
-- файлов ядра (engine, sql): connect() предупреждает, если функции схемы ставила другая версия движка той же ревизии.
|
|
4
4
|
-- Ревизия 8 — первая у 2.0 (план 2.0, редакция 2): схему ниже неё (letopis 1.x) up() и connect() отклоняют.
|
|
5
|
-
|
|
5
|
+
-- Ревизия 9 — 2.1.0: restore(id, rev), auth_logout_token(), auth_tenants() с именем, validate_row(); миграция 0009.
|
|
6
|
+
-- Ревизия 10 — 3.0.0: ctx() — имперсонация несуществующего аккаунта — «не найден» (target_not_found); миграция 0010.
|
|
7
|
+
-- Ревизия 11 — 3.0.0: вход по ключу и отзыв ключа — под блокировкой строки ключа, отключённые OTP и TOTP не
|
|
8
|
+
-- принимаются; миграция 0011.
|
|
9
|
+
comment on schema @SCHEMA@ is 'letopis ddl_revision=11 engine=@ENGINE_VERSION@ sql=@ENGINE_SQL@';
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
-- letopis 2.1.0 — ревизия 9 (план 2.1, A1, A3, A5). Миграция снимает функции, чью сигнатуру или вид результата меняет
|
|
2
|
+
-- ревизия: create or replace их не заменит (новый аргумент дал бы вторую перегрузку и неоднозначный вызов restore(id),
|
|
3
|
+
-- новый вид результата PostgreSQL не принимает). Новые функции и права ставит повторный накат файлов ядра сразу после
|
|
4
|
+
-- миграции (90-time.sql, 80-auth.sql); новая auth_logout_token(text) и validate_row(text, jsonb) — тоже он.
|
|
5
|
+
-- Второй раунд 2.1 (ревизия та же, 9): новые тела restore(uuid, int) — отказы по смыслу restore_already, restore_id_taken,
|
|
6
|
+
-- restore_stale с after, deleted, live — и validate_data(jsonb, jsonpath, jsonb, jsonb) — граница в тексте длины строки
|
|
7
|
+
-- (sql-core.issue_string_too_short, issue_string_too_long) — сигнатур не меняют: их ставит тот же повторный накат, а
|
|
8
|
+
-- тексты новых ключей — каталог сообщений (message_catalog), который накат пишет заново. Снимать здесь нечего.
|
|
9
|
+
drop function if exists @SCHEMA@.restore(uuid);
|
|
10
|
+
drop function if exists @SCHEMA@.auth_tenants();
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
-- letopis 3.0.0 — ревизия 10. Новое тело ctx() (20-context.sql): имперсонация аккаунта, которого нет, — отказ
|
|
2
|
+
-- sql-core.impersonation_no_account (target_not_found) вместо sql-core.impersonation_denied (acl_denied). Сигнатура та же:
|
|
3
|
+
-- тело ставит повторный накат файлов ядра сразу после миграции, текст ключа — каталог сообщений (message_catalog),
|
|
4
|
+
-- который накат пишет заново. Снимать и переносить нечего; оператор ниже — чтобы файл не был пустым запросом.
|
|
5
|
+
select 1;
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
-- letopis 3.0.0 — ревизия 11. Новые тела функций входа и кредов (20-context.sql, 80-auth.sql): вход по ключу
|
|
2
|
+
-- (login_apikey, auth_login_apikey, login_keysecret) берёт строку ключа for share, отзыв (auth_revoke_credential) —
|
|
3
|
+
-- for update: сессия, открытая параллельно с отзывом, гаснет вместе с ключом; отключённые (meta.enabled = false) OTP
|
|
4
|
+
-- и TOTP не принимаются (login_otp, auth_verify_totp, auth_totp_enabled); отключение креда (auth_set_flags) берёт его
|
|
5
|
+
-- строку for update и гасит его сессии, последний ключ сервиса не отключается. Сигнатуры те же: тела ставит повторный
|
|
6
|
+
-- накат файлов ядра сразу после миграции. Снимать и переносить нечего; оператор ниже — чтобы файл не был пустым запросом.
|
|
7
|
+
select 1;
|
package/sql/migrate/README.md
CHANGED
|
@@ -11,4 +11,8 @@
|
|
|
11
11
|
у файлов ядра (`@SCHEMA@`, `@OWNER@` и другие — таблица в `lib/sql/README.md`).
|
|
12
12
|
- В миграцию идёт только то, чего не догонит повторный накат: колонка или ограничение существующей таблицы, перенос
|
|
13
13
|
данных. Функции, индексы, триггеры и политики ставят файлы ядра.
|
|
14
|
-
- У 2.0 (ревизия 8) миграций нет.
|
|
14
|
+
- У 2.0 (ревизия 8) миграций нет. Ревизия 9 (2.1.0) — `0009-restore-rev.tenants-name.sql`: снимает `restore(uuid)` и
|
|
15
|
+
`auth_tenants()` прежнего вида, новые ставит повторный накат. Ревизия 10 (3.0.0) — `0010-impersonation-no-account.sql`:
|
|
16
|
+
новое тело `ctx()` ставит повторный накат, миграции переносить нечего. Ревизия 11 (3.0.0) —
|
|
17
|
+
`0011-key-lock.otp-enabled.sql`: новые тела функций входа по ключу, отзыва креда, OTP и TOTP ставит повторный накат,
|
|
18
|
+
миграции переносить нечего.
|