letopis 0.21.0 → 1.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.
Files changed (89) hide show
  1. package/AGENT-CHEATSHEET.en.md +368 -0
  2. package/AGENT-CHEATSHEET.md +354 -0
  3. package/CHANGELOG.md +337 -0
  4. package/MIGRATION.md +190 -0
  5. package/README.en.md +1938 -0
  6. package/README.md +1493 -3469
  7. package/dist/acl.d.ts +26 -50
  8. package/dist/acl.js +22 -267
  9. package/dist/admin.d.ts +138 -0
  10. package/dist/admin.js +170 -0
  11. package/dist/auth.d.ts +120 -73
  12. package/dist/auth.js +121 -306
  13. package/dist/cache.d.ts +73 -0
  14. package/dist/cache.js +148 -0
  15. package/dist/chain.d.ts +124 -191
  16. package/dist/chain.js +362 -563
  17. package/dist/cli.d.ts +2 -0
  18. package/dist/cli.js +178 -0
  19. package/dist/demo/booking.d.ts +289 -0
  20. package/dist/demo/booking.js +159 -0
  21. package/dist/errors.d.ts +29 -0
  22. package/dist/errors.js +70 -0
  23. package/dist/import.d.ts +179 -0
  24. package/dist/import.js +792 -0
  25. package/dist/index.d.ts +172 -26
  26. package/dist/index.js +304 -178
  27. package/dist/jsonschema.d.ts +22 -0
  28. package/dist/jsonschema.js +167 -0
  29. package/dist/load.d.ts +76 -0
  30. package/dist/load.js +884 -0
  31. package/dist/model.d.ts +166 -0
  32. package/dist/model.js +224 -0
  33. package/dist/ops.d.ts +7 -6
  34. package/dist/ops.js +7 -51
  35. package/dist/pglite.d.ts +22 -0
  36. package/dist/pglite.js +45 -0
  37. package/dist/registry.d.ts +57 -0
  38. package/dist/registry.js +82 -0
  39. package/dist/sql.d.ts +59 -142
  40. package/dist/sql.js +568 -654
  41. package/dist/sync.d.ts +31 -0
  42. package/dist/sync.js +108 -0
  43. package/dist/tx.d.ts +129 -8
  44. package/dist/tx.js +300 -73
  45. package/dist/typed.d.ts +97 -0
  46. package/dist/typed.js +1 -0
  47. package/dist/types.d.ts +71 -252
  48. package/dist/types.js +27 -108
  49. package/dist/up.d.ts +140 -47
  50. package/dist/up.js +339 -267
  51. package/dist/uuid.d.ts +21 -6
  52. package/dist/uuid.js +48 -64
  53. package/dist/validate.d.ts +24 -0
  54. package/dist/validate.js +251 -0
  55. package/dist/watch.d.ts +62 -0
  56. package/dist/watch.js +168 -0
  57. package/dist/write.d.ts +117 -74
  58. package/dist/write.js +658 -720
  59. package/llms.txt +26 -0
  60. package/package.json +49 -19
  61. package/sql/10-core.sql +136 -0
  62. package/sql/15-errors.sql +60 -0
  63. package/sql/20-context.sql +153 -0
  64. package/sql/30-validate.sql +423 -0
  65. package/sql/40-class.sql +259 -0
  66. package/sql/50-acl.sql +539 -0
  67. package/sql/60-write.sql +1372 -0
  68. package/sql/70-read.sql +245 -0
  69. package/sql/80-auth.sql +827 -0
  70. package/sql/90-time.sql +957 -0
  71. package/sql/95-seed.system.sql +178 -0
  72. package/sql/99-revision.sql +3 -0
  73. package/sql/README.md +56 -0
  74. package/sql/seed.booking.sql +39 -112
  75. package/dist/schema.d.ts +0 -15
  76. package/dist/schema.js +0 -352
  77. package/dist/sessions.d.ts +0 -32
  78. package/dist/sessions.js +0 -114
  79. package/dist/tables.d.ts +0 -105
  80. package/dist/tables.js +0 -248
  81. package/docker/Dockerfile +0 -40
  82. package/docker/start.sh +0 -18
  83. package/scripts/check-docs.mjs +0 -375
  84. package/scripts/gen-api-contract.mjs +0 -226
  85. package/scripts/gen-types.mjs +0 -350
  86. package/scripts/release-notes.mjs +0 -76
  87. package/scripts/schema-sync.mjs +0 -185
  88. package/sql/ddl.sql +0 -685
  89. package/sql/seed.auth.sql +0 -73
