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.
- package/AGENT-CHEATSHEET.md +516 -398
- package/CHANGELOG.md +739 -0
- package/LICENSE +75 -21
- package/LICENSE-COMMERCIAL.md +49 -0
- package/README.md +1312 -1190
- package/api-contract.json +381 -0
- package/dist/acl.d.ts +81 -1
- package/dist/acl.js +281 -13
- package/dist/admin.d.ts +11 -141
- package/dist/admin.js +28 -186
- package/dist/auth.d.ts +20 -5
- package/dist/auth.js +80 -21
- package/dist/cache.d.ts +8 -1
- package/dist/cache.js +30 -8
- package/dist/chain.d.ts +46 -48
- package/dist/chain.js +556 -223
- package/dist/cli.js +73 -96
- package/dist/demo/booking.d.ts +352 -36
- package/dist/describe.d.ts +47 -0
- package/dist/describe.js +195 -0
- package/dist/errors.d.ts +77 -13
- package/dist/errors.js +231 -30
- package/dist/index.d.ts +62 -74
- package/dist/index.js +312 -155
- package/dist/internal.d.ts +7 -0
- package/dist/jsonschema.js +18 -17
- package/dist/load.d.ts +29 -17
- package/dist/load.js +235 -190
- package/dist/log.d.ts +121 -0
- package/dist/log.js +633 -0
- package/dist/mcp.d.ts +5 -7
- package/dist/mcp.js +194 -225
- package/dist/messages/access.d.ts +90 -0
- package/dist/messages/access.js +113 -0
- package/dist/messages/api.d.ts +237 -0
- package/dist/messages/api.js +304 -0
- package/dist/messages/boot.d.ts +201 -0
- package/dist/messages/boot.js +256 -0
- package/dist/messages/codes.d.ts +172 -0
- package/dist/messages/codes.js +173 -0
- package/dist/messages/index.d.ts +98 -0
- package/dist/messages/index.js +121 -0
- package/dist/messages/log.d.ts +229 -0
- package/dist/messages/log.js +102 -0
- package/dist/messages/mcp.d.ts +306 -0
- package/dist/messages/mcp.js +387 -0
- package/dist/messages/model.d.ts +256 -0
- package/dist/messages/model.js +314 -0
- package/dist/messages/query.d.ts +141 -0
- package/dist/messages/query.js +171 -0
- package/dist/messages/sql-access.d.ts +70 -0
- package/dist/messages/sql-access.js +87 -0
- package/dist/messages/sql-core.d.ts +164 -0
- package/dist/messages/sql-core.js +198 -0
- package/dist/messages/sql-time.d.ts +86 -0
- package/dist/messages/sql-time.js +111 -0
- package/dist/messages/sql-write.d.ts +246 -0
- package/dist/messages/sql-write.js +270 -0
- package/dist/messages/time.d.ts +23 -0
- package/dist/messages/time.js +29 -0
- package/dist/messages/types.d.ts +27 -0
- package/dist/messages/types.js +5 -0
- package/dist/messages/write.d.ts +452 -0
- package/dist/messages/write.js +572 -0
- package/dist/model.d.ts +77 -60
- package/dist/model.js +43 -34
- package/dist/ops.d.ts +35 -0
- package/dist/ops.js +101 -0
- package/dist/pglite.d.ts +0 -9
- package/dist/pglite.js +3 -1
- package/dist/registry.d.ts +3 -19
- package/dist/registry.js +3 -16
- package/dist/signals.d.ts +1 -5
- package/dist/signals.js +1 -5
- package/dist/sql.d.ts +9 -1
- package/dist/sql.js +175 -107
- package/dist/sync.d.ts +10 -4
- package/dist/sync.js +90 -17
- package/dist/tx.d.ts +21 -2
- package/dist/tx.js +76 -13
- package/dist/typed.d.ts +252 -53
- package/dist/types.d.ts +93 -90
- package/dist/types.js +17 -14
- package/dist/up.d.ts +56 -35
- package/dist/up.js +229 -83
- package/dist/uuid.d.ts +8 -0
- package/dist/uuid.js +14 -6
- package/dist/validate.d.ts +13 -1
- package/dist/validate.js +82 -33
- package/dist/watch.d.ts +10 -16
- package/dist/watch.js +36 -77
- package/dist/write.d.ts +87 -17
- package/dist/write.js +441 -206
- package/llms.txt +48 -31
- package/package.json +13 -14
- package/sql/10-core.sql +7 -50
- package/sql/15-errors.sql +64 -10
- package/sql/20-context.sql +18 -9
- package/sql/30-validate.sql +79 -30
- package/sql/40-class.sql +41 -115
- package/sql/50-acl.sql +8 -13
- package/sql/60-write.sql +220 -676
- package/sql/70-read.sql +11 -13
- package/sql/80-auth.sql +109 -42
- package/sql/90-time.sql +108 -947
- package/sql/95-seed.system.sql +7 -14
- package/sql/99-revision.sql +6 -1
- package/sql/README.md +32 -24
- package/sql/migrate/0009-restore-rev.tenants-name.sql +10 -0
- package/sql/migrate/0010-impersonation-no-account.sql +5 -0
- package/sql/migrate/0011-key-lock.otp-enabled.sql +7 -0
- package/sql/migrate/README.md +18 -0
- package/AGENT-CHEATSHEET.en.md +0 -488
- package/MIGRATION.md +0 -190
- package/README.en.md +0 -2910
- package/dist/import.d.ts +0 -185
- package/dist/import.js +0 -820
- package/dist/project.d.ts +0 -359
- package/dist/project.js +0 -1258
- package/sql/75-history.sql +0 -436
- package/sql/88-derived.sql +0 -1236
- package/sql/migrate/0006-changed-paths.sql +0 -3
- package/sql/migrate/0007-secret-account-fk.sql +0 -12
package/dist/admin.js
CHANGED
|
@@ -1,68 +1,35 @@
|
|
|
1
|
-
|
|
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
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
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
|
|
16
|
+
export async function maintain(a) {
|
|
23
17
|
if (a.exec.explicit)
|
|
24
|
-
throw
|
|
25
|
-
const
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
|
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
|
-
*
|
|
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(
|
|
101
|
-
.then(() => { }, (e) =>
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
-
* Удалить
|
|
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 {
|
|
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
|
|
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
|
|
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
|
|
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
|
|
126
|
-
|
|
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
|
|
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
|
-
|
|
208
|
-
|
|
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
|
-
*
|
|
8
|
+
* каскада и unset); запись через db.sql — весь кэш;
|
|
9
9
|
* - чужие записи — по сигналу базы (нагрузка по режиму установки, signals.ts): класс в арендаторе
|
|
10
10
|
* сигнала (ответ помнит токен арендатора запроса; сигнал System и процесс без ключа — во всех), в режиме
|
|
11
|
-
* opaque без ключа — весь кэш; классы прав и схемы (rule, Resource, member, Account, Class)
|
|
12
|
-
*
|
|
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:
|
|
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.
|
|
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
|
-
//
|
|
186
|
+
// переподключение слушателя, opaque без ключа, нагрузка не по режиму
|
|
165
187
|
return this.clear();
|
|
166
188
|
}
|
|
167
189
|
}
|