letopis 1.4.2 → 2.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 (112) hide show
  1. package/AGENT-CHEATSHEET.md +329 -281
  2. package/CHANGELOG.md +418 -0
  3. package/README.md +572 -1053
  4. package/dist/acl.d.ts +78 -1
  5. package/dist/acl.js +264 -13
  6. package/dist/admin.d.ts +10 -141
  7. package/dist/admin.js +24 -184
  8. package/dist/auth.d.ts +2 -2
  9. package/dist/auth.js +5 -15
  10. package/dist/cache.d.ts +8 -1
  11. package/dist/cache.js +30 -8
  12. package/dist/chain.d.ts +18 -41
  13. package/dist/chain.js +165 -203
  14. package/dist/cli.js +45 -90
  15. package/dist/demo/booking.d.ts +352 -36
  16. package/dist/describe.d.ts +47 -0
  17. package/dist/describe.js +195 -0
  18. package/dist/errors.d.ts +59 -13
  19. package/dist/errors.js +190 -29
  20. package/dist/index.d.ts +52 -70
  21. package/dist/index.js +189 -122
  22. package/dist/internal.d.ts +5 -0
  23. package/dist/jsonschema.js +18 -17
  24. package/dist/load.d.ts +20 -12
  25. package/dist/load.js +207 -181
  26. package/dist/mcp.d.ts +5 -7
  27. package/dist/mcp.js +168 -214
  28. package/dist/messages/access.d.ts +90 -0
  29. package/dist/messages/access.js +113 -0
  30. package/dist/messages/api.d.ts +171 -0
  31. package/dist/messages/api.js +215 -0
  32. package/dist/messages/boot.d.ts +201 -0
  33. package/dist/messages/boot.js +256 -0
  34. package/dist/messages/codes.d.ts +172 -0
  35. package/dist/messages/codes.js +173 -0
  36. package/dist/messages/index.d.ts +95 -0
  37. package/dist/messages/index.js +118 -0
  38. package/dist/messages/mcp.d.ts +306 -0
  39. package/dist/messages/mcp.js +387 -0
  40. package/dist/messages/model.d.ts +233 -0
  41. package/dist/messages/model.js +283 -0
  42. package/dist/messages/query.d.ts +131 -0
  43. package/dist/messages/query.js +158 -0
  44. package/dist/messages/sql-access.d.ts +70 -0
  45. package/dist/messages/sql-access.js +87 -0
  46. package/dist/messages/sql-core.d.ts +150 -0
  47. package/dist/messages/sql-core.js +181 -0
  48. package/dist/messages/sql-time.d.ts +71 -0
  49. package/dist/messages/sql-time.js +88 -0
  50. package/dist/messages/sql-write.d.ts +246 -0
  51. package/dist/messages/sql-write.js +270 -0
  52. package/dist/messages/time.d.ts +23 -0
  53. package/dist/messages/time.js +29 -0
  54. package/dist/messages/types.d.ts +27 -0
  55. package/dist/messages/types.js +5 -0
  56. package/dist/messages/write.d.ts +405 -0
  57. package/dist/messages/write.js +509 -0
  58. package/dist/model.d.ts +73 -60
  59. package/dist/model.js +20 -29
  60. package/dist/ops.d.ts +35 -0
  61. package/dist/ops.js +101 -0
  62. package/dist/pglite.d.ts +0 -9
  63. package/dist/pglite.js +3 -1
  64. package/dist/registry.d.ts +3 -19
  65. package/dist/registry.js +3 -16
  66. package/dist/signals.d.ts +1 -5
  67. package/dist/signals.js +1 -5
  68. package/dist/sql.d.ts +7 -1
  69. package/dist/sql.js +101 -85
  70. package/dist/sync.d.ts +9 -3
  71. package/dist/sync.js +40 -17
  72. package/dist/tx.d.ts +11 -1
  73. package/dist/tx.js +32 -11
  74. package/dist/typed.d.ts +240 -45
  75. package/dist/types.d.ts +15 -90
  76. package/dist/types.js +12 -14
  77. package/dist/up.d.ts +53 -32
  78. package/dist/up.js +180 -74
  79. package/dist/uuid.js +5 -5
  80. package/dist/validate.d.ts +13 -1
  81. package/dist/validate.js +61 -29
  82. package/dist/watch.d.ts +5 -16
  83. package/dist/watch.js +23 -77
  84. package/dist/write.d.ts +9 -16
  85. package/dist/write.js +102 -185
  86. package/llms.txt +22 -25
  87. package/package.json +2 -9
  88. package/sql/10-core.sql +7 -50
  89. package/sql/15-errors.sql +64 -10
  90. package/sql/20-context.sql +7 -6
  91. package/sql/30-validate.sql +35 -27
  92. package/sql/40-class.sql +20 -114
  93. package/sql/50-acl.sql +8 -13
  94. package/sql/60-write.sql +220 -676
  95. package/sql/70-read.sql +11 -13
  96. package/sql/80-auth.sql +23 -27
  97. package/sql/90-time.sql +77 -944
  98. package/sql/95-seed.system.sql +7 -14
  99. package/sql/99-revision.sql +2 -1
  100. package/sql/README.md +32 -24
  101. package/sql/migrate/README.md +14 -0
  102. package/AGENT-CHEATSHEET.en.md +0 -488
  103. package/MIGRATION.md +0 -190
  104. package/README.en.md +0 -2910
  105. package/dist/import.d.ts +0 -185
  106. package/dist/import.js +0 -820
  107. package/dist/project.d.ts +0 -359
  108. package/dist/project.js +0 -1258
  109. package/sql/75-history.sql +0 -436
  110. package/sql/88-derived.sql +0 -1236
  111. package/sql/migrate/0006-changed-paths.sql +0 -3
  112. package/sql/migrate/0007-secret-account-fk.sql +0 -12
