letopis 1.4.2 → 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 (123) hide show
  1. package/AGENT-CHEATSHEET.md +516 -398
  2. package/CHANGELOG.md +739 -0
  3. package/LICENSE +75 -21
  4. package/LICENSE-COMMERCIAL.md +49 -0
  5. package/README.md +1312 -1190
  6. package/api-contract.json +381 -0
  7. package/dist/acl.d.ts +81 -1
  8. package/dist/acl.js +281 -13
  9. package/dist/admin.d.ts +11 -141
  10. package/dist/admin.js +28 -186
  11. package/dist/auth.d.ts +20 -5
  12. package/dist/auth.js +80 -21
  13. package/dist/cache.d.ts +8 -1
  14. package/dist/cache.js +30 -8
  15. package/dist/chain.d.ts +46 -48
  16. package/dist/chain.js +556 -223
  17. package/dist/cli.js +73 -96
  18. package/dist/demo/booking.d.ts +352 -36
  19. package/dist/describe.d.ts +47 -0
  20. package/dist/describe.js +195 -0
  21. package/dist/errors.d.ts +77 -13
  22. package/dist/errors.js +231 -30
  23. package/dist/index.d.ts +62 -74
  24. package/dist/index.js +312 -155
  25. package/dist/internal.d.ts +7 -0
  26. package/dist/jsonschema.js +18 -17
  27. package/dist/load.d.ts +29 -17
  28. package/dist/load.js +235 -190
  29. package/dist/log.d.ts +121 -0
  30. package/dist/log.js +633 -0
  31. package/dist/mcp.d.ts +5 -7
  32. package/dist/mcp.js +194 -225
  33. package/dist/messages/access.d.ts +90 -0
  34. package/dist/messages/access.js +113 -0
  35. package/dist/messages/api.d.ts +237 -0
  36. package/dist/messages/api.js +304 -0
  37. package/dist/messages/boot.d.ts +201 -0
  38. package/dist/messages/boot.js +256 -0
  39. package/dist/messages/codes.d.ts +172 -0
  40. package/dist/messages/codes.js +173 -0
  41. package/dist/messages/index.d.ts +98 -0
  42. package/dist/messages/index.js +121 -0
  43. package/dist/messages/log.d.ts +229 -0
  44. package/dist/messages/log.js +102 -0
  45. package/dist/messages/mcp.d.ts +306 -0
  46. package/dist/messages/mcp.js +387 -0
  47. package/dist/messages/model.d.ts +256 -0
  48. package/dist/messages/model.js +314 -0
  49. package/dist/messages/query.d.ts +141 -0
  50. package/dist/messages/query.js +171 -0
  51. package/dist/messages/sql-access.d.ts +70 -0
  52. package/dist/messages/sql-access.js +87 -0
  53. package/dist/messages/sql-core.d.ts +164 -0
  54. package/dist/messages/sql-core.js +198 -0
  55. package/dist/messages/sql-time.d.ts +86 -0
  56. package/dist/messages/sql-time.js +111 -0
  57. package/dist/messages/sql-write.d.ts +246 -0
  58. package/dist/messages/sql-write.js +270 -0
  59. package/dist/messages/time.d.ts +23 -0
  60. package/dist/messages/time.js +29 -0
  61. package/dist/messages/types.d.ts +27 -0
  62. package/dist/messages/types.js +5 -0
  63. package/dist/messages/write.d.ts +452 -0
  64. package/dist/messages/write.js +572 -0
  65. package/dist/model.d.ts +77 -60
  66. package/dist/model.js +43 -34
  67. package/dist/ops.d.ts +35 -0
  68. package/dist/ops.js +101 -0
  69. package/dist/pglite.d.ts +0 -9
  70. package/dist/pglite.js +3 -1
  71. package/dist/registry.d.ts +3 -19
  72. package/dist/registry.js +3 -16
  73. package/dist/signals.d.ts +1 -5
  74. package/dist/signals.js +1 -5
  75. package/dist/sql.d.ts +9 -1
  76. package/dist/sql.js +175 -107
  77. package/dist/sync.d.ts +10 -4
  78. package/dist/sync.js +90 -17
  79. package/dist/tx.d.ts +21 -2
  80. package/dist/tx.js +76 -13
  81. package/dist/typed.d.ts +252 -53
  82. package/dist/types.d.ts +93 -90
  83. package/dist/types.js +17 -14
  84. package/dist/up.d.ts +56 -35
  85. package/dist/up.js +229 -83
  86. package/dist/uuid.d.ts +8 -0
  87. package/dist/uuid.js +14 -6
  88. package/dist/validate.d.ts +13 -1
  89. package/dist/validate.js +82 -33
  90. package/dist/watch.d.ts +10 -16
  91. package/dist/watch.js +36 -77
  92. package/dist/write.d.ts +87 -17
  93. package/dist/write.js +441 -206
  94. package/llms.txt +48 -31
  95. package/package.json +13 -14
  96. package/sql/10-core.sql +7 -50
  97. package/sql/15-errors.sql +64 -10
  98. package/sql/20-context.sql +18 -9
  99. package/sql/30-validate.sql +79 -30
  100. package/sql/40-class.sql +41 -115
  101. package/sql/50-acl.sql +8 -13
  102. package/sql/60-write.sql +220 -676
  103. package/sql/70-read.sql +11 -13
  104. package/sql/80-auth.sql +109 -42
  105. package/sql/90-time.sql +108 -947
  106. package/sql/95-seed.system.sql +7 -14
  107. package/sql/99-revision.sql +6 -1
  108. package/sql/README.md +32 -24
  109. package/sql/migrate/0009-restore-rev.tenants-name.sql +10 -0
  110. package/sql/migrate/0010-impersonation-no-account.sql +5 -0
  111. package/sql/migrate/0011-key-lock.otp-enabled.sql +7 -0
  112. package/sql/migrate/README.md +18 -0
  113. package/AGENT-CHEATSHEET.en.md +0 -488
  114. package/MIGRATION.md +0 -190
  115. package/README.en.md +0 -2910
  116. package/dist/import.d.ts +0 -185
  117. package/dist/import.js +0 -820
  118. package/dist/project.d.ts +0 -359
  119. package/dist/project.js +0 -1258
  120. package/sql/75-history.sql +0 -436
  121. package/sql/88-derived.sql +0 -1236
  122. package/sql/migrate/0006-changed-paths.sql +0 -3
  123. package/sql/migrate/0007-secret-account-fk.sql +0 -12