package/dist/auth.js CHANGED
@@ -1,86 +1,39 @@
1
1
  /**
2
- * db.auth: вход по таблице Credential — на аккаунт много способов:
3
- * пароль (логин/почта), api-ключ, ключ-секрет, внешние identity (oauth/sso/telegram).
4
- * Пароли — scrypt (node:crypto, без зависимостей); ключи/секреты — в БД только sha256.
5
- * Однозначность входа даёт credential_identity_udx: один живой кред на (category, identifier).
6
- * OAuth-танец, rate-limit, lockout — на приложении: здесь хранение и проверка.
2
+ * db.auth — креды, вход и сессии (план, §2.9, этап 5, п. 1–4). Всё хранится в таблице secret, к
3
+ * которой у приложения нет доступа: библиотека вызывает функции базы (80-auth.sql), а они проверяют
4
+ * права (auth.* — по правилам System: сервис, системный администратор; свои креды аккаунт привязывает
5
+ * сам после недавнего входа).
6
+ *
7
+ * Пароли — scrypt в Node (как в 0.21): база хранит готовый хэш и отдаёт его только сервису с правом
8
+ * auth.password; проверку для несуществующего идентификатора выравнивает по времени холостой scrypt.
9
+ * Ключи API, пары ключ + секрет, одноразовые коды и TOTP проверяет база. Успешный вход — новая
10
+ * сессия: { account, token }.
7
11
  */
8
- // FILE: lib/src/auth.ts
9
- // VERSION: 1.0.0
10
- // START_MODULE_CONTRACT
11
- // PURPOSE: Жизненный цикл и проверка кредов аккаунта — пароли (scrypt), api-ключи, ключ+секрет, TOTP, OTP, внешние identity — плюс выдача сессий.
12
- // SCOPE: makeAuth (AuthApi) + totpCode + локальные крипто-хелперы (hashPassword/verifyHash/base32Encode/base32Decode/hotp)
13
- // DEPENDS: M-TYPES, M-TABLES, M-SESSIONS
14
- // LINKS: M-AUTH, V-M-AUTH
15
- // ROLE: RUNTIME
16
- // MAP_MODE: EXPORTS
17
- // END_MODULE_CONTRACT
18
- //
19
- // START_MODULE_MAP
20
- // totpCode - TOTP-код authenticator-а для base32-секрета на момент времени (хелпер для тестов/сервисов)
21
- // AuthResult - успешный вход: аккаунт + конкретный кред
22
- // AuthApi - контракт всех способов входа (пароль/api-ключ/ключ-секрет/TOTP/OTP/identity) + выдача сессий
23
- // makeAuth - фабрика AuthApi поверх Tables
24
- // END_MODULE_MAP
25
- //
26
- // START_CHANGE_SUMMARY
27
- // LAST_CHANGE: [v1.0.0 - Documented existing module: reverse-engineered contract + markup]
28
- // END_CHANGE_SUMMARY
29
- import { createHash, createHmac, randomBytes, randomInt, scrypt, timingSafeEqual } from 'node:crypto';
12
+ import { createHmac, randomBytes, scrypt, timingSafeEqual } from 'node:crypto';
30
13
  import { promisify } from 'node:util';
31
- import { makeSessions } from './sessions.js';
14
+ import { LetopisError, removed } from './errors.js';
32
15
  const scryptAsync = promisify(scrypt);
33
- // OWASP-порядок стойкости; maxmem с запасом (128*N*r байт впритык к дефолтным 32MiB)
34
16
  const SCRYPT = { N: 32768, r: 8, p: 1, maxmem: 64 * 1024 * 1024 };
35
17
  const KEYLEN = 32;
