letopis 0.16.0 → 0.18.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/dist/acl.js CHANGED
@@ -1,5 +1,12 @@
1
1
  export const ACL_OPS = ['READ', 'WRITE', 'DELETE'];
2
2
  // --- матчеры -----------------------------------------------------------------
3
+ // START_CONTRACT: matchCategories
4
+ // PURPOSE: Проверить шаблон pattern.categories ("{A,B}" | "!{A,B}" | NULL) против категорий аккаунта.
5
+ // INPUTS: { pattern: object|null - шаблон ресурса ACCOUNT; cats: string[] - категории аккаунта }
6
+ // OUTPUTS: { boolean - подходит ли субъект }
7
+ // SIDE_EFFECTS: none
8
+ // LINKS: M-ACL, V-M-ACL
9
+ // END_CONTRACT: matchCategories
3
10
  /** pattern.categories: "{A,B}" | "!{A,B}" | отсутствие/NULL (матч всех). */
4
11
  export function matchCategories(pattern, cats) {
5
12
  const spec = pattern?.categories;
@@ -16,8 +23,16 @@ export function matchCategories(pattern, cats) {
16
23
  const hit = list.some((c) => cats.includes(c));
17
24
  return neg ? !hit : hit;
18
25
  }
26
+ // START_CONTRACT: maskBody
27
+ // PURPOSE: Скомпилировать тело маски в regex-фрагмент: '{a,b}' → альтернативы (рекурсивно), '*' → хвост из 1+ сегментов.
28
+ // INPUTS: { mask: string - маска с '.'-сегментами }
29
+ // OUTPUTS: { string - regex-исходник без якорей }
30
+ // SIDE_EFFECTS: none
31
+ // LINKS: M-ACL, V-M-ACL
32
+ // END_CONTRACT: maskBody
19
33
  /** Маска → regex: '{a,b}' — альтернативы (рекурсивно), '*' — хвост из 1+ сегментов. */
20
34
  function maskBody(mask) {
35
+ // START_BLOCK_MASK_COMPILE
21
36
  let out = '';
22
37
  for (let i = 0; i < mask.length; i++) {
23
38
  const ch = mask[i];
@@ -58,9 +73,17 @@ function maskBody(mask) {
58
73
  out += ch;
59
74
  }
60
75
  }
76
+ // END_BLOCK_MASK_COMPILE
61
77
  return out;
62
78
  }
63
79
  const regexCache = new Map();
80
+ // START_CONTRACT: matchMask
81
+ // PURPOSE: Сопоставить значение с маской, кэшируя скомпилированный RegExp.
82
+ // INPUTS: { mask: string; value: string }
83
+ // OUTPUTS: { boolean }
84
+ // SIDE_EFFECTS: заполняет regexCache
85
+ // LINKS: M-ACL, V-M-ACL
86
+ // END_CONTRACT: matchMask
64
87
  export function matchMask(mask, value) {
65
88
  let re = regexCache.get(mask);
66
89
  if (!re) {
@@ -123,8 +146,16 @@ function rowFilter(r, account) {
123
146
  return undefined;
124
147
  return Object.fromEntries(rest.map(([k, v]) => [k, substitute(v, account)]));
125
148
  }
149
+ // START_CONTRACT: decide
150
+ // PURPOSE: Выбрать РОВНО ОДНО победившее правило: max weight; при равенстве deny бьёт allow, равные allow — по alias ресурса.
151
+ // INPUTS: { rules: Rule[]; subjects: Set<string> - alias'ы субъекта; objects: Map<string,Resource> - применимые ресурсы }
152
+ // OUTPUTS: { { winner?: Rule } }
153
+ // SIDE_EFFECTS: none
154
+ // LINKS: M-ACL, V-M-ACL
155
+ // END_CONTRACT: decide
126
156
  /** Победитель: max weight; равный вес — deny бьёт allow, равные allow — по alias объекта. */
127
157
  function decide(rules, subjects, objects) {
158
+ // START_BLOCK_DECIDE_WINNER
128
159
  let winner;
129
160
  for (const rule of rules) {
130
161
  if (!rule.enabled || !subjects.has(rule.account) || !objects.has(rule.resource))
@@ -141,8 +172,16 @@ function decide(rules, subjects, objects) {
141
172
  winner = rule;
142
173
  }
143
174
  }
175
+ // END_BLOCK_DECIDE_WINNER
144
176
  return { winner };
145
177
  }
178
+ // START_CONTRACT: toDecision
179
+ // PURPOSE: Превратить победителя в AclDecision (allow + остаточный row-фильтр | deny + code/message | deny-by-default).
180
+ // INPUTS: { winner?: Rule; objects: Map<string,Resource>; account: string }
181
+ // OUTPUTS: { AclDecision }
182
+ // SIDE_EFFECTS: none
183
+ // LINKS: M-ACL, V-M-ACL, type-AclDecision
184
+ // END_CONTRACT: toDecision
146
185
  function toDecision(winner, objects, account) {
147
186
  if (!winner)
148
187
  return { allow: false, message: 'no matching rule (deny by default)' };
@@ -158,6 +197,13 @@ function toDecision(winner, objects, account) {
158
197
  const subjectAliases = (resources, cats) => new Set(resources.filter((r) => r.category === 'ACCOUNT' && matchCategories(r.pattern, cats)).map((r) => r.alias));
159
198
  const toMap = (rs) => new Map(rs.map((r) => [r.alias, r]));
160
199
  const accId = (a) => (typeof a === 'object' ? a.id : a);
200
+ // START_CONTRACT: makeAcl
201
+ // PURPOSE: Построить рантайм-фасад AclApi (check по эндпоинту / checkData по классу+op / reload) с кэшом Resource/Rule.
202
+ // INPUTS: { tables: Tables; registry: Registry }
203
+ // OUTPUTS: { AclApi }
204
+ // SIDE_EFFECTS: лениво читает resources/rules через tables (кэш до reload())
205
+ // LINKS: M-ACL, V-M-ACL
206
+ // END_CONTRACT: makeAcl
161
207
  export function makeAcl(tables, registry) {
162
208
  let cache = null;
163
209
  const load = () => (cache ??= (async () => ({
@@ -185,8 +231,16 @@ export function makeAcl(tables, registry) {
185
231
  };
186
232
  }
187
233
  // --- enforce-компилятор (connect({enforceAcl: true})) ----------------------------
234
+ // START_CONTRACT: compileEnforcer
235
+ // PURPOSE: Скомпилировать синхронный энфорсер (ClassDef, op) → AclDecision с мемоизацией — субъект и правила зафиксированы на connect.
236
+ // INPUTS: { src: AclSource; account: string; cats: string[]; registry: Registry }
237
+ // OUTPUTS: { (cls: ClassDef, op: AclOp) => AclDecision }
238
+ // SIDE_EFFECTS: none (мемо-кэш решений)
239
+ // LINKS: M-ACL, V-M-ACL, M-SQL, M-WRITE
240
+ // END_CONTRACT: compileEnforcer
188
241
  /** Синхронный резолвер для цепочек: категории субъекта и правила зафиксированы на connect. */
189
242
  export function compileEnforcer(src, account, cats, registry) {
243
+ // START_BLOCK_ENFORCER_MEMO
190
244
  const subjects = subjectAliases(src.resources, cats);
191
245
  const memo = new Map();
192
246
  return (cls, op) => {
@@ -202,6 +256,14 @@ export function compileEnforcer(src, account, cats, registry) {
202
256
  return d;
203
257
  };
204
258
  }
259
+ // END_BLOCK_ENFORCER_MEMO
260
+ // START_CONTRACT: aclDenied
261
+ // PURPOSE: Собрать читаемую Error отказа ACL для цепочек (её бросают вызывающие sql/write).
262
+ // INPUTS: { op: AclOp; clsId: string; d: AclDecision }
263
+ // OUTPUTS: { Error }
264
+ // SIDE_EFFECTS: none (возвращает Error, не бросает)
265
+ // LINKS: M-ACL, V-M-ACL
266
+ // END_CONTRACT: aclDenied
205
267
  /** Читаемая ошибка отказа для цепочек. */
206
268
  export function aclDenied(op, clsId, d) {
207
269
  return new Error(`letopis: acl denies ${op} on ${clsId}${d.message ? ` — ${d.message}` : ''}`);
package/dist/auth.d.ts CHANGED
@@ -1,3 +1,10 @@
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 — на приложении: здесь хранение и проверка.
7
+ */
1
8
  import type { Account, Credential } from './types.js';
2
9
  import type { Tables } from './tables.js';
3
10
  import { type SessionStore, type Sessions } from './sessions.js';
package/dist/auth.js CHANGED
@@ -5,6 +5,27 @@
5
5
  * Однозначность входа даёт credential_identity_udx: один живой кред на (category, identifier).
6
6
  * OAuth-танец, rate-limit, lockout — на приложении: здесь хранение и проверка.
7
7
  */
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
8
29
  import { createHash, createHmac, randomBytes, randomInt, scrypt, timingSafeEqual } from 'node:crypto';
9
30
  import { promisify } from 'node:util';
10
31
  import { makeSessions } from './sessions.js';
@@ -13,11 +34,26 @@ const scryptAsync = promisify(scrypt);
13
34
  const SCRYPT = { N: 32768, r: 8, p: 1, maxmem: 64 * 1024 * 1024 };
14
35
  const KEYLEN = 32;
15
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
16
45
  async function hashPassword(password) {
17
46
  const salt = randomBytes(16);
18
47
  const hash = await scryptAsync(password, salt, KEYLEN, SCRYPT);
19
48
  return `scrypt$${SCRYPT.N}$${SCRYPT.r}$${SCRYPT.p}$${salt.toString('base64')}$${hash.toString('base64')}`;
20
49
  }
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
21
57
  async function verifyHash(password, stored) {
22
58
  const [algo, N, r, p, saltB64, hashB64] = stored.split('$');
23
59
  if (algo !== 'scrypt')
@@ -30,6 +66,7 @@ async function verifyHash(password, stored) {
30
66
  }
31
67
  /** Выравнивание времени ответа: «identifier не существует» не быстрее «пароль неверен». */
32
68
  const dummyVerify = (password) => scryptAsync(password, Buffer.alloc(16), KEYLEN, SCRYPT).then(() => false);
69
+ // END_BLOCK_SCRYPT_HASH
33
70
  const eqHex = (a, b) => {
34
71
  const ba = Buffer.from(a, 'hex');
35
72
  const bb = Buffer.from(b, 'hex');
@@ -41,6 +78,7 @@ const eqStr = (a, b) => {
41
78
  return ba.length === bb.length && timingSafeEqual(ba, bb);
42
79
  };
43
80
  // --- TOTP (RFC 6238 / HOTP RFC 4226) ---------------------------------------
81
+ // START_BLOCK_TOTP
44
82
  const B32 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
45
83
  function base32Encode(buf) {
46
84
  let bits = 0;
@@ -58,6 +96,13 @@ function base32Encode(buf) {
58
96
  out += B32[(value << (5 - bits)) & 31];
59
97
  return out;
60
98
  }
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
61
106
  function base32Decode(s) {
62
107
  let bits = 0;
63
108
  let value = 0;
@@ -77,6 +122,13 @@ function base32Decode(s) {
77
122
  }
78
123
  const TOTP_PERIOD = 30; // секунд на шаг (стандарт authenticator-приложений)
79
124
  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
80
132
  function hotp(secret, counter) {
81
133
  const buf = Buffer.alloc(8);
82
134
  buf.writeBigUInt64BE(BigInt(counter));
@@ -87,11 +139,25 @@ function hotp(secret, counter) {
87
139
  }
88
140
  const totpStep = (atMs) => Math.floor(atMs / 1000 / TOTP_PERIOD);
89
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
90
149
  export function totpCode(secretBase32, atMs = Date.now()) {
91
150
  return hotp(base32Decode(secretBase32), totpStep(atMs));
92
151
  }
93
152
  // ---------------------------------------------------------------------------
94
153
  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
95
161
  export function makeAuth(tables) {
96
162
  /** Живой кред по глобально-уникальной паре (category, identifier). */
97
163
  const findLive = async (category, identifier) => (await tables.credentials.find({ category, identifier }))[0] ?? null;
@@ -105,6 +171,13 @@ export function makeAuth(tables) {
105
171
  return { account, credential: cred };
106
172
  }
107
173
  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
108
181
  async setPassword(a) {
109
182
  return tables.credentials.set({
110
183
  account: accId(a.account),
@@ -114,6 +187,13 @@ export function makeAuth(tables) {
114
187
  confirmed: a.confirmed,
115
188
  });
116
189
  },
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
117
197
  async verifyPassword(a) {
118
198
  const cred = await findLive(a.category ?? 'PASSWORD', a.identifier);
119
199
  const stored = cred?.meta.password;
@@ -125,6 +205,14 @@ export function makeAuth(tables) {
125
205
  return null;
126
206
  return finalize(cred, a);
127
207
  },
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
128
216
  async issueApiKey(a) {
129
217
  const key = `lts_${randomBytes(24).toString('hex')}`;
130
218
  const credential = await tables.credentials.set({
@@ -135,6 +223,13 @@ export function makeAuth(tables) {
135
223
  });
136
224
  return { key, credential };
137
225
  },
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
138
233
  async verifyApiKey(key, opts = {}) {
139
234
  const cred = await findLive('APIKEY', sha256(key));
140
235
  return cred ? finalize(cred, opts) : null;
@@ -157,6 +252,14 @@ export function makeAuth(tables) {
157
252
  return null;
158
253
  return finalize(cred, opts);
159
254
  },
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
160
263
  async enrollTotp(a) {
161
264
  const id = accId(a.account);
162
265
  const secret = base32Encode(randomBytes(20));
@@ -174,6 +277,13 @@ export function makeAuth(tables) {
174
277
  });
175
278
  return { secret, uri, credential };
176
279
  },
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
177
287
  async verifyTotp(a) {
178
288
  const id = accId(a.account);
179
289
  const cred = (await tables.credentials.find({ account: id, category: 'TOTP' }))[0];
@@ -204,6 +314,14 @@ export function makeAuth(tables) {
204
314
  const cred = (await tables.credentials.find({ account: accId(account), category: 'TOTP' }))[0];
205
315
  return cred?.confirmed === true;
206
316
  },
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
207
325
  async issueOtp(a) {
208
326
  const digits = a.digits ?? 6;
209
327
  const code = String(randomInt(0, 10 ** digits)).padStart(digits, '0');
@@ -220,6 +338,13 @@ export function makeAuth(tables) {
220
338
  });
221
339
  return { code, credential };
222
340
  },
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
223
348
  async verifyOtp(a) {
224
349
  const cred = await findLive(a.category ?? 'OTP', a.identifier);
225
350
  if (!cred)
@@ -245,6 +370,7 @@ export function makeAuth(tables) {
245
370
  await tables.credentials.delete(cred.id); // одноразовость: успех сжигает код
246
371
  return finalize(cred, {});
247
372
  },
373
+ // END_BLOCK_OTP
248
374
  async link(a) {
249
375
  return tables.credentials.set({
250
376
  account: accId(a.account),