package/dist/admin.js CHANGED
@@ -1,68 +1,35 @@
1
- import { LetopisError, fromDbError } from './errors.js';
1
+ /**
2
+ * Эксплуатация (план, этап 6, п. 7, 8): maintain и его запуск по расписанию. Всё проверяет база: функция владельца
3
+ * 90-time.sql сама сверяет права (letopis.maintain — системный администратор или администраторское подключение).
4
+ */
5
+ import { lerr, lmsg, lwarn, warnText } from './errors.js';
6
+ import { safeErrorText } from './log.js';
2
7
  import { runTx } from './tx.js';
3
- import { CHAIN_METHODS, DB_METHODS } from './types.js';
4
- /** Внутренняя опция maintain(): остановка запуска по расписанию — текущая пачка доделывается, следующая не начинается. */
5
- export const MAINTAIN_STOP = Symbol('letopis.maintain.stop');
6
- const SYSTEM_CLASSES = new Set(['Class', 'Account', 'member', 'Resource', 'rule']);
7
- const one = async (a, text, params = []) => a.exec.run(async (q) => (await q.unsafe(text, params))[0]);
8
- export async function verify(a, opts = {}) {
9
- const r = await one(a, `select ${a.S}.verify($1::boolean) as v`, [opts.data === true]);
10
- return r.v;
11
- }
12
8
  /**
13
- * Обслуживание: истёкшие сессии и коды, прореживание журнала пачками (каждая — своя транзакция,
14
- * долгих блокировок нет), заполнение очереди сигналов. Из нескольких процессов работает один: пачка
15
- * первым делом берёт строку-замок обслуживания (lock_try в maintain() базы: for update skip locked строки
16
- * secret, которую посторонняя роль не займёт), и та снимается с фиксацией пачки. Сеансовая блокировка
17
- * за пулом в режиме транзакций (PgBouncer) оставалась бы на
18
- * серверном соединении, которое пул отдаёт другим клиентам, — и обслуживание молча вставало бы
19
- * (skipped у всех). Блокировку держит другой процесс: до первой пачки — skipped; между пачками —
20
- * вызов останавливается (продолжит тот процесс), отчёт — о сделанном, more: true.
9
+ * Обслуживание: истёкшие и погашенные сессии, одноразовые коды, строки-замки удалённых семейств и заполнение очереди
10
+ * сигналов — одной транзакцией. Из нескольких процессов работает один: maintain() базы первым делом берёт строку-замок
11
+ * обслуживания (lock_try: for update skip locked строки secret, которую посторонняя роль не займёт), и та снимается с
12
+ * фиксацией. Сеансовая блокировка за пулом в режиме транзакций (PgBouncer) оставалась бы на серверном соединении,
13
+ * которое пул отдаёт другим клиентам, — и обслуживание молча вставало бы (skipped у всех). Строку-замок держит другой
14
+ * процесс — skipped.
21
15
  */
