letopis 0.20.3 → 1.0.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/AGENT-CHEATSHEET.en.md +368 -0
- package/AGENT-CHEATSHEET.md +354 -0
- package/CHANGELOG.md +348 -0
- package/MIGRATION.md +190 -0
- package/README.en.md +1937 -0
- package/README.md +1493 -3466
- package/dist/acl.d.ts +26 -50
- package/dist/acl.js +22 -267
- package/dist/admin.d.ts +138 -0
- package/dist/admin.js +170 -0
- package/dist/auth.d.ts +120 -73
- package/dist/auth.js +121 -306
- package/dist/cache.d.ts +73 -0
- package/dist/cache.js +148 -0
- package/dist/chain.d.ts +124 -191
- package/dist/chain.js +369 -551
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +164 -0
- package/dist/demo/booking.d.ts +289 -0
- package/dist/demo/booking.js +159 -0
- package/dist/errors.d.ts +29 -0
- package/dist/errors.js +70 -0
- package/dist/import.d.ts +179 -0
- package/dist/import.js +792 -0
- package/dist/index.d.ts +172 -26
- package/dist/index.js +304 -178
- package/dist/jsonschema.d.ts +22 -0
- package/dist/jsonschema.js +167 -0
- package/dist/load.d.ts +76 -0
- package/dist/load.js +884 -0
- package/dist/model.d.ts +166 -0
- package/dist/model.js +224 -0
- package/dist/ops.d.ts +7 -6
- package/dist/ops.js +7 -51
- package/dist/pglite.d.ts +22 -0
- package/dist/pglite.js +45 -0
- package/dist/registry.d.ts +57 -0
- package/dist/registry.js +82 -0
- package/dist/sql.d.ts +59 -142
- package/dist/sql.js +568 -654
- package/dist/sync.d.ts +31 -0
- package/dist/sync.js +108 -0
- package/dist/tx.d.ts +129 -8
- package/dist/tx.js +300 -73
- package/dist/typed.d.ts +97 -0
- package/dist/typed.js +1 -0
- package/dist/types.d.ts +71 -250
- package/dist/types.js +27 -108
- package/dist/up.d.ts +140 -47
- package/dist/up.js +339 -267
- package/dist/uuid.d.ts +21 -6
- package/dist/uuid.js +48 -64
- package/dist/validate.d.ts +24 -0
- package/dist/validate.js +251 -0
- package/dist/watch.d.ts +62 -0
- package/dist/watch.js +168 -0
- package/dist/write.d.ts +117 -74
- package/dist/write.js +658 -720
- package/llms.txt +26 -0
- package/package.json +49 -19
- package/sql/10-core.sql +136 -0
- package/sql/15-errors.sql +60 -0
- package/sql/20-context.sql +153 -0
- package/sql/30-validate.sql +423 -0
- package/sql/40-class.sql +259 -0
- package/sql/50-acl.sql +539 -0
- package/sql/60-write.sql +1369 -0
- package/sql/70-read.sql +245 -0
- package/sql/80-auth.sql +827 -0
- package/sql/90-time.sql +957 -0
- package/sql/95-seed.system.sql +178 -0
- package/sql/99-revision.sql +3 -0
- package/sql/README.md +56 -0
- package/sql/seed.booking.sql +39 -112
- package/dist/schema.d.ts +0 -15
- package/dist/schema.js +0 -351
- package/dist/sessions.d.ts +0 -32
- package/dist/sessions.js +0 -114
- package/dist/tables.d.ts +0 -105
- package/dist/tables.js +0 -248
- package/docker/Dockerfile +0 -40
- package/docker/start.sh +0 -18
- package/scripts/check-docs.mjs +0 -375
- package/scripts/gen-api-contract.mjs +0 -226
- package/scripts/gen-types.mjs +0 -350
- package/scripts/release-notes.mjs +0 -76
- package/scripts/schema-sync.mjs +0 -185
- package/sql/ddl.sql +0 -600
- package/sql/seed.auth.sql +0 -73
package/dist/auth.js
CHANGED
|
@@ -1,86 +1,39 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* db.auth
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
37
|
-
|
|
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
|
-
|
|
51
|
-
|
|
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;
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
|
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;
|
|
69
|
+
const TOTP_PERIOD = 30;
|
|
124
70
|
const TOTP_DIGITS = 6;
|
|
125
|
-
|
|
126
|
-
|
|
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(
|
|
135
|
-
const h = createHmac('sha1',
|
|
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
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
const
|
|
169
|
-
|
|
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
|
-
|
|
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
|
-
|
|
183
|
-
|
|
184
|
-
|
|
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
|
|
199
|
-
const
|
|
200
|
-
if (!cred
|
|
201
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
227
|
-
|
|
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
|
-
|
|
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
|
|
249
|
-
|
|
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
|
|
265
|
-
const secret = base32Encode(
|
|
266
|
-
const label = encodeURIComponent(a.label ??
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
327
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
385
|
-
return
|
|
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
|
}
|
package/dist/cache.d.ts
ADDED
|
@@ -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
|
+
}
|