package/dist/acl.d.ts CHANGED
@@ -20,6 +20,52 @@ export interface AclDataDecision {
20
20
  allow: boolean;
21
21
  filter?: Record<string, unknown>;
22
22
  }
23
+ /**
24
+ * Цель правила на строки: имя класса (правило действует и на потомков), `'*'` — все классы, или класс с условием на
25
+ * строки — шаблоном колонок `owner`, `tenant`, `tags`, `data`, `links`, `id` (`'$actor'` — тот, от чьего имени запрос,
26
+ * `'$tenant'` — рабочий арендатор): `{ class: 'Ticket', owner: '$actor' }`.
27
+ */
28
+ export type AclTarget = string | ({
29
+ class: string;
30
+ } & {
31
+ [column: string]: unknown;
32
+ });
33
+ /**
34
+ * Что разрешить, запретить или снять: кому — ровно одно из `role` и `everyone` (иначе `invalid_data`), операции — с
35
+ * классами, ресурсами приложения или с теми и другими.
36
+ */
37
+ export interface AclRuleSpec {
38
+ /** Роль членства в текущем арендаторе (как в `member.roles`). */
39
+ role?: string;
40
+ /** Все аккаунты арендатора, кроме анонимных и теневых, — группа правил «можно всё» от createTenant. */
41
+ everyone?: true;
42
+ read?: AclTarget | readonly AclTarget[];
43
+ write?: AclTarget | readonly AclTarget[];
44
+ delete?: AclTarget | readonly AclTarget[];
45
+ /** Ресурсы приложения — маски эндпоинтов, как у `check()`: `'reports.*'` — `reports.daily`, но не `reports`. */
46
+ api?: string | readonly string[];
47
+ /** Вес правила, целое от 1 до 999; по умолчанию 50 — сильнее правила «можно всё» (вес 10) от createTenant. */
48
+ weight?: number;
49
+ }
50
+ /** Правило арендатора в виде обёртки. */
51
+ export interface AclRule {
52
+ id: string;
53
+ /** Роль группы `role:<роль>:ACCOUNT`; null — группа не одной роли (все аккаунты или своя группа). */
54
+ role: string | null;
55
+ /** Группа всех аккаунтов арендатора: `authenticated:ACCOUNT` от createTenant или `everyone:ACCOUNT` обёртки. */
56
+ everyone: boolean;
57
+ /** Alias группы. */
58
+ group: string;
59
+ op: AclOp | 'API';
60
+ /** Класс (`'*'` — все классы) или маска эндпоинта. */
61
+ target: string;
62
+ /** Условие на строки — шаблон колонок. */
63
+ condition?: Record<string, unknown>;
64
+ permission: 'allow' | 'deny';
65
+ weight: number;
66
+ /** Действует ли правило: `false` — выключено полем `enabled` строки `rule`, база его не применяет. */
67
+ enabled: boolean;
68
+ }
23
69
  export interface AclApi {
24
70
  /**
25
71
  * Можно ли тому, от чьего имени идёт запрос, обратиться к ресурсу приложения (эндпоинту). Решают правила доступа
@@ -31,5 +77,36 @@ export interface AclApi {
31
77
  * что подходят под условие `filter` в ответе.
32
78
  */
33
79
  checkData(className: string, op: AclOp): Promise<AclDataDecision>;
80
+ /**
81
+ * Разрешить роли (`role`) или всем аккаунтам арендатора (`everyone: true`) операции с классами и ресурсы приложения —
82
+ * по правилу на каждую операцию и цель, с весом `weight` (по умолчанию 50). Повтор ничего не меняет; запрет той же
83
+ * группы и цели заменяется разрешением. Внутри `db.begin()` — в её транзакции, иначе — своей.
84
+ */
85
+ grant(spec: AclRuleSpec): Promise<AclRule[]>;
86
+ /** Запретить — те же правила с запретом: при равном весе запрет сильнее разрешения. */
87
+ deny(spec: AclRuleSpec): Promise<AclRule[]>;
88
+ /**
89
+ * Снять правила роли или всех аккаунтов — разрешения и запреты — для этих операций и целей; число снятых.
90
+ * `revoke({ everyone: true, read: '*', write: '*', delete: '*' })` снимает «можно всё» от createTenant: дальше права —
91
+ * только по ролям.
92
+ */
93
+ revoke(spec: Omit<AclRuleSpec, 'weight'>): Promise<number>;
94
+ /**
95
+ * Роли аккаунта в текущем арендаторе (строка `member`); null — не член. Со списком ролей — заменить их (членство
96
+ * заводится, если его нет), с null — снять членство. Сам арендатор (вход под его аккаунтом) — владелец без строки
97
+ * `member`: для него ответ null.
98
+ */
99
+ roles(account: string | {
100
+ id: string;
101
+ }, roles?: readonly string[] | null): Promise<string[] | null>;
102
+ /**
103
+ * Правила текущего арендатора — свои и «можно всё» от createTenant. На подключении без RLS (`allowBypassRls`) — тоже
104
+ * только правила арендатора сессии, без правил других арендаторов и System.
105
+ */
106
+ list(): Promise<AclRule[]>;
34
107
  }