36
- const sha256 = (s) => createHash('sha256').update(s).digest('hex');
37
- // START_BLOCK_SCRYPT_HASH
38
- // START_CONTRACT: hashPassword
39
- // PURPOSE: Захэшировать пароль scrypt-ом со случайной солью в самодостаточную строку.
40
- // INPUTS: { password: string - открытый пароль }
41
- // OUTPUTS: { Promise<string> - строка `scrypt$N$r$p$saltB64$hashB64` }
42
- // SIDE_EFFECTS: none (CSPRNG-соль, scrypt-KDF)
43
- // LINKS: M-AUTH, V-M-AUTH
44
- // END_CONTRACT: hashPassword
45
- async function hashPassword(password) {
18
+ /** Хэш пароля scrypt: `scrypt$N$r$p$соль$хэш` (формат 0.21). */
19
+ export async function hashPassword(password) {
46
20
  const salt = randomBytes(16);
47
21
  const hash = await scryptAsync(password, salt, KEYLEN, SCRYPT);
48
22
  return `scrypt$${SCRYPT.N}$${SCRYPT.r}$${SCRYPT.p}$${salt.toString('base64')}$${hash.toString('base64')}`;
49
23
  }
50
- // START_CONTRACT: verifyHash
51
- // PURPOSE: Проверить пароль против сохранённого scrypt-хэша в постоянное время.
52
- // INPUTS: { password: string - открытый пароль; stored: string - строка формата scrypt$… }
53
- // OUTPUTS: { Promise<boolean> - true при совпадении; false для не-scrypt (legacy) форматов }
54
- // SIDE_EFFECTS: none (timingSafeEqual — сравнение в постоянное время)
55
- // LINKS: M-AUTH, V-M-AUTH
56
- // END_CONTRACT: verifyHash
57
- async function verifyHash(password, stored) {
24
+ /** Проверка пароля по хэшу scrypt; другие форматы не проверяются. */
25
+ export async function verifyHash(password, stored) {
58
26
  const [algo, N, r, p, saltB64, hashB64] = stored.split('$');
59
- if (algo !== 'scrypt')
60
- return false; // legacy-форматы (bcrypt из дампа) не верифицируем
27
+ if (algo !== 'scrypt' || !hashB64)
28
+ return false;
61
29
  const want = Buffer.from(hashB64, 'base64');
62
- const got = await scryptAsync(password, Buffer.from(saltB64, 'base64'), want.length, {
63
- N: Number(N), r: Number(r), p: Number(p), maxmem: SCRYPT.maxmem,
64
- });
65
- return timingSafeEqual(got, want);
30
+ const got = await scryptAsync(password, Buffer.from(saltB64, 'base64'), want.length, { N: Number(N), r: Number(r), p: Number(p), maxmem: SCRYPT.maxmem });
31
+ return got.length === want.length && timingSafeEqual(got, want);
66
32
  }
67
- /** Выравнивание времени ответа: «identifier не существует» не быстрее «пароль неверен». */
33
+ /** Холостая проверка: «идентификатора нет» отвечает не быстрее, чем «пароль неверен». */
68
34
  const dummyVerify = (password) => scryptAsync(password, Buffer.alloc(16), KEYLEN, SCRYPT).then(() => false);
69
- // END_BLOCK_SCRYPT_HASH
70
- const eqHex = (a, b) => {
71
- const ba = Buffer.from(a, 'hex');
72
- const bb = Buffer.from(b, 'hex');
73
- return ba.length === bb.length && timingSafeEqual(ba, bb);
74
- };
75
- const eqStr = (a, b) => {
76
- const ba = Buffer.from(a);
77
- const bb = Buffer.from(b);
78
- return ba.length === bb.length && timingSafeEqual(ba, bb);
79
- };
80
- // --- TOTP (RFC 6238 / HOTP RFC 4226) ---------------------------------------
81
- // START_BLOCK_TOTP
82
35
  const B32 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
83
- function base32Encode(buf) {
36
+ export function base32Encode(buf) {
84
37
  let bits = 0;
85
38
  let value = 0;
86
39
  let out = '';
@@ -96,21 +49,14 @@ function base32Encode(buf) {
96
49
  out += B32[(value << (5 - bits)) & 31];
97
50
  return out;
98
51
  }
99
- // START_CONTRACT: base32Decode
100
- // PURPOSE: Декодировать base32 (RFC 4648, алфавит A-Z2-7) в байты.
101
- // INPUTS: { s: string - base32-строка (хвостовой padding и регистр допустимы) }
102
- // OUTPUTS: { Buffer - декодированные байты }
103
- // SIDE_EFFECTS: бросает Error 'letopis: invalid base32 TOTP secret' на недопустимый символ
104
- // LINKS: M-AUTH, V-M-AUTH
105
- // END_CONTRACT: base32Decode
106
- function base32Decode(s) {
52
+ export function base32Decode(s) {
107
53
  let bits = 0;
108
54
  let value = 0;
109
55
  const out = [];
110
56
  for (const ch of s.replace(/=+$/, '').toUpperCase()) {
111
57
  const idx = B32.indexOf(ch);
112
58
  if (idx < 0)
113
- throw new Error('letopis: invalid base32 TOTP secret');
59
+ throw new LetopisError('invalid_data', 'секрет TOTP — не base32');
114
60
  value = (value << 5) | idx;
115
61
  bits += 5;
116
62
  if (bits >= 8) {
@@ -120,270 +66,139 @@ function base32Decode(s) {
120
66
  }
121
67
  return Buffer.from(out);
122
68
  }
123
- const TOTP_PERIOD = 30; // секунд на шаг (стандарт authenticator-приложений)
69
+ const TOTP_PERIOD = 30;
124
70
  const TOTP_DIGITS = 6;
125
- // START_CONTRACT: hotp
126
- // PURPOSE: HOTP (RFC 4226) — HMAC-SHA1 счётчика + dynamic truncation в цифровой код.
127
- // INPUTS: { secret: Buffer - общий секрет; counter: number - шаг/счётчик }
128
- // OUTPUTS: { string - код из TOTP_DIGITS (6) цифр с ведущими нулями }
129
- // SIDE_EFFECTS: none
130
- // LINKS: M-AUTH, V-M-AUTH
131
- // END_CONTRACT: hotp
132
- function hotp(secret, counter) {
71
+ /** Код приложения-аутентификатора для секрета на момент времени (RFC 6238; помощник для тестов и сервисов). */
72
+ export function totpCode(secretBase32, atMs = Date.now()) {
133
73
  const buf = Buffer.alloc(8);
134
- buf.writeBigUInt64BE(BigInt(counter));
135
- const h = createHmac('sha1', secret).update(buf).digest();
74
+ buf.writeBigUInt64BE(BigInt(Math.floor(atMs / 1000 / TOTP_PERIOD)));
75
+ const h = createHmac('sha1', base32Decode(secretBase32)).update(buf).digest();
136
76
  const off = h[h.length - 1] & 0xf;
137
77
  const code = (((h[off] & 0x7f) << 24) | (h[off + 1] << 16) | (h[off + 2] << 8) | h[off + 3]) % 10 ** TOTP_DIGITS;
138
78
  return String(code).padStart(TOTP_DIGITS, '0');
139
79
  }
140
- const totpStep = (atMs) => Math.floor(atMs / 1000 / TOTP_PERIOD);
141
- /** Код authenticator-а для секрета на момент времени (хелпер для тестов/сервисов). */
142
- // START_CONTRACT: totpCode
143
- // PURPOSE: TOTP-код authenticator-а для base32-секрета на заданный момент времени.
144
- // INPUTS: { secretBase32: string - секрет в base32; atMs?: number - момент в мс (default Date.now()) }
145
- // OUTPUTS: { string - 6-значный код текущего 30-секундного шага }
146
- // SIDE_EFFECTS: none (при отсутствии atMs читает Date.now())
147
- // LINKS: M-AUTH, V-M-AUTH
148
- // END_CONTRACT: totpCode
149
- export function totpCode(secretBase32, atMs = Date.now()) {
150
- return hotp(base32Decode(secretBase32), totpStep(atMs));
151
- }
152
- // ---------------------------------------------------------------------------
153
80
  const accId = (a) => (typeof a === 'object' ? a.id : a);
154
- // START_CONTRACT: makeAuth
155
- // PURPOSE: Собрать AuthApi поверх Tables — все способы входа + выдача сессий.
156
- // INPUTS: { tables: Tables - доступ к таблицам Credential/Account }
157
- // OUTPUTS: { AuthApi - методы setPassword/verify*/issue*/enrollTotp/link/lookup/sessions }
158
- // SIDE_EFFECTS: none при сборке; методы читают/пишут строки Credential и читают Account через Tables
159
- // LINKS: M-AUTH, V-M-AUTH, M-TABLES, M-SESSIONS
160
- // END_CONTRACT: makeAuth
161
- export function makeAuth(tables) {
162
- /** Живой кред по глобально-уникальной паре (category, identifier). */
163
- const findLive = async (category, identifier) => (await tables.credentials.find({ category, identifier }))[0] ?? null;
164
- /** Общие ворота: confirmed-кред + enabled-аккаунт. */
165
- async function finalize(cred, opts) {
166
- if (!cred.confirmed && opts.requireConfirmed !== false)
167
- return null;
168
- const account = await tables.accounts.get(cred.account);
169
- if (!account?.enabled)
81
+ const ttlOf = (sec) => (sec === undefined ? null : `${Math.round(sec)} seconds`);
82
+ /** Фасад db.auth. onSwitch — подключение перечитывает арендатора, реестр и пропуска прав. */
83
+ export function makeAuth(S, exec, onSwitch, canSwitch) {
84
+ const api = makeAuthCore(S, exec, onSwitch, canSwitch);
85
+ return new Proxy(api, {
86
+ get(t, p, r) {
87
+ if (p === 'sessions')
88
+ throw removed('db.auth.sessions(store)', 'сессии хранятся в базе: sessionFor, refresh, revoke, revokeAll');
89
+ return Reflect.get(t, p, r);
90
+ },
91
+ });
92
+ }
93
+ function makeAuthCore(S, exec, onSwitch, canSwitch) {
94
+ const one = async (text, params = []) => {
95
+ const rows = await exec.run((q) => q.unsafe(text, params));
96
+ return rows[0];
97
+ };
98
+ // Вход после первой проверки. Код TOTP проверяет база той же функцией, что verifyTotp (право auth.totp,
99
+ // окно ±1 шаг, повтор шага, счётчик неудач и блокировка), секрет из базы не выходит; способ TOTP
100
+ // сессия получает только после удачи. Без кода — сессия без отметки TOTP, как раньше.
101
+ const login = async (account, ttl, methods, totp) => {
102
+ if (!account)
170
103
  return null;
171
- return { account, credential: cred };
172
- }
104
+ if (totp != null) {
105
+ const v = await one(`select ${S}.auth_verify_totp($1::uuid, $2::text, 1) as ok`, [account, String(totp)]);
106
+ if (!v.ok)
107
+ return null;
108
+ methods = [...methods, 'TOTP'];
109
+ }
110
+ const r = await one(`select ${S}.issue_session($1::uuid, coalesce($2::interval, interval '7 days'), $3::text[]) as t`, [account, ttlOf(ttl), methods]);
111
+ return { account, token: r.t };
112
+ };
173
113
  return {
174
- // START_CONTRACT: setPassword
175
- // PURPOSE: Upsert пароля (scrypt) для (account, category, identifier).
176
- // INPUTS: { a: { account: string|{id}, identifier: string, password: string, category?='PASSWORD', confirmed?: boolean } }
177
- // OUTPUTS: { Promise<Credential> - записанный кред }
178
- // SIDE_EFFECTS: пишет строку Credential (meta.password = scrypt-хэш) через Tables
179
- // LINKS: M-AUTH, V-M-AUTH, M-TABLES
180
- // END_CONTRACT: setPassword
181
114
  async setPassword(a) {
182
- return tables.credentials.set({
183
- account: accId(a.account),
184
- category: a.category ?? 'PASSWORD',
185
- identifier: a.identifier,
186
- meta: { password: await hashPassword(a.password) },
187
- confirmed: a.confirmed,
188
- });
115
+ const hash = await hashPassword(a.password);
116
+ 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]);
117
+ return r.id;
189
118
  },
190
- // START_CONTRACT: verifyPassword
191
- // PURPOSE: Проверить identifier+пароль; на miss — dummy-verify для выравнивания времени ответа.
192
- // INPUTS: { a: { identifier: string, password: string, category?='PASSWORD', requireConfirmed?: boolean } }
193
- // OUTPUTS: { Promise<AuthResult|null> - аккаунт+кред при успехе, иначе null }
194
- // SIDE_EFFECTS: читает Credential/Account; при отсутствии креда выполняет dummyVerify (scrypt) для timing-выравнивания
195
- // LINKS: M-AUTH, V-M-AUTH, M-TABLES
196
- // END_CONTRACT: verifyPassword
197
119
  async verifyPassword(a) {
198
- const cred = await findLive(a.category ?? 'PASSWORD', a.identifier);
199
- const stored = cred?.meta.password;
200
- if (!cred || typeof stored !== 'string') {
201
- await dummyVerify(a.password);
120
+ const rows = await exec.run((q) => q.unsafe(`select account, hash, confirmed from ${S}.auth_password_get($1, $2)`, [a.identifier, a.kind ?? 'PASSWORD']));
121
+ const cred = rows[0];
122
+ if (!cred)
123
+ return dummyVerify(a.password).then(() => null);
124
+ if (!(await verifyHash(a.password, cred.hash)))
202
125
  return null;
203
- }
204
- if (!(await verifyHash(a.password, stored)))
126
+ if (!cred.confirmed && a.requireConfirmed !== false)
205
127
  return null;
206
- return finalize(cred, a);
128
+ return login(cred.account, a.ttl, [a.kind ?? 'PASSWORD'], a.totp);
207
129
  },
208
- // START_BLOCK_APIKEY
209
- // START_CONTRACT: issueApiKey
210
- // PURPOSE: Выдать api-ключ `lts_<48hex>` (в БД — только sha256 + префикс).
211
- // INPUTS: { a: { account: string|{id}, name?: string } }
212
- // OUTPUTS: { Promise<{ key: string, credential: Credential }> - ключ отдаётся ОДИН раз }
213
- // SIDE_EFFECTS: пишет Credential (category='APIKEY', identifier=sha256(key), meta.prefix)
214
- // LINKS: M-AUTH, V-M-AUTH, M-TABLES
215
- // END_CONTRACT: issueApiKey
216
130
  async issueApiKey(a) {
217
- const key = `lts_${randomBytes(24).toString('hex')}`;
218
- const credential = await tables.credentials.set({
219
- account: accId(a.account),
220
- category: 'APIKEY',
221
- identifier: sha256(key),
222
- meta: { name: a.name ?? null, prefix: key.slice(0, 12) },
223
- });
224
- return { key, credential };
131
+ return one(`select key, id from ${S}.auth_issue_apikey($1::uuid, $2, $3::interval)`, [accId(a.account), a.name ?? null, ttlOf(a.ttl)]);
225
132
  },
226
- // START_CONTRACT: verifyApiKey
227
- // PURPOSE: Проверить api-ключ по sha256(key).
228
- // INPUTS: { key: string - api-ключ; opts?: { requireConfirmed?: boolean } }
229
- // OUTPUTS: { Promise<AuthResult|null> }
230
- // SIDE_EFFECTS: читает Credential/Account
231
- // LINKS: M-AUTH, V-M-AUTH, M-TABLES
232
- // END_CONTRACT: verifyApiKey
233
- async verifyApiKey(key, opts = {}) {
234
- const cred = await findLive('APIKEY', sha256(key));
235
- return cred ? finalize(cred, opts) : null;
133
+ async verifyApiKey(key) {
134
+ return (await one(`select ${S}.auth_login_apikey($1) as r`, [key])).r;
236
135
  },
237
136
  async issueKeySecret(a) {
238
- const key = randomBytes(8).toString('hex');
239
- const secret = randomBytes(24).toString('hex');
240
- const credential = await tables.credentials.set({
241
- account: accId(a.account),
242
- category: 'KEYSECRET',
243
- identifier: key,
244
- meta: { name: a.name ?? null, secret: sha256(secret) },
245
- });
246
- return { key, secret, credential };
137
+ return one(`select key, secret, id from ${S}.auth_issue_keysecret($1::uuid, $2)`, [accId(a.account), a.name ?? null]);
247
138
  },
248
- async verifyKeySecret(key, secret, opts = {}) {
249
- const cred = await findLive('KEYSECRET', key);
250
- const stored = cred?.meta.secret;
251
- if (!cred || typeof stored !== 'string' || !eqHex(sha256(secret), stored))
252
- return null;
253
- return finalize(cred, opts);
139
+ async verifyKeySecret(key, secret) {
140
+ return (await one(`select ${S}.login_keysecret($1, $2) as r`, [key, secret])).r;
254
141
  },
255
- // END_BLOCK_APIKEY
256
- // START_CONTRACT: enrollTotp
257
- // PURPOSE: Завести TOTP-фактор: случайный секрет + otpauth-URI для QR (неподтверждён до первой проверки).
258
- // INPUTS: { a: { account: string|{id}, issuer?: string, label?: string } }
259
- // OUTPUTS: { Promise<{ secret: string, uri: string, credential: Credential }> }
260
- // SIDE_EFFECTS: пишет/перезаписывает Credential (category='TOTP', identifier=id, confirmed=false, meta.secret/lastStep)
261
- // LINKS: M-AUTH, V-M-AUTH, M-TABLES
262
- // END_CONTRACT: enrollTotp
263
142
  async enrollTotp(a) {
264
- const id = accId(a.account);
265
- const secret = base32Encode(randomBytes(20));
266
- const label = encodeURIComponent(a.label ?? id);
143
+ const r = await one(`select ${S}.auth_enroll_totp($1::uuid) as s`, [accId(a.account)]);
144
+ const secret = base32Encode(r.s);
145
+ const label = encodeURIComponent(a.label ?? accId(a.account));
267
146
  const issuer = a.issuer ? `&issuer=${encodeURIComponent(a.issuer)}` : '';
268
- const uri = `otpauth://totp/${label}?secret=${secret}${issuer}` +
269
- `&algorithm=SHA1&digits=${TOTP_DIGITS}&period=${TOTP_PERIOD}`;
270
- // re-enroll перезаписывает секрет и сбрасывает подтверждение
271
- const credential = await tables.credentials.set({
272
- account: id,
273
- category: 'TOTP',
274
- identifier: id, // один TOTP-фактор на аккаунт
275
- meta: { secret, lastStep: 0 },
276
- confirmed: false,
277
- });
278
- return { secret, uri, credential };
147
+ return { secret, uri: `otpauth://totp/${label}?secret=${secret}${issuer}&algorithm=SHA1&digits=${TOTP_DIGITS}&period=${TOTP_PERIOD}` };
279
148
  },
280
- // START_CONTRACT: verifyTotp
281
- // PURPOSE: Проверить 6-значный код в окне ±window; replay использованного шага отбит; первая удача активирует фактор.
282
- // INPUTS: { a: { account: string|{id}, code: string, window?: number=1 } }
283
- // OUTPUTS: { Promise<boolean> - true при валидном коде }
284
- // SIDE_EFFECTS: при успехе пишет Credential (meta.lastStep=s, confirmed=true)
285
- // LINKS: M-AUTH, V-M-AUTH, M-TABLES
286
- // END_CONTRACT: verifyTotp
287
149
  async verifyTotp(a) {
288
- const id = accId(a.account);
289
- const cred = (await tables.credentials.find({ account: id, category: 'TOTP' }))[0];
290
- const secretB32 = cred?.meta.secret;
291
- if (!cred || typeof secretB32 !== 'string')
292
- return false;
293
- const secret = base32Decode(secretB32);
294
- const lastStep = typeof cred.meta.lastStep === 'number' ? cred.meta.lastStep : 0;
295
- const now = totpStep(Date.now());
296
- const window = a.window ?? 1;
297
- for (let s = now - window; s <= now + window; s++) {
298
- if (s <= lastStep)
299
- continue; // replay: шаг уже использован
300
- if (!eqStr(a.code, hotp(secret, s)))
301
- continue;
302
- await tables.credentials.set({
303
- account: id,
304
- category: 'TOTP',
305
- identifier: cred.identifier,
306
- meta: { ...cred.meta, lastStep: s },
307
- confirmed: true, // первая успешная проверка активирует фактор
308
- });
309
- return true;
310
- }
311
- return false;
150
+ return (await one(`select ${S}.auth_verify_totp($1::uuid, $2, $3) as ok`, [accId(a.account), a.code, a.window ?? 1])).ok;
312
151
  },
313
152
  async totpEnabled(account) {
314
- const cred = (await tables.credentials.find({ account: accId(account), category: 'TOTP' }))[0];
315
- return cred?.confirmed === true;
153
+ return (await one(`select ${S}.auth_totp_enabled($1::uuid) as ok`, [accId(account)])).ok;
316
154
  },
317
- // START_BLOCK_OTP
318
- // START_CONTRACT: issueOtp
319
- // PURPOSE: Выдать одноразовый код (в БД sha256 + TTL + счётчик попыток); доставка — на приложении.
320
- // INPUTS: { a: { account: string|{id}, identifier: string, category?='OTP', ttlSec?=600, digits?=6 } }
321
- // OUTPUTS: { Promise<{ code: string, credential: Credential }> - код отдаётся один раз }
322
- // SIDE_EFFECTS: пишет/перезаписывает Credential (meta.code=sha256, expires, attempts=0)
323
- // LINKS: M-AUTH, V-M-AUTH, M-TABLES
324
- // END_CONTRACT: issueOtp
325
155
  async issueOtp(a) {
326
- const digits = a.digits ?? 6;
327
- const code = String(randomInt(0, 10 ** digits)).padStart(digits, '0');
328
- // повторный issue на тот же identifier перезаписывает код (валиден только последний)
329
- const credential = await tables.credentials.set({
330
- account: accId(a.account),
331
- category: a.category ?? 'OTP',
332
- identifier: a.identifier,
333
- meta: {
334
- code: sha256(code),
335
- expires: new Date(Date.now() + (a.ttlSec ?? 600) * 1000).toISOString(),
336
- attempts: 0,
337
- },
338
- });
339
- return { code, credential };
156
+ const r = await 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), a.digits ?? 6, a.maxAttempts ?? 5]);
157
+ return { code: r.c };
340
158
  },
341
- // START_CONTRACT: verifyOtp
342
- // PURPOSE: Проверить одноразовый код; успех и перебор попыток (maxAttempts) сжигают код.
343
- // INPUTS: { a: { identifier: string, code: string, category?='OTP', maxAttempts?=5 } }
344
- // OUTPUTS: { Promise<AuthResult|null> }
345
- // SIDE_EFFECTS: читает Credential; удаляет кред при успехе/просрочке/переборе, иначе инкрементит meta.attempts
346
- // LINKS: M-AUTH, V-M-AUTH, M-TABLES
347
- // END_CONTRACT: verifyOtp
348
159
  async verifyOtp(a) {
349
- const cred = await findLive(a.category ?? 'OTP', a.identifier);
350
- if (!cred)
351
- return null;
352
- const { code, expires, attempts } = cred.meta;
353
- if (typeof code !== 'string' || !expires || Date.parse(expires) < Date.now()) {
354
- await tables.credentials.delete(cred.id);
355
- return null;
356
- }
357
- if (!eqHex(sha256(a.code), code)) {
358
- const n = (attempts ?? 0) + 1;
359
- if (n >= (a.maxAttempts ?? 5))
360
- await tables.credentials.delete(cred.id); // перебор — код сожжён
361
- else
362
- await tables.credentials.set({
363
- account: cred.account,
364
- category: cred.category,
365
- identifier: cred.identifier,
366
- meta: { ...cred.meta, attempts: n },
367
- });
368
- return null;
369
- }
370
- await tables.credentials.delete(cred.id); // одноразовость: успех сжигает код
371
- return finalize(cred, {});
160
+ return (await one(`select ${S}.login_otp($1, $2, $3) as r`, [a.identifier, a.code, a.kind ?? 'OTP'])).r;
372
161
  },
373
- // END_BLOCK_OTP
374
162
  async link(a) {
375
- return tables.credentials.set({
376
- account: accId(a.account),
377
- category: a.category,
378
- identifier: a.identifier,
379
- meta: a.meta ?? {},
380
- confirmed: a.confirmed,
381
- });
163
+ 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;
382
164
  },
383
165
  async lookup(a) {
384
- const cred = await findLive(a.category, a.identifier);
385
- return cred ? finalize(cred, a) : null;
166
+ const r = await one(`select ${S}.auth_lookup($1, $2, $3) as a`, [a.kind, a.identifier, a.requireConfirmed ?? true]);
167
+ return login(r.a, a.ttl, [a.kind], a.totp);
168
+ },
169
+ async credentials(account) {
170
+ return exec.run((q) => q.unsafe(`select * from ${S}.auth_credentials($1::uuid)`, [accId(account)]));
171
+ },
172
+ async revokeCredential(id, opts) {
173
+ return (await one(`select ${S}.auth_revoke_credential($1::uuid, $2) as ok`, [id, opts?.keepCurrent ?? true])).ok;
174
+ },
175
+ async setFlags(id, flags) {
176
+ return (await one(`select ${S}.auth_set_flags($1::uuid, $2, $3) as ok`, [id, flags.confirmed ?? null, flags.enabled ?? null])).ok;
177
+ },
178
+ async sessionFor(account, opts) {
179
+ return (await login(accId(account), opts?.ttl, ['SERVICE'])).token;
180
+ },
181
+ async refresh(opts) {
182
+ return (await one(`select ${S}.auth_refresh(coalesce($1::interval, interval '7 days')) as e`, [ttlOf(opts?.ttl)])).e;
183
+ },
184
+ async revoke() {
185
+ return (await one(`select ${S}.auth_logout() as ok`)).ok;
186
+ },
187
+ async revokeAll(account) {
188
+ return (await one(`select ${S}.auth_logout_all($1::uuid) as n`, [account === undefined ? null : accId(account)])).n;
189
+ },
190
+ async switch(tenant) {
191
+ if (!canSwitch)
192
+ throw new LetopisError('invalid_query', 'auth.switch() — от корня подключения: при имперсонации арендатора задаёт db.as(…, { tenant })');
193
+ await one(`select ${S}.auth_switch($1::uuid)`, [tenant]);
194
+ await onSwitch();
195
+ },
196
+ async tenants() {
197
+ const rows = await exec.run((q) => q.unsafe(`select tenant, roles from ${S}.auth_tenants()`));
198
+ return rows.map((r) => ({ tenant: r.tenant, roles: r.roles }));
199
+ },
200
+ async purgeAccount(account) {
201
+ return (await one(`select ${S}.account_purge($1::uuid) as r`, [accId(account)])).r;
386
202
  },
387
- sessions: makeSessions,
388
203
  };
389
204
  }
@@ -0,0 +1,73 @@
1
+ /** Классы, строки которых меняют права и схему: их сигнал сбрасывает весь кэш. */
2
+ export declare const ACL_CLASSES: ReadonlySet<string>;
3
+ export interface CacheOptions {
4
+ /** Срок ответа по умолчанию, секунды (60). */
5
+ ttl?: number;
6
+ /** Работать без слушателя сигналов — только по сроку (пул в режиме транзакций, §2.12). */
7
+ ttlOnly?: boolean;
8
+ /** Не больше ответов в памяти (10 000): старые вытесняются. */
9
+ max?: number;
10
+ }
11
+ export interface CacheStats {
12
+ size: number;
13
+ hits: number;
14
+ misses: number;
15
+ stores: number;
16
+ /** Ответ не положен: за время запроса пришёл сигнал о его классах или сессии. */
17
+ raced: number;
18
+ /** Получено сигналов. */
19
+ signals: number;
20
+ /** (Пере)подключений слушателя. */
21
+ listens: number;
22
+ /** Кэш работает: слушатель сигналов на месте (или ttlOnly). */
23
+ enabled: boolean;
24
+ }
25
+ /** Снимок счётчиков до запроса: ответ кладётся, только если они не изменились. */
26
+ export interface CacheToken {
27
+ epoch: number;
28
+ classes: [string, number][];
29
+ session: number;
30
+ }
31
+ /** Начало sha256 токена — так база называет сессию в сигнале session:<16 hex>. */
32
+ export declare const sessionTag: (token: string | null) => string;
33
+ export declare class ResultCache {
34
+ private map;
35
+ private classEpoch;
36
+ private sessionEpoch;
37
+ private epoch;
38
+ private listening;
39
+ private st;
40
+ readonly ttlMs: number;
41
+ readonly ttlOnly: boolean;
42
+ private max;
43
+ constructor(opts?: CacheOptions);
44
+ /** Кэш работает: слушатель на месте или режим только по сроку. */
45
+ get enabled(): boolean;
46
+ /** Ключ ответа: запрос, параметры и тот, кто спрашивает. */
47
+ key(text: string, params: unknown[], who: {
48
+ token: string | null;
49
+ account?: string;
50
+ tenant?: string | null;
51
+ }): string;
52
+ /** Снимок счётчиков классов и сессии — до запроса. */
53
+ token(classes: readonly string[], session: string): CacheToken;
54
+ get(key: string): unknown[] | undefined;
55
+ /** Положить ответ, если с момента снимка не было сигналов о его классах и сессии. */
56
+ set(key: string, value: unknown[], classes: readonly string[], session: string, tok: CacheToken, ttlMs: number, sessionExpires: number | null): boolean;
57
+ /** Сбросить ответы, читавшие эти классы. */
58
+ invalidate(classes: Iterable<string>): void;
59
+ /** Сбросить ответы сессии (тег — начало sha256 токена). */
60
+ invalidateSession(tag: string): void;
61
+ /** Весь кэш. */
62
+ clear(): void;
63
+ /**
64
+ * Имена из сигнала или из letopis.changed своей транзакции: классы, `*`, `session:<тег>`,
65
+ * `session:*`. raw — запись через db.sql: весь кэш.
66
+ */
67
+ changed(names: Iterable<string>, raw?: boolean): void;
68
+ /** Сигнал от базы. */
69
+ signal(name: string): void;
70
+ /** Слушатель (пере)подключился: сигналы за время обрыва потеряны — весь кэш. */
71
+ listen(): void;
72
+ stats(): CacheStats;
73
+ }