letopis 0.13.0 → 0.18.0
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/CHANGELOG.md +203 -3
- package/README.md +825 -602
- package/dist/acl.js +62 -0
- package/dist/auth.d.ts +7 -0
- package/dist/auth.js +126 -0
- package/dist/chain.d.ts +44 -16
- package/dist/chain.js +309 -26
- package/dist/index.d.ts +7 -0
- package/dist/index.js +38 -0
- package/dist/ops.js +45 -0
- package/dist/schema.js +169 -4
- package/dist/sessions.js +66 -0
- package/dist/sql.d.ts +23 -3
- package/dist/sql.js +161 -25
- package/dist/tables.js +37 -0
- package/dist/tx.js +36 -0
- package/dist/types.d.ts +40 -4
- package/dist/types.js +46 -0
- package/dist/up.js +83 -0
- package/dist/uuid.d.ts +6 -0
- package/dist/uuid.js +68 -0
- package/dist/write.d.ts +12 -5
- package/dist/write.js +359 -95
- package/docker/Dockerfile +23 -0
- package/docker/start.sh +14 -0
- package/package.json +1 -1
- package/sql/ddl.sql +124 -12
- package/sql/seed.auth.sql +27 -0
- package/sql/seed.booking.sql +64 -17
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),
|
package/dist/chain.d.ts
CHANGED
|
@@ -4,8 +4,9 @@
|
|
|
4
4
|
*
|
|
5
5
|
* Выборка настраивается модификаторами: .limit(n) .offset(n) .sort(field, dir?)
|
|
6
6
|
* Колонки — модификаторами: .tags(…) .account(…) .owner(…); ключ путей — .alias(…).
|
|
7
|
-
* Запись: .
|
|
8
|
-
* цепочку, продолжение — от результата); исполняет ТЕРМИНАЛ, весь план
|
|
7
|
+
* Запись: .create(data) / .update(data) / .delete({confirm}) / .anonymize(fields) — ЗВЕНЬЯ
|
|
8
|
+
* (возвращают цепочку, продолжение — от результата); исполняет ТЕРМИНАЛ, весь план
|
|
9
|
+
* одной транзакцией.
|
|
9
10
|
*/
|
|
10
11
|
import type { Row, Path, Filter } from './types.js';
|
|
11
12
|
import { type Ctx } from './sql.js';
|
|
@@ -49,31 +50,37 @@ export interface ChainCore {
|
|
|
49
50
|
anonymize(fields: string[]): Chain;
|
|
50
51
|
/** Переименовать ключ ТЕКУЩЕГО шага в выводе путей. */
|
|
51
52
|
alias(name: string): Chain;
|
|
52
|
-
/** Колонка tags: строка | string[] (все) | has/hasAny/hasAll. В
|
|
53
|
+
/** Колонка tags: строка | string[] (все) | has/hasAny/hasAll. В записи — значение тегов. */
|
|
53
54
|
tags(v: string | string[] | object): Chain;
|
|
54
|
-
/** Колонка account (uuid или Row/Account). В
|
|
55
|
+
/** Колонка account (uuid или Row/Account). В create() — значение. */
|
|
55
56
|
account(v: string | {
|
|
56
57
|
id: string;
|
|
57
58
|
}): Chain;
|
|
58
|
-
/** Колонка owner (uuid или Row/Account). В
|
|
59
|
+
/** Колонка owner (uuid или Row/Account). В create() — значение. */
|
|
59
60
|
owner(v: string | {
|
|
60
61
|
id: string;
|
|
61
62
|
}): Chain;
|
|
62
63
|
/**
|
|
63
|
-
*
|
|
64
|
-
*
|
|
64
|
+
* Вставить узел в путь: x — ленивая цепочка-паттерн (стык проверяется правилами
|
|
65
|
+
* переходов) либо Row (шаг его класса по id). ТА ЖЕ переменная-цепочка повторно
|
|
66
|
+
* в одном пути — возврат к её узлу (ветвление); разные переменные — разные узлы.
|
|
65
67
|
*/
|
|
66
|
-
|
|
67
|
-
id: string;
|
|
68
|
-
}): Chain;
|
|
68
|
+
entity(x: Chain | Row): Chain;
|
|
69
69
|
/**
|
|
70
|
-
*
|
|
71
|
-
* await db.Организация(org).Сотрудник().
|
|
72
|
-
* Связи — контекст-шагами до
|
|
73
|
-
* id
|
|
70
|
+
* Создать сущность (ЗВЕНО — вернёт цепочку, исполняет терминал):
|
|
71
|
+
* await db.Организация(org).Сотрудник().create({ name: 'Вася' }).rows()
|
|
72
|
+
* Связи — контекст-шагами до create() + слотами .Класс.set(…) после. id — по Schema
|
|
73
|
+
* (attributes.id: v4 | v7 | v5-вычисляемый); известный id (Класс(id) / data.id / v5)
|
|
74
|
+
* уже существует → новая версия (идемпотентно). Фильтр-объект — ошибка: это update().
|
|
75
|
+
*/
|
|
76
|
+
create(data?: Record<string, unknown>): Chain;
|
|
77
|
+
/**
|
|
78
|
+
* Новая версия КАЖДОГО найденного путём (deep-merge листьев data):
|
|
79
|
+
* await db.Запись(з).позиция({ qty: 1 }).update({ qty: 2 }).rows()
|
|
80
|
+
* Класс() ≡ Класс({}) — все в границах контекста. Не найдено → [] (НИКОГДА не создаёт).
|
|
74
81
|
* Продолжение цепочки — от записанных строк (fan-out при множестве).
|
|
75
82
|
*/
|
|
76
|
-
|
|
83
|
+
update(data?: Record<string, unknown>): Chain;
|
|
77
84
|
/**
|
|
78
85
|
* Удаление-звено. { confirm: true } — серверный tombstone + каскад (продолжение — от
|
|
79
86
|
* затомбстоуненных, $deleted: true); БЕЗ confirm — превью: терминал вернёт кандидатов
|
|
@@ -83,8 +90,25 @@ export interface ChainCore {
|
|
|
83
90
|
confirm?: boolean;
|
|
84
91
|
}): Chain;
|
|
85
92
|
}
|
|
93
|
+
/**
|
|
94
|
+
* Свойство-класс на цепочке:
|
|
95
|
+
* ВЫЗОВ `Класс(фильтр?)` — шаг-навигация; повтор LINK-класса в пути = возврат
|
|
96
|
+
* к его узлу (pivot: ветвление к другому концу, дофильтровка AND);
|
|
97
|
+
* СЛОТ `Класс` без скобок — конец links записываемой версии:
|
|
98
|
+
* .set(target) — установить конец (id | Row | вложенная цепочка: та же транзакция,
|
|
99
|
+
* ровно одна сущность класса конца); союз-конец замещается целиком;
|
|
100
|
+
* .unset() — снять optional-конец.
|
|
101
|
+
* Слот валиден только для конца из Schema.links владельца и ТОЛЬКО после операции
|
|
102
|
+
* записи: …create(…).Класс.set(x) / …update(…).Класс.unset() — та же версия строки.
|
|
103
|
+
*/
|
|
104
|
+
export type StepProp = ((filter?: Filter) => Chain) & {
|
|
105
|
+
set(target: string | {
|
|
106
|
+
id: string;
|
|
107
|
+
} | Chain): Chain;
|
|
108
|
+
unset(): Chain;
|
|
109
|
+
};
|
|
86
110
|
export type Chain = ChainCore & {
|
|
87
|
-
[className: string]:
|
|
111
|
+
[className: string]: StepProp;
|
|
88
112
|
};
|
|
89
113
|
export interface DbCore {
|
|
90
114
|
/** Открыть транзакцию: tr — тот же API + commit/rollback/lock. */
|
|
@@ -114,6 +138,8 @@ export interface DbCore {
|
|
|
114
138
|
auth: AuthApi;
|
|
115
139
|
/** ACL по Resource/Rule: check(эндпоинт) / checkData(класс, READ|WRITE|DELETE) / reload. */
|
|
116
140
|
acl: AclApi;
|
|
141
|
+
/** Начать путь с готового узла/паттерна: db.entity(pos).… (pos — ленивая цепочка или Row). */
|
|
142
|
+
entity(x: Chain | Row): Chain;
|
|
117
143
|
}
|
|
118
144
|
export type EntityDb = DbCore & {
|
|
119
145
|
[className: string]: (filter?: Filter) => Chain;
|
|
@@ -147,5 +173,7 @@ export type Batch = {
|
|
|
147
173
|
interface RootState {
|
|
148
174
|
batches: Map<string, BatchPlan[]>;
|
|
149
175
|
}
|
|
176
|
+
/** Внутренний канал: план чужой ленивой цепочки (слот-значения, entity()). */
|
|
177
|
+
export declare const PLAN: unique symbol;
|
|
150
178
|
export declare function makeDb(ctx: Ctx, root?: RootState): EntityDb;
|
|
151
179
|
export {};
|