35
- export declare function makeAcl(S: string, exec: Executor): AclApi;
108
+ /**
109
+ * db.acl фасада. db — сам фасад (корень, db.as(), db.begin()): запись правил идёт его цепочками, внутри db.begin() — в её
110
+ * транзакции.
111
+ */
112
+ export declare function makeAcl(S: string, exec: Executor, db?: () => unknown): AclApi;
package/dist/acl.js CHANGED
@@ -1,11 +1,180 @@
1
1
  /**
2
- * db.acl — решения прав для приложения (план, §2.8, этап 5, п. 7). Сами права применяет база
3
- * (политики RLS по плану прав, 50-acl.sql); эти вызовы отвечают «можно ли» заранее — для интерфейса
4
- * и эндпоинтов приложения. Правила — строки Resource и rule (группа → объект, allow | deny, вес);
5
- * решение — ровно одно правило с наибольшим весом, при равном весе запрет сильнее, нет правил — запрет.
2
+ * db.acl — права приложения (план, §2.8, этап 5, п. 7; план 2.0, этап 3, п. 1). Сами права применяет база
3
+ * (политики RLS по плану прав, 50-acl.sql); check и checkData отвечают «можно ли» заранее — для интерфейса и
4
+ * эндпоинтов приложения. Правила — строки Resource и rule (группа → объект, allow | deny, вес); решение — ровно одно
5
+ * правило с наибольшим весом, при равном весе запрет сильнее, нет правил — запрет.
6
+ *
7
+ * Запись правил (план 2.0, 7.9 №1): grant, deny, revoke, roles и list — обёртка над теми же строками Resource, rule и
8
+ * member текущего арендатора, цепочками фасада: право на саму запись, триггеры и RLS проверяет база, как у любой
9
+ * записи. Alias ресурсов обёртки детерминированные — группа `role:<роль>:ACCOUNT`, объект `class:<класс>[условие]:<ОП>`
10
+ * или `api:<эндпоинт>:API`, — а ключ правила — пара [Group, Object]: повтор вызова строк и версий не заводит, а grant
11
+ * после deny той же роли и цели заменяет запрет разрешением. Ресурсы обёртки живут в арендаторе: ссылку правила
12
+ * арендатора на ресурс System база не пропустит (цель должна быть видна).
13
+ *
14
+ * Все аккаунты арендатора (`everyone: true` вместо `role`): группа обёртки `everyone:ACCOUNT` с маской группы
15
+ * `authenticated:ACCOUNT` System — её правила «можно всё» (вес 10) заводит createTenant. revoke для всех аккаунтов
16
+ * снимает правила обеих групп, а цель `'*'` — и объект обёртки `class:*:<ОП>`, и объект System `all:<ОП>`: так
17
+ * `revoke({ everyone: true, read: '*', write: '*', delete: '*' })` снимает «можно всё». Строк System арендатор не видит,
18
+ * их id — `v5Id(System, 'Resource', [alias])`.
6
19
  */
