letopis 1.4.0 → 1.4.2
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.en.md +15 -7
- package/AGENT-CHEATSHEET.md +19 -11
- package/CHANGELOG.md +299 -1
- package/README.en.md +180 -62
- package/README.md +165 -55
- package/dist/acl.d.ts +11 -5
- package/dist/admin.d.ts +30 -14
- package/dist/admin.js +53 -18
- package/dist/auth.d.ts +61 -17
- package/dist/auth.js +16 -2
- package/dist/cache.d.ts +5 -1
- package/dist/cache.js +3 -2
- package/dist/chain.d.ts +92 -15
- package/dist/chain.js +56 -7
- package/dist/cli.js +16 -2
- package/dist/errors.d.ts +2 -2
- package/dist/errors.js +5 -5
- package/dist/import.js +7 -0
- package/dist/index.d.ts +73 -45
- package/dist/index.js +69 -14
- package/dist/internal.d.ts +7 -0
- package/dist/internal.js +5 -0
- package/dist/load.d.ts +10 -3
- package/dist/load.js +39 -11
- package/dist/mcp.d.ts +20 -11
- package/dist/mcp.js +8 -6
- package/dist/model.d.ts +4 -1
- package/dist/model.js +7 -1
- package/dist/ops.d.ts +58 -18
- package/dist/ops.js +58 -18
- package/dist/project.d.ts +39 -11
- package/dist/project.js +75 -15
- package/dist/registry.d.ts +4 -1
- package/dist/registry.js +4 -1
- package/dist/signals.d.ts +4 -4
- package/dist/signals.js +4 -4
- package/dist/sql.d.ts +8 -4
- package/dist/sql.js +110 -25
- package/dist/sync.js +11 -4
- package/dist/tx.d.ts +15 -0
- package/dist/tx.js +22 -4
- package/dist/types.d.ts +21 -7
- package/dist/types.js +13 -6
- package/dist/up.d.ts +48 -25
- package/dist/up.js +94 -41
- package/dist/validate.d.ts +9 -2
- package/dist/validate.js +19 -3
- package/dist/watch.d.ts +29 -5
- package/dist/watch.js +62 -13
- package/dist/write.d.ts +3 -2
- package/dist/write.js +52 -19
- package/package.json +8 -4
- package/sql/10-core.sql +6 -1
- package/sql/60-write.sql +47 -18
- package/sql/70-read.sql +13 -7
- package/sql/75-history.sql +106 -28
- package/sql/80-auth.sql +41 -3
- package/sql/88-derived.sql +3 -1
- package/sql/90-time.sql +118 -30
- package/sql/95-seed.system.sql +6 -3
- package/sql/99-revision.sql +1 -1
- package/sql/migrate/0007-secret-account-fk.sql +12 -0
package/dist/admin.d.ts
CHANGED
|
@@ -17,22 +17,28 @@ export interface VerifyIssue {
|
|
|
17
17
|
export interface VerifyResult {
|
|
18
18
|
ok: boolean;
|
|
19
19
|
issues: VerifyIssue[];
|
|
20
|
-
/**
|
|
20
|
+
/** Один хэш текущих версий всех объектов — храните его вне базы: подмену самых свежих версий выдаст его расхождение. */
|
|
21
21
|
anchor: string;
|
|
22
22
|
}
|
|
23
23
|
export interface MaintainOptions {
|
|
24
|
-
/** Момент «сейчас» для прореживания — только в установке с allowReset (тесты). */
|
|
24
|
+
/** Момент «сейчас» для прореживания — только в установке с `allowReset` (тесты). */
|
|
25
25
|
now?: string | Date;
|
|
26
|
-
/**
|
|
26
|
+
/** Сколько пачек прореживания журнала самое большее сделать за один вызов; каждая пачка — своя транзакция. */
|
|
27
27
|
batches?: number;
|
|
28
|
-
/**
|
|
28
|
+
/** Сколько версий самое большее удалить в одной пачке прореживания. */
|
|
29
29
|
limit?: number;
|
|
30
30
|
}
|
|
31
|
+
/** Внутренняя опция maintain(): остановка запуска по расписанию — текущая пачка доделывается, следующая не начинается. */
|
|
32
|
+
export declare const MAINTAIN_STOP: unique symbol;
|
|
31
33
|
export interface MaintainReport {
|
|
32
34
|
/** Другой процесс уже обслуживает установку (держит строку-замок обслуживания): этот вызов ничего не делал. */
|
|
33
35
|
skipped: boolean;
|
|
34
|
-
/** Удалено истёкших и погашенных
|
|
36
|
+
/** Удалено сессий: истёкших и погашенных (выход, отзыв, смена пароля, отключение или удаление аккаунта). */
|
|
35
37
|
sessions: number;
|
|
38
|
+
/**
|
|
39
|
+
* Удалено одноразовых кодов (вид `OTP` и свои виды `OTP_<ИМЯ>` из `issueOtp({ kind })`): истёкших, использованных,
|
|
40
|
+
* заменённых новым и исчерпавших попытки.
|
|
41
|
+
*/
|
|
36
42
|
codes: number;
|
|
37
43
|
/** Прорежено версий журнала. */
|
|
38
44
|
thinned: number;
|
|
@@ -46,7 +52,7 @@ export interface ResetOptions {
|
|
|
46
52
|
level: 'sessions' | 'tenant' | 'all';
|
|
47
53
|
/** Подтверждение — имя схемы установки. */
|
|
48
54
|
confirm: string;
|
|
49
|
-
/** level: 'tenant' —
|
|
55
|
+
/** Какого арендатора сбросить при `level: 'tenant'` — его id; на других уровнях не учитывается. */
|
|
50
56
|
tenant?: string;
|
|
51
57
|
}
|
|
52
58
|
export interface ResetResult {
|
|
@@ -55,10 +61,10 @@ export interface ResetResult {
|
|
|
55
61
|
tenant?: string;
|
|
56
62
|
rows?: number;
|
|
57
63
|
versions?: number;
|
|
58
|
-
/** level: 'all' — новый ключ сервиса: прежние сессии и ключи больше не действуют. */
|
|
64
|
+
/** `level: 'all'` — новый ключ сервиса: прежние сессии и ключи больше не действуют. */
|
|
59
65
|
serviceKey?: string;
|
|
60
66
|
}
|
|
61
|
-
/** Машинное описание модели (db.describe()). */
|
|
67
|
+
/** Машинное описание модели (`db.describe()`). */
|
|
62
68
|
export interface ModelDescription {
|
|
63
69
|
schema: string;
|
|
64
70
|
tenant: string | null;
|
|
@@ -117,13 +123,23 @@ export declare function verify(a: AdminCtx, opts?: {
|
|
|
117
123
|
* (skipped у всех). Блокировку держит другой процесс: до первой пачки — skipped; между пачками —
|
|
118
124
|
* вызов останавливается (продолжит тот процесс), отчёт — о сделанном, more: true.
|
|
119
125
|
*/
|
|
120
|
-
export declare function maintain(a: AdminCtx, opts?: MaintainOptions
|
|
126
|
+
export declare function maintain(a: AdminCtx, opts?: MaintainOptions & {
|
|
127
|
+
[MAINTAIN_STOP]?: () => boolean;
|
|
128
|
+
}): Promise<MaintainReport>;
|
|
121
129
|
/** Предупреждение о заполненной очереди сигналов (§2.12): больше половины — зависший слушатель. */
|
|
122
130
|
export declare function queueWarning(queue: number): string | null;
|
|
123
|
-
/**
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
131
|
+
/**
|
|
132
|
+
* Интервал расписания: число мс или строка '500ms', '30s', '15m', '1h', '1d' — конечный, от 1 мс до 2³¹−1 мс (около 24
|
|
133
|
+
* суток); иначе invalid_data. Ноль, отрицательное, NaN и слишком длинный интервал таймер Node.js превращает в 1 мс —
|
|
134
|
+
* обслуживание шло бы без перерыва.
|
|
135
|
+
*/
|
|
136
|
+
export declare function parseInterval(v: unknown): number;
|
|
137
|
+
/**
|
|
138
|
+
* Запуск maintain() по расписанию (connect({ maintain: '1h' }); ms — уже проверенный интервал): ошибки — предупреждением,
|
|
139
|
+
* следующий запуск не начинается, пока идёт прежний. Возвращает остановку: она снимает таймер, просит идущий запуск не
|
|
140
|
+
* начинать новых пачек и ждёт его конца — close() закрывает пул после неё, а не посреди пачки.
|
|
141
|
+
*/
|
|
142
|
+
export declare function scheduleMaintain(run: (stopped: () => boolean) => Promise<unknown>, ms: number): () => Promise<void>;
|
|
127
143
|
/**
|
|
128
144
|
* Сброс (этап 6, п. 6). Предохранители — в базе: флаг allowReset установки, право letopis.reset,
|
|
129
145
|
* подтверждение именем схемы. tenant — пачками (длинная операция не держит горизонт подписок).
|
|
@@ -153,6 +169,6 @@ export declare function rebuildAggregates(env: {
|
|
|
153
169
|
}, cls: string): Promise<number>;
|
|
154
170
|
/** Машинное описание модели арендатора вместе с метаданными классов (этап 6, п. 12). */
|
|
155
171
|
export declare function describe(a: AdminCtx): ModelDescription;
|
|
156
|
-
/** Описание модели текстом для языковых
|
|
172
|
+
/** Описание модели текстом для языковых моделей. */
|
|
157
173
|
export declare function describeText(d: ModelDescription): string;
|
|
158
174
|
export {};
|
package/dist/admin.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { LetopisError, fromDbError } from './errors.js';
|
|
2
2
|
import { runTx } from './tx.js';
|
|
3
3
|
import { CHAIN_METHODS, DB_METHODS } from './types.js';
|
|
4
|
+
/** Внутренняя опция maintain(): остановка запуска по расписанию — текущая пачка доделывается, следующая не начинается. */
|
|
5
|
+
export const MAINTAIN_STOP = Symbol('letopis.maintain.stop');
|
|
4
6
|
const SYSTEM_CLASSES = new Set(['Class', 'Account', 'member', 'Resource', 'rule']);
|
|
5
7
|
const one = async (a, text, params = []) => a.exec.run(async (q) => (await q.unsafe(text, params))[0]);
|
|
6
8
|
export async function verify(a, opts = {}) {
|
|
@@ -24,8 +26,20 @@ export async function maintain(a, opts = {}) {
|
|
|
24
26
|
const report = { skipped: false, sessions: 0, codes: 0, thinned: 0, batches: 0, more: false, queue: 0 };
|
|
25
27
|
const max = opts.batches ?? 100;
|
|
26
28
|
for (let i = 0; i < max; i++) {
|
|
27
|
-
//
|
|
28
|
-
|
|
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
|
+
});
|
|
29
43
|
if (r === null) {
|
|
30
44
|
if (i === 0)
|
|
31
45
|
return { ...report, skipped: true };
|
|
@@ -50,28 +64,49 @@ export async function maintain(a, opts = {}) {
|
|
|
50
64
|
export function queueWarning(queue) {
|
|
51
65
|
return queue > 0.5 ? `letopis: очередь сигналов PostgreSQL заполнена на ${(queue * 100).toFixed(0)} % — проверьте зависших слушателей` : null;
|
|
52
66
|
}
|
|
53
|
-
/**
|
|
67
|
+
/** Самый длинный интервал таймера Node.js — 2³¹−1 мс (около 24,8 суток): дольше таймер не ждёт и срабатывает сразу. */
|
|
68
|
+
const MAX_TIMER_MS = 2 ** 31 - 1;
|
|
69
|
+
/**
|
|
70
|
+
* Интервал расписания: число мс или строка '500ms', '30s', '15m', '1h', '1d' — конечный, от 1 мс до 2³¹−1 мс (около 24
|
|
71
|
+
* суток); иначе invalid_data. Ноль, отрицательное, NaN и слишком длинный интервал таймер Node.js превращает в 1 мс —
|
|
72
|
+
* обслуживание шло бы без перерыва.
|
|
73
|
+
*/
|
|
54
74
|
export function parseInterval(v) {
|
|
75
|
+
let ms = Number.NaN;
|
|
55
76
|
if (typeof v === 'number')
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
77
|
+
ms = v;
|
|
78
|
+
else if (typeof v === 'string') {
|
|
79
|
+
const m = /^(\d+(?:\.\d+)?)\s*(ms|s|m|h|d)$/.exec(v.trim());
|
|
80
|
+
if (m)
|
|
81
|
+
ms = Number(m[1]) * { ms: 1, s: 1000, m: 60_000, h: 3_600_000, d: 86_400_000 }[m[2]];
|
|
82
|
+
}
|
|
83
|
+
if (!Number.isFinite(ms) || ms < 1 || ms > MAX_TIMER_MS) {
|
|
84
|
+
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 суток)`);
|
|
86
|
+
}
|
|
87
|
+
return ms;
|
|
62
88
|
}
|
|
63
|
-
/**
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
89
|
+
/**
|
|
90
|
+
* Запуск maintain() по расписанию (connect({ maintain: '1h' }); ms — уже проверенный интервал): ошибки — предупреждением,
|
|
91
|
+
* следующий запуск не начинается, пока идёт прежний. Возвращает остановку: она снимает таймер, просит идущий запуск не
|
|
92
|
+
* начинать новых пачек и ждёт его конца — close() закрывает пул после неё, а не посреди пачки.
|
|
93
|
+
*/
|
|
94
|
+
export function scheduleMaintain(run, ms) {
|
|
95
|
+
let stopped = false;
|
|
96
|
+
let current = null;
|
|
67
97
|
const timer = setInterval(() => {
|
|
68
|
-
if (
|
|
98
|
+
if (current || stopped)
|
|
69
99
|
return;
|
|
70
|
-
|
|
71
|
-
|
|
100
|
+
current = run(() => stopped)
|
|
101
|
+
.then(() => { }, (e) => console.warn(`letopis: maintain по расписанию: ${e.message}`))
|
|
102
|
+
.finally(() => (current = null));
|
|
72
103
|
}, ms);
|
|
73
104
|
timer.unref?.();
|
|
74
|
-
return () =>
|
|
105
|
+
return async () => {
|
|
106
|
+
stopped = true;
|
|
107
|
+
clearInterval(timer);
|
|
108
|
+
await current;
|
|
109
|
+
};
|
|
75
110
|
}
|
|
76
111
|
/**
|
|
77
112
|
* Сброс (этап 6, п. 6). Предохранители — в базе: флаг allowReset установки, право letopis.reset,
|
|
@@ -163,7 +198,7 @@ export function describe(a) {
|
|
|
163
198
|
})).sort((x, y) => Number(x.system) - Number(y.system) || x.name.localeCompare(y.name));
|
|
164
199
|
return { schema: a.schema, tenant: a.tenant(), classes, methods: { chain: CHAIN_METHODS, db: DB_METHODS } };
|
|
165
200
|
}
|
|
166
|
-
/** Описание модели текстом для языковых
|
|
201
|
+
/** Описание модели текстом для языковых моделей. */
|
|
167
202
|
export function describeText(d) {
|
|
168
203
|
const out = [`# letopis: модель схемы ${d.schema}`, '',
|
|
169
204
|
'Цепочка: db.Класс(фильтр).Связь().Класс().rows(); запись — create, update, upsert, delete; история — versions(), asOf(), withDeleted().', ''];
|
package/dist/auth.d.ts
CHANGED
|
@@ -15,6 +15,24 @@ export interface LoginResult {
|
|
|
15
15
|
account: string;
|
|
16
16
|
token: string;
|
|
17
17
|
}
|
|
18
|
+
/** Сведения о сессии по её токену (`db.auth.session()`). Время — строкой ISO 8601. */
|
|
19
|
+
export interface SessionInfo {
|
|
20
|
+
/** Аккаунт сессии. */
|
|
21
|
+
account: string;
|
|
22
|
+
/** Рабочий арендатор сессии: выбранный `auth.switch()` или собственный арендатор аккаунта. */
|
|
23
|
+
tenant: string;
|
|
24
|
+
/** Членство в рабочем арендаторе цело и арендатор включён; в собственном арендаторе — всегда true. */
|
|
25
|
+
member: boolean;
|
|
26
|
+
/** Когда сессия истечёт, если ею не пользоваться (подключение продлевает её само). */
|
|
27
|
+
expiresAt: string;
|
|
28
|
+
/** Время входа. */
|
|
29
|
+
loginAt: string;
|
|
30
|
+
/**
|
|
31
|
+
* Способы входа: вид креда, по которому вошли (`PASSWORD`, `APIKEY`, `KEYSECRET`, `OTP`, `TELEGRAM` и т. п.), `SERVICE` —
|
|
32
|
+
* сессию выпустил сервис (`sessionFor()`), `TOTP` — пройден второй фактор. У сессии от `createTenant()` список пуст.
|
|
33
|
+
*/
|
|
34
|
+
methods: string[];
|
|
35
|
+
}
|
|
18
36
|
/** Кред без секретного материала. */
|
|
19
37
|
export interface Credential {
|
|
20
38
|
id: string;
|
|
@@ -28,7 +46,7 @@ export interface Credential {
|
|
|
28
46
|
expires_at: string | null;
|
|
29
47
|
}
|
|
30
48
|
export interface AuthApi {
|
|
31
|
-
/** Пароль (и любой «логин/пароль»: kind — PASSWORD
|
|
49
|
+
/** Пароль (и любой «логин/пароль»: `kind` — `PASSWORD`, `EMAIL`, …): создать или заменить. Смена гасит остальные сессии аккаунта. */
|
|
32
50
|
setPassword(a: {
|
|
33
51
|
account: AccountRef;
|
|
34
52
|
identifier: string;
|
|
@@ -38,9 +56,9 @@ export interface AuthApi {
|
|
|
38
56
|
keepCurrent?: boolean;
|
|
39
57
|
}): Promise<string>;
|
|
40
58
|
/**
|
|
41
|
-
* Вход по паролю — для сервиса с правом auth.password и auth.session
|
|
42
|
-
* верного пароля база проверяет код TOTP аккаунта (право auth.totp
|
|
43
|
-
* с verifyTotp): верный — сессия со способами [kind, 'TOTP']
|
|
59
|
+
* Вход по паролю — для сервиса с правом `auth.password` и `auth.session`. Неудача — `null`. С `totp` после
|
|
60
|
+
* верного пароля база проверяет код TOTP аккаунта (право `auth.totp`; счётчик неудач и блокировка — общие
|
|
61
|
+
* с `verifyTotp()`): верный — сессия со способами `[kind, 'TOTP']`, неверный или повторный — `null`.
|
|
44
62
|
*/
|
|
45
63
|
verifyPassword(a: {
|
|
46
64
|
identifier: string;
|
|
@@ -70,7 +88,10 @@ export interface AuthApi {
|
|
|
70
88
|
id: string;
|
|
71
89
|
}>;
|
|
72
90
|
verifyKeySecret(key: string, secret: string): Promise<LoginResult | null>;
|
|
73
|
-
/**
|
|
91
|
+
/**
|
|
92
|
+
* Подключает второй фактор TOTP: выдаёт секрет (base32) и адрес `otpauth://` для QR-кода в приложении-аутентификаторе.
|
|
93
|
+
* Фактор включится после первой верной проверки кода — `verifyTotp()` или входа с `totp`.
|
|
94
|
+
*/
|
|
74
95
|
enrollTotp(a: {
|
|
75
96
|
account: AccountRef;
|
|
76
97
|
issuer?: string;
|
|
@@ -85,7 +106,7 @@ export interface AuthApi {
|
|
|
85
106
|
window?: number;
|
|
86
107
|
}): Promise<boolean>;
|
|
87
108
|
totpEnabled(account: AccountRef): Promise<boolean>;
|
|
88
|
-
/** Одноразовый код для доставки сервисом (право auth.otp). */
|
|
109
|
+
/** Одноразовый код для доставки сервисом (право `auth.otp`). */
|
|
89
110
|
issueOtp(a: {
|
|
90
111
|
account: AccountRef;
|
|
91
112
|
identifier: string;
|
|
@@ -96,13 +117,19 @@ export interface AuthApi {
|
|
|
96
117
|
}): Promise<{
|
|
97
118
|
code: string;
|
|
98
119
|
}>;
|
|
99
|
-
/**
|
|
120
|
+
/**
|
|
121
|
+
* Вход по одноразовому коду: верный код открывает новую сессию. Код сгорает после верного ввода и после исчерпания
|
|
122
|
+
* неверных попыток — их число задаёт `issueOtp({ maxAttempts })`.
|
|
123
|
+
*/
|
|
100
124
|
verifyOtp(a: {
|
|
101
125
|
identifier: string;
|
|
102
126
|
code: string;
|
|
103
127
|
kind?: string;
|
|
104
128
|
}): Promise<LoginResult | null>;
|
|
105
|
-
/**
|
|
129
|
+
/**
|
|
130
|
+
* Привязывает к аккаунту внешнюю учётную запись — например, для входа через Telegram, Google или SSO. Входят по привязке
|
|
131
|
+
* через `lookup()`; токен внешнего сервиса ваш сервис проверяет сам.
|
|
132
|
+
*/
|
|
106
133
|
link(a: {
|
|
107
134
|
account: AccountRef;
|
|
108
135
|
kind: string;
|
|
@@ -110,7 +137,11 @@ export interface AuthApi {
|
|
|
110
137
|
meta?: Record<string, unknown>;
|
|
111
138
|
confirmed?: boolean;
|
|
112
139
|
}): Promise<string>;
|
|
113
|
-
/**
|
|
140
|
+
/**
|
|
141
|
+
* Вход через привязанную внешнюю учётную запись: находит аккаунт по виду и id у внешнего сервиса и открывает ему новую
|
|
142
|
+
* сессию. Вызывает сервис с правами `auth.identity` и `auth.session`, когда уже сам проверил токен внешнего сервиса;
|
|
143
|
+
* `totp` — как у `verifyPassword()`.
|
|
144
|
+
*/
|
|
114
145
|
lookup(a: {
|
|
115
146
|
kind: string;
|
|
116
147
|
identifier: string;
|
|
@@ -118,36 +149,48 @@ export interface AuthApi {
|
|
|
118
149
|
ttl?: number;
|
|
119
150
|
totp?: string;
|
|
120
151
|
}): Promise<LoginResult | null>;
|
|
121
|
-
/**
|
|
152
|
+
/** Способы входа (креды) аккаунта — без хэшей и секретов. */
|
|
122
153
|
credentials(account: AccountRef): Promise<Credential[]>;
|
|
123
154
|
/**
|
|
124
155
|
* Удалить кред. Ключ API и пара ключ + секрет гасят только свои сессии, прочие креды — остальные сессии
|
|
125
|
-
* аккаунта; текущая гаснет, если keepCurrent: false
|
|
126
|
-
* действующий ключ сервиса не отзывается (acl_denied).
|
|
156
|
+
* аккаунта; текущая гаснет, если `keepCurrent: false`. Свои ключи сервис отзывает сам; последний
|
|
157
|
+
* действующий ключ сервиса не отзывается (`acl_denied`).
|
|
127
158
|
*/
|
|
128
159
|
revokeCredential(id: string, opts?: {
|
|
129
160
|
keepCurrent?: boolean;
|
|
130
161
|
}): Promise<boolean>;
|
|
131
|
-
/** Признаки креда confirmed и enabled (право auth.credential). */
|
|
162
|
+
/** Признаки креда `confirmed` и `enabled` (право `auth.credential`). */
|
|
132
163
|
setFlags(id: string, flags: {
|
|
133
164
|
confirmed?: boolean;
|
|
134
165
|
enabled?: boolean;
|
|
135
166
|
}): Promise<boolean>;
|
|
136
|
-
/**
|
|
167
|
+
/** Сессия пользователя для другого процесса (право `auth.session`); срок — `ttl` в секундах, по умолчанию 7 суток. */
|
|
137
168
|
sessionFor(account: AccountRef, opts?: {
|
|
138
169
|
ttl?: number;
|
|
139
170
|
}): Promise<string>;
|
|
171
|
+
/**
|
|
172
|
+
* Сведения о сессии по её токену — для сервиса, который держит токен пользователя (например, в cookie): чья это
|
|
173
|
+
* сессия, в каком арендаторе она работает, цело ли там членство, когда истечёт, когда и как был вход. Подключение
|
|
174
|
+
* по этому токену не открывается, срок сессии не продлевается. Вызывает сервис или системный администратор
|
|
175
|
+
* (право `auth.session`).
|
|
176
|
+
* @param token Токен сессии пользователя — тот, что выдали вход или `sessionFor()`.
|
|
177
|
+
* @returns Сведения о сессии или `null`: токен неверный, сессия погашена или истекла, аккаунт отключён — причина
|
|
178
|
+
* не различается.
|
|
179
|
+
* @throws LetopisError `acl_denied` — у вызывающего нет права `auth.session` (например, это обычный пользователь);
|
|
180
|
+
* `no_session` — у подключения нет действующей сессии.
|
|
181
|
+
*/
|
|
182
|
+
session(token: string): Promise<SessionInfo | null>;
|
|
140
183
|
/** Продлить текущую сессию; новый срок — от сейчас. */
|
|
141
184
|
refresh(opts?: {
|
|
142
185
|
ttl?: number;
|
|
143
186
|
}): Promise<string>;
|
|
144
187
|
/** Выход: погасить текущую сессию. */
|
|
145
188
|
revoke(): Promise<boolean>;
|
|
146
|
-
/** Выход везде: все сессии аккаунта (чужого — с правом auth.session). */
|
|
189
|
+
/** Выход везде: все сессии аккаунта (чужого — с правом `auth.session`). */
|
|
147
190
|
revokeAll(account?: AccountRef): Promise<number>;
|
|
148
191
|
/** Переключить арендатора сессии: собственный или по членству. */
|
|
149
192
|
switch(tenant: string): Promise<void>;
|
|
150
|
-
/** Арендаторы, доступные аккаунту сессии: собственный (owner) и по членству. */
|
|
193
|
+
/** Арендаторы, доступные аккаунту сессии: собственный (`owner`) и по членству. */
|
|
151
194
|
tenants(): Promise<{
|
|
152
195
|
tenant: string;
|
|
153
196
|
roles: string[];
|
|
@@ -155,7 +198,8 @@ export interface AuthApi {
|
|
|
155
198
|
/**
|
|
156
199
|
* Удалить аккаунт (этап 6; accounts.purge 0.21): системный администратор или владелец его
|
|
157
200
|
* арендатора; себя и последнего владельца арендатора — нельзя. Данные арендатора стираются,
|
|
158
|
-
* владение строками в чужих арендаторах переходит им, сессии гаснут, креды удаляются.
|
|
201
|
+
* владение строками в чужих арендаторах переходит им, сессии гаснут, креды удаляются. Владелец стирает
|
|
202
|
+
* и арендатора, в котором работает: его сессия возвращается в личный арендатор.
|
|
159
203
|
*/
|
|
160
204
|
purgeAccount(account: AccountRef): Promise<{
|
|
161
205
|
account: string;
|
package/dist/auth.js
CHANGED
|
@@ -27,7 +27,12 @@ export async function verifyHash(password, stored) {
|
|
|
27
27
|
if (algo !== 'scrypt' || !hashB64)
|
|
28
28
|
return false;
|
|
29
29
|
const want = Buffer.from(hashB64, 'base64');
|
|
30
|
-
|
|
30
|
+
// N, r, p — из строки хэша (подходят и хэши, импортированные из других систем), память — не больше 64 МБ: хэш,
|
|
31
|
+
// которому нужно больше, или с недопустимыми параметрами scrypt Node.js отвергает — это понятная ошибка, а не «неверный
|
|
32
|
+
// пароль» (false). Текст Node.js в сообщение не идёт: он зависит от сборки OpenSSL
|
|
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 МБ памяти`);
|
|
35
|
+
});
|
|
31
36
|
return got.length === want.length && timingSafeEqual(got, want);
|
|
32
37
|
}
|
|
33
38
|
/** Холостая проверка: «идентификатора нет» отвечает не быстрее, чем «пароль неверен». */
|
|
@@ -178,6 +183,11 @@ function makeAuthCore(S, exec, onSwitch, canSwitch) {
|
|
|
178
183
|
async sessionFor(account, opts) {
|
|
179
184
|
return (await login(accId(account), opts?.ttl, ['SERVICE'])).token;
|
|
180
185
|
},
|
|
186
|
+
async session(token) {
|
|
187
|
+
const r = (await one(`select ${S}.auth_session_info($1) as r`, [token])).r;
|
|
188
|
+
// jsonb хранит ключи в своём порядке — объект собирается в порядке описания SessionInfo
|
|
189
|
+
return r && { account: r.account, tenant: r.tenant, member: r.member, expiresAt: r.expiresAt, loginAt: r.loginAt, methods: r.methods };
|
|
190
|
+
},
|
|
181
191
|
async refresh(opts) {
|
|
182
192
|
return (await one(`select ${S}.auth_refresh(coalesce($1::interval, interval '7 days')) as e`, [ttlOf(opts?.ttl)])).e;
|
|
183
193
|
},
|
|
@@ -198,7 +208,11 @@ function makeAuthCore(S, exec, onSwitch, canSwitch) {
|
|
|
198
208
|
return rows.map((r) => ({ tenant: r.tenant, roles: r.roles }));
|
|
199
209
|
},
|
|
200
210
|
async purgeAccount(account) {
|
|
201
|
-
|
|
211
|
+
const r = (await one(`select ${S}.account_purge($1::uuid) as r`, [accId(account)])).r;
|
|
212
|
+
// стёрт арендатор, в котором работала сессия корня: база вернула её в личный арендатор — перечитать
|
|
213
|
+
if (canSwitch)
|
|
214
|
+
await onSwitch();
|
|
215
|
+
return r;
|
|
202
216
|
},
|
|
203
217
|
};
|
|
204
218
|
}
|
package/dist/cache.d.ts
CHANGED
|
@@ -4,7 +4,11 @@ export declare const ACL_CLASSES: ReadonlySet<string>;
|
|
|
4
4
|
export interface CacheOptions {
|
|
5
5
|
/** Срок ответа по умолчанию, секунды (60). */
|
|
6
6
|
ttl?: number;
|
|
7
|
-
/**
|
|
7
|
+
/**
|
|
8
|
+
* Включает кэш, даже когда подключение не слушает сигналы базы: `connect({ listen: false })` (например, за PgBouncer в
|
|
9
|
+
* режиме транзакций) или установка с `up({ signals: 'none' })`; без опции кэш тогда выключен. Такой ответ живёт до
|
|
10
|
+
* конца срока: записи других процессов его не сбрасывают, записи этого подключения — сбрасывают (§2.12).
|
|
11
|
+
*/
|
|
8
12
|
ttlOnly?: boolean;
|
|
9
13
|
/** Не больше ответов в памяти (10 000): старые вытесняются. */
|
|
10
14
|
max?: number;
|
package/dist/cache.js
CHANGED
|
@@ -85,7 +85,8 @@ export class ResultCache {
|
|
|
85
85
|
return undefined;
|
|
86
86
|
}
|
|
87
87
|
this.st.hits++;
|
|
88
|
-
|
|
88
|
+
// копия: правка выданного ответа не портит запись кэша (план 2.0, 7.2 №5)
|
|
89
|
+
return structuredClone(e.value);
|
|
89
90
|
}
|
|
90
91
|
/** Положить ответ, если с момента снимка не было сигналов о его классах и сессии. */
|
|
91
92
|
set(key, value, classes, session, tok, ttlMs, sessionExpires) {
|
|
@@ -100,7 +101,7 @@ export class ResultCache {
|
|
|
100
101
|
}
|
|
101
102
|
if (this.map.size >= this.max)
|
|
102
103
|
this.map.delete(this.map.keys().next().value);
|
|
103
|
-
this.map.set(key, { value, classes: [...classes], session, tenant: tok.tenant, expires: Date.now() + ttlMs, sessionExpires });
|
|
104
|
+
this.map.set(key, { value: structuredClone(value), classes: [...classes], session, tenant: tok.tenant, expires: Date.now() + ttlMs, sessionExpires });
|
|
104
105
|
this.st.stores++;
|
|
105
106
|
return true;
|
|
106
107
|
}
|