22
- export async function maintain(a, opts = {}) {
16
+ export async function maintain(a) {
23
17
  if (a.exec.explicit)
24
- throw new LetopisError('invalid_query', 'maintain() — пачками в своих транзакциях, не внутри db.begin()');
25
- const now = opts.now === undefined ? null : opts.now instanceof Date ? opts.now.toISOString() : opts.now;
26
- const report = { skipped: false, sessions: 0, codes: 0, thinned: 0, batches: 0, more: false, queue: 0 };
27
- const max = opts.batches ?? 100;
28
- for (let i = 0; i < max; i++) {
29
- // запуск по расписанию остановлен (close()): следующая пачка не начинается, отчёт — с more: true
30
- if (i > 0 && opts[MAINTAIN_STOP]?.())
31
- break;
32
- // блокировку берёт сама maintain() базы — строку-замок, которую посторонняя роль не займёт; занята — null.
33
- // Истёкшие сессии и коды — только в первой пачке (план 2.0, 7.3 №4); схема, функции которой ещё не обновил up()
34
- // (нет третьего аргумента, 42883), — прежний вызов: он чистит в каждой пачке
35
- const call = (cleanup) => runTx(a.txc, (q) => q.unsafe(cleanup === null
36
- ? `select ${a.S}.maintain($1::text::timestamptz, $2::int) as r`
37
- : `select ${a.S}.maintain($1::text::timestamptz, $2::int, $3::boolean) as r`, (cleanup === null ? [now, opts.limit ?? 10_000] : [now, opts.limit ?? 10_000, cleanup])));
38
- const [{ r }] = await call(i === 0).catch((e) => {
39
- if (e.code === '42883')
40
- return call(null);
41
- throw e;
42
- });
43
- if (r === null) {
44
- if (i === 0)
45
- return { ...report, skipped: true };
46
- break;
47
- }
48
- const b = r;
49
- report.sessions += b.sessions;
50
- report.codes += b.codes;
51
- report.thinned += b.thinned;
52
- report.queue = Number(b.queue);
53
- report.more = b.more;
54
- report.batches++;
55
- if (!b.more)
56
- break;
57
- }
18
+ throw lerr('time.maintain_in_tx');
19
+ const [{ r }] = await runTx(a.txc, (q) => q.unsafe(`select ${a.S}.maintain() as r`));
20
+ if (r === null)
21
+ return { skipped: true, sessions: 0, codes: 0, queue: 0 };
22
+ const b = r;
23
+ const report = { skipped: false, sessions: b.sessions, codes: b.codes, queue: Number(b.queue) };
58
24
  const warn = queueWarning(report.queue);
25
+ // логгер задан — событие warning (time.signal_queue), иначе console.warn, как прежде
59
26
  if (warn)
60
- console.warn(warn);
27
+ warnText(warn, 'time.signal_queue', a.txc.log, { queue: report.queue });
61
28
  return report;
62
29
  }
63
30
  /** Предупреждение о заполненной очереди сигналов (§2.12): больше половины — зависший слушатель. */
64
31
  export function queueWarning(queue) {
65
- return queue > 0.5 ? `letopis: очередь сигналов PostgreSQL заполнена на ${(queue * 100).toFixed(0)} % — проверьте зависших слушателей` : null;
32
+ return queue > 0.5 ? `letopis: ${lmsg('time.signal_queue', { pct: (queue * 100).toFixed(0) })}` : null;
66
33
  }
67
34
  /** Самый длинный интервал таймера Node.js — 2³¹−1 мс (около 24,8 суток): дольше таймер не ждёт и срабатывает сразу. */
68
35
  const MAX_TIMER_MS = 2 ** 31 - 1;
@@ -82,23 +49,23 @@ export function parseInterval(v) {
82
49
  }
83
50
  if (!Number.isFinite(ms) || ms < 1 || ms > MAX_TIMER_MS) {
84
51
  const shown = typeof v === 'string' ? `'${v}'` : typeof v === 'number' || typeof v === 'boolean' || v == null ? String(v) : typeof v;
85
- throw new LetopisError('invalid_data', `интервал ${shown}: число мс или строка '30s', '15m', '1h', '1d' — от 1 мс до 2³¹−1 мс (около 24 суток)`);
52
+ throw lerr('time.bad_interval', { value: shown });
86
53
  }
87
54
  return ms;
88
55
  }
89
56
  /**
90
57
  * Запуск maintain() по расписанию (connect({ maintain: '1h' }); ms — уже проверенный интервал): ошибки — предупреждением,
91
- * следующий запуск не начинается, пока идёт прежний. Возвращает остановку: она снимает таймер, просит идущий запуск не
92
- * начинать новых пачек и ждёт его конца — close() закрывает пул после неё, а не посреди пачки.
58
+ * следующий запуск не начинается, пока идёт прежний. Возвращает остановку: она снимает таймер и ждёт конца идущего
59
+ * запуска — close() закрывает пул после него, а не посреди транзакции.
93
60
  */
94
- export function scheduleMaintain(run, ms) {
61
+ export function scheduleMaintain(run, ms, log) {
95
62
  let stopped = false;
96
63
  let current = null;
97
64
  const timer = setInterval(() => {
98
65
  if (current || stopped)
99
66
  return;
100
- current = run(() => stopped)
101
- .then(() => { }, (e) => console.warn(`letopis: maintain по расписанию: ${e.message}`))
67
+ current = run()
68
+ .then(() => { }, (e) => lwarn('time.maintain_failed', { message: safeErrorText(e) }, { log }))
102
69
  .finally(() => (current = null));
103
70
  }, ms);
104
71
  timer.unref?.();
@@ -108,128 +75,3 @@ export function scheduleMaintain(run, ms) {
108
75
  await current;
109
76
  };
110
77
  }
111
- /**
112
- * Сброс (этап 6, п. 6). Предохранители — в базе: флаг allowReset установки, право letopis.reset,
113
- * подтверждение именем схемы. tenant — пачками (длинная операция не держит горизонт подписок).
114
- */
115
- export async function reset(a, opts) {
116
- if (opts.level === 'tenant') {
117
- if (a.exec.explicit)
118
- throw new LetopisError('invalid_query', "reset({ level: 'tenant' }) — пачками в своих транзакциях, не внутри db.begin()");
119
- let rows = 0;
120
- let versions = 0;
121
- for (;;) {
122
- const [{ r }] = await runTx(a.txc, (q) => q.unsafe(`select ${a.S}.reset($1, $2, $3::uuid, 10000) as r`, [opts.level, opts.confirm, opts.tenant ?? null]));
123
- const b = r;
124
- rows += b.rows;
125
- versions += b.versions;
126
- if (b.done)
127
- return { level: opts.level, tenant: b.tenant, rows, versions };
128
- }
129
- }
130
- const r = await one(a, `select ${a.S}.reset($1, $2, $3::uuid) as r`, [opts.level, opts.confirm, opts.tenant ?? null]);
131
- return r.r;
132
- }
133
- /**
134
- * Обрезка истории (этап 6, п. 3): версии строже момента before удаляются, текущая — никогда; на место
135
- * версии 1 встаёт запись trim. target — имя класса (с потомками), id или список id, строка ответа.
136
- */
137
- export async function trimHistory(a, target, before) {
138
- const at = before instanceof Date ? before.toISOString() : before;
139
- let ids = null;
140
- let classes = null;
141
- if (typeof target === 'string' && a.registry().has(target)) {
142
- classes = a.registry().family(target).map((c) => c.name);
143
- }
144
- else {
145
- const list = Array.isArray(target) ? target : [target];
146
- ids = list.map((x) => (typeof x === 'string' ? x : x.id));
147
- }
148
- const r = await one(a, `select ${a.S}.trim_history($1::uuid[], $2::text[], $3::text::timestamptz) as n`, [ids, classes, at]);
149
- return r.n;
150
- }
151
- /** Новый ключ класса (этап 6, п. 7): строки семейства переносятся на новые id. Возвращает их число. */
152
- export async function rekeyClass(a, cls, key) {
153
- const name = a.registry().resolve(cls).name;
154
- const r = await one(a, `select ${a.S}.rekey_class($1, $2::jsonb) as n`, [name, key]);
155
- return r.n;
156
- }
157
- /**
158
- * Ремонт агрегатов (план 1.1, §2.4): поля класса и его потомков во всех арендаторах — заново из живых источников,
159
- * под исключительными блокировками семейств целей и источников (запись в них ждёт). Версия пишется только там, где
160
- * значение изменилось; возвращает число исправленных строк. Функция владельца — от его роли (set local role), как
161
- * загрузчик; подключение проверил вызывающий (admin_required).
162
- */
163
- export async function rebuildAggregates(env, cls) {
164
- if (typeof cls !== 'string' || !cls)
165
- throw new LetopisError('invalid_data', 'rebuildAggregates(класс): имя класса');
166
- const role = `"${env.ownerRole.replace(/"/g, '""')}"`;
167
- return env.sql.begin(async (q) => {
168
- await q.unsafe(`set local role ${role}`);
169
- await q.unsafe(`select set_config('letopis.reason', 'rebuildAggregates', true)`);
170
- const [{ n }] = await q.unsafe(`select ${env.S}.rebuild_aggregates($1) as n`, [cls]);
171
- return n;
172
- }).catch((e) => {
173
- throw fromDbError(e);
174
- });
175
- }
176
- /** Машинное описание модели арендатора вместе с метаданными классов (этап 6, п. 12). */
177
- export function describe(a) {
178
- const classes = a.registry().all().map((c) => ({
179
- name: c.name,
180
- kind: c.kind,
181
- ...(c.alias ? { alias: c.alias } : {}),
182
- abstract: c.abstract,
183
- ...(c.ancestors[1] ? { parent: c.ancestors[1] } : {}),
184
- ancestors: c.ancestors,
185
- system: SYSTEM_CLASSES.has(c.name),
186
- fields: (c.schema.properties ?? {}),
187
- required: (c.schema.required ?? []),
188
- ...(Array.isArray(c.schema.anyOf) ? { anyOf: c.schema.anyOf } : {}),
189
- ...(Array.isArray(c.schema.oneOf) ? { oneOf: c.schema.oneOf } : {}),
190
- ends: c.ends,
191
- key: c.key,
192
- history: c.history,
193
- ownerDefault: c.ownerDefault,
194
- ...(c.cache ? { cache: c.cache } : {}),
195
- meta: c.meta,
196
- ...(c.derivedBy ? { derivedBy: c.derivedBy } : {}),
197
- ...(c.aggregate ? { aggregate: c.aggregate } : {}),
198
- })).sort((x, y) => Number(x.system) - Number(y.system) || x.name.localeCompare(y.name));
199
- return { schema: a.schema, tenant: a.tenant(), classes, methods: { chain: CHAIN_METHODS, db: DB_METHODS } };
200
- }
201
- /** Описание модели текстом для языковых моделей. */
202
- export function describeText(d) {
203
- const out = [`# letopis: модель схемы ${d.schema}`, '',
204
- 'Цепочка: db.Класс(фильтр).Связь().Класс().rows(); запись — create, update, upsert, delete; история — versions(), asOf(), withDeleted().', ''];
205
- for (const c of d.classes) {
206
- const title = typeof c.meta.title === 'string' ? ` — ${c.meta.title}` : '';
207
- out.push(`## ${c.name}${c.alias ? ` (${c.alias})` : ''}${title}`);
208
- out.push(`- вид: ${c.kind === 'hub' ? 'хаб (объект)' : 'связь'}${c.abstract ? ', абстрактный' : ''}${c.parent ? `, предок ${c.parent}` : ''}${c.system ? ', системный' : ''}`);
209
- out.push(`- ключ: ${c.key.length ? c.key.join(', ') : 'нет (id выдаёт база)'}`);
210
- const fields = Object.entries(c.fields).map(([k, v]) => {
211
- const t = v.type;
212
- return `${k}${c.required.includes(k) ? '*' : ''}: ${Array.isArray(t) ? t.join('|') : t ?? 'any'}`;
213
- });
214
- if (fields.length)
215
- out.push(`- поля: ${fields.join(', ')}`);
216
- if (c.anyOf)
217
- out.push(`- условия anyOf (хотя бы одно): ${JSON.stringify(c.anyOf)}`);
218
- if (c.oneOf)
219
- out.push(`- условия oneOf (ровно одно): ${JSON.stringify(c.oneOf)}`);
220
- const ends = Object.entries(c.ends).map(([k, v]) => {
221
- const e = v;
222
- return `${k}${e.required ? '*' : ''} → ${Array.isArray(e.class) ? e.class.join('|') : e.class}${e.many ? '[]' : ''}`;
223
- });
224
- if (ends.length)
225
- out.push(`- концы: ${ends.join(', ')}`);
226
- if (c.derivedBy)
227
- out.push(`- производный: строки пишет проектор ${c.derivedBy}, приложение только читает`);
228
- for (const [field, a] of Object.entries(c.aggregate ?? {})) {
229
- const what = a.sum !== undefined ? `сумма ${a.sum}` : 'число';
230
- out.push(`- агрегат ${field} = ${what} строк ${a.of} по концу ${a.via}${a.where ? `, условие ${JSON.stringify(a.where)}` : ''}; поле считает база, приложение его не пишет`);
231
- }
232
- out.push('');
233
- }
234
- return out.join('\n');
235
- }
package/dist/auth.d.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import type { Executor } from './tx.js';
2
- /** Хэш пароля scrypt: `scrypt$N$r$p$соль$хэш` (формат 0.21). */
2
+ import type { CallSpec } from './log.js';
3
+ /** Хэш пароля scrypt: `scrypt$N$r$p$соль$хэш`. */
3
4
  export declare function hashPassword(password: string): Promise<string>;
4
5
  /** Проверка пароля по хэшу scrypt; другие форматы не проверяются. */
5
6
  export declare function verifyHash(password: string, stored: string): Promise<boolean>;
@@ -184,19 +185,31 @@ export interface AuthApi {
184
185
  refresh(opts?: {
185
186
  ttl?: number;
186
187
  }): Promise<string>;
187
- /** Выход: погасить текущую сессию. */
188
- revoke(): Promise<boolean>;
188
+ /**
189
+ * Выход. Без аргумента — погасить сессию этого подключения. С токеном — погасить сессию пользователя по её токену
190
+ * (например, из cookie веб-сервиса): так сервис выводит пользователя, не подключаясь по его токену; нужно право
191
+ * `auth.session`, как у `session(token)`.
192
+ * @returns true — сессия погашена; false — токен неверный, сессия уже погашена или истекла (причина не различается).
193
+ * @throws LetopisError `acl_denied` — с токеном, а у вызывающего нет права `auth.session`; `no_session` — у подключения
194
+ * нет действующей сессии.
195
+ */
196
+ revoke(token?: string): Promise<boolean>;
189
197
  /** Выход везде: все сессии аккаунта (чужого — с правом `auth.session`). */
190
198
  revokeAll(account?: AccountRef): Promise<number>;
191
199
  /** Переключить арендатора сессии: собственный или по членству. */
192
200
  switch(tenant: string): Promise<void>;
193
- /** Арендаторы, доступные аккаунту сессии: собственный (`owner`) и по членству. */
201
+ /**
202
+ * Арендаторы, доступные аккаунту сессии: собственный (`roles: ['owner']`, `personal: true`) и по членству. `name` — имя
203
+ * аккаунта арендатора (`createTenant({ name })`, у личного — имя пользователя).
204
+ */
194
205
  tenants(): Promise<{
195
206
  tenant: string;
196
207
  roles: string[];
208
+ name: string | null;
209
+ personal: boolean;
197
210
  }[]>;
198
211
  /**
199
- * Удалить аккаунт (этап 6; accounts.purge 0.21): системный администратор или владелец его
212
+ * Удалить аккаунт: системный администратор или владелец его
200
213
  * арендатора; себя и последнего владельца арендатора — нельзя. Данные арендатора стираются,
201
214
  * владение строками в чужих арендаторах переходит им, сессии гаснут, креды удаляются. Владелец стирает
202
215
  * и арендатора, в котором работает: его сессия возвращается в личный арендатор.
@@ -211,4 +224,6 @@ export interface AuthApi {
211
224
  }
212
225
  /** Фасад db.auth. onSwitch — подключение перечитывает арендатора, реестр и пропуска прав. */
213
226
  export declare function makeAuth(S: string, exec: Executor, onSwitch: () => Promise<void>, canSwitch: boolean): AuthApi;
227
+ /** Что пишет в лог каждый метод db.auth. */
228
+ export declare const AUTH_LOG: Readonly<Record<string, CallSpec>>;
214
229
  export {};
package/dist/auth.js CHANGED
@@ -11,11 +11,11 @@
11
11
  */
12
12
  import { createHmac, randomBytes, scrypt, timingSafeEqual } from 'node:crypto';
13
13
  import { promisify } from 'node:util';
14
- import { LetopisError, removed } from './errors.js';
14
+ import { lerr } from './errors.js';
15
15
  const scryptAsync = promisify(scrypt);
16
16
  const SCRYPT = { N: 32768, r: 8, p: 1, maxmem: 64 * 1024 * 1024 };
17
17
  const KEYLEN = 32;
18
- /** Хэш пароля scrypt: `scrypt$N$r$p$соль$хэш` (формат 0.21). */
18
+ /** Хэш пароля scrypt: `scrypt$N$r$p$соль$хэш`. */
19
19
  export async function hashPassword(password) {
20
20
  const salt = randomBytes(16);
21
21
  const hash = await scryptAsync(password, salt, KEYLEN, SCRYPT);
@@ -31,7 +31,7 @@ export async function verifyHash(password, stored) {
31
31
  // которому нужно больше, или с недопустимыми параметрами scrypt Node.js отвергает — это понятная ошибка, а не «неверный
32
32
  // пароль» (false). Текст Node.js в сообщение не идёт: он зависит от сборки OpenSSL
33
33
  const got = await scryptAsync(password, Buffer.from(saltB64, 'base64'), want.length, { N: Number(N), r: Number(r), p: Number(p), maxmem: SCRYPT.maxmem }).catch(() => {
34
- throw new LetopisError('invalid_data', `хэш пароля: параметры scrypt N=${N}, r=${r}, p=${p} недопустимы или требуют больше 64 МБ памяти`);
34
+ throw lerr('access.bad_scrypt_params', { N, r, p });
35
35
  });
36
36
  return got.length === want.length && timingSafeEqual(got, want);
37
37
  }
@@ -61,7 +61,7 @@ export function base32Decode(s) {
61
61
  for (const ch of s.replace(/=+$/, '').toUpperCase()) {
62
62
  const idx = B32.indexOf(ch);
63
63
  if (idx < 0)
64
- throw new LetopisError('invalid_data', 'секрет TOTP — не base32');
64
+ throw lerr('access.bad_totp_secret');
65
65
  value = (value << 5) | idx;
66
66
  bits += 5;
67
67
  if (bits >= 8) {
@@ -83,19 +83,11 @@ export function totpCode(secretBase32, atMs = Date.now()) {
83
83
  return String(code).padStart(TOTP_DIGITS, '0');
84
84
  }
85
85
  const accId = (a) => (typeof a === 'object' ? a.id : a);
86
+ /** Логин без крайних пробелов (план 2.1, A12): « anna » и «anna» — один логин; не строка — как есть (отказ даст база). */
87
+ const trimLogin = (v) => (typeof v === 'string' ? v.trim() : v);
86
88
  const ttlOf = (sec) => (sec === undefined ? null : `${Math.round(sec)} seconds`);
87
89
  /** Фасад db.auth. onSwitch — подключение перечитывает арендатора, реестр и пропуска прав. */
88
90
  export function makeAuth(S, exec, onSwitch, canSwitch) {
89
- const api = makeAuthCore(S, exec, onSwitch, canSwitch);
90
- return new Proxy(api, {
91
- get(t, p, r) {
92
- if (p === 'sessions')
93
- throw removed('db.auth.sessions(store)', 'сессии хранятся в базе: sessionFor, refresh, revoke, revokeAll');
94
- return Reflect.get(t, p, r);
95
- },
96
- });
97
- }
98
- function makeAuthCore(S, exec, onSwitch, canSwitch) {
99
91
  const one = async (text, params = []) => {
100
92
  const rows = await exec.run((q) => q.unsafe(text, params));
101
93
  return rows[0];
@@ -118,12 +110,14 @@ function makeAuthCore(S, exec, onSwitch, canSwitch) {
118
110
  return {
119
111
  async setPassword(a) {
120
112
  const hash = await hashPassword(a.password);
121
- const r = await one(`select ${S}.auth_set_password($1::uuid, $2, $3, $4, $5, $6) as id`, [accId(a.account), a.identifier, hash, a.kind ?? 'PASSWORD', a.confirmed ?? null, a.keepCurrent ?? true]);
113
+ const r = await one(`select ${S}.auth_set_password($1::uuid, $2, $3, $4, $5, $6) as id`, [accId(a.account), trimLogin(a.identifier), hash, a.kind ?? 'PASSWORD', a.confirmed ?? null, a.keepCurrent ?? true]);
122
114
  return r.id;
123
115
  },
124
116
  async verifyPassword(a) {
125
- const rows = await exec.run((q) => q.unsafe(`select account, hash, confirmed from ${S}.auth_password_get($1, $2)`, [a.identifier, a.kind ?? 'PASSWORD']));
126
- const cred = rows[0];
117
+ const get = async (ident) => (await exec.run((q) => q.unsafe(`select account, hash, confirmed from ${S}.auth_password_get($1, $2)`, [ident, a.kind ?? 'PASSWORD'])))[0];
118
+ // логин без крайних пробелов (план 2.1, A12); кред, записанный до 2.1 с пробелами, находится по логину как есть
119
+ const ident = trimLogin(a.identifier);
120
+ const cred = (await get(ident)) ?? (ident !== a.identifier ? await get(a.identifier) : undefined);
127
121
  if (!cred)
128
122
  return dummyVerify(a.password).then(() => null);
129
123
  if (!(await verifyHash(a.password, cred.hash)))
@@ -191,7 +185,10 @@ function makeAuthCore(S, exec, onSwitch, canSwitch) {
191
185
  async refresh(opts) {
192
186
  return (await one(`select ${S}.auth_refresh(coalesce($1::interval, interval '7 days')) as e`, [ttlOf(opts?.ttl)])).e;
193
187
  },
194
- async revoke() {
188
+ async revoke(token) {
189
+ // с токеном — выход пользователя по токену (план 2.1, A3): право auth.session, как у session(token)
190
+ if (token !== undefined)
191
+ return (await one(`select ${S}.auth_logout_token($1) as ok`, [token])).ok;
195
192
  return (await one(`select ${S}.auth_logout() as ok`)).ok;
196
193
  },
197
194
  async revokeAll(account) {
@@ -199,13 +196,14 @@ function makeAuthCore(S, exec, onSwitch, canSwitch) {
199
196
  },
200
197
  async switch(tenant) {
201
198
  if (!canSwitch)
202
- throw new LetopisError('invalid_query', 'auth.switch() — от корня подключения: при имперсонации арендатора задаёт db.as(…, { tenant })');
199
+ throw lerr('access.switch_not_root');
203
200
  await one(`select ${S}.auth_switch($1::uuid)`, [tenant]);
204
201
  await onSwitch();
205
202
  },
206
203
  async tenants() {
207
- const rows = await exec.run((q) => q.unsafe(`select tenant, roles from ${S}.auth_tenants()`));
208
- return rows.map((r) => ({ tenant: r.tenant, roles: r.roles }));
204
+ // name и personal — с ревизии 9 (план 2.1, A5): personal — собственный арендатор аккаунта сессии (tenant = account)
205
+ const rows = await exec.run((q) => q.unsafe(`select tenant, roles, name, personal from ${S}.auth_tenants()`));
206
+ return rows.map((r) => ({ tenant: r.tenant, roles: r.roles, name: r.name ?? null, personal: r.personal === true }));
209
207
  },
210
208
  async purgeAccount(account) {
211
209
  const r = (await one(`select ${S}.account_purge($1::uuid) as r`, [accId(account)])).r;
@@ -216,3 +214,64 @@ function makeAuthCore(S, exec, onSwitch, canSwitch) {
216
214
  },
217
215
  };
218
216
  }
217
+ const argOf = (args, i = 0) => (args[i] !== null && typeof args[i] === 'object' ? args[i] : {});
218
+ /** Аккаунт аргумента: id или { id }. */
219
+ const accOf = (a) => (typeof a === 'string' ? a : a !== null && typeof a === 'object' && typeof a.id === 'string' ? a.id : undefined);
220
+ const loginOf = (v) => (typeof v === 'string' ? { login: v.trim() } : {});
221
+ /** Вход: успех — auth.login (account — вошедший), отказ — auth.fail. */
222
+ function loginEvent(r, method, extra = {}) {
223
+ const res = r;
224
+ const detail = { method, ...extra };
225
+ return res && typeof res === 'object' ? { event: 'auth.login', account: res.account, detail } : { event: 'auth.fail', detail };
226
+ }
227
+ const credential = (action, detail) => ({ event: 'auth.credential', detail: { action, ...detail } });
228
+ const call = (method, detail = {}) => ({ event: 'auth.call', detail: { method, ...detail } });
229
+ /** Что пишет в лог каждый метод db.auth. */
230
+ export const AUTH_LOG = {
231
+ setPassword: (args, id) => {
232
+ const a = argOf(args);
233
+ return credential('setPassword', { account: accOf(a.account), kind: a.kind ?? 'PASSWORD', ...loginOf(a.identifier), id });
234
+ },
235
+ verifyPassword: (args, r) => {
236
+ const a = argOf(args);
237
+ return loginEvent(r, String(a.kind ?? 'PASSWORD'), { ...loginOf(a.identifier), ...(a.totp != null ? { totp: true } : {}) });
238
+ },
239
+ issueApiKey: (args, r) => credential('issueApiKey', { account: accOf(argOf(args).account), name: argOf(args).name, id: r?.id }),
240
+ verifyApiKey: (_args, r) => loginEvent(r, 'APIKEY'),
241
+ issueKeySecret: (args, r) => credential('issueKeySecret', { account: accOf(argOf(args).account), name: argOf(args).name, id: r?.id }),
242
+ verifyKeySecret: (_args, r) => loginEvent(r, 'KEYSECRET'),
243
+ enrollTotp: (args) => credential('enrollTotp', { account: accOf(argOf(args).account) }),
244
+ verifyTotp: (args, ok) => (ok === true
245
+ ? call('verifyTotp', { account: accOf(argOf(args).account), ok: true })
246
+ : { event: 'auth.fail', detail: { method: 'TOTP', account: accOf(argOf(args).account) } }),
247
+ totpEnabled: (args, r) => call('totpEnabled', { account: accOf(args[0]), enabled: r }),
248
+ issueOtp: (args) => {
249
+ const a = argOf(args);
250
+ return credential('issueOtp', { account: accOf(a.account), kind: a.kind ?? 'OTP', ...loginOf(a.identifier) });
251
+ },
252
+ verifyOtp: (args, r) => {
253
+ const a = argOf(args);
254
+ return loginEvent(r, String(a.kind ?? 'OTP'), loginOf(a.identifier));
255
+ },
256
+ link: (args, id) => {
257
+ const a = argOf(args);
258
+ return credential('link', { account: accOf(a.account), kind: a.kind, ...loginOf(a.identifier), id });
259
+ },
260
+ lookup: (args, r) => {
261
+ const a = argOf(args);
262
+ return loginEvent(r, String(a.kind), { ...loginOf(a.identifier), ...(a.totp != null ? { totp: true } : {}) });
263
+ },
264
+ credentials: (args, r) => ({ ...call('credentials', { account: accOf(args[0]) }), rows: Array.isArray(r) ? r.length : undefined }),
265
+ revokeCredential: (args, ok) => ({ event: 'auth.revoke', detail: { target: 'credential', id: args[0], ok } }),
266
+ setFlags: (args, ok) => credential('setFlags', { id: args[0], ...argOf(args, 1), ok }),
267
+ sessionFor: (args) => ({ event: 'auth.session', detail: { account: accOf(args[0]), method: 'SERVICE' } }),
268
+ session: (_args, r) => call('session', { found: r !== null }),
269
+ refresh: () => call('refresh'),
270
+ revoke: (args, ok) => (args[0] !== undefined
271
+ ? { event: 'auth.revoke', detail: { target: 'token', ok } }
272
+ : { event: 'auth.logout', detail: { ok } }),
273
+ revokeAll: (args, n) => ({ event: 'auth.revoke', detail: { target: 'all', account: accOf(args[0]), n } }),
274
+ switch: (args, _r, before) => ({ event: 'auth.switch', tenant: String(args[0]), detail: { from: before } }),
275
+ tenants: (_args, r) => ({ ...call('tenants'), rows: Array.isArray(r) ? r.length : undefined }),
276
+ purgeAccount: (args, r) => ({ event: 'account.purge', detail: { account: accOf(args[0]), ...r } }),
277
+ };
package/dist/cache.d.ts CHANGED
@@ -68,6 +68,13 @@ export declare class ResultCache {
68
68
  }): string;
69
69
  /** Снимок счётчиков классов, пар с арендатором запроса (id) и сессии — до запроса. */
70
70
  token(classes: readonly string[], session: string, tenantId?: string | null): CacheToken;
71
+ /**
72
+ * Счётчик сессии заводит снимок запроса (token), а сигнал о гашении меняет только заведённые: снимок в полёте бывает
73
+ * лишь у сессии, которая читала через этот кэш. Гасят же в основном чужие сессии — вход и выход пользователей
74
+ * сервиса, — и счётчик на каждый тег копил бы карту до предела EPOCH_MAX (долгий прогон: вход и выход раз в секунду —
75
+ * 0,7–0,9 МБ/ч на подключение). Заведённый счётчик не удаляется: иначе снимок, взятый до гашения, сошёлся бы с нулём.
76
+ */
77
+ private watchSession;
71
78
  private pairCount;
72
79
  get(key: string): unknown[] | undefined;
73
80
  /** Положить ответ, если с момента снимка не было сигналов о его классах и сессии. */
@@ -76,7 +83,7 @@ export declare class ResultCache {
76
83
  invalidate(classes: Iterable<string>): void;
77
84
  /** Сбросить ответы класса в арендаторе (токен) и ответы без арендатора; null — во всех. */
78
85
  private invalidateIn;
79
- /** Сбросить ответы сессии (тег — начало sha256 токена). */
86
+ /** Сбросить ответы сессии (тег — начало sha256 токена); счётчик — только у заведённой снимком (watchSession). */
80
87
  invalidateSession(tag: string): void;
81
88
  /**
82
89
  * Счётчик сигналов по ключу (класс, пара «класс, арендатор», сессия). Ключи приходят в нагрузке сигнала, а её
package/dist/cache.js CHANGED
@@ -5,13 +5,14 @@
5
5
  * Ключ — текст запроса, параметры и тот, кто спрашивает (токен, актор, арендатор): под RLS один
6
6
  * запрос разным пользователям отдаёт разные строки. Сброс:
7
7
  * - свои записи — сразу после фиксации, по классам из letopis.changed (туда попадают и классы
8
- * каскада, unset, смены класса и ключа); запись через db.sql — весь кэш;
8
+ * каскада и unset); запись через db.sql — весь кэш;
9
9
  * - чужие записи — по сигналу базы (нагрузка по режиму установки, signals.ts): класс в арендаторе
10
10
  * сигнала (ответ помнит токен арендатора запроса; сигнал System и процесс без ключа — во всех), в режиме
11
- * opaque без ключа — весь кэш; классы прав и схемы (rule, Resource, member, Account, Class) и «*» —
12
- * весь кэш; `session:<16 hex>` — ответы погашенной сессии, `session:*` — все;
11
+ * opaque без ключа — весь кэш; классы прав и схемы (rule, Resource, member, Account, Class) — весь кэш;
12
+ * `session:<16 hex>` — ответы погашенной сессии;
13
13
  * - гонка запроса и сигнала: счётчики сигналов по классам, парам «класс, арендатор» и сессиям
14
- * снимаются до запроса, ответ кладётся, только если они не изменились;
14
+ * снимаются до запроса, ответ кладётся, только если они не изменились; счётчик сессии заводит сам
15
+ * снимок — гашение сессии, которая через кэш не читала, следа в картах не оставляет;
15
16
  * - (пере)подключение слушателя сигналов — весь кэш: сигналы за время обрыва потеряны; без слушателя
16
17
  * кэш выключен (с ttlOnly — работает только по сроку);
17
18
  * - срок (по умолчанию 60 с) — предохранитель; ответы истёкшей сессии не используются.
@@ -63,13 +64,32 @@ export class ResultCache {
63
64
  /** Снимок счётчиков классов, пар с арендатором запроса (id) и сессии — до запроса. */
64
65
  token(classes, session, tenantId) {
65
66
  const tenant = this.allTenants ? null : this.sig.tenantToken(tenantId);
67
+ // счётчик сессии — до снимка epoch: переполнение карты сдвигает epoch
68
+ const sessionCount = this.watchSession(session);
66
69
  return {
67
70
  epoch: this.epoch,
68
71
  classes: classes.map((c) => [c, this.classEpoch.get(c) ?? 0, this.pairCount(c, tenant)]),
69
- session: this.sessionEpoch.get(session) ?? 0,
72
+ session: sessionCount,
70
73
  tenant,
71
74
  };
72
75
  }
76
+ /**
77
+ * Счётчик сессии заводит снимок запроса (token), а сигнал о гашении меняет только заведённые: снимок в полёте бывает
78
+ * лишь у сессии, которая читала через этот кэш. Гасят же в основном чужие сессии — вход и выход пользователей
79
+ * сервиса, — и счётчик на каждый тег копил бы карту до предела EPOCH_MAX (долгий прогон: вход и выход раз в секунду —
80
+ * 0,7–0,9 МБ/ч на подключение). Заведённый счётчик не удаляется: иначе снимок, взятый до гашения, сошёлся бы с нулём.
81
+ */
82
+ watchSession(tag) {
83
+ const n = this.sessionEpoch.get(tag);
84
+ if (n !== undefined)
85
+ return n;
86
+ if (this.sessionEpoch.size >= EPOCH_MAX) {
87
+ this.sessionEpoch.clear();
88
+ this.epoch++;
89
+ }
90
+ this.sessionEpoch.set(tag, 0);
91
+ return 0;
92
+ }
73
93
  pairCount(cls, tenant) {
74
94
  return (tenant === null ? this.anyEpoch.get(cls) : this.pairEpoch.get(`${cls}@${tenant}`)) ?? 0;
75
95
  }
@@ -126,9 +146,11 @@ export class ResultCache {
126
146
  if ((e.tenant === null || e.tenant === tenant) && e.classes.includes(cls))
127
147
  this.map.delete(key);
128
148
  }
129
- /** Сбросить ответы сессии (тег — начало sha256 токена). */
149
+ /** Сбросить ответы сессии (тег — начало sha256 токена); счётчик — только у заведённой снимком (watchSession). */
130
150
  invalidateSession(tag) {
131
- this.bump(this.sessionEpoch, tag);
151
+ const n = this.sessionEpoch.get(tag);
152
+ if (n !== undefined)
153
+ this.sessionEpoch.set(tag, n + 1);
132
154
  for (const [k, e] of this.map)
133
155
  if (e.session === tag)
134
156
  this.map.delete(k);
@@ -161,7 +183,7 @@ export class ResultCache {
161
183
  // класса нет в реестрах процесса — и ответов с ним нет
162
184
  return;
163
185
  default:
164
- // *, session:*, opaque без ключа, нагрузка не по режиму
186
+ // переподключение слушателя, opaque без ключа, нагрузка не по режиму
165
187
  return this.clear();
166
188
  }
167
189
  }