7
- import { removed } from './errors.js';
8
- export function makeAcl(S, exec) {
20
+ import { lerr } from './errors.js';
21
+ import { internalsOf } from './internal.js';
22
+ import { v5Id } from './uuid.js';
23
+ const SYSTEM_ID = '2eba6d0a-1edd-4bb6-a85a-a703dab49035';
24
+ /** Имя класса — как в метасхеме Class (40-class.sql). */
25
+ const NAME = /^[A-Za-zА-Яа-яЁё_][A-Za-zА-Яа-яЁё0-9_]{0,62}$/;
26
+ /** Колонки, на которые ставят условие правила (шаблон acl_row_ok, 50-acl.sql). */
27
+ const COLUMNS = ['id', 'owner', 'tenant', 'tags', 'data', 'links'];
28
+ const OPS = { read: 'READ', write: 'WRITE', delete: 'DELETE' };
29
+ /** Ресурсы сида System (95-seed.system.sql): на них ссылаются правила grantAll арендатора, а самих строк арендатор не видит. */
30
+ const SYSTEM_RESOURCES = ['anonymous:ACCOUNT', 'authenticated:ACCOUNT', 'any:ACCOUNT', 'service:ACCOUNT', 'admin:ACCOUNT',
31
+ 'any:API', 'auth:API', 'letopis:API', 'signals:API', 'all:READ', 'all:WRITE', 'all:DELETE'];
32
+ /** JSON с ключами объектов по порядку: одно условие — один alias. */
33
+ function canon(v) {
34
+ if (Array.isArray(v))
35
+ return `[${v.map(canon).join(',')}]`;
36
+ if (v && typeof v === 'object') {
37
+ return `{${Object.keys(v).sort().map((k) => `${JSON.stringify(k)}:${canon(v[k])}`).join(',')}}`;
38
+ }
39
+ return JSON.stringify(v);
40
+ }
41
+ /**
42
+ * Имя роли — по тому же правилу, что роли createTenant: маска группы делит роли по запятой и берёт их в скобки. what —
43
+ * метод db.acl (grant, deny, revoke, roles): им начинается текст ошибки, как и у classItem, apiItem и parse.
44
+ */
45
+ function roleName(v, what) {
46
+ if (typeof v !== 'string')
47
+ throw lerr('access.role_not_string', { what, type: v === null ? 'null' : typeof v });
48
+ if (!v || v !== v.trim() || /[,{}]/.test(v) || v.startsWith('!')) {
49
+ throw lerr('access.bad_role_name', { what, role: JSON.stringify(v) });
50
+ }
51
+ return v;
52
+ }
53
+ const many = (v) => (Array.isArray(v) ? v : [v]);
54
+ function groupOf(role) {
55
+ return { alias: `role:${role}:ACCOUNT`, category: 'ACCOUNT', pattern: { roles: `{${role}}` } };
56
+ }
57
+ /** Все аккаунты арендатора: маска как у группы authenticated:ACCOUNT System (95-seed.system.sql). */
58
+ const EVERYONE = { alias: 'everyone:ACCOUNT', category: 'ACCOUNT', pattern: { categories: '!{Anonymous,Shadow}' } };
59
+ function classItem(op, t, what) {
60
+ let cls = t;
61
+ let cond = {};
62
+ if (t && typeof t === 'object' && !Array.isArray(t)) {
63
+ ({ class: cls, ...cond } = t);
64
+ for (const k of Object.keys(cond)) {
65
+ if (!COLUMNS.includes(k))
66
+ throw lerr('access.bad_condition_column', { what, column: k, columns: COLUMNS.join(', ') });
67
+ }
68
+ }
69
+ if (typeof cls !== 'string' || (cls !== '*' && !NAME.test(cls))) {
70
+ throw lerr('access.bad_rule_target', { what, target: JSON.stringify(t) });
71
+ }
72
+ const condition = Object.keys(cond).length ? cond : undefined;
73
+ return {
74
+ op, category: op, target: cls,
75
+ alias: `class:${cls}${condition ? canon(condition) : ''}:${op}`,
76
+ pattern: { ...(cls === '*' ? {} : { class: cls }), ...cond },
77
+ ...(condition ? { condition } : {}),
78
+ };
79
+ }
80
+ function apiItem(ep, what) {
81
+ if (typeof ep !== 'string' || !/^[\p{L}\p{N}_.*{},-]+$/u.test(ep)) {
82
+ throw lerr('access.bad_endpoint_mask', { what, endpoint: JSON.stringify(ep) });
83
+ }
84
+ return { op: 'API', category: 'API', target: ep, alias: `api:${ep}:API`, pattern: { endpoint: ep } };
85
+ }
86
+ /** Спецификация правила; role — null, когда правило для всех аккаунтов арендатора (everyone: true). */
87
+ function parse(spec, what, withWeight) {
88
+ if (!spec || typeof spec !== 'object' || Array.isArray(spec))
89
+ throw lerr('access.rule_spec_not_object', { what });
90
+ const o = spec;
91
+ const keys = ['role', 'everyone', 'read', 'write', 'delete', 'api', ...(withWeight ? ['weight'] : [])];
92
+ for (const k of Object.keys(o))
93
+ if (!keys.includes(k))
94
+ throw lerr('access.unknown_rule_field', { what, field: k, fields: keys.join(', ') });
95
+ // кому: ровно одно из role и everyone: true
96
+ if (o.everyone === undefined ? o.role === undefined : o.everyone !== true || o.role !== undefined) {
97
+ throw lerr('access.rule_who', { what });
98
+ }
99
+ const role = o.everyone === true ? null : roleName(o.role, what);
100
+ let weight = 50;
101
+ if (o.weight !== undefined) {
102
+ if (typeof o.weight !== 'number' || !Number.isInteger(o.weight) || o.weight < 1 || o.weight > 999) {
103
+ throw lerr('access.bad_rule_weight', { what });
104
+ }
105
+ weight = o.weight;
106
+ }
107
+ const items = [];
108
+ for (const [key, op] of Object.entries(OPS)) {
109
+ if (o[key] !== undefined)
110
+ for (const t of many(o[key]))
111
+ items.push(classItem(op, t, what));
112
+ }
113
+ if (o.api !== undefined)
114
+ for (const ep of many(o.api))
115
+ items.push(apiItem(ep, what));
116
+ if (!items.length)
117
+ throw lerr('access.no_rule_operations', { what });
118
+ return { role, weight, items };
119
+ }
120
+ /** Роль группы обёртки и групп вида `{роль}`; иначе null. */
121
+ function roleOf(pattern) {
122
+ const p = pattern;
123
+ if (!p || p.categories !== undefined || typeof p.roles !== 'string')
124
+ return null;
125
+ const m = /^\{([^,{}!]+)\}$/.exec(p.roles);
126
+ return m ? m[1] : null;
127
+ }
128
+ /**
129
+ * db.acl фасада. db — сам фасад (корень, db.as(), db.begin()): запись правил идёт его цепочками, внутри db.begin() — в её
130
+ * транзакции.
131
+ */
132
+ export function makeAcl(S, exec, db) {
133
+ const facade = () => {
134
+ const d = db?.();
135
+ if (!d)
136
+ throw lerr('access.no_facade');
137
+ return d;
138
+ };
139
+ async function inTx(what, fn) {
140
+ const d = facade();
141
+ if (internalsOf(d)?.explicit)
142
+ return fn(d);
143
+ const tr = await d.begin({ reason: `db.acl.${what}` });
144
+ try {
145
+ const r = await fn(tr);
146
+ await tr.commit();
147
+ return r;
148
+ }
149
+ catch (e) {
150
+ await tr.rollback().catch(() => { });
151
+ throw e;
152
+ }
153
+ }
154
+ /** Ресурс целиком (upsert по alias): та же строка — пустая правка, без новой версии. */
155
+ async function resource(tr, r) {
156
+ const [row] = await tr.Resource().upsert({ alias: r.alias, category: r.category, pattern: r.pattern }).rows();
157
+ return row.id;
158
+ }
159
+ async function put(spec, permission, what) {
160
+ const p = parse(spec, what, true);
161
+ return inTx(what, async (tr) => {
162
+ const group = p.role === null ? EVERYONE : groupOf(p.role);
163
+ const g = await resource(tr, group);
164
+ const out = [];
165
+ for (const it of p.items) {
166
+ const o = await resource(tr, it);
167
+ const [r] = await tr.rule().upsert({ permission, weight: p.weight }).Group.set(g).Object.set(o).rows();
168
+ out.push({ id: r.id, role: p.role, everyone: p.role === null, group: group.alias, op: it.op, target: it.target,
169
+ ...(it.condition ? { condition: it.condition } : {}), permission, weight: p.weight, enabled: true });
170
+ }
171
+ return out;
172
+ });
173
+ }
174
+ /** id ресурса сида System: строку арендатор не видит, ссылаться на неё правилом обёртки не может. */
175
+ const sysId = (alias) => v5Id(SYSTEM_ID, 'Resource', [alias]);
176
+ const sys = new Map(SYSTEM_RESOURCES.map((a) => [sysId(a), a]));
177
+ const AUTHENTICATED = sysId('authenticated:ACCOUNT');
9
178
  const api = {
10
179
  async check(endpoint) {
11
180
  const [r] = await exec.run((q) => q.unsafe(`select ${S}.acl_check($1) as d`, [endpoint]), { read: true });
@@ -15,12 +184,94 @@ export function makeAcl(S, exec) {
15
184
  const [r] = await exec.run((q) => q.unsafe(`select ${S}.acl_check_data($1, $2) as d`, [className, op]), { read: true });
16
185
  return r.d;
17
186
  },
18
- };
19
- return new Proxy(api, {
20
- get(t, p, r) {
21
- if (p === 'reload')
22
- throw removed('db.acl.reload()', 'права обновляются сами: база применяет действующие правила');
23
- return Reflect.get(t, p, r);
187
+ grant: (spec) => put(spec, 'allow', 'grant'),
188
+ deny: (spec) => put(spec, 'deny', 'deny'),
189
+ async revoke(spec) {
190
+ const p = parse(spec, 'revoke', false);
191
+ return inTx('revoke', async (tr) => {
192
+ const find = async (alias) => (await tr.Resource({ alias }).rows())[0]?.id;
193
+ // все аккаунты — группа обёртки и группа «можно всё» System
194
+ const groups = p.role === null ? [await find(EVERYONE.alias), AUTHENTICATED] : [await find(groupOf(p.role).alias)];
195
+ let n = 0;
196
+ for (const it of p.items) {
197
+ const objects = [await find(it.alias)];
198
+ // все классы — объект обёртки class:*:<ОП> и объект «можно всё» System all:<ОП>
199
+ if (it.op !== 'API' && it.target === '*' && !it.condition)
200
+ objects.push(sysId(`all:${it.op}`));
201
+ for (const g of groups) {
202
+ for (const o of objects) {
203
+ if (g && o)
204
+ n += (await tr.rule({ Group: g, Object: o }).delete({ confirm: true }).rows()).length;
205
+ }
206
+ }
207
+ }
208
+ return n;
209
+ });
210
+ },
211
+ async roles(account, roles) {
212
+ const acc = typeof account === 'object' && account !== null ? account.id : account;
213
+ if (typeof acc !== 'string' || !acc)
214
+ throw lerr('access.bad_account');
215
+ const d = facade();
216
+ const tenant = d.tenant;
217
+ if (!tenant)
218
+ throw lerr('access.no_session_tenant', { what: 'roles' });
219
+ if (roles === undefined) {
220
+ const [m] = await d.member({ User: acc, Tenant: tenant }).rows();
221
+ return m ? [...(m.data.roles ?? [])] : null;
222
+ }
223
+ if (roles === null) {
224
+ await inTx('roles', (tr) => tr.member({ User: acc, Tenant: tenant }).delete({ confirm: true }).rows());
225
+ return null;
226
+ }
227
+ if (!Array.isArray(roles))
228
+ throw lerr('access.roles_not_array');
229
+ const list = roles.map((r) => roleName(r, 'roles'));
230
+ if (new Set(list).size !== list.length)
231
+ throw lerr('access.duplicate_roles');
232
+ await inTx('roles', (tr) => tr.member().upsert({ roles: list }).User.set(acc).Tenant.set(tenant).rows());
233
+ return list;
24
234
  },
25
- });
235
+ async list() {
236
+ const d = facade();
237
+ const tenant = d.tenant?.toLowerCase();
238
+ if (!tenant)
239
+ throw lerr('access.no_session_tenant', { what: 'list' });
240
+ // под RLS строки rule — только арендатора сессии; роль без RLS видит правила всех арендаторов и System
241
+ const rules = (await d.rule().rows()).filter((r) => String(r.tenant).toLowerCase() === tenant);
242
+ const res = new Map((await d.Resource().rows()).map((r) => [r.id, r.data]));
243
+ const out = rules.map((r) => {
244
+ const g = res.get(r.links.Group);
245
+ const o = res.get(r.links.Object);
246
+ let op;
247
+ let target;
248
+ let condition;
249
+ if (o) {
250
+ op = o.category === 'ACCOUNT' ? 'API' : o.category;
251
+ const { class: cls, endpoint, ...rest } = (o.pattern ?? {});
252
+ target = String((op === 'API' ? endpoint : cls) ?? '*');
253
+ if (op !== 'API' && Object.keys(rest).length)
254
+ condition = rest;
255
+ }
256
+ else {
257
+ // ресурс System (grantAll от createTenant): арендатор строку не видит — по id сида
258
+ const a = sys.get(r.links.Object) ?? r.links.Object;
259
+ op = a.split(':').pop() ?? 'READ';
260
+ target = /^(all|any):/.test(a) ? '*' : a.split(':')[0];
261
+ }
262
+ const group = g?.alias ?? sys.get(r.links.Group) ?? r.links.Group;
263
+ return {
264
+ id: r.id, role: g ? roleOf(g.pattern) : null,
265
+ everyone: r.links.Group === AUTHENTICATED || (group === EVERYONE.alias && !sys.has(r.links.Group)), group, op, target,
266
+ ...(condition ? { condition } : {}),
267
+ permission: r.data.permission === 'allow' ? 'allow' : 'deny', weight: Number(r.data.weight ?? 0),
268
+ // как acl_plan_for (50-acl.sql): нет поля — правило действует
269
+ enabled: r.data.enabled !== false,
270
+ };
271
+ });
272
+ const key = (x) => `${x.op}\u0000${x.target}\u0000${x.role ?? x.group}\u0000${x.permission}`;
273
+ return out.sort((a, b) => (key(a) < key(b) ? -1 : key(a) > key(b) ? 1 : 0));
274
+ },
275
+ };
276
+ return api;
26
277
  }
package/dist/admin.d.ts CHANGED
@@ -1,35 +1,4 @@
1
- /**
2
- * Эксплуатация (план, этап 6, п. 3, 4, 6–8, 12): verify, maintain и его запуск по расписанию, reset,
3
- * обрезка истории, новый ключ класса, describe. Всё проверяет база: функции владельца 90-time.sql
4
- * сами сверяют права (letopis.* — системный администратор или администраторское подключение).
5
- */
6
- import type postgres from 'postgres';
7
- import type { AggregateInfo, Registry } from './registry.js';
8
1
  import { type Executor, type TxContext } from './tx.js';
9
- type Sql = postgres.Sql<Record<string, unknown>>;
10
- export interface VerifyIssue {
11
- id: string;
12
- rev?: number;
13
- class?: string;
14
- problem: string;
15
- [k: string]: unknown;
16
- }
17
- export interface VerifyResult {
18
- ok: boolean;
19
- issues: VerifyIssue[];
20
- /** Один хэш текущих версий всех объектов — храните его вне базы: подмену самых свежих версий выдаст его расхождение. */
21
- anchor: string;
22
- }
23
- export interface MaintainOptions {
24
- /** Момент «сейчас» для прореживания — только в установке с `allowReset` (тесты). */
25
- now?: string | Date;
26
- /** Сколько пачек прореживания журнала самое большее сделать за один вызов; каждая пачка — своя транзакция. */
27
- batches?: number;
28
- /** Сколько версий самое большее удалить в одной пачке прореживания. */
29
- limit?: number;
30
- }
31
- /** Внутренняя опция maintain(): остановка запуска по расписанию — текущая пачка доделывается, следующая не начинается. */
32
- export declare const MAINTAIN_STOP: unique symbol;
33
2
  export interface MaintainReport {
34
3
  /** Другой процесс уже обслуживает установку (держит строку-замок обслуживания): этот вызов ничего не делал. */
35
4
  skipped: boolean;
@@ -40,92 +9,24 @@ export interface MaintainReport {
40
9
  * заменённых новым и исчерпавших попытки.
41
10
  */
42
11
  codes: number;
43
- /** Прорежено версий журнала. */
44
- thinned: number;
45
- batches: number;
46
- /** Прореживание не дошло до конца журнала — следующий вызов продолжит. */
47
- more: boolean;
48
12
  /** Заполнение очереди сигналов PostgreSQL (0…1); больше половины — предупреждение. */
49
13
  queue: number;
50
14
  }
51
- export interface ResetOptions {
52
- level: 'sessions' | 'tenant' | 'all';
53
- /** Подтверждение — имя схемы установки. */
54
- confirm: string;
55
- /** Какого арендатора сбросить при `level: 'tenant'` — его id; на других уровнях не учитывается. */
56
- tenant?: string;
57
- }
58
- export interface ResetResult {
59
- level: string;
60
- sessions?: number;
61
- tenant?: string;
62
- rows?: number;
63
- versions?: number;
64
- /** `level: 'all'` — новый ключ сервиса: прежние сессии и ключи больше не действуют. */
65
- serviceKey?: string;
66
- }
67
- /** Машинное описание модели (`db.describe()`). */
68
- export interface ModelDescription {
69
- schema: string;
70
- tenant: string | null;
71
- classes: {
72
- name: string;
73
- kind: 'hub' | 'link';
74
- alias?: string;
75
- abstract: boolean;
76
- parent?: string;
77
- ancestors: string[];
78
- system: boolean;
79
- fields: Record<string, unknown>;
80
- required: string[];
81
- /** Условия по нескольким полям (`fields.anyOf` описания с учётом наследования): хотя бы одно должно выполняться. */
82
- anyOf?: unknown[];
83
- /** Условия по нескольким полям (`fields.oneOf`): ровно одно должно выполняться. */
84
- oneOf?: unknown[];
85
- ends: Record<string, unknown>;
86
- key: string[];
87
- history: unknown;
88
- ownerDefault: string;
89
- cache?: {
90
- ttl?: number;
91
- };
92
- meta: Record<string, unknown>;
93
- /** Производный класс: строки пишет только проектор с этим именем (план 1.1, §2.4). */
94
- derivedBy?: string;
95
- /** Агрегатные поля: сумма или число живых строк класса-источника, их считает база (план 1.1, §2.4). */
96
- aggregate?: Record<string, AggregateInfo>;
97
- }[];
98
- methods: {
99
- chain: readonly string[];
100
- db: readonly string[];
101
- };
102
- }
103
15
  export interface AdminCtx {
104
- sql: Sql;
105
- schema: string;
106
16
  /** Схема в кавычках. */
107
17
  S: string;
108
18
  txc: TxContext;
109
19
  exec: Executor;
110
- registry: () => Registry;
111
- tenant: () => string | null;
112
20
  }
113
- export declare function verify(a: AdminCtx, opts?: {
114
- data?: boolean;
115
- }): Promise<VerifyResult>;
116
21
  /**
117
- * Обслуживание: истёкшие сессии и коды, прореживание журнала пачками (каждая — своя транзакция,
118
- * долгих блокировок нет), заполнение очереди сигналов. Из нескольких процессов работает один: пачка
119
- * первым делом берёт строку-замок обслуживания (lock_try в maintain() базы: for update skip locked строки
120
- * secret, которую посторонняя роль не займёт), и та снимается с фиксацией пачки. Сеансовая блокировка
121
- * за пулом в режиме транзакций (PgBouncer) оставалась бы на
122
- * серверном соединении, которое пул отдаёт другим клиентам, — и обслуживание молча вставало бы
123
- * (skipped у всех). Блокировку держит другой процесс: до первой пачки — skipped; между пачками —
124
- * вызов останавливается (продолжит тот процесс), отчёт — о сделанном, more: true.
22
+ * Обслуживание: истёкшие и погашенные сессии, одноразовые коды, строки-замки удалённых семейств и заполнение очереди
23
+ * сигналов — одной транзакцией. Из нескольких процессов работает один: maintain() базы первым делом берёт строку-замок
24
+ * обслуживания (lock_try: for update skip locked строки secret, которую посторонняя роль не займёт), и та снимается с
25
+ * фиксацией. Сеансовая блокировка за пулом в режиме транзакций (PgBouncer) оставалась бы на серверном соединении,
26
+ * которое пул отдаёт другим клиентам, — и обслуживание молча вставало бы (skipped у всех). Строку-замок держит другой
27
+ * процесс — skipped.
125
28
  */
126
- export declare function maintain(a: AdminCtx, opts?: MaintainOptions & {
127
- [MAINTAIN_STOP]?: () => boolean;
128
- }): Promise<MaintainReport>;
29
+ export declare function maintain(a: AdminCtx): Promise<MaintainReport>;
129
30
  /** Предупреждение о заполненной очереди сигналов (§2.12): больше половины — зависший слушатель. */
130
31
  export declare function queueWarning(queue: number): string | null;
131
32
  /**
@@ -136,39 +37,7 @@ export declare function queueWarning(queue: number): string | null;
136
37
  export declare function parseInterval(v: unknown): number;
137
38
  /**
138
39
  * Запуск maintain() по расписанию (connect({ maintain: '1h' }); ms — уже проверенный интервал): ошибки — предупреждением,
139
- * следующий запуск не начинается, пока идёт прежний. Возвращает остановку: она снимает таймер, просит идущий запуск не
140
- * начинать новых пачек и ждёт его конца — close() закрывает пул после неё, а не посреди пачки.
40
+ * следующий запуск не начинается, пока идёт прежний. Возвращает остановку: она снимает таймер и ждёт конца идущего
41
+ * запуска — close() закрывает пул после него, а не посреди транзакции.
141
42
  */
142
- export declare function scheduleMaintain(run: (stopped: () => boolean) => Promise<unknown>, ms: number): () => Promise<void>;
143
- /**
144
- * Сброс (этап 6, п. 6). Предохранители — в базе: флаг allowReset установки, право letopis.reset,
145
- * подтверждение именем схемы. tenant — пачками (длинная операция не держит горизонт подписок).
146
- */
147
- export declare function reset(a: AdminCtx, opts: ResetOptions): Promise<ResetResult>;
148
- /**
149
- * Обрезка истории (этап 6, п. 3): версии строже момента before удаляются, текущая — никогда; на место
150
- * версии 1 встаёт запись trim. target — имя класса (с потомками), id или список id, строка ответа.
151
- */
152
- export declare function trimHistory(a: AdminCtx, target: string | string[] | {
153
- id: string;
154
- } | {
155
- id: string;
156
- }[], before: string | Date): Promise<number>;
157
- /** Новый ключ класса (этап 6, п. 7): строки семейства переносятся на новые id. Возвращает их число. */
158
- export declare function rekeyClass(a: AdminCtx, cls: string, key: string[]): Promise<number>;
159
- /**
160
- * Ремонт агрегатов (план 1.1, §2.4): поля класса и его потомков во всех арендаторах — заново из живых источников,
161
- * под исключительными блокировками семейств целей и источников (запись в них ждёт). Версия пишется только там, где
162
- * значение изменилось; возвращает число исправленных строк. Функция владельца — от его роли (set local role), как
163
- * загрузчик; подключение проверил вызывающий (admin_required).
164
- */
165
- export declare function rebuildAggregates(env: {
166
- sql: Sql;
167
- S: string;
168
- ownerRole: string;
169
- }, cls: string): Promise<number>;
170
- /** Машинное описание модели арендатора вместе с метаданными классов (этап 6, п. 12). */
171
- export declare function describe(a: AdminCtx): ModelDescription;
172
- /** Описание модели текстом для языковых моделей. */
173
- export declare function describeText(d: ModelDescription): string;
174
- export {};
43
+ export declare function scheduleMaintain(run: () => Promise<unknown>, ms: number): () => Promise<void>;