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.
Files changed (62) hide show
  1. package/AGENT-CHEATSHEET.en.md +15 -7
  2. package/AGENT-CHEATSHEET.md +19 -11
  3. package/CHANGELOG.md +299 -1
  4. package/README.en.md +180 -62
  5. package/README.md +165 -55
  6. package/dist/acl.d.ts +11 -5
  7. package/dist/admin.d.ts +30 -14
  8. package/dist/admin.js +53 -18
  9. package/dist/auth.d.ts +61 -17
  10. package/dist/auth.js +16 -2
  11. package/dist/cache.d.ts +5 -1
  12. package/dist/cache.js +3 -2
  13. package/dist/chain.d.ts +92 -15
  14. package/dist/chain.js +56 -7
  15. package/dist/cli.js +16 -2
  16. package/dist/errors.d.ts +2 -2
  17. package/dist/errors.js +5 -5
  18. package/dist/import.js +7 -0
  19. package/dist/index.d.ts +73 -45
  20. package/dist/index.js +69 -14
  21. package/dist/internal.d.ts +7 -0
  22. package/dist/internal.js +5 -0
  23. package/dist/load.d.ts +10 -3
  24. package/dist/load.js +39 -11
  25. package/dist/mcp.d.ts +20 -11
  26. package/dist/mcp.js +8 -6
  27. package/dist/model.d.ts +4 -1
  28. package/dist/model.js +7 -1
  29. package/dist/ops.d.ts +58 -18
  30. package/dist/ops.js +58 -18
  31. package/dist/project.d.ts +39 -11
  32. package/dist/project.js +75 -15
  33. package/dist/registry.d.ts +4 -1
  34. package/dist/registry.js +4 -1
  35. package/dist/signals.d.ts +4 -4
  36. package/dist/signals.js +4 -4
  37. package/dist/sql.d.ts +8 -4
  38. package/dist/sql.js +110 -25
  39. package/dist/sync.js +11 -4
  40. package/dist/tx.d.ts +15 -0
  41. package/dist/tx.js +22 -4
  42. package/dist/types.d.ts +21 -7
  43. package/dist/types.js +13 -6
  44. package/dist/up.d.ts +48 -25
  45. package/dist/up.js +94 -41
  46. package/dist/validate.d.ts +9 -2
  47. package/dist/validate.js +19 -3
  48. package/dist/watch.d.ts +29 -5
  49. package/dist/watch.js +62 -13
  50. package/dist/write.d.ts +3 -2
  51. package/dist/write.js +52 -19
  52. package/package.json +8 -4
  53. package/sql/10-core.sql +6 -1
  54. package/sql/60-write.sql +47 -18
  55. package/sql/70-read.sql +13 -7
  56. package/sql/75-history.sql +106 -28
  57. package/sql/80-auth.sql +41 -3
  58. package/sql/88-derived.sql +3 -1
  59. package/sql/90-time.sql +118 -30
  60. package/sql/95-seed.system.sql +6 -3
  61. package/sql/99-revision.sql +1 -1
  62. 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
- /** Пачек прореживания за вызов (по умолчанию 100); каждая — своя транзакция. */
26
+ /** Сколько пачек прореживания журнала самое большее сделать за один вызов; каждая пачка — своя транзакция. */
27
27
  batches?: number;
28
- /** Версий в пачке (по умолчанию 10 000). */
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): Promise<MaintainReport>;
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
- /** Интервал расписания: число мс или '30s', '15m', '1h', '1d'. */
124
- export declare function parseInterval(v: string | number): number;
125
- /** Запуск maintain() по расписанию (connect({ maintain: '1h' })): ошибки — предупреждением. */
126
- export declare function scheduleMaintain(run: () => Promise<unknown>, every: string | number): () => void;
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
- /** Описание модели текстом для языковых моделей (llms.txt). */
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
- // блокировку берёт сама maintain() базы — строку-замок, которую посторонняя роль не займёт; занята — null
28
- const [{ r }] = await runTx(a.txc, (q) => q.unsafe(`select ${a.S}.maintain($1::text::timestamptz, $2::int) as r`, [now, opts.limit ?? 10_000]));
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
- /** Интервал расписания: число мс или '30s', '15m', '1h', '1d'. */
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
- return v;
57
- const m = /^(\d+(?:\.\d+)?)\s*(ms|s|m|h|d)$/.exec(v.trim());
58
- if (!m)
59
- throw new LetopisError('invalid_data', `интервал ${v}: число мс или '30s', '15m', '1h', '1d'`);
60
- const n = Number(m[1]);
61
- return n * { ms: 1, s: 1000, m: 60_000, h: 3_600_000, d: 86_400_000 }[m[2]];
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
- /** Запуск maintain() по расписанию (connect({ maintain: '1h' })): ошибки — предупреждением. */
64
- export function scheduleMaintain(run, every) {
65
- const ms = parseInterval(every);
66
- let busy = false;
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 (busy)
98
+ if (current || stopped)
69
99
  return;
70
- busy = true;
71
- run().catch((e) => console.warn(`letopis: maintain по расписанию: ${e.message}`)).finally(() => (busy = false));
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 () => clearInterval(timer);
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
- /** Описание модели текстом для языковых моделей (llms.txt). */
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, EMAIL, …): создать или заменить. Смена гасит остальные сессии аккаунта. */
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. Неудача — null. С `totp` после
42
- * верного пароля база проверяет код TOTP аккаунта (право auth.totp; счётчик неудач и блокировка — общие
43
- * с verifyTotp): верный — сессия со способами [kind, 'TOTP'], неверный или повторный — null.
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
- /** TOTP: секрет в base32 и ссылка otpauth:// для QR; фактор включает первая успешная проверка. */
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
- /** Вход по одноразовому коду: успех и перебор (лимит задал выпуск, 1–10, по умолчанию 5) сжигают код. */
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
- /** Внешняя identity (TELEGRAM, GOOGLE, SSO): токен проверяет сервис, здесь — связка. */
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
- /** Вход по identity — для сервиса с правами auth.identity и auth.session; `totp` — как у verifyPassword. */
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
- /** Короткая сессия пользователя для другого процесса (право auth.session). */
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
- const got = await scryptAsync(password, Buffer.from(saltB64, 'base64'), want.length, { N: Number(N), r: Number(r), p: Number(p), maxmem: SCRYPT.maxmem });
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
- return (await one(`select ${S}.account_purge($1::uuid) as r`, [accId(account)])).r;
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
- /** Работать без слушателя сигналов — только по сроку (пул в режиме транзакций, §2.12). */
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
- return e.value;
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
  }