letopis 3.0.0 → 3.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENT-CHEATSHEET.md +12 -5
- package/CHANGELOG.md +40 -0
- package/README.md +202 -46
- package/api-contract.json +4 -0
- package/dist/acl.d.ts +4 -2
- package/dist/admin.d.ts +5 -0
- package/dist/admin.js +10 -5
- package/dist/auth.d.ts +29 -8
- package/dist/auth.js +54 -9
- package/dist/chain.d.ts +19 -13
- package/dist/chain.js +17 -7
- package/dist/cli.js +46 -16
- package/dist/errors.d.ts +4 -5
- package/dist/errors.js +6 -7
- package/dist/index.d.ts +24 -13
- package/dist/index.js +75 -24
- package/dist/load.d.ts +1 -1
- package/dist/log.js +23 -2
- package/dist/mcp.d.ts +1 -1
- package/dist/mcp.js +8 -5
- package/dist/messages/access.d.ts +10 -0
- package/dist/messages/access.js +13 -0
- package/dist/messages/api.d.ts +10 -0
- package/dist/messages/api.js +23 -10
- package/dist/messages/boot.d.ts +5 -0
- package/dist/messages/boot.js +7 -0
- package/dist/messages/codes.js +2 -2
- package/dist/messages/log.d.ts +8 -5
- package/dist/messages/log.js +8 -5
- package/dist/messages/mcp.js +7 -7
- package/dist/messages/model.d.ts +16 -0
- package/dist/messages/model.js +20 -2
- package/dist/messages/write.d.ts +5 -0
- package/dist/messages/write.js +22 -14
- package/dist/model.d.ts +6 -2
- package/dist/model.js +5 -1
- package/dist/registry.d.ts +6 -0
- package/dist/registry.js +6 -0
- package/dist/sync.d.ts +3 -4
- package/dist/sync.js +3 -4
- package/dist/typed.d.ts +6 -6
- package/dist/types.d.ts +14 -9
- package/dist/types.js +2 -2
- package/dist/up.d.ts +5 -2
- package/dist/up.js +14 -6
- package/dist/write.d.ts +13 -9
- package/dist/write.js +81 -12
- package/llms.txt +2 -1
- package/package.json +1 -1
- package/sql/60-write.sql +18 -6
package/dist/acl.d.ts
CHANGED
|
@@ -51,7 +51,7 @@ export interface AclRuleSpec {
|
|
|
51
51
|
/** Правило арендатора в виде обёртки. */
|
|
52
52
|
export interface AclRule {
|
|
53
53
|
id: string;
|
|
54
|
-
/** Роль
|
|
54
|
+
/** Роль правила; null — правило не одной роли (все аккаунты или своя группа). */
|
|
55
55
|
role: string | null;
|
|
56
56
|
/** Группа всех аккаунтов арендатора: `authenticated:ACCOUNT` от createTenant или `everyone:ACCOUNT` обёртки. */
|
|
57
57
|
everyone: boolean;
|
|
@@ -87,7 +87,9 @@ export interface AclApi {
|
|
|
87
87
|
/** Запретить — те же правила с запретом: при равном весе запрет сильнее разрешения. */
|
|
88
88
|
deny(spec: AclRuleSpec): Promise<AclRule[]>;
|
|
89
89
|
/**
|
|
90
|
-
* Снять правила роли или всех аккаунтов — разрешения и запреты — для этих операций и целей; число
|
|
90
|
+
* Снять правила роли или всех аккаунтов — разрешения и запреты — для этих операций и целей; возвращает число снятых
|
|
91
|
+
* правил. Операции снимаются по очереди, и правило на несколько операций считается один раз: «можно всё» на чтение,
|
|
92
|
+
* запись и удаление от createTenant даёт 1.
|
|
91
93
|
* `revoke({ everyone: true, read: '*', write: '*', delete: '*' })` снимает «можно всё» от createTenant: дальше права —
|
|
92
94
|
* только по ролям.
|
|
93
95
|
*/
|
package/dist/admin.d.ts
CHANGED
|
@@ -30,6 +30,11 @@ export interface AdminCtx {
|
|
|
30
30
|
export declare function maintain(a: AdminCtx): Promise<MaintainReport>;
|
|
31
31
|
/** Предупреждение о заполненной очереди сигналов (§2.12): больше половины — зависший слушатель. */
|
|
32
32
|
export declare function queueWarning(queue: number): string | null;
|
|
33
|
+
/**
|
|
34
|
+
* Короткая форма интервала — '500ms', '30s', '15m', '1h', '1d' (дробное число тоже) — в миллисекундах; другая строка —
|
|
35
|
+
* null. Общая для connect({ maintain }) и срока ttl у auth (3.0.1).
|
|
36
|
+
*/
|
|
37
|
+
export declare function shortIntervalMs(v: string): number | null;
|
|
33
38
|
/**
|
|
34
39
|
* Интервал расписания: число мс или строка '500ms', '30s', '15m', '1h', '1d' — конечный, от 1 мс до 2³¹−1 мс (около 24
|
|
35
40
|
* суток); иначе invalid_data. Ноль, отрицательное, NaN и слишком длинный интервал таймер Node.js превращает в 1 мс —
|
package/dist/admin.js
CHANGED
|
@@ -33,6 +33,14 @@ export function queueWarning(queue) {
|
|
|
33
33
|
}
|
|
34
34
|
/** Самый длинный интервал таймера Node.js — 2³¹−1 мс (около 24,8 суток): дольше таймер не ждёт и срабатывает сразу. */
|
|
35
35
|
const MAX_TIMER_MS = 2 ** 31 - 1;
|
|
36
|
+
/**
|
|
37
|
+
* Короткая форма интервала — '500ms', '30s', '15m', '1h', '1d' (дробное число тоже) — в миллисекундах; другая строка —
|
|
38
|
+
* null. Общая для connect({ maintain }) и срока ttl у auth (3.0.1).
|
|
39
|
+
*/
|
|
40
|
+
export function shortIntervalMs(v) {
|
|
41
|
+
const m = /^(\d+(?:\.\d+)?)\s*(ms|s|m|h|d)$/.exec(v.trim());
|
|
42
|
+
return m ? Number(m[1]) * { ms: 1, s: 1000, m: 60_000, h: 3_600_000, d: 86_400_000 }[m[2]] : null;
|
|
43
|
+
}
|
|
36
44
|
/**
|
|
37
45
|
* Интервал расписания: число мс или строка '500ms', '30s', '15m', '1h', '1d' — конечный, от 1 мс до 2³¹−1 мс (около 24
|
|
38
46
|
* суток); иначе invalid_data. Ноль, отрицательное, NaN и слишком длинный интервал таймер Node.js превращает в 1 мс —
|
|
@@ -42,11 +50,8 @@ export function parseInterval(v) {
|
|
|
42
50
|
let ms = Number.NaN;
|
|
43
51
|
if (typeof v === 'number')
|
|
44
52
|
ms = v;
|
|
45
|
-
else if (typeof v === 'string')
|
|
46
|
-
|
|
47
|
-
if (m)
|
|
48
|
-
ms = Number(m[1]) * { ms: 1, s: 1000, m: 60_000, h: 3_600_000, d: 86_400_000 }[m[2]];
|
|
49
|
-
}
|
|
53
|
+
else if (typeof v === 'string')
|
|
54
|
+
ms = shortIntervalMs(v) ?? Number.NaN;
|
|
50
55
|
if (!Number.isFinite(ms) || ms < 1 || ms > MAX_TIMER_MS) {
|
|
51
56
|
const shown = typeof v === 'string' ? `'${v}'` : typeof v === 'number' || typeof v === 'boolean' || v == null ? String(v) : typeof v;
|
|
52
57
|
throw lerr('time.bad_interval', { value: shown });
|
package/dist/auth.d.ts
CHANGED
|
@@ -11,6 +11,17 @@ export declare function totpCode(secretBase32: string, atMs?: number): string;
|
|
|
11
11
|
type AccountRef = string | {
|
|
12
12
|
id: string;
|
|
13
13
|
};
|
|
14
|
+
/**
|
|
15
|
+
* Срок ttl (3.0.1) — интервал для базы: число секунд, короткая форма как у connect({ maintain }) ('30s', '15m', '2h',
|
|
16
|
+
* '1d') или строка-интервал PostgreSQL ('15 minutes') — её разбирает база, ошибку разбора переводит ttlGuard. Не задан —
|
|
17
|
+
* null (срок по умолчанию). Не число больше нуля, пустая строка, другой тип — invalid_data (access.bad_ttl).
|
|
18
|
+
*/
|
|
19
|
+
export declare function ttlOf(v: unknown, where: string): string | null;
|
|
20
|
+
/**
|
|
21
|
+
* Строка ttl, которую не принял разбор интервала PostgreSQL (22007 — не интервал, 22008 и 22015 — вне диапазона), —
|
|
22
|
+
* invalid_data access.bad_ttl вместо сырой ошибки базы; прочие ошибки — как есть.
|
|
23
|
+
*/
|
|
24
|
+
export declare function ttlGuard<T>(run: Promise<T>, ttl: unknown, where: string): Promise<T>;
|
|
14
25
|
/** Успешный вход: аккаунт и токен новой сессии. */
|
|
15
26
|
export interface LoginResult {
|
|
16
27
|
account: string;
|
|
@@ -46,6 +57,10 @@ export interface Credential {
|
|
|
46
57
|
created_at: string;
|
|
47
58
|
expires_at: string | null;
|
|
48
59
|
}
|
|
60
|
+
/**
|
|
61
|
+
* Вход, креды и сессии (`db.auth`). Срок `ttl` у входа, ключей, кодов и сессий — число секунд или строка: короткая форма
|
|
62
|
+
* `'15m'`/`'2h'`/`'30s'`/`'1d'` либо интервал PostgreSQL `'15 minutes'`; иное — `invalid_data`.
|
|
63
|
+
*/
|
|
49
64
|
export interface AuthApi {
|
|
50
65
|
/** Пароль (и любой «логин/пароль»: `kind` — `PASSWORD`, `EMAIL`, …): создать или заменить. Смена гасит остальные сессии аккаунта. */
|
|
51
66
|
setPassword(a: {
|
|
@@ -66,14 +81,14 @@ export interface AuthApi {
|
|
|
66
81
|
password: string;
|
|
67
82
|
kind?: string;
|
|
68
83
|
requireConfirmed?: boolean;
|
|
69
|
-
ttl?: number;
|
|
84
|
+
ttl?: number | string;
|
|
70
85
|
totp?: string;
|
|
71
86
|
}): Promise<LoginResult | null>;
|
|
72
87
|
/** Ключ API `lts_…`: отдаётся один раз. Себе сервис выпускает ключ сам по сессии своего ключа. */
|
|
73
88
|
issueApiKey(a: {
|
|
74
89
|
account: AccountRef;
|
|
75
90
|
name?: string;
|
|
76
|
-
ttl?: number;
|
|
91
|
+
ttl?: number | string;
|
|
77
92
|
}): Promise<{
|
|
78
93
|
key: string;
|
|
79
94
|
id: string;
|
|
@@ -112,7 +127,7 @@ export interface AuthApi {
|
|
|
112
127
|
account: AccountRef;
|
|
113
128
|
identifier: string;
|
|
114
129
|
kind?: string;
|
|
115
|
-
ttl?: number;
|
|
130
|
+
ttl?: number | string;
|
|
116
131
|
digits?: number;
|
|
117
132
|
maxAttempts?: number;
|
|
118
133
|
}): Promise<{
|
|
@@ -147,7 +162,7 @@ export interface AuthApi {
|
|
|
147
162
|
kind: string;
|
|
148
163
|
identifier: string;
|
|
149
164
|
requireConfirmed?: boolean;
|
|
150
|
-
ttl?: number;
|
|
165
|
+
ttl?: number | string;
|
|
151
166
|
totp?: string;
|
|
152
167
|
}): Promise<LoginResult | null>;
|
|
153
168
|
/** Способы входа (креды) аккаунта — без хэшей и секретов. */
|
|
@@ -165,9 +180,12 @@ export interface AuthApi {
|
|
|
165
180
|
confirmed?: boolean;
|
|
166
181
|
enabled?: boolean;
|
|
167
182
|
}): Promise<boolean>;
|
|
168
|
-
/**
|
|
183
|
+
/**
|
|
184
|
+
* Сессия пользователя для другого процесса (право `auth.session`). Срок `ttl` — число секунд или строка: короткая форма
|
|
185
|
+
* `'15m'`/`'2h'`/`'30s'`/`'1d'` либо интервал PostgreSQL `'15 minutes'`; по умолчанию 7 суток.
|
|
186
|
+
*/
|
|
169
187
|
sessionFor(account: AccountRef, opts?: {
|
|
170
|
-
ttl?: number;
|
|
188
|
+
ttl?: number | string;
|
|
171
189
|
}): Promise<string>;
|
|
172
190
|
/**
|
|
173
191
|
* Сведения о сессии по её токену — для сервиса, который держит токен пользователя (например, в cookie): чья это
|
|
@@ -181,9 +199,12 @@ export interface AuthApi {
|
|
|
181
199
|
* `no_session` — у подключения нет действующей сессии.
|
|
182
200
|
*/
|
|
183
201
|
session(token: string): Promise<SessionInfo | null>;
|
|
184
|
-
/**
|
|
202
|
+
/**
|
|
203
|
+
* Продлить текущую сессию; новый срок — от сейчас. `ttl` — число секунд или строка: короткая форма `'15m'`/`'2h'`/`'30s'`/`'1d'`
|
|
204
|
+
* либо интервал PostgreSQL `'15 minutes'`; по умолчанию 7 суток.
|
|
205
|
+
*/
|
|
185
206
|
refresh(opts?: {
|
|
186
|
-
ttl?: number;
|
|
207
|
+
ttl?: number | string;
|
|
187
208
|
}): Promise<string>;
|
|
188
209
|
/**
|
|
189
210
|
* Выход. Без аргумента — погасить сессию этого подключения. С токеном — погасить сессию пользователя по её токену
|
package/dist/auth.js
CHANGED
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
import { createHmac, randomBytes, scrypt, timingSafeEqual } from 'node:crypto';
|
|
13
13
|
import { promisify } from 'node:util';
|
|
14
14
|
import { lerr } from './errors.js';
|
|
15
|
+
import { shortIntervalMs } from './admin.js';
|
|
15
16
|
const scryptAsync = promisify(scrypt);
|
|
16
17
|
const SCRYPT = { N: 32768, r: 8, p: 1, maxmem: 64 * 1024 * 1024 };
|
|
17
18
|
const KEYLEN = 32;
|
|
@@ -85,7 +86,44 @@ export function totpCode(secretBase32, atMs = Date.now()) {
|
|
|
85
86
|
const accId = (a) => (typeof a === 'object' ? a.id : a);
|
|
86
87
|
/** Логин без крайних пробелов (план 2.1, A12): « anna » и «anna» — один логин; не строка — как есть (отказ даст база). */
|
|
87
88
|
const trimLogin = (v) => (typeof v === 'string' ? v.trim() : v);
|
|
88
|
-
|
|
89
|
+
/**
|
|
90
|
+
* Срок ttl (3.0.1) — интервал для базы: число секунд, короткая форма как у connect({ maintain }) ('30s', '15m', '2h',
|
|
91
|
+
* '1d') или строка-интервал PostgreSQL ('15 minutes') — её разбирает база, ошибку разбора переводит ttlGuard. Не задан —
|
|
92
|
+
* null (срок по умолчанию). Не число больше нуля, пустая строка, другой тип — invalid_data (access.bad_ttl).
|
|
93
|
+
*/
|
|
94
|
+
export function ttlOf(v, where) {
|
|
95
|
+
if (v === undefined || v === null)
|
|
96
|
+
return null;
|
|
97
|
+
if (typeof v === 'number') {
|
|
98
|
+
if (Number.isFinite(v) && v > 0)
|
|
99
|
+
return `${Math.max(1, Math.round(v))} seconds`;
|
|
100
|
+
}
|
|
101
|
+
else if (typeof v === 'string' && v.trim() !== '') {
|
|
102
|
+
const ms = shortIntervalMs(v);
|
|
103
|
+
if (ms === null)
|
|
104
|
+
return v.trim();
|
|
105
|
+
if (ms > 0)
|
|
106
|
+
return `${ms / 1000} seconds`;
|
|
107
|
+
}
|
|
108
|
+
throw lerr('access.bad_ttl', { where, value: shownTtl(v) });
|
|
109
|
+
}
|
|
110
|
+
const shownTtl = (v) => (typeof v === 'string' ? `'${v}'` : typeof v === 'number' || typeof v === 'boolean' || v == null ? String(v) : typeof v);
|
|
111
|
+
/**
|
|
112
|
+
* Строка ttl, которую не принял разбор интервала PostgreSQL (22007 — не интервал, 22008 и 22015 — вне диапазона), —
|
|
113
|
+
* invalid_data access.bad_ttl вместо сырой ошибки базы; прочие ошибки — как есть.
|
|
114
|
+
*/
|
|
115
|
+
export async function ttlGuard(run, ttl, where) {
|
|
116
|
+
try {
|
|
117
|
+
return await run;
|
|
118
|
+
}
|
|
119
|
+
catch (e) {
|
|
120
|
+
const code = e?.code;
|
|
121
|
+
if (typeof ttl === 'string' && (code === '22007' || code === '22008' || code === '22015')) {
|
|
122
|
+
throw lerr('access.bad_ttl', { where, value: shownTtl(ttl) }, { cause: e });
|
|
123
|
+
}
|
|
124
|
+
throw e;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
89
127
|
/** Фасад db.auth. onSwitch — подключение перечитывает арендатора, реестр и пропуска прав. */
|
|
90
128
|
export function makeAuth(S, exec, onSwitch, canSwitch) {
|
|
91
129
|
const one = async (text, params = []) => {
|
|
@@ -95,7 +133,8 @@ export function makeAuth(S, exec, onSwitch, canSwitch) {
|
|
|
95
133
|
// Вход после первой проверки. Код TOTP проверяет база той же функцией, что verifyTotp (право auth.totp,
|
|
96
134
|
// окно ±1 шаг, повтор шага, счётчик неудач и блокировка), секрет из базы не выходит; способ TOTP
|
|
97
135
|
// сессия получает только после удачи. Без кода — сессия без отметки TOTP, как раньше.
|
|
98
|
-
const login = async (account, ttl, methods, totp) => {
|
|
136
|
+
const login = async (account, ttl, methods, totp, where = 'auth') => {
|
|
137
|
+
const interval = ttlOf(ttl, where);
|
|
99
138
|
if (!account)
|
|
100
139
|
return null;
|
|
101
140
|
if (totp != null) {
|
|
@@ -104,7 +143,7 @@ export function makeAuth(S, exec, onSwitch, canSwitch) {
|
|
|
104
143
|
return null;
|
|
105
144
|
methods = [...methods, 'TOTP'];
|
|
106
145
|
}
|
|
107
|
-
const r = await one(`select ${S}.issue_session($1::uuid, coalesce($2::interval, interval '7 days'), $3::text[]) as t`, [account,
|
|
146
|
+
const r = await ttlGuard(one(`select ${S}.issue_session($1::uuid, coalesce($2::interval, interval '7 days'), $3::text[]) as t`, [account, interval, methods]), ttl, where);
|
|
108
147
|
return { account, token: r.t };
|
|
109
148
|
};
|
|
110
149
|
return {
|
|
@@ -114,6 +153,7 @@ export function makeAuth(S, exec, onSwitch, canSwitch) {
|
|
|
114
153
|
return r.id;
|
|
115
154
|
},
|
|
116
155
|
async verifyPassword(a) {
|
|
156
|
+
ttlOf(a.ttl, 'verifyPassword');
|
|
117
157
|
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
158
|
// логин без крайних пробелов (план 2.1, A12); кред, записанный до 2.1 с пробелами, находится по логину как есть
|
|
119
159
|
const ident = trimLogin(a.identifier);
|
|
@@ -124,10 +164,10 @@ export function makeAuth(S, exec, onSwitch, canSwitch) {
|
|
|
124
164
|
return null;
|
|
125
165
|
if (!cred.confirmed && a.requireConfirmed !== false)
|
|
126
166
|
return null;
|
|
127
|
-
return login(cred.account, a.ttl, [a.kind ?? 'PASSWORD'], a.totp);
|
|
167
|
+
return login(cred.account, a.ttl, [a.kind ?? 'PASSWORD'], a.totp, 'verifyPassword');
|
|
128
168
|
},
|
|
129
169
|
async issueApiKey(a) {
|
|
130
|
-
return one(`select key, id from ${S}.auth_issue_apikey($1::uuid, $2, $3::interval)`, [accId(a.account), a.name ?? null, ttlOf(a.ttl)]);
|
|
170
|
+
return ttlGuard(one(`select key, id from ${S}.auth_issue_apikey($1::uuid, $2, $3::interval)`, [accId(a.account), a.name ?? null, ttlOf(a.ttl, 'issueApiKey')]), a.ttl, 'issueApiKey');
|
|
131
171
|
},
|
|
132
172
|
async verifyApiKey(key) {
|
|
133
173
|
return (await one(`select ${S}.auth_login_apikey($1) as r`, [key])).r;
|
|
@@ -152,7 +192,11 @@ export function makeAuth(S, exec, onSwitch, canSwitch) {
|
|
|
152
192
|
return (await one(`select ${S}.auth_totp_enabled($1::uuid) as ok`, [accId(account)])).ok;
|
|
153
193
|
},
|
|
154
194
|
async issueOtp(a) {
|
|
155
|
-
|
|
195
|
+
// 3.0.1: число попыток вне 1–10 — отказ, а не молчаливое приведение к пределу в базе
|
|
196
|
+
const max = a.maxAttempts;
|
|
197
|
+
if (max !== undefined && !(Number.isInteger(max) && max >= 1 && max <= 10))
|
|
198
|
+
throw lerr('access.bad_max_attempts', { value: shownTtl(max) });
|
|
199
|
+
const r = await ttlGuard(one(`select ${S}.auth_issue_otp($1::uuid, $2, $3, coalesce($4::interval, interval '10 minutes'), $5, $6) as c`, [accId(a.account), a.identifier, a.kind ?? 'OTP', ttlOf(a.ttl, 'issueOtp'), a.digits ?? 6, max ?? 5]), a.ttl, 'issueOtp');
|
|
156
200
|
return { code: r.c };
|
|
157
201
|
},
|
|
158
202
|
async verifyOtp(a) {
|
|
@@ -162,8 +206,9 @@ export function makeAuth(S, exec, onSwitch, canSwitch) {
|
|
|
162
206
|
return (await one(`select ${S}.auth_link($1::uuid, $2, $3, $4::jsonb, $5) as id`, [accId(a.account), a.kind, a.identifier, (a.meta ?? {}), a.confirmed ?? false])).id;
|
|
163
207
|
},
|
|
164
208
|
async lookup(a) {
|
|
209
|
+
ttlOf(a.ttl, 'lookup');
|
|
165
210
|
const r = await one(`select ${S}.auth_lookup($1, $2, $3) as a`, [a.kind, a.identifier, a.requireConfirmed ?? true]);
|
|
166
|
-
return login(r.a, a.ttl, [a.kind], a.totp);
|
|
211
|
+
return login(r.a, a.ttl, [a.kind], a.totp, 'lookup');
|
|
167
212
|
},
|
|
168
213
|
async credentials(account) {
|
|
169
214
|
return exec.run((q) => q.unsafe(`select * from ${S}.auth_credentials($1::uuid)`, [accId(account)]));
|
|
@@ -175,7 +220,7 @@ export function makeAuth(S, exec, onSwitch, canSwitch) {
|
|
|
175
220
|
return (await one(`select ${S}.auth_set_flags($1::uuid, $2, $3) as ok`, [id, flags.confirmed ?? null, flags.enabled ?? null])).ok;
|
|
176
221
|
},
|
|
177
222
|
async sessionFor(account, opts) {
|
|
178
|
-
return (await login(accId(account), opts?.ttl, ['SERVICE'])).token;
|
|
223
|
+
return (await login(accId(account), opts?.ttl, ['SERVICE'], undefined, 'sessionFor')).token;
|
|
179
224
|
},
|
|
180
225
|
async session(token) {
|
|
181
226
|
const r = (await one(`select ${S}.auth_session_info($1) as r`, [token])).r;
|
|
@@ -183,7 +228,7 @@ export function makeAuth(S, exec, onSwitch, canSwitch) {
|
|
|
183
228
|
return r && { account: r.account, tenant: r.tenant, member: r.member, expiresAt: r.expiresAt, loginAt: r.loginAt, methods: r.methods };
|
|
184
229
|
},
|
|
185
230
|
async refresh(opts) {
|
|
186
|
-
return (await one(`select ${S}.auth_refresh(coalesce($1::interval, interval '7 days')) as e`, [ttlOf(opts?.ttl)])).e;
|
|
231
|
+
return (await ttlGuard(one(`select ${S}.auth_refresh(coalesce($1::interval, interval '7 days')) as e`, [ttlOf(opts?.ttl, 'refresh')]), opts?.ttl, 'refresh')).e;
|
|
187
232
|
},
|
|
188
233
|
async revoke(token) {
|
|
189
234
|
// с токеном — выход пользователя по токену (план 2.1, A3): право auth.session, как у session(token)
|
package/dist/chain.d.ts
CHANGED
|
@@ -12,6 +12,8 @@ export interface ChainCtx {
|
|
|
12
12
|
readonly exec: Executor;
|
|
13
13
|
/** Арендатор сессии. */
|
|
14
14
|
readonly tenant: () => string | null;
|
|
15
|
+
/** Аккаунт сессии (с имперсонацией — тот, от чьего имени): арендатор равен ему — личный арендатор. */
|
|
16
|
+
readonly account?: () => string | null;
|
|
15
17
|
/** Фасад входит по токену сессии (connect({ token }), ключ API): без арендатора сессии шаг — sessionless(). */
|
|
16
18
|
readonly hasToken?: () => boolean;
|
|
17
19
|
/** Владелец новых строк по умолчанию (db.as(…, { owner })). */
|
|
@@ -59,7 +61,7 @@ export type Batch = {
|
|
|
59
61
|
};
|
|
60
62
|
export interface ChainCore {
|
|
61
63
|
/**
|
|
62
|
-
* Все пути цепочки. Путь — строки всех шагов, по одной на шаг, связанные
|
|
64
|
+
* Все пути цепочки. Путь — строки всех шагов, по одной на шаг, связанные концами; приходит объектом, где ключ — имя
|
|
63
65
|
* шага (или имя из `alias()`), а значение — строка этого шага: `{ Shop: …, Product: … }`. Если к объекту последнего
|
|
64
66
|
* шага ведут несколько путей, придут все.
|
|
65
67
|
*/
|
|
@@ -71,12 +73,12 @@ export interface ChainCore {
|
|
|
71
73
|
rows(): Promise<Row[]>;
|
|
72
74
|
first(): Promise<Row | null>;
|
|
73
75
|
ids(): Promise<string[]>;
|
|
74
|
-
/** Число
|
|
76
|
+
/** Число объектов последнего шага; { paths: true } — число путей. */
|
|
75
77
|
count(opts?: {
|
|
76
78
|
paths?: boolean;
|
|
77
79
|
}): Promise<number>;
|
|
78
80
|
/**
|
|
79
|
-
* Все версии
|
|
81
|
+
* Все версии объектов последнего шага (надгробия — $deleted), по id и номеру версии. Шаг с явными id находит и
|
|
80
82
|
* удалённые объекты: `db.Класс(id).versions()` отдаёт историю удалённого.
|
|
81
83
|
*/
|
|
82
84
|
versions(): Promise<Row[]>;
|
|
@@ -110,10 +112,14 @@ export interface ChainCore {
|
|
|
110
112
|
/**
|
|
111
113
|
* Не больше n строк ответа: объектов последнего шага, у `paths()` — путей, у `versions()` — версий, перед `countBy()` —
|
|
112
114
|
* групп. До глагола сужает его цели. Сразу после глагола перед `rows`, `first`, `ids`, `count` и `paths` —
|
|
113
|
-
* `invalid_query`: глагол уже прошёл по всем целям, сужать нечего.
|
|
115
|
+
* `invalid_query`: глагол уже прошёл по всем целям, сужать нечего. `n` — целое от 0: дробное — `invalid_query`,
|
|
116
|
+
* отрицательное отклоняет PostgreSQL (ошибка базы с SQLSTATE 2201W).
|
|
114
117
|
*/
|
|
115
118
|
limit(n: number): Chain;
|
|
116
|
-
/**
|
|
119
|
+
/**
|
|
120
|
+
* Пропустить первые n строк ответа — тех же, что считает `limit()`; где действует — как у `limit()`. `n` — целое от 0:
|
|
121
|
+
* дробное — `invalid_query`, отрицательное отклоняет PostgreSQL (ошибка базы с SQLSTATE 2201X).
|
|
122
|
+
*/
|
|
117
123
|
offset(n: number): Chain;
|
|
118
124
|
/** Сортировка по полю последнего шага: 'at' | 'rev' | 'id' | 'data.<путь>'. */
|
|
119
125
|
sort(field: string, dir?: 'asc' | 'desc' | boolean): Chain;
|
|
@@ -163,7 +169,7 @@ export interface ChainCore {
|
|
|
163
169
|
* следующий шаг пойдёт от него.
|
|
164
170
|
*/
|
|
165
171
|
entity(x: ChainRef | Row): Chain;
|
|
166
|
-
/** Создать объект: концы — из пути и
|
|
172
|
+
/** Создать объект: концы — из пути и шагов роли; занятый ключ — ошибка exists с id. */
|
|
167
173
|
create(data?: Record<string, unknown>, opts?: {
|
|
168
174
|
reason?: string;
|
|
169
175
|
}): Chain;
|
|
@@ -183,10 +189,10 @@ export interface ChainCore {
|
|
|
183
189
|
}): Chain;
|
|
184
190
|
/**
|
|
185
191
|
* Удаляет цели шага; строки, которые на них ссылаются, база удаляет вместе с ними, отвязывает или не даёт удалить цель —
|
|
186
|
-
* как задано в `onDelete` их
|
|
187
|
-
* вернёт все строки, которых оно коснулось бы, у каждой `$action` — что с ней будет. `{ confirm: true, expect:
|
|
188
|
-
* удаляет ровно показанное: в транзакции удаления
|
|
189
|
-
* `$action`; расхождение — `conflict` (ничего не удалено,
|
|
192
|
+
* как задано в `onDelete` их концов. Без `{ confirm: true }` — предпросмотр удаления: ничего не удаляется, терминал
|
|
193
|
+
* вернёт все строки, которых оно коснулось бы, у каждой `$action` — что с ней будет. `{ confirm: true, expect: предпросмотр }`
|
|
194
|
+
* удаляет ровно показанное: в транзакции удаления предпросмотр строится заново и сравнивается с `expect` по `id`, `rev` и
|
|
195
|
+
* `$action`; расхождение — `conflict` (ничего не удалено, новый предпросмотр — `err.detail.preview`); цели сужают фильтр, id,
|
|
190
196
|
* `tags()`, `owner()` или `limit()` хотя бы у одного шага, иначе `invalid_query`. `{ confirm: true }` без `expect` —
|
|
191
197
|
* `invalid_query` (`write.confirm_without_expect`); осознанно удалить текущий состав целей без сверки (и без фильтра —
|
|
192
198
|
* все строки) — `{ confirm: true, all: true }`.
|
|
@@ -212,8 +218,8 @@ export interface ChainCore {
|
|
|
212
218
|
}
|
|
213
219
|
/**
|
|
214
220
|
* Шаг цепочки — имя класса, его алиаса или роли с необязательным фильтром: `db.Shop(id).Product({ price: gt(1000) })`;
|
|
215
|
-
* каждый следующий шаг переходит по
|
|
216
|
-
* роли без вызова задаёт
|
|
221
|
+
* каждый следующий шаг переходит по концам от объектов предыдущего. После `create()`, `update()` или `upsert()` имя
|
|
222
|
+
* роли без вызова задаёт конец записываемой строки: `.Роль.set(…)`, `.Роль.unset()`, у множественного конца ещё
|
|
217
223
|
* `.Роль.add(…)` и `.Роль.remove(…)`.
|
|
218
224
|
*/
|
|
219
225
|
export type StepFn = ((filter?: Filter) => Chain) & {
|
|
@@ -225,7 +231,7 @@ export type StepFn = ((filter?: Filter) => Chain) & {
|
|
|
225
231
|
export type Chain = ChainCore & {
|
|
226
232
|
[className: string]: StepFn;
|
|
227
233
|
};
|
|
228
|
-
/** Любая цепочка — с типами по моделям (`TypedChain`) или без них (`Chain`): аргумент `entity()` и значение
|
|
234
|
+
/** Любая цепочка — с типами по моделям (`TypedChain`) или без них (`Chain`): аргумент `entity()` и значение шага роли (`.Роль.set(цепочка)`). */
|
|
229
235
|
export type ChainRef = Pick<ChainCore, 'ids'>;
|
|
230
236
|
/** Внутренний канал: план чужой ленивой цепочки (entity()). */
|
|
231
237
|
export declare const PLAN: unique symbol;
|
package/dist/chain.js
CHANGED
|
@@ -52,13 +52,21 @@ const stepNames = (reg) => reg.all().filter((c) => !isSystem(c)).flatMap((c) =>
|
|
|
52
52
|
*/
|
|
53
53
|
const ARRAY_PROPS = new Set(['length', 'map', 'filter', 'forEach', 'reduce', 'find', 'some', 'every', 'slice', 'at', 'toJSON', '0']);
|
|
54
54
|
/**
|
|
55
|
-
* Неизвестный класс или роль — с похожими именами реестра (план 2.0, 7.9 №6). Свойство массива — api.chain_not_rows
|
|
56
|
-
*
|
|
55
|
+
* Неизвестный класс или роль — с похожими именами реестра (план 2.0, 7.9 №6). Свойство массива — api.chain_not_rows.
|
|
56
|
+
* В реестре только классы System (3.0.1): сессия в личном арендаторе (арендатор = аккаунт сессии) —
|
|
57
|
+
* api.unknown_step_no_classes, иначе в арендаторе просто нет классов — api.unknown_step_empty_tenant.
|
|
57
58
|
*/
|
|
58
|
-
const unknownName = (
|
|
59
|
+
const unknownName = (ctx, name) => {
|
|
60
|
+
const reg = ctx.reg;
|
|
59
61
|
if (ARRAY_PROPS.has(name))
|
|
60
62
|
throw lerr('api.chain_not_rows', { name });
|
|
61
|
-
|
|
63
|
+
let key = 'api.unknown_step';
|
|
64
|
+
if (reg.all().every((c) => c.tenant === SYSTEM_ID)) {
|
|
65
|
+
const tenant = ctx.tenant();
|
|
66
|
+
const account = ctx.account?.() ?? null;
|
|
67
|
+
key = tenant !== null && account !== null && tenant.toLowerCase() === account.toLowerCase()
|
|
68
|
+
? 'api.unknown_step_no_classes' : 'api.unknown_step_empty_tenant';
|
|
69
|
+
}
|
|
62
70
|
throw lerr(key, { name, similar: similar(name, stepNames(reg)) });
|
|
63
71
|
};
|
|
64
72
|
/** Терминалы и агрегаты цепочки: у цепочки без сессии они отдают отказ промисом (sessionless). */
|
|
@@ -288,10 +296,12 @@ function writeDetail(op, rows) {
|
|
|
288
296
|
d.locked = true;
|
|
289
297
|
if (rows && !op.confirm) {
|
|
290
298
|
const actions = {};
|
|
299
|
+
// строки-сводки скрытых правами строк (3.0.1): в actions.hidden — их число, в классы и id сводки не идут
|
|
291
300
|
for (const r of rows)
|
|
292
301
|
if (r.$action)
|
|
293
|
-
actions[r.$action] = (actions[r.$action] ?? 0) + 1;
|
|
302
|
+
actions[r.$action] = (actions[r.$action] ?? 0) + (r.$action === 'hidden' ? r.$count ?? 0 : 1);
|
|
294
303
|
d.actions = actions;
|
|
304
|
+
rows = rows.filter((r) => r.$action !== 'hidden');
|
|
295
305
|
}
|
|
296
306
|
}
|
|
297
307
|
else if (op.kind === 'restore') {
|
|
@@ -833,7 +843,7 @@ export function makeChain(ctx, steps, mods, nodes = NO_NODES) {
|
|
|
833
843
|
const found = lookupStep(ctx.reg, prop);
|
|
834
844
|
if (!found && noSession(ctx, prop))
|
|
835
845
|
return sessionless();
|
|
836
|
-
return classProp(ctx, prop, found ?? unknownName(ctx
|
|
846
|
+
return classProp(ctx, prop, found ?? unknownName(ctx, prop), steps, mods, nodes);
|
|
837
847
|
};
|
|
838
848
|
const log = ctx.log;
|
|
839
849
|
const handler = {
|
|
@@ -906,7 +916,7 @@ export function startStep(ctx, name) {
|
|
|
906
916
|
const found = lookupStep(ctx.reg, name);
|
|
907
917
|
if (!found && noSession(ctx, name))
|
|
908
918
|
return sessionless();
|
|
909
|
-
return classProp(ctx, name, found ?? unknownName(ctx
|
|
919
|
+
return classProp(ctx, name, found ?? unknownName(ctx, name), undefined, {}, NO_NODES);
|
|
910
920
|
}
|
|
911
921
|
/** Старт пути с готового узла: db.entity(row | цепочка). */
|
|
912
922
|
export function startEntity(ctx, x) {
|
package/dist/cli.js
CHANGED
|
@@ -26,14 +26,15 @@ import { connect } from './index.js';
|
|
|
26
26
|
import { syncModels, generateTypes } from './sync.js';
|
|
27
27
|
import { lerr, lmsg } from './errors.js';
|
|
28
28
|
import { stderrLog } from './log.js';
|
|
29
|
-
const USAGE = `letopis sync <модуль> --dsn <строка> --schema
|
|
30
|
-
letopis types --dsn <строка> --schema
|
|
31
|
-
letopis load <файлы…> --dsn <строка> --schema
|
|
29
|
+
const USAGE = `letopis sync <модуль> --dsn <строка> --schema <схема> (--token <токен> | --api-key <ключ>) [--apply | --check]
|
|
30
|
+
letopis types --dsn <строка> --schema <схема> (--token <токен> | --api-key <ключ>) [--out <файл>] [--system]
|
|
31
|
+
letopis load <файлы…> --dsn <строка> --schema <схема> (--token <токен> | --api-key <ключ>) [--class <Класс>]
|
|
32
32
|
[--duplicates error|first|last] [--existing error|skip|update] [--commit-every <N>] [--initial]
|
|
33
|
-
letopis maintain --dsn <строка> --schema
|
|
34
|
-
letopis mcp --dsn <строка> --schema
|
|
33
|
+
letopis maintain --dsn <строка> --schema <схема> [--token <токен> | --api-key <ключ>]
|
|
34
|
+
letopis mcp --dsn <строка> --schema <схема> --token <токен пользователя> [--write]
|
|
35
35
|
(или LETOPIS_DSN, LETOPIS_SCHEMA, LETOPIS_TOKEN; ключ сервиса --api-key не принимается)
|
|
36
|
-
у любой команды: --log[=debug|info|warn|error|off] — события JSON-строками в stderr (без значения — info)
|
|
36
|
+
у любой команды: --log[=debug|info|warn|error|off] — события JSON-строками в stderr (без значения — info)
|
|
37
|
+
(<схема> — v2.<имя> или просто <имя>: shop — это v2.shop)`;
|
|
37
38
|
/** Команды CLI: остальное — USAGE с кодом 2 ещё до подключения. */
|
|
38
39
|
const COMMANDS = new Set(['sync', 'types', 'load', 'maintain', 'mcp']);
|
|
39
40
|
const isModel = (v) => typeof v === 'object' && v !== null && typeof v.describe === 'function' && typeof v.name === 'string';
|
|
@@ -59,18 +60,47 @@ function logFlag(argv) {
|
|
|
59
60
|
}
|
|
60
61
|
return { args, log: level === undefined ? {} : { log: stderrLog, logLevel: level } };
|
|
61
62
|
}
|
|
63
|
+
/** Флаги CLI для parseArgs. */
|
|
64
|
+
const OPTIONS = {
|
|
65
|
+
dsn: { type: 'string' }, schema: { type: 'string' }, token: { type: 'string' }, 'api-key': { type: 'string' },
|
|
66
|
+
apply: { type: 'boolean' }, out: { type: 'string' }, help: { type: 'boolean' },
|
|
67
|
+
class: { type: 'string' }, duplicates: { type: 'string' }, existing: { type: 'string' }, 'commit-every': { type: 'string' },
|
|
68
|
+
initial: { type: 'boolean' }, write: { type: 'boolean' }, system: { type: 'boolean' }, check: { type: 'boolean' },
|
|
69
|
+
};
|
|
70
|
+
/**
|
|
71
|
+
* Ошибка разбора аргументов parseArgs — русский текст каталога (английский текст Node — только для прочих ошибок);
|
|
72
|
+
* null — не ошибка разбора.
|
|
73
|
+
*/
|
|
74
|
+
function parseError(e) {
|
|
75
|
+
const code = e?.code;
|
|
76
|
+
const message = String(e?.message ?? '');
|
|
77
|
+
// имя флага или аргумент — первое в кавычках: «Unknown option '--aply'…», «Option '--dsn <value>' argument missing»
|
|
78
|
+
const quoted = (/'([^']*)'/.exec(message)?.[1] ?? '').split(' ')[0] ?? '';
|
|
79
|
+
if (code === 'ERR_PARSE_ARGS_UNKNOWN_OPTION')
|
|
80
|
+
return lmsg('model.cli_unknown_flag', { flag: quoted });
|
|
81
|
+
if (code === 'ERR_PARSE_ARGS_INVALID_OPTION_VALUE') {
|
|
82
|
+
// строковому флагу не дали значения или булевому дали
|
|
83
|
+
return lmsg(/does not take/i.test(message) ? 'model.cli_flag_no_value' : 'model.cli_flag_needs_value', { flag: quoted });
|
|
84
|
+
}
|
|
85
|
+
if (code === 'ERR_PARSE_ARGS_UNEXPECTED_POSITIONAL')
|
|
86
|
+
return lmsg('model.cli_unexpected_arg', { arg: quoted });
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
62
89
|
export async function main(argv) {
|
|
63
90
|
const { args, log } = logFlag(argv);
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
allowPositionals: true,
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
91
|
+
let parsed;
|
|
92
|
+
try {
|
|
93
|
+
parsed = parseArgs({ args, allowPositionals: true, options: OPTIONS });
|
|
94
|
+
}
|
|
95
|
+
catch (e) {
|
|
96
|
+
const text = parseError(e);
|
|
97
|
+
if (text === null)
|
|
98
|
+
throw e;
|
|
99
|
+
// код выхода 1 — как у прочих ошибок до подключения (прежде — необработанное исключение parseArgs)
|
|
100
|
+
console.error(`letopis: ${text}`);
|
|
101
|
+
return 1;
|
|
102
|
+
}
|
|
103
|
+
const { values, positionals } = parsed;
|
|
74
104
|
const [cmd, arg] = positionals;
|
|
75
105
|
// клиенты MCP передают строку подключения и токен окружением (план 1.1, §2.5) — только для этой команды
|
|
76
106
|
if (cmd === 'mcp') {
|
package/dist/errors.d.ts
CHANGED
|
@@ -84,10 +84,9 @@ export declare function issuesFromDb(issues: unknown): Issue[];
|
|
|
84
84
|
*/
|
|
85
85
|
export declare function findingsFromDb<T>(rows: readonly T[]): T[];
|
|
86
86
|
/**
|
|
87
|
-
* Ошибка базы → LetopisError (ValidationError для invalid_data с нарушениями)
|
|
88
|
-
*
|
|
89
|
-
*
|
|
90
|
-
*
|
|
91
|
-
* ошибки возвращаются как есть: на SQLSTATE конфликта держатся повторы (tx.ts).
|
|
87
|
+
* Ошибка базы → LetopisError (ValidationError для invalid_data с нарушениями) — для ошибок сырого SQL (`db.sql`,
|
|
88
|
+
* `db.transaction()`): тот же код, ключ и подробности, что у цепочек. Подробности ошибки базы переходят в `detail`, ключ
|
|
89
|
+
* каталога и параметры — в `key` и `params`, текст — русский; без ключа — текст базы. Значение не того типа (22P02) —
|
|
90
|
+
* `invalid_query`. Повторяемый конфликт (40001, 40P01) и прочие ошибки возвращаются как есть.
|
|
92
91
|
*/
|
|
93
92
|
export declare function fromDbError(e: unknown): unknown;
|
package/dist/errors.js
CHANGED
|
@@ -2,10 +2,10 @@
|
|
|
2
2
|
* Ошибки letopis 1.0 (план, §7.1, этап 2, п. 10). Текст — «letopis: <код>: <сообщение>», как у ошибок
|
|
3
3
|
* базы; код — имя из каталога (`error_catalog()` в lib/sql/15-errors.sql) или код самой библиотеки.
|
|
4
4
|
*
|
|
5
|
-
* Каталог
|
|
5
|
+
* Каталог сообщений: ошибка по ключу — `lerr(ключ, параметры)`, текст —
|
|
6
6
|
* шаблон записи с параметрами. У каждой ошибки — подсказка «что делать» (`hint`) и ссылка на строку её кода в разделе
|
|
7
7
|
* «Ошибки» README на GitHub по тегу версии (`docs`). База собирает текст по ключу и параметрам из того же шаблона и
|
|
8
|
-
*
|
|
8
|
+
* передаёт ключ и параметры вместе с ошибкой, а fromDbError переносит их в поля ошибки (`key`, `params`); без ключа —
|
|
9
9
|
* текст базы как есть. У нарушений (validate_data) и строк отказа ужесточения key и params наружу не идут: issuesFromDb,
|
|
10
10
|
* findingsFromDb.
|
|
11
11
|
*/
|
|
@@ -213,11 +213,10 @@ function parseDetail(detail) {
|
|
|
213
213
|
}
|
|
214
214
|
}
|
|
215
215
|
/**
|
|
216
|
-
* Ошибка базы → LetopisError (ValidationError для invalid_data с нарушениями)
|
|
217
|
-
*
|
|
218
|
-
*
|
|
219
|
-
*
|
|
220
|
-
* ошибки возвращаются как есть: на SQLSTATE конфликта держатся повторы (tx.ts).
|
|
216
|
+
* Ошибка базы → LetopisError (ValidationError для invalid_data с нарушениями) — для ошибок сырого SQL (`db.sql`,
|
|
217
|
+
* `db.transaction()`): тот же код, ключ и подробности, что у цепочек. Подробности ошибки базы переходят в `detail`, ключ
|
|
218
|
+
* каталога и параметры — в `key` и `params`, текст — русский; без ключа — текст базы. Значение не того типа (22P02) —
|
|
219
|
+
* `invalid_query`. Повторяемый конфликт (40001, 40P01) и прочие ошибки возвращаются как есть.
|
|
221
220
|
*/
|
|
222
221
|
export function fromDbError(e) {
|
|
223
222
|
const err = e;
|