letopis 2.0.0 → 3.0.1

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 (83) hide show
  1. package/AGENT-CHEATSHEET.md +491 -414
  2. package/CHANGELOG.md +361 -0
  3. package/LICENSE +75 -21
  4. package/LICENSE-COMMERCIAL.md +49 -0
  5. package/README.md +969 -210
  6. package/api-contract.json +385 -0
  7. package/dist/acl.d.ts +7 -2
  8. package/dist/acl.js +19 -2
  9. package/dist/admin.d.ts +7 -1
  10. package/dist/admin.js +16 -9
  11. package/dist/auth.d.ts +47 -11
  12. package/dist/auth.js +129 -15
  13. package/dist/chain.d.ts +52 -25
  14. package/dist/chain.js +445 -64
  15. package/dist/cli.js +72 -20
  16. package/dist/errors.d.ts +23 -6
  17. package/dist/errors.js +52 -13
  18. package/dist/index.d.ts +32 -15
  19. package/dist/index.js +209 -68
  20. package/dist/internal.d.ts +2 -0
  21. package/dist/load.d.ts +10 -6
  22. package/dist/load.js +30 -11
  23. package/dist/log.d.ts +121 -0
  24. package/dist/log.js +654 -0
  25. package/dist/mcp.d.ts +1 -1
  26. package/dist/mcp.js +34 -16
  27. package/dist/messages/access.d.ts +10 -0
  28. package/dist/messages/access.js +13 -0
  29. package/dist/messages/api.d.ts +76 -0
  30. package/dist/messages/api.js +109 -7
  31. package/dist/messages/boot.d.ts +5 -0
  32. package/dist/messages/boot.js +11 -4
  33. package/dist/messages/codes.js +4 -4
  34. package/dist/messages/index.d.ts +5 -2
  35. package/dist/messages/index.js +5 -2
  36. package/dist/messages/log.d.ts +232 -0
  37. package/dist/messages/log.js +105 -0
  38. package/dist/messages/mcp.js +7 -7
  39. package/dist/messages/model.d.ts +40 -1
  40. package/dist/messages/model.js +52 -3
  41. package/dist/messages/query.d.ts +10 -0
  42. package/dist/messages/query.js +13 -0
  43. package/dist/messages/sql-core.d.ts +15 -1
  44. package/dist/messages/sql-core.js +20 -3
  45. package/dist/messages/sql-time.d.ts +15 -0
  46. package/dist/messages/sql-time.js +23 -0
  47. package/dist/messages/write.d.ts +52 -0
  48. package/dist/messages/write.js +77 -6
  49. package/dist/model.d.ts +10 -2
  50. package/dist/model.js +28 -6
  51. package/dist/registry.d.ts +6 -0
  52. package/dist/registry.js +6 -0
  53. package/dist/sql.d.ts +2 -0
  54. package/dist/sql.js +77 -25
  55. package/dist/sync.d.ts +4 -5
  56. package/dist/sync.js +53 -4
  57. package/dist/tx.d.ts +10 -1
  58. package/dist/tx.js +44 -2
  59. package/dist/typed.d.ts +18 -14
  60. package/dist/types.d.ts +91 -8
  61. package/dist/types.js +8 -3
  62. package/dist/up.d.ts +8 -5
  63. package/dist/up.js +71 -23
  64. package/dist/uuid.d.ts +8 -0
  65. package/dist/uuid.js +9 -1
  66. package/dist/validate.js +24 -7
  67. package/dist/watch.d.ts +5 -0
  68. package/dist/watch.js +13 -0
  69. package/dist/write.d.ts +84 -3
  70. package/dist/write.js +427 -40
  71. package/llms.txt +38 -17
  72. package/package.json +12 -6
  73. package/sql/20-context.sql +11 -3
  74. package/sql/30-validate.sql +51 -10
  75. package/sql/40-class.sql +21 -1
  76. package/sql/60-write.sql +18 -6
  77. package/sql/80-auth.sql +88 -17
  78. package/sql/90-time.sql +34 -6
  79. package/sql/99-revision.sql +5 -1
  80. package/sql/migrate/0009-restore-rev.tenants-name.sql +10 -0
  81. package/sql/migrate/0010-impersonation-no-account.sql +5 -0
  82. package/sql/migrate/0011-key-lock.otp-enabled.sql +7 -0
  83. package/sql/migrate/README.md +5 -1
package/llms.txt CHANGED
@@ -1,30 +1,51 @@
1
1
  # letopis
2
2
 
3
- > Хранилище версионированных объектов для Node.js на PostgreSQL 18. Каждое изменение — версия в журнале (история, чтение на момент, подписка по курсору). Целостность, изоляцию арендаторов и права доступа проверяет сама база (триггеры и политики RLS), поэтому они действуют и для сырого SQL. Данные читают и пишут цепочками вида `db.Shop(shop).Product().rows()`.
3
+ > ИИ-агенту: код на letopis пишите по [AGENT-CHEATSHEET.md](AGENT-CHEATSHEET.md) — памятка лежит в пакете рядом с этим файлом (`node_modules/letopis/AGENT-CHEATSHEET.md`); прочитайте её целиком: «Правила для любой задачи», рабочий цикл и «Грабли».
4
+ >
5
+ > Хранилище версионированных объектов для Node.js на PostgreSQL 18, версия 3.0. Каждое изменение — версия в журнале (история, чтение на момент, подписка по курсору). Целостность, изоляцию арендаторов и права доступа проверяет сама база (триггеры и политики RLS), поэтому они действуют и для сырого SQL. Данные читают и пишут цепочками вида `db.Team(g).Item().rows()`.
6
+
7
+ Безопасность по умолчанию (одна строка — одно правило):
8
+
9
+ - Удаление — только по превью: `delete()` → показать → `delete({ confirm: true, expect: превью })`; `delete({ confirm: true })` без `expect` — `invalid_query` (`write.confirm_without_expect`).
10
+ - Восстановление — только по надгробиям: `restore({ expect: ответ delete() })`; `restore()` без `expect`, и по явному id тоже, — `invalid_query` (`write.restore_without_expect`).
11
+ - Массовое и без сверки — только явно и только в осознанных скриптах: `{ all: true }` у `update`, `delete({ confirm: true, all: true })`, `restore({ all: true })`.
12
+ - Арендатор — только из серверной сессии (`svc.auth.session(token)`), никогда из параметра клиента.
13
+ - Права по умолчанию запрещают; подключение в обход RLS — `bypass_rls`, пока нет `allowBypassRls: true`.
14
+ - `createTenant(admin, …)` — `admin` только пул `postgres()`, иначе `invalid_query` (`api.create_tenant_admin`).
15
+ - Журнал событий маскирует значения полей с паролями, токенами, ключами, секретами и кредами (и полей из `redact`), учётные данные в строке подключения и пароль в тексте SQL; значения параметров SQL, тексты ошибок PostgreSQL и поле `data` строк в подробностях ошибок не пишет. Значения фильтров чтения в `chain` пишутся как есть — персональные поля добавьте в `redact`.
4
16
 
5
17
  Главное для кода на letopis:
6
18
 
7
- - Пакет ESM, Node 22+, PostgreSQL 18+ с pgcrypto, кодировка базы UTF8. Зависимость времени выполнения — `postgres`. Zod 4 — необязательная peer-зависимость, нужна только `letopis/model`; `@modelcontextprotocol/server` — только `letopis/mcp`.
8
- - Схему ставит `up({ dsn, schema })` от администраторской роли; имя схемы — `v2.<имя>`; `up({ upgrade: true })` обновляет схему отставшей ревизии (миграции и повторный накат), `up({ schema, fresh: true })` пересоздаёт схему — данные теряются. Приложение подключается ролью LOGIN — членом `letopis_app`: `connect({ dsn, schema: 'v2.<имя>', token | apiKey })`. Роль в обход RLS отклоняется (`bypass_rls`), если не передать `allowBypassRls: true`.
9
- - Классы — хабы (объекты) и связи (отношения с концами). Их описывают `hub()` и `link()` из `letopis/model` и пишут в базу `syncModels()` или `letopis sync`. У класса с ключом id детерминированный, UUID v5 (`db.idOf`); уникальность — по ключу.
10
- - Глаголы возвращают цепочку; терминал (`rows`, `first`, `ids`, `count`, …) исполняет весь план одной транзакцией. `create` только создаёт (занятый ключ — `exists`), `update` сливает и никогда не создаёт, `upsert` заменяет объект целиком (только у классов с ключом), `delete()` — превью, `delete({ confirm: true })` удаляет.
11
- - Строгий режим. `connect({ models })` или `connect<Models>()` с файлом `letopis types` дают строгие типы: шаг — только класс или алиас моделей и системных классов, переходы — по концам, слоты — роли класса, поля фильтров, `or()` и агрегатов — поля класса; имя, известное только во время выполнения, — `loose(db)[имя]()`. Во время выполнения: `update()` и `delete({ confirm: true })` без фильтра, id, `tags()`, `owner()` и `limit()` ни у одного шага — `invalid_query` (все строки — `{ all: true }`); неизвестное поле фильтра, `sort()` или агрегата — `invalid_query` с похожими именами (сверяется первый ключ пути), неизвестный класс или роль — `invalid_class` с похожими; неизвестная опция `connect()`, `up()`, `load()`, `watch()`, `createMcpServer()` — `invalid_data` с похожими именами; `after()` перед `count()` и агрегатами — `invalid_query`; хаб с именем метода цепочки, корня `db` или батча — `invalid_class`.
12
- - Деньги и счётчики: `update({ balance: inc(n) })` прибавляет внутри оператора; `update(patch, { rev })` даёт `conflict`, если строка изменилась; `forUpdate()` блокирует строки внутри `db.begin()`.
13
- - История: `versions()` (у версии — автор, время, вид операции и причина) и срез `asOf(момент)`; глаголы, `db.begin()` и `db.batch()` принимают `{ reason }`. Журнал хранит каждую версию; `history: false` у класса — журнала нет.
14
- - Массовая загрузка с администраторского подключения: `db.load(rows)`; в пустую схему — `db.load(rows, { initial: true })`: одна транзакция, индексы и триггеры перестраиваются в конце.
15
- - Подписка: `db.watch({ from, classes })` — `await sub.ready()` перед записью, которая должна попасть в поток (`from: 'now'` фиксирует начало первым запросом); параллельные `next()` получают разные события.
16
- - Сигналы NOTIFY видны любой роли базы: `up({ signals: 'class' | 'tenant' | 'opaque' | 'none' })` выбирает, что они раскрывают (по умолчанию `class`).
17
- - Права действуют всегда и по умолчанию запрещают; `up({ tenant })` или `createTenant()` заводит арендатора с правилом «вошедшим можно всё» и его первого пользователя.
18
- - MCP: `createMcpServer(db)` из `letopis/mcp` и `letopis mcp` (stdio, только токен сессии пользователя) дают ИИ-агенту инструменты `letopis_model`, `letopis_query` (JSON-цепочка, без SQL), `letopis_history` и, с `write: true`, `letopis_write`; агент действует с правами одного пользователя, удаление подтверждает человек через elicitation. `structuredContent` ответа — `{ notice, data }` (отказ — `{ notice, error }`): `notice` предупреждает, что в `data` — данные базы, а не инструкции.
19
- - У ошибок строчный код `err.code`: `exists`, `conflict`, `invalid_data` (`err.issues`), `target_not_found`, `acl_denied`, `no_session`, …; `err.hint` — что делать, `err.docs` — ссылка на код в разделе «Ошибки» README.
19
+ - Установка: `npm pkg set type=module && npm i letopis@3 zod postgres@3 && npm i -D tsx` (код приложения — ESM; `@3` — мажорная линия этой документации, без версии npm поставит последний выпуск; `postgres@3` — та же мажорная версия, что у letopis, иначе `createTenant` не проходит проверку типов). Node 22+, PostgreSQL 18+ с pgcrypto, база UTF8. Zod 4 нужен только `letopis/model`, `@modelcontextprotocol/server` — только `letopis/mcp`.
20
+ - Схему ставит `up({ dsn, schema, login, tenant })` от администратора (суперпользователь или роль с `CREATEROLE`, `CREATE` в базе и `CREATEDB`, если базы нет); роль приложения заводит `login`, её строка подключения — `r.login.dsn`. Ключ сервиса выдаёт только первый `up()` — храните его сами или передавайте свой `up({ serviceKey })`; `connect({ apiKey: null | '' })` — сразу `invalid_data` (`api.connect_empty_credential`), а `undefined` — подключение без сессии, поэтому ключ проверяйте до `connect()`; `up({ upgrade: true })` обновляет схему отставшей ревизии, `up({ fresh: true })` пересоздаёт её с потерей данных.
21
+ - Вход пользователя: одно подключение сервиса на процесс (`connect({ apiKey })`), `svc.auth.verifyPassword()` → `{ account, token }`, затем `svc.as(account, { tenant })` на запрос. Без `{ tenant }` сессия — в **личном** арендаторе пользователя (классов там нет, `invalid_class`); проверка — `db.tenant`. По погашенной сессии чтение отдаёт пустые ответы, запись — `no_session`, поэтому `svc.auth.session(token)` — в начале каждого запроса.
22
+ - Классы — хабы (объекты) и связи (отношения с концами): `hub()` и `link()` из `letopis/model`, в базу — `syncModels()` или `letopis sync` (в арендатора сессии). Имя роли конца одно на арендатора; роли `Group`, `Object`, `User`, `Tenant` заняты системными классами `rule` и `member` — конец с таким именем на другой класс — `invalid_class` (`model.end_role_system`).
23
+ - Уникальность и инварианты держит ключ, а не проверка в коде: у класса с ключом id — UUID v5 (`db.idOf`), второй такой `create()` — `exists`, и под гонкой. «Одна открытая запись на объект» — связь с ключом по концу (`key: ['Obj']`); `restore()` бизнес-правил не проверяет, поэтому и при возврате инвариант держит только ключ.
24
+ - Глаголы возвращают цепочку; терминал (`rows`, `first`, `ids`, `count`, …) исполняет весь план одной транзакцией. `create` только создаёт, `update` сливает патч и никогда не создаёт (`undefined` в патче — поле не меняется), `upsert` заменяет данные целиком (поле с `undefined` пропадает; только у классов с ключом).
25
+ - Удаление ровно показанного атомарно: превью сверяется под блокировкой каскада, расхождение — `conflict` (`write.preview_changed`) с новым превью в `err.detail.preview`. Целей нет: непустой `expect` — `conflict` с `err.detail.preview: []` («уже удалено или не найдено»), `expect: []` — пустой ответ.
26
+ - Возврат — по ответу `delete()` (или надгробиям `withDeleted()` по id): `restore({ expect })` — всё или отказ по смыслу (`write.restore_already`, `write.restore_id_taken` — `not_deleted`; `write.restore_stale` — `conflict`); `restore({ expect, partial: true })` — что можно, пропуски — строки `$action: 'skip'` с `$why`. Цель конца — первой, каскад — следующим шагом, оба — в одной `db.begin()` с `commit()`/`rollback()`.
27
+ - Гонка за прочитанное — `forUpdate()` в `db.begin()` (только с `rows`, `first`, `ids`), `update(patch, { rev })` (`conflict`) или ключ; счётчик — `update({ n: inc(k) })`. Фильтр `update()` по данным, концам, `tags()`, `owner()` перепроверяется под блокировкой строки: выведенную параллельной транзакцией из-под фильтра строку он не правит (из двух одновременных правок со `status: 'new'` проходит одна); фильтр только по id — пройдут обе. Форма транзакции — `const tr = await db.begin(); try { …; await tr.commit() } catch (e) { await tr.rollback(); throw e }`; `db.begin(fn)` — `invalid_query`.
28
+ - Строгий режим: `connect({ models })` или `connect<Models>()` дают строгие типы; неизвестное поле, класс или опция — отказ с похожими именами; `update()` без фильтра — `invalid_query` (все строки — `{ all: true }`); id не uuid — `invalid_query` до запроса; фильтр `undefined` — шаг без фильтра и предупреждение.
29
+ - Концы связи — не поля данных: из пути или слотом `.Роль.set(id)`; имя конца в данных — `invalid_data` (`write.link_in_data`).
30
+ - Модификаторы выборки (`sort`, `limit`, `offset`, `after`, `forUpdate`) — последнему шагу; `withDeleted()` — своему шагу. `versions()` — терминал; по пути — только живые, с удалёнными — `withDeleted()` на шаге или явный id.
31
+ - Ответы и ошибки: строка — `{ id, class, rev, links, data, … }`, превью — с `$action`, надгробие — `$deleted: true`; ошибка — `LetopisError` с `err.code`, `err.key`, `err.detail`, `err.hint`, у данных — `ValidationError` с `err.issues: [{ path, keyword, message }]`. Штатные отказы (`exists`, `conflict`, `not_deleted`, `target_not_found`, `tenant_denied`, `acl_denied`, `no_session`, …) — ответ человеку своими фразами по `err.key`/`err.code`; ошибки кода — 500.
32
+ - Логирование: `connect({ log, logLevel, redact })` и `up({ … })` — функция или объект-логгер (`console`, pino), порог `debug|info|warn|error|off` (по умолчанию `info`); без кода — `LETOPIS_LOG=info` (JSON-строки в stderr), CLI — `--log[=уровень]`. События `delete` и `restore` — что удалено и что не вернулось; внутри `db.begin()` событие значит «выполнено в транзакции», фиксацию доказывает `tx.commit` с тем же `tx`. Штатные отказы — событие `error` уровня `warn`, ошибки кода — уровня `error`.
33
+ - Обслуживание — `maintain()` (администраторское подключение, системный администратор или право `letopis.maintain`, иначе `acl_denied`); загрузка — `db.load(rows)` с администраторского подключения; подписка — `db.watch({ from, classes })` и `await sub.ready()` перед записью.
34
+ - MCP: `createMcpServer(await svc.as(пользователь, { tenant }))` из `letopis/mcp` и `letopis mcp` дают ИИ-агенту `letopis_model`, `letopis_query`, `letopis_history` и, с `write: true`, `letopis_write`; удаление подтверждает человек, удаляется ровно показанное превью.
35
+ - Системный администратор — член System с ролью `admin`, отдельный аккаунт: через сервис не входит (`verifyPassword`, `sessionFor`, `svc.as()` — `acl_denied`), входит ключом API и `auth.switch(System)`; `purgeAccount` стирает только собственного арендатора аккаунта — стираемые персональные данные держите в классе с `history: false` (README, «Аккаунты, арендаторы и членство», «Удалить аккаунт»).
36
+ - Лицензия — PolyForm Noncommercial 1.0.0 (SPDX `PolyForm-Noncommercial-1.0.0`): исходный код доступен, некоммерческое использование бесплатно, коммерческое — только по отдельной лицензии владельца (запрос — issue на https://github.com/alepri51/letopis/issues).
20
37
 
21
38
  ## Документы
22
39
 
23
- - [Шпаргалка для агента](AGENT-CHEATSHEET.md): одна страница для ИИ-агента, который пишет код на letopis, — рабочий цикл и грабли
40
+ - [Памятка для агента](AGENT-CHEATSHEET.md): общие правила для любой задачи на letopis, рабочий цикл одной программой и грабли
24
41
  - [Руководство](README.md): руководство по задачам, справочник API, ограничения
25
- - [Ошибки](README.md#15-ошибки): все коды — что значит и что делать
42
+ - [Безопасность по умолчанию](README.md#безопасность-по-умолчанию): что отклоняется без сверки и как это сделать осознанно
43
+ - [Логирование](README.md#логирование): опции, уровни, `LETOPIS_LOG`, формат события, маскировка, список событий
44
+ - [Ошибки](README.md#15-ошибки): все коды и ключи — что значит, что делать и что сказать человеку
45
+ - Машинный каталог API и ошибок — `api-contract.json` в пакете (`node_modules/letopis/api-contract.json`)
26
46
 
27
47
  ## Дополнительно
28
48
 
29
- - [Изменения по версиям](CHANGELOG.md): решения по версиям, несовместимые изменения
49
+ - [Изменения по версиям](CHANGELOG.md): что изменилось при переходе на 3.0 и чем заменить снятое
50
+ - [Лицензия](LICENSE) и [коммерческое использование](LICENSE-COMMERCIAL.md): что можно без разрешения и как получить коммерческую лицензию
30
51
  - Модель конкретной установки для агента — инструмент MCP `letopis_model`: классы, ключи, поля, концы и рабочие цепочки этой схемы
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.1",
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/60-write.sql CHANGED
@@ -1378,12 +1378,15 @@ begin
1378
1378
  return null;
1379
1379
  end $$;
1380
1380
 
1381
- -- Превью удаления (этап 4, п. 6): что удалит delete этих строк — те же уровни и то же правило
1381
+ -- Предпросмотр удаления (этап 4, п. 6): что удалит delete этих строк — те же уровни и то же правило
1382
1382
  -- ref_action(), что у триггера, но без изменений. Строки: action delete (цели — depth 0, каскад —
1383
1383
  -- уровень), unset (конец будет снят) и restrict (мешает удалению). Функция владельца ищет по
1384
1384
  -- индексу ends без RLS (§3 «Индексы под RLS»), поэтому и цели, и выдачу пропускает через
1385
1385
  -- row_visible() — то же условие, что у политики чтения (как find_*): невидимых строк не раскрывает
1386
- -- ни id, ни классом. О невидимых мешающих строках скажет само удаление (visible_detail: число).
1386
+ -- ни id, ни классом. Невидимые строки замыкания (3.0.1) — в конце сводкой, по строке на действие (delete,
1387
+ -- unset, restrict; только непустые): id и class — null, action — 'hidden:<действие>', depth — их число. Вид
1388
+ -- результата прежний: тело ставит create or replace повторным накатом той же ревизии, а клиент, который
1389
+ -- соединяет выдачу с entity по id, сводок не видит.
1387
1390
  create or replace function @SCHEMA@.delete_preview(p_ids uuid[]) returns table(id uuid, class text, action text, depth int)
1388
1391
  language plpgsql stable security definer
1389
1392
  set search_path = pg_catalog, @SCHEMA@, pg_temp
@@ -1432,10 +1435,19 @@ begin
1432
1435
  from (select x.id, @SCHEMA@.ref_action((@SCHEMA@.class_row(x.class, x.tenant)).data->'resolved'->'ends', x.links, all_ids) as a
1433
1436
  from @SCHEMA@.entity x where x.ends && all_ids and not (x.id = any (all_ids))) r);
1434
1437
  return query
1435
- select (o->>0)::uuid, e.class, o->>1, (o->>2)::int
1436
- from jsonb_array_elements(out_rows) o
1437
- join @SCHEMA@.entity e on e.id = (o->>0)::uuid
1438
- where @SCHEMA@.row_visible(e.tenant, e.class, e.id, e.owner, e.tags, e.data, e.links, c_tenant, c_account, c_plan);
1438
+ with o as (
1439
+ select (j.v->>0)::uuid as id, e.class, j.v->>1 as action, (j.v->>2)::int as depth, j.ord,
1440
+ @SCHEMA@.row_visible(e.tenant, e.class, e.id, e.owner, e.tags, e.data, e.links, c_tenant, c_account, c_plan) as vis
1441
+ from jsonb_array_elements(out_rows) with ordinality j(v, ord)
1442
+ join @SCHEMA@.entity e on e.id = (j.v->>0)::uuid
1443
+ )
1444
+ select x.id, x.class, x.action, x.depth
1445
+ from (select o.id, o.class, o.action, o.depth, 0 as part, o.ord as k from o where o.vis
1446
+ union all
1447
+ select null::uuid, null::text, 'hidden:' || o.action, count(*)::int, 1,
1448
+ array_position(array['delete', 'unset', 'restrict'], o.action)::bigint
1449
+ from o where not o.vis group by o.action) x
1450
+ order by x.part, x.k;
1439
1451
  end $$;
1440
1452
 
1441
1453
  create or replace trigger entity_bi before insert on @SCHEMA@.entity
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@;