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.
Files changed (75) hide show
  1. package/AGENT-CHEATSHEET.md +484 -414
  2. package/CHANGELOG.md +321 -0
  3. package/LICENSE +75 -21
  4. package/LICENSE-COMMERCIAL.md +49 -0
  5. package/README.md +786 -183
  6. package/api-contract.json +381 -0
  7. package/dist/acl.d.ts +3 -0
  8. package/dist/acl.js +19 -2
  9. package/dist/admin.d.ts +2 -1
  10. package/dist/admin.js +6 -4
  11. package/dist/auth.d.ts +18 -3
  12. package/dist/auth.js +75 -6
  13. package/dist/chain.d.ts +38 -17
  14. package/dist/chain.js +434 -63
  15. package/dist/cli.js +28 -6
  16. package/dist/errors.d.ts +19 -1
  17. package/dist/errors.js +46 -6
  18. package/dist/index.d.ts +10 -4
  19. package/dist/index.js +136 -46
  20. package/dist/internal.d.ts +2 -0
  21. package/dist/load.d.ts +9 -5
  22. package/dist/load.js +30 -11
  23. package/dist/log.d.ts +121 -0
  24. package/dist/log.js +633 -0
  25. package/dist/mcp.js +26 -11
  26. package/dist/messages/api.d.ts +66 -0
  27. package/dist/messages/api.js +90 -1
  28. package/dist/messages/boot.js +4 -4
  29. package/dist/messages/codes.js +4 -4
  30. package/dist/messages/index.d.ts +5 -2
  31. package/dist/messages/index.js +5 -2
  32. package/dist/messages/log.d.ts +229 -0
  33. package/dist/messages/log.js +102 -0
  34. package/dist/messages/model.d.ts +24 -1
  35. package/dist/messages/model.js +34 -3
  36. package/dist/messages/query.d.ts +10 -0
  37. package/dist/messages/query.js +13 -0
  38. package/dist/messages/sql-core.d.ts +15 -1
  39. package/dist/messages/sql-core.js +20 -3
  40. package/dist/messages/sql-time.d.ts +15 -0
  41. package/dist/messages/sql-time.js +23 -0
  42. package/dist/messages/write.d.ts +47 -0
  43. package/dist/messages/write.js +63 -0
  44. package/dist/model.d.ts +4 -0
  45. package/dist/model.js +23 -5
  46. package/dist/sql.d.ts +2 -0
  47. package/dist/sql.js +77 -25
  48. package/dist/sync.d.ts +1 -1
  49. package/dist/sync.js +50 -0
  50. package/dist/tx.d.ts +10 -1
  51. package/dist/tx.js +44 -2
  52. package/dist/typed.d.ts +14 -10
  53. package/dist/types.d.ts +82 -4
  54. package/dist/types.js +6 -1
  55. package/dist/up.d.ts +3 -3
  56. package/dist/up.js +57 -17
  57. package/dist/uuid.d.ts +8 -0
  58. package/dist/uuid.js +9 -1
  59. package/dist/validate.js +24 -7
  60. package/dist/watch.d.ts +5 -0
  61. package/dist/watch.js +13 -0
  62. package/dist/write.d.ts +78 -1
  63. package/dist/write.js +357 -39
  64. package/llms.txt +37 -17
  65. package/package.json +12 -6
  66. package/sql/20-context.sql +11 -3
  67. package/sql/30-validate.sql +51 -10
  68. package/sql/40-class.sql +21 -1
  69. package/sql/80-auth.sql +88 -17
  70. package/sql/90-time.sql +34 -6
  71. package/sql/99-revision.sql +5 -1
  72. package/sql/migrate/0009-restore-rev.tenants-name.sql +10 -0
  73. package/sql/migrate/0010-impersonation-no-account.sql +5 -0
  74. package/sql/migrate/0011-key-lock.otp-enabled.sql +7 -0
  75. package/sql/migrate/README.md +5 -1
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "letopis",
3
- "version": "2.0.0",
4
- "description": "Letopis (летопись): versioned entity store on PostgreSQL 18 — integrity, history, tenants and access control enforced by the database itself, dot-notation chains on top",
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": "MIT",
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",
@@ -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 a.id is null or not coalesce((a.data->>'enabled')::boolean, true) or a.id = '@SYSTEM@'::uuid
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');
@@ -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
- if char_length(x #>> '{}') < coalesce((e->'extra'->>'min')::int, 0)
377
- or char_length(x #>> '{}') > coalesce((e->'extra'->>'max')::int, 2147483647) then
378
- bad := bad || jsonb_build_object('path', e->'path', 'keyword', e->>'keyword', 'message', @SCHEMA@.lt_text('sql-core.issue_string_length'),
379
- 'key', 'sql-core.issue_string_length', 'params', '{}'::jsonb);
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
- bad := bad || jsonb_build_object('path', e->'path', 'keyword', e->>'keyword', 'message', @SCHEMA@.lt_text('sql-core.issue_enum'),
401
- 'key', 'sql-core.issue_enum', 'params', '{}'::jsonb);
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
- p := jsonb_build_object('keyword', e->>'keyword');
408
- bad := bad || jsonb_build_object('path', e->'path', 'keyword', e->>'keyword', 'message', @SCHEMA@.lt_text('sql-core.issue_rule', p),
409
- 'key', 'sql-core.issue_rule', 'params', p);
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))) then
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
- select s.account into acc from @SCHEMA@.secret s where s.id = p_id and s.kind not in ('SESSION', 'KEY', 'LOCK') and s.deleted_at is null;
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
- return found;
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
- create or replace function @SCHEMA@.auth_tenants() returns table (tenant uuid, roles jsonb)
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 c.account, '["owner"]'::jsonb
793
- union all
794
- select m.tenant, coalesce(m.data->'roles', '[]'::jsonb)
795
- from @SCHEMA@.entity m
796
- where m.class = 'member' and m.links->>'User' = c.account::text and @SCHEMA@.auth_enabled(m.tenant);
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()', 'auth_logout_all(uuid)', 'auth_switch(uuid)', 'auth_tenants()', 'auth_session_info(text)'] loop
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@.auth_logout_all(uuid), @SCHEMA@.auth_switch(uuid),
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
- create or replace function @SCHEMA@.restore(p_id uuid) returns uuid
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
- if l.op <> 'delete' or exists (select 1 from @SCHEMA@.entity e where e.id = p_id) then
138
- perform @SCHEMA@.lt_fail('sql-time.restore_not_deleted');
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@;
@@ -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
- comment on schema @SCHEMA@ is 'letopis ddl_revision=8 engine=@ENGINE_VERSION@ sql=@ENGINE_SQL@';
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;
@@ -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
+ миграции переносить нечего.