letopis 2.0.0 → 3.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.md +484 -414
- package/CHANGELOG.md +321 -0
- package/LICENSE +75 -21
- package/LICENSE-COMMERCIAL.md +49 -0
- package/README.md +786 -183
- package/api-contract.json +381 -0
- package/dist/acl.d.ts +3 -0
- package/dist/acl.js +19 -2
- package/dist/admin.d.ts +2 -1
- package/dist/admin.js +6 -4
- package/dist/auth.d.ts +18 -3
- package/dist/auth.js +75 -6
- package/dist/chain.d.ts +38 -17
- package/dist/chain.js +434 -63
- package/dist/cli.js +28 -6
- package/dist/errors.d.ts +19 -1
- package/dist/errors.js +46 -6
- package/dist/index.d.ts +10 -4
- package/dist/index.js +136 -46
- package/dist/internal.d.ts +2 -0
- package/dist/load.d.ts +9 -5
- package/dist/load.js +30 -11
- package/dist/log.d.ts +121 -0
- package/dist/log.js +633 -0
- package/dist/mcp.js +26 -11
- package/dist/messages/api.d.ts +66 -0
- package/dist/messages/api.js +90 -1
- package/dist/messages/boot.js +4 -4
- package/dist/messages/codes.js +4 -4
- package/dist/messages/index.d.ts +5 -2
- package/dist/messages/index.js +5 -2
- package/dist/messages/log.d.ts +229 -0
- package/dist/messages/log.js +102 -0
- package/dist/messages/model.d.ts +24 -1
- package/dist/messages/model.js +34 -3
- package/dist/messages/query.d.ts +10 -0
- package/dist/messages/query.js +13 -0
- package/dist/messages/sql-core.d.ts +15 -1
- package/dist/messages/sql-core.js +20 -3
- package/dist/messages/sql-time.d.ts +15 -0
- package/dist/messages/sql-time.js +23 -0
- package/dist/messages/write.d.ts +47 -0
- package/dist/messages/write.js +63 -0
- package/dist/model.d.ts +4 -0
- package/dist/model.js +23 -5
- package/dist/sql.d.ts +2 -0
- package/dist/sql.js +77 -25
- package/dist/sync.d.ts +1 -1
- package/dist/sync.js +50 -0
- package/dist/tx.d.ts +10 -1
- package/dist/tx.js +44 -2
- package/dist/typed.d.ts +14 -10
- package/dist/types.d.ts +82 -4
- package/dist/types.js +6 -1
- package/dist/up.d.ts +3 -3
- package/dist/up.js +57 -17
- package/dist/uuid.d.ts +8 -0
- package/dist/uuid.js +9 -1
- package/dist/validate.js +24 -7
- package/dist/watch.d.ts +5 -0
- package/dist/watch.js +13 -0
- package/dist/write.d.ts +78 -1
- package/dist/write.js +357 -39
- package/llms.txt +37 -17
- package/package.json +12 -6
- package/sql/20-context.sql +11 -3
- package/sql/30-validate.sql +51 -10
- package/sql/40-class.sql +21 -1
- package/sql/80-auth.sql +88 -17
- package/sql/90-time.sql +34 -6
- package/sql/99-revision.sql +5 -1
- package/sql/migrate/0009-restore-rev.tenants-name.sql +10 -0
- package/sql/migrate/0010-impersonation-no-account.sql +5 -0
- package/sql/migrate/0011-key-lock.otp-enabled.sql +7 -0
- package/sql/migrate/README.md +5 -1
|
@@ -0,0 +1,381 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$comment": "СГЕНЕРИРОВАНО lib/scripts/gen-api-contract.mjs из исходников — не редактировать руками. Регенерация: cd lib && npm run api:contract. CI сверяет через npm run check:docs.",
|
|
3
|
+
"package": {
|
|
4
|
+
"name": "letopis",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"engines": {
|
|
7
|
+
"node": ">=22"
|
|
8
|
+
},
|
|
9
|
+
"exports": [
|
|
10
|
+
".",
|
|
11
|
+
"./model",
|
|
12
|
+
"./mcp"
|
|
13
|
+
]
|
|
14
|
+
},
|
|
15
|
+
"exports": {
|
|
16
|
+
".": {
|
|
17
|
+
"values": [
|
|
18
|
+
"CHAIN_METHODS",
|
|
19
|
+
"DB_METHODS",
|
|
20
|
+
"DDL_REVISION",
|
|
21
|
+
"LOG_EVENTS",
|
|
22
|
+
"LetopisError",
|
|
23
|
+
"RESERVED_CLASS_NAMES",
|
|
24
|
+
"Registry",
|
|
25
|
+
"ValidationError",
|
|
26
|
+
"applyDefaults",
|
|
27
|
+
"between",
|
|
28
|
+
"compileValidator",
|
|
29
|
+
"connect",
|
|
30
|
+
"createTenant",
|
|
31
|
+
"cursorOf",
|
|
32
|
+
"ends",
|
|
33
|
+
"exists",
|
|
34
|
+
"fromDbError",
|
|
35
|
+
"generateTypes",
|
|
36
|
+
"gt",
|
|
37
|
+
"gte",
|
|
38
|
+
"has",
|
|
39
|
+
"hasAll",
|
|
40
|
+
"hasAny",
|
|
41
|
+
"hashPassword",
|
|
42
|
+
"idOf",
|
|
43
|
+
"ilike",
|
|
44
|
+
"inList",
|
|
45
|
+
"inc",
|
|
46
|
+
"isInc",
|
|
47
|
+
"isNull",
|
|
48
|
+
"isOp",
|
|
49
|
+
"like",
|
|
50
|
+
"loose",
|
|
51
|
+
"lt",
|
|
52
|
+
"lte",
|
|
53
|
+
"ne",
|
|
54
|
+
"not",
|
|
55
|
+
"or",
|
|
56
|
+
"schemaToTs",
|
|
57
|
+
"starts",
|
|
58
|
+
"syncModels",
|
|
59
|
+
"totpCode",
|
|
60
|
+
"up",
|
|
61
|
+
"uuidv5",
|
|
62
|
+
"v5Id",
|
|
63
|
+
"v5Name",
|
|
64
|
+
"verifyHash"
|
|
65
|
+
],
|
|
66
|
+
"types": [
|
|
67
|
+
"AclApi",
|
|
68
|
+
"AclDataDecision",
|
|
69
|
+
"AclDecision",
|
|
70
|
+
"AclOp",
|
|
71
|
+
"AclRule",
|
|
72
|
+
"AclRuleSpec",
|
|
73
|
+
"AclTarget",
|
|
74
|
+
"AnyModelLike",
|
|
75
|
+
"AuthApi",
|
|
76
|
+
"Batch",
|
|
77
|
+
"BatchEntry",
|
|
78
|
+
"CacheOptions",
|
|
79
|
+
"CacheStats",
|
|
80
|
+
"Chain",
|
|
81
|
+
"ChainCore",
|
|
82
|
+
"ChainMods",
|
|
83
|
+
"ChainRef",
|
|
84
|
+
"ClassInfo",
|
|
85
|
+
"ConnectOptions",
|
|
86
|
+
"Credential",
|
|
87
|
+
"Cursor",
|
|
88
|
+
"Db",
|
|
89
|
+
"DbCore",
|
|
90
|
+
"DbTx",
|
|
91
|
+
"DeleteOptions",
|
|
92
|
+
"EndInfo",
|
|
93
|
+
"Expected",
|
|
94
|
+
"FieldFilter",
|
|
95
|
+
"FieldFilterOf",
|
|
96
|
+
"Filter",
|
|
97
|
+
"FilterOf",
|
|
98
|
+
"IncMarker",
|
|
99
|
+
"Issue",
|
|
100
|
+
"KeyedClass",
|
|
101
|
+
"LeafType",
|
|
102
|
+
"LoadInput",
|
|
103
|
+
"LoadIssue",
|
|
104
|
+
"LoadOptions",
|
|
105
|
+
"LoadReport",
|
|
106
|
+
"LogEvent",
|
|
107
|
+
"LogEventName",
|
|
108
|
+
"LogFn",
|
|
109
|
+
"LogLevel",
|
|
110
|
+
"LogObject",
|
|
111
|
+
"LogOptions",
|
|
112
|
+
"Logger",
|
|
113
|
+
"LoginOptions",
|
|
114
|
+
"LoginResult",
|
|
115
|
+
"MaintainReport",
|
|
116
|
+
"ModelLike",
|
|
117
|
+
"Op",
|
|
118
|
+
"OrOp",
|
|
119
|
+
"PatchOf",
|
|
120
|
+
"Path",
|
|
121
|
+
"QueryEvent",
|
|
122
|
+
"RestoreOptions",
|
|
123
|
+
"Row",
|
|
124
|
+
"RowOf",
|
|
125
|
+
"Scalar",
|
|
126
|
+
"SessionInfo",
|
|
127
|
+
"SignalMode",
|
|
128
|
+
"StepFn",
|
|
129
|
+
"StepsOf",
|
|
130
|
+
"Subscription",
|
|
131
|
+
"SyncResult",
|
|
132
|
+
"SystemModels",
|
|
133
|
+
"TenantOptions",
|
|
134
|
+
"TenantResult",
|
|
135
|
+
"TypedChain",
|
|
136
|
+
"TypedDb",
|
|
137
|
+
"UpOptions",
|
|
138
|
+
"UpResult",
|
|
139
|
+
"Validator",
|
|
140
|
+
"WatchEvent",
|
|
141
|
+
"WatchOptions",
|
|
142
|
+
"WriteRow"
|
|
143
|
+
]
|
|
144
|
+
},
|
|
145
|
+
"./model": {
|
|
146
|
+
"values": [
|
|
147
|
+
"End",
|
|
148
|
+
"hub",
|
|
149
|
+
"link",
|
|
150
|
+
"many",
|
|
151
|
+
"normalizeSchema",
|
|
152
|
+
"one",
|
|
153
|
+
"translatePattern"
|
|
154
|
+
],
|
|
155
|
+
"types": [
|
|
156
|
+
"AnyModel",
|
|
157
|
+
"ClassDef",
|
|
158
|
+
"ClassDescription",
|
|
159
|
+
"ClassMeta",
|
|
160
|
+
"Infer",
|
|
161
|
+
"InferLinks",
|
|
162
|
+
"JsonSchema",
|
|
163
|
+
"LostRule",
|
|
164
|
+
"Model",
|
|
165
|
+
"OnDelete"
|
|
166
|
+
]
|
|
167
|
+
},
|
|
168
|
+
"./mcp": {
|
|
169
|
+
"values": [
|
|
170
|
+
"createMcpServer"
|
|
171
|
+
],
|
|
172
|
+
"types": [
|
|
173
|
+
"McpOptions"
|
|
174
|
+
]
|
|
175
|
+
}
|
|
176
|
+
},
|
|
177
|
+
"chain": [
|
|
178
|
+
"add",
|
|
179
|
+
"after",
|
|
180
|
+
"alias",
|
|
181
|
+
"asOf",
|
|
182
|
+
"avg",
|
|
183
|
+
"cache",
|
|
184
|
+
"count",
|
|
185
|
+
"countBy",
|
|
186
|
+
"create",
|
|
187
|
+
"deep",
|
|
188
|
+
"delete",
|
|
189
|
+
"entity",
|
|
190
|
+
"exact",
|
|
191
|
+
"first",
|
|
192
|
+
"forUpdate",
|
|
193
|
+
"ids",
|
|
194
|
+
"limit",
|
|
195
|
+
"max",
|
|
196
|
+
"min",
|
|
197
|
+
"offset",
|
|
198
|
+
"owner",
|
|
199
|
+
"paths",
|
|
200
|
+
"remove",
|
|
201
|
+
"restore",
|
|
202
|
+
"rows",
|
|
203
|
+
"set",
|
|
204
|
+
"sort",
|
|
205
|
+
"sum",
|
|
206
|
+
"tags",
|
|
207
|
+
"then",
|
|
208
|
+
"unset",
|
|
209
|
+
"update",
|
|
210
|
+
"upsert",
|
|
211
|
+
"versions",
|
|
212
|
+
"withDeleted"
|
|
213
|
+
],
|
|
214
|
+
"db": [
|
|
215
|
+
"acl",
|
|
216
|
+
"as",
|
|
217
|
+
"auth",
|
|
218
|
+
"batch",
|
|
219
|
+
"begin",
|
|
220
|
+
"cache",
|
|
221
|
+
"close",
|
|
222
|
+
"commit",
|
|
223
|
+
"flushSignals",
|
|
224
|
+
"idOf",
|
|
225
|
+
"load",
|
|
226
|
+
"lock",
|
|
227
|
+
"maintain",
|
|
228
|
+
"refresh",
|
|
229
|
+
"registry",
|
|
230
|
+
"rollback",
|
|
231
|
+
"schema",
|
|
232
|
+
"sql",
|
|
233
|
+
"tenant",
|
|
234
|
+
"token",
|
|
235
|
+
"transaction",
|
|
236
|
+
"watch"
|
|
237
|
+
],
|
|
238
|
+
"batch": [
|
|
239
|
+
"discard",
|
|
240
|
+
"run",
|
|
241
|
+
"size"
|
|
242
|
+
],
|
|
243
|
+
"codes": [
|
|
244
|
+
"acl_denied",
|
|
245
|
+
"admin_required",
|
|
246
|
+
"bypass_rls",
|
|
247
|
+
"class_not_loadable",
|
|
248
|
+
"commit_unknown",
|
|
249
|
+
"confirmation_required",
|
|
250
|
+
"conflict",
|
|
251
|
+
"delete_restricted",
|
|
252
|
+
"deleted",
|
|
253
|
+
"duplicate",
|
|
254
|
+
"exists",
|
|
255
|
+
"id_from_db",
|
|
256
|
+
"id_mismatch",
|
|
257
|
+
"immutable_key",
|
|
258
|
+
"internal",
|
|
259
|
+
"invalid_class",
|
|
260
|
+
"invalid_data",
|
|
261
|
+
"invalid_query",
|
|
262
|
+
"isolation_level",
|
|
263
|
+
"lock_unsupported",
|
|
264
|
+
"no_key",
|
|
265
|
+
"no_path",
|
|
266
|
+
"no_session",
|
|
267
|
+
"not_deleted",
|
|
268
|
+
"not_empty",
|
|
269
|
+
"recreated_in_statement",
|
|
270
|
+
"rev_ambiguous",
|
|
271
|
+
"target_not_found",
|
|
272
|
+
"tenant_denied",
|
|
273
|
+
"tightening_conflict",
|
|
274
|
+
"tx_required",
|
|
275
|
+
"tx_rolled_back",
|
|
276
|
+
"undeclared_end",
|
|
277
|
+
"user_session_required"
|
|
278
|
+
],
|
|
279
|
+
"errors": [
|
|
280
|
+
{
|
|
281
|
+
"module": "15-errors",
|
|
282
|
+
"message": "letopis: acl_denied: отказ по правилам доступа"
|
|
283
|
+
},
|
|
284
|
+
{
|
|
285
|
+
"module": "15-errors",
|
|
286
|
+
"message": "letopis: delete_restricted: удалению мешают ссылающиеся строки"
|
|
287
|
+
},
|
|
288
|
+
{
|
|
289
|
+
"module": "15-errors",
|
|
290
|
+
"message": "letopis: id_from_db: id класса без ключа выдаёт база"
|
|
291
|
+
},
|
|
292
|
+
{
|
|
293
|
+
"module": "15-errors",
|
|
294
|
+
"message": "letopis: id_mismatch: id не равен v5 от ключа"
|
|
295
|
+
},
|
|
296
|
+
{
|
|
297
|
+
"module": "15-errors",
|
|
298
|
+
"message": "letopis: immutable_key: ключевое поле менять нельзя"
|
|
299
|
+
},
|
|
300
|
+
{
|
|
301
|
+
"module": "15-errors",
|
|
302
|
+
"message": "letopis: invalid_class: описание класса отклонено"
|
|
303
|
+
},
|
|
304
|
+
{
|
|
305
|
+
"module": "15-errors",
|
|
306
|
+
"message": "letopis: invalid_data: данные не проходят описание класса"
|
|
307
|
+
},
|
|
308
|
+
{
|
|
309
|
+
"module": "15-errors",
|
|
310
|
+
"message": "letopis: isolation_level: операция разрешена только в read committed"
|
|
311
|
+
},
|
|
312
|
+
{
|
|
313
|
+
"module": "15-errors",
|
|
314
|
+
"message": "letopis: no_session: нет действующей сессии"
|
|
315
|
+
},
|
|
316
|
+
{
|
|
317
|
+
"module": "15-errors",
|
|
318
|
+
"message": "letopis: not_deleted: объект не удалён — restore только для удалённых"
|
|
319
|
+
},
|
|
320
|
+
{
|
|
321
|
+
"module": "15-errors",
|
|
322
|
+
"message": "letopis: recreated_in_statement: id удалён и создан заново одним оператором"
|
|
323
|
+
},
|
|
324
|
+
{
|
|
325
|
+
"module": "15-errors",
|
|
326
|
+
"message": "letopis: target_not_found: цель не найдена"
|
|
327
|
+
},
|
|
328
|
+
{
|
|
329
|
+
"module": "15-errors",
|
|
330
|
+
"message": "letopis: tenant_denied: чужой арендатор"
|
|
331
|
+
},
|
|
332
|
+
{
|
|
333
|
+
"module": "15-errors",
|
|
334
|
+
"message": "letopis: tightening_conflict: ужесточению описания мешают строки"
|
|
335
|
+
},
|
|
336
|
+
{
|
|
337
|
+
"module": "15-errors",
|
|
338
|
+
"message": "letopis: undeclared_end: конец не объявлен в классе"
|
|
339
|
+
},
|
|
340
|
+
{
|
|
341
|
+
"module": "60-write",
|
|
342
|
+
"message": "letopis: invalid_data: "
|
|
343
|
+
},
|
|
344
|
+
{
|
|
345
|
+
"module": "errors",
|
|
346
|
+
"message": "letopis: <…>"
|
|
347
|
+
},
|
|
348
|
+
{
|
|
349
|
+
"module": "errors",
|
|
350
|
+
"message": "letopis: <…>: <…>"
|
|
351
|
+
},
|
|
352
|
+
{
|
|
353
|
+
"module": "errors",
|
|
354
|
+
"message": "letopis: invalid_data: <…><…>"
|
|
355
|
+
},
|
|
356
|
+
{
|
|
357
|
+
"module": "messages/index",
|
|
358
|
+
"message": "letopis: ключ каталога <…> повторяется"
|
|
359
|
+
},
|
|
360
|
+
{
|
|
361
|
+
"module": "messages/index",
|
|
362
|
+
"message": "letopis: ключ каталога <…> — не из раздела <…>"
|
|
363
|
+
},
|
|
364
|
+
{
|
|
365
|
+
"module": "up",
|
|
366
|
+
"message": "letopis.up: ${own ? seed : "
|
|
367
|
+
},
|
|
368
|
+
{
|
|
369
|
+
"module": "up",
|
|
370
|
+
"message": "letopis.up: <…>"
|
|
371
|
+
},
|
|
372
|
+
{
|
|
373
|
+
"module": "up",
|
|
374
|
+
"message": "letopis.up: <…>: <…>"
|
|
375
|
+
},
|
|
376
|
+
{
|
|
377
|
+
"module": "up",
|
|
378
|
+
"message": "letopis.up: migrate/<…>: <…>"
|
|
379
|
+
}
|
|
380
|
+
]
|
|
381
|
+
}
|
package/dist/acl.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { Executor } from './tx.js';
|
|
2
|
+
import type { CallSpec } from './log.js';
|
|
2
3
|
export type AclOp = 'READ' | 'WRITE' | 'DELETE';
|
|
3
4
|
/** Решение по ресурсу приложения (эндпоинту): можно ли к нему обратиться. */
|
|
4
5
|
export interface AclDecision {
|
|
@@ -110,3 +111,5 @@ export interface AclApi {
|
|
|
110
111
|
* транзакции.
|
|
111
112
|
*/
|
|
112
113
|
export declare function makeAcl(S: string, exec: Executor, db?: () => unknown): AclApi;
|
|
114
|
+
/** Что пишет в лог каждый метод db.acl: правка прав — acl.change, проверки и список — acl.check. */
|
|
115
|
+
export declare const ACL_LOG: Readonly<Record<string, CallSpec>>;
|
package/dist/acl.js
CHANGED
|
@@ -200,8 +200,9 @@ export function makeAcl(S, exec, db) {
|
|
|
200
200
|
objects.push(sysId(`all:${it.op}`));
|
|
201
201
|
for (const g of groups) {
|
|
202
202
|
for (const o of objects) {
|
|
203
|
+
// снять правило — замысел вызова, превью некому показывать: { all: true } (3.0) — без сверки, цели сужает фильтр
|
|
203
204
|
if (g && o)
|
|
204
|
-
n += (await tr.rule({ Group: g, Object: o }).delete({ confirm: true }).rows()).length;
|
|
205
|
+
n += (await tr.rule({ Group: g, Object: o }).delete({ confirm: true, all: true }).rows()).length;
|
|
205
206
|
}
|
|
206
207
|
}
|
|
207
208
|
}
|
|
@@ -221,7 +222,8 @@ export function makeAcl(S, exec, db) {
|
|
|
221
222
|
return m ? [...(m.data.roles ?? [])] : null;
|
|
222
223
|
}
|
|
223
224
|
if (roles === null) {
|
|
224
|
-
|
|
225
|
+
// roles(account, null) — снять членство: замысел вызова, без сверки с превью (3.0)
|
|
226
|
+
await inTx('roles', (tr) => tr.member({ User: acc, Tenant: tenant }).delete({ confirm: true, all: true }).rows());
|
|
225
227
|
return null;
|
|
226
228
|
}
|
|
227
229
|
if (!Array.isArray(roles))
|
|
@@ -275,3 +277,18 @@ export function makeAcl(S, exec, db) {
|
|
|
275
277
|
};
|
|
276
278
|
return api;
|
|
277
279
|
}
|
|
280
|
+
// ---------------------------------------------------------------------------
|
|
281
|
+
// Логирование (3.0): события db.acl — обёртка instrument (log.ts)
|
|
282
|
+
// ---------------------------------------------------------------------------
|
|
283
|
+
const accountOf = (a) => (a !== null && typeof a === 'object' ? a.id : a);
|
|
284
|
+
/** Что пишет в лог каждый метод db.acl: правка прав — acl.change, проверки и список — acl.check. */
|
|
285
|
+
export const ACL_LOG = {
|
|
286
|
+
check: (args, r) => ({ event: 'acl.check', detail: { endpoint: args[0], decision: r } }),
|
|
287
|
+
checkData: (args, r) => ({ event: 'acl.check', detail: { class: args[0], access: args[1], decision: r } }),
|
|
288
|
+
grant: (args, r) => ({ event: 'acl.change', rows: Array.isArray(r) ? r.length : undefined, detail: { action: 'grant', spec: args[0] } }),
|
|
289
|
+
deny: (args, r) => ({ event: 'acl.change', rows: Array.isArray(r) ? r.length : undefined, detail: { action: 'deny', spec: args[0] } }),
|
|
290
|
+
revoke: (args, n) => ({ event: 'acl.change', rows: typeof n === 'number' ? n : undefined, detail: { action: 'revoke', spec: args[0] } }),
|
|
291
|
+
roles: (args, r) => ({ event: args.length > 1 && args[1] !== undefined ? 'acl.change' : 'acl.check',
|
|
292
|
+
detail: { action: 'roles', account: accountOf(args[0]), roles: r } }),
|
|
293
|
+
list: (_args, r) => ({ event: 'acl.check', rows: Array.isArray(r) ? r.length : undefined, detail: { action: 'list' } }),
|
|
294
|
+
};
|
package/dist/admin.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type Log } from './log.js';
|
|
1
2
|
import { type Executor, type TxContext } from './tx.js';
|
|
2
3
|
export interface MaintainReport {
|
|
3
4
|
/** Другой процесс уже обслуживает установку (держит строку-замок обслуживания): этот вызов ничего не делал. */
|
|
@@ -40,4 +41,4 @@ export declare function parseInterval(v: unknown): number;
|
|
|
40
41
|
* следующий запуск не начинается, пока идёт прежний. Возвращает остановку: она снимает таймер и ждёт конца идущего
|
|
41
42
|
* запуска — close() закрывает пул после него, а не посреди транзакции.
|
|
42
43
|
*/
|
|
43
|
-
export declare function scheduleMaintain(run: () => Promise<unknown>, ms: number): () => Promise<void>;
|
|
44
|
+
export declare function scheduleMaintain(run: () => Promise<unknown>, ms: number, log?: Log): () => Promise<void>;
|
package/dist/admin.js
CHANGED
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
* Эксплуатация (план, этап 6, п. 7, 8): maintain и его запуск по расписанию. Всё проверяет база: функция владельца
|
|
3
3
|
* 90-time.sql сама сверяет права (letopis.maintain — системный администратор или администраторское подключение).
|
|
4
4
|
*/
|
|
5
|
-
import { lerr, lmsg, lwarn } from './errors.js';
|
|
5
|
+
import { lerr, lmsg, lwarn, warnText } from './errors.js';
|
|
6
|
+
import { safeErrorText } from './log.js';
|
|
6
7
|
import { runTx } from './tx.js';
|
|
7
8
|
/**
|
|
8
9
|
* Обслуживание: истёкшие и погашенные сессии, одноразовые коды, строки-замки удалённых семейств и заполнение очереди
|
|
@@ -21,8 +22,9 @@ export async function maintain(a) {
|
|
|
21
22
|
const b = r;
|
|
22
23
|
const report = { skipped: false, sessions: b.sessions, codes: b.codes, queue: Number(b.queue) };
|
|
23
24
|
const warn = queueWarning(report.queue);
|
|
25
|
+
// логгер задан — событие warning (time.signal_queue), иначе console.warn, как прежде
|
|
24
26
|
if (warn)
|
|
25
|
-
|
|
27
|
+
warnText(warn, 'time.signal_queue', a.txc.log, { queue: report.queue });
|
|
26
28
|
return report;
|
|
27
29
|
}
|
|
28
30
|
/** Предупреждение о заполненной очереди сигналов (§2.12): больше половины — зависший слушатель. */
|
|
@@ -56,14 +58,14 @@ export function parseInterval(v) {
|
|
|
56
58
|
* следующий запуск не начинается, пока идёт прежний. Возвращает остановку: она снимает таймер и ждёт конца идущего
|
|
57
59
|
* запуска — close() закрывает пул после него, а не посреди транзакции.
|
|
58
60
|
*/
|
|
59
|
-
export function scheduleMaintain(run, ms) {
|
|
61
|
+
export function scheduleMaintain(run, ms, log) {
|
|
60
62
|
let stopped = false;
|
|
61
63
|
let current = null;
|
|
62
64
|
const timer = setInterval(() => {
|
|
63
65
|
if (current || stopped)
|
|
64
66
|
return;
|
|
65
67
|
current = run()
|
|
66
|
-
.then(() => { }, (e) => lwarn('time.maintain_failed', { message: e
|
|
68
|
+
.then(() => { }, (e) => lwarn('time.maintain_failed', { message: safeErrorText(e) }, { log }))
|
|
67
69
|
.finally(() => (current = null));
|
|
68
70
|
}, ms);
|
|
69
71
|
timer.unref?.();
|
package/dist/auth.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { Executor } from './tx.js';
|
|
2
|
+
import type { CallSpec } from './log.js';
|
|
2
3
|
/** Хэш пароля scrypt: `scrypt$N$r$p$соль$хэш`. */
|
|
3
4
|
export declare function hashPassword(password: string): Promise<string>;
|
|
4
5
|
/** Проверка пароля по хэшу scrypt; другие форматы не проверяются. */
|
|
@@ -184,16 +185,28 @@ export interface AuthApi {
|
|
|
184
185
|
refresh(opts?: {
|
|
185
186
|
ttl?: number;
|
|
186
187
|
}): Promise<string>;
|
|
187
|
-
/**
|
|
188
|
-
|
|
188
|
+
/**
|
|
189
|
+
* Выход. Без аргумента — погасить сессию этого подключения. С токеном — погасить сессию пользователя по её токену
|
|
190
|
+
* (например, из cookie веб-сервиса): так сервис выводит пользователя, не подключаясь по его токену; нужно право
|
|
191
|
+
* `auth.session`, как у `session(token)`.
|
|
192
|
+
* @returns true — сессия погашена; false — токен неверный, сессия уже погашена или истекла (причина не различается).
|
|
193
|
+
* @throws LetopisError `acl_denied` — с токеном, а у вызывающего нет права `auth.session`; `no_session` — у подключения
|
|
194
|
+
* нет действующей сессии.
|
|
195
|
+
*/
|
|
196
|
+
revoke(token?: string): Promise<boolean>;
|
|
189
197
|
/** Выход везде: все сессии аккаунта (чужого — с правом `auth.session`). */
|
|
190
198
|
revokeAll(account?: AccountRef): Promise<number>;
|
|
191
199
|
/** Переключить арендатора сессии: собственный или по членству. */
|
|
192
200
|
switch(tenant: string): Promise<void>;
|
|
193
|
-
/**
|
|
201
|
+
/**
|
|
202
|
+
* Арендаторы, доступные аккаунту сессии: собственный (`roles: ['owner']`, `personal: true`) и по членству. `name` — имя
|
|
203
|
+
* аккаунта арендатора (`createTenant({ name })`, у личного — имя пользователя).
|
|
204
|
+
*/
|
|
194
205
|
tenants(): Promise<{
|
|
195
206
|
tenant: string;
|
|
196
207
|
roles: string[];
|
|
208
|
+
name: string | null;
|
|
209
|
+
personal: boolean;
|
|
197
210
|
}[]>;
|
|
198
211
|
/**
|
|
199
212
|
* Удалить аккаунт: системный администратор или владелец его
|
|
@@ -211,4 +224,6 @@ export interface AuthApi {
|
|
|
211
224
|
}
|
|
212
225
|
/** Фасад db.auth. onSwitch — подключение перечитывает арендатора, реестр и пропуска прав. */
|
|
213
226
|
export declare function makeAuth(S: string, exec: Executor, onSwitch: () => Promise<void>, canSwitch: boolean): AuthApi;
|
|
227
|
+
/** Что пишет в лог каждый метод db.auth. */
|
|
228
|
+
export declare const AUTH_LOG: Readonly<Record<string, CallSpec>>;
|
|
214
229
|
export {};
|
package/dist/auth.js
CHANGED
|
@@ -83,6 +83,8 @@ export function totpCode(secretBase32, atMs = Date.now()) {
|
|
|
83
83
|
return String(code).padStart(TOTP_DIGITS, '0');
|
|
84
84
|
}
|
|
85
85
|
const accId = (a) => (typeof a === 'object' ? a.id : a);
|
|
86
|
+
/** Логин без крайних пробелов (план 2.1, A12): « anna » и «anna» — один логин; не строка — как есть (отказ даст база). */
|
|
87
|
+
const trimLogin = (v) => (typeof v === 'string' ? v.trim() : v);
|
|
86
88
|
const ttlOf = (sec) => (sec === undefined ? null : `${Math.round(sec)} seconds`);
|
|
87
89
|
/** Фасад db.auth. onSwitch — подключение перечитывает арендатора, реестр и пропуска прав. */
|
|
88
90
|
export function makeAuth(S, exec, onSwitch, canSwitch) {
|
|
@@ -108,12 +110,14 @@ export function makeAuth(S, exec, onSwitch, canSwitch) {
|
|
|
108
110
|
return {
|
|
109
111
|
async setPassword(a) {
|
|
110
112
|
const hash = await hashPassword(a.password);
|
|
111
|
-
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]);
|
|
113
|
+
const r = await one(`select ${S}.auth_set_password($1::uuid, $2, $3, $4, $5, $6) as id`, [accId(a.account), trimLogin(a.identifier), hash, a.kind ?? 'PASSWORD', a.confirmed ?? null, a.keepCurrent ?? true]);
|
|
112
114
|
return r.id;
|
|
113
115
|
},
|
|
114
116
|
async verifyPassword(a) {
|
|
115
|
-
const
|
|
116
|
-
|
|
117
|
+
const get = async (ident) => (await exec.run((q) => q.unsafe(`select account, hash, confirmed from ${S}.auth_password_get($1, $2)`, [ident, a.kind ?? 'PASSWORD'])))[0];
|
|
118
|
+
// логин без крайних пробелов (план 2.1, A12); кред, записанный до 2.1 с пробелами, находится по логину как есть
|
|
119
|
+
const ident = trimLogin(a.identifier);
|
|
120
|
+
const cred = (await get(ident)) ?? (ident !== a.identifier ? await get(a.identifier) : undefined);
|
|
117
121
|
if (!cred)
|
|
118
122
|
return dummyVerify(a.password).then(() => null);
|
|
119
123
|
if (!(await verifyHash(a.password, cred.hash)))
|
|
@@ -181,7 +185,10 @@ export function makeAuth(S, exec, onSwitch, canSwitch) {
|
|
|
181
185
|
async refresh(opts) {
|
|
182
186
|
return (await one(`select ${S}.auth_refresh(coalesce($1::interval, interval '7 days')) as e`, [ttlOf(opts?.ttl)])).e;
|
|
183
187
|
},
|
|
184
|
-
async revoke() {
|
|
188
|
+
async revoke(token) {
|
|
189
|
+
// с токеном — выход пользователя по токену (план 2.1, A3): право auth.session, как у session(token)
|
|
190
|
+
if (token !== undefined)
|
|
191
|
+
return (await one(`select ${S}.auth_logout_token($1) as ok`, [token])).ok;
|
|
185
192
|
return (await one(`select ${S}.auth_logout() as ok`)).ok;
|
|
186
193
|
},
|
|
187
194
|
async revokeAll(account) {
|
|
@@ -194,8 +201,9 @@ export function makeAuth(S, exec, onSwitch, canSwitch) {
|
|
|
194
201
|
await onSwitch();
|
|
195
202
|
},
|
|
196
203
|
async tenants() {
|
|
197
|
-
|
|
198
|
-
|
|
204
|
+
// name и personal — с ревизии 9 (план 2.1, A5): personal — собственный арендатор аккаунта сессии (tenant = account)
|
|
205
|
+
const rows = await exec.run((q) => q.unsafe(`select tenant, roles, name, personal from ${S}.auth_tenants()`));
|
|
206
|
+
return rows.map((r) => ({ tenant: r.tenant, roles: r.roles, name: r.name ?? null, personal: r.personal === true }));
|
|
199
207
|
},
|
|
200
208
|
async purgeAccount(account) {
|
|
201
209
|
const r = (await one(`select ${S}.account_purge($1::uuid) as r`, [accId(account)])).r;
|
|
@@ -206,3 +214,64 @@ export function makeAuth(S, exec, onSwitch, canSwitch) {
|
|
|
206
214
|
},
|
|
207
215
|
};
|
|
208
216
|
}
|
|
217
|
+
const argOf = (args, i = 0) => (args[i] !== null && typeof args[i] === 'object' ? args[i] : {});
|
|
218
|
+
/** Аккаунт аргумента: id или { id }. */
|
|
219
|
+
const accOf = (a) => (typeof a === 'string' ? a : a !== null && typeof a === 'object' && typeof a.id === 'string' ? a.id : undefined);
|
|
220
|
+
const loginOf = (v) => (typeof v === 'string' ? { login: v.trim() } : {});
|
|
221
|
+
/** Вход: успех — auth.login (account — вошедший), отказ — auth.fail. */
|
|
222
|
+
function loginEvent(r, method, extra = {}) {
|
|
223
|
+
const res = r;
|
|
224
|
+
const detail = { method, ...extra };
|
|
225
|
+
return res && typeof res === 'object' ? { event: 'auth.login', account: res.account, detail } : { event: 'auth.fail', detail };
|
|
226
|
+
}
|
|
227
|
+
const credential = (action, detail) => ({ event: 'auth.credential', detail: { action, ...detail } });
|
|
228
|
+
const call = (method, detail = {}) => ({ event: 'auth.call', detail: { method, ...detail } });
|
|
229
|
+
/** Что пишет в лог каждый метод db.auth. */
|
|
230
|
+
export const AUTH_LOG = {
|
|
231
|
+
setPassword: (args, id) => {
|
|
232
|
+
const a = argOf(args);
|
|
233
|
+
return credential('setPassword', { account: accOf(a.account), kind: a.kind ?? 'PASSWORD', ...loginOf(a.identifier), id });
|
|
234
|
+
},
|
|
235
|
+
verifyPassword: (args, r) => {
|
|
236
|
+
const a = argOf(args);
|
|
237
|
+
return loginEvent(r, String(a.kind ?? 'PASSWORD'), { ...loginOf(a.identifier), ...(a.totp != null ? { totp: true } : {}) });
|
|
238
|
+
},
|
|
239
|
+
issueApiKey: (args, r) => credential('issueApiKey', { account: accOf(argOf(args).account), name: argOf(args).name, id: r?.id }),
|
|
240
|
+
verifyApiKey: (_args, r) => loginEvent(r, 'APIKEY'),
|
|
241
|
+
issueKeySecret: (args, r) => credential('issueKeySecret', { account: accOf(argOf(args).account), name: argOf(args).name, id: r?.id }),
|
|
242
|
+
verifyKeySecret: (_args, r) => loginEvent(r, 'KEYSECRET'),
|
|
243
|
+
enrollTotp: (args) => credential('enrollTotp', { account: accOf(argOf(args).account) }),
|
|
244
|
+
verifyTotp: (args, ok) => (ok === true
|
|
245
|
+
? call('verifyTotp', { account: accOf(argOf(args).account), ok: true })
|
|
246
|
+
: { event: 'auth.fail', detail: { method: 'TOTP', account: accOf(argOf(args).account) } }),
|
|
247
|
+
totpEnabled: (args, r) => call('totpEnabled', { account: accOf(args[0]), enabled: r }),
|
|
248
|
+
issueOtp: (args) => {
|
|
249
|
+
const a = argOf(args);
|
|
250
|
+
return credential('issueOtp', { account: accOf(a.account), kind: a.kind ?? 'OTP', ...loginOf(a.identifier) });
|
|
251
|
+
},
|
|
252
|
+
verifyOtp: (args, r) => {
|
|
253
|
+
const a = argOf(args);
|
|
254
|
+
return loginEvent(r, String(a.kind ?? 'OTP'), loginOf(a.identifier));
|
|
255
|
+
},
|
|
256
|
+
link: (args, id) => {
|
|
257
|
+
const a = argOf(args);
|
|
258
|
+
return credential('link', { account: accOf(a.account), kind: a.kind, ...loginOf(a.identifier), id });
|
|
259
|
+
},
|
|
260
|
+
lookup: (args, r) => {
|
|
261
|
+
const a = argOf(args);
|
|
262
|
+
return loginEvent(r, String(a.kind), { ...loginOf(a.identifier), ...(a.totp != null ? { totp: true } : {}) });
|
|
263
|
+
},
|
|
264
|
+
credentials: (args, r) => ({ ...call('credentials', { account: accOf(args[0]) }), rows: Array.isArray(r) ? r.length : undefined }),
|
|
265
|
+
revokeCredential: (args, ok) => ({ event: 'auth.revoke', detail: { target: 'credential', id: args[0], ok } }),
|
|
266
|
+
setFlags: (args, ok) => credential('setFlags', { id: args[0], ...argOf(args, 1), ok }),
|
|
267
|
+
sessionFor: (args) => ({ event: 'auth.session', detail: { account: accOf(args[0]), method: 'SERVICE' } }),
|
|
268
|
+
session: (_args, r) => call('session', { found: r !== null }),
|
|
269
|
+
refresh: () => call('refresh'),
|
|
270
|
+
revoke: (args, ok) => (args[0] !== undefined
|
|
271
|
+
? { event: 'auth.revoke', detail: { target: 'token', ok } }
|
|
272
|
+
: { event: 'auth.logout', detail: { ok } }),
|
|
273
|
+
revokeAll: (args, n) => ({ event: 'auth.revoke', detail: { target: 'all', account: accOf(args[0]), n } }),
|
|
274
|
+
switch: (args, _r, before) => ({ event: 'auth.switch', tenant: String(args[0]), detail: { from: before } }),
|
|
275
|
+
tenants: (_args, r) => ({ ...call('tenants'), rows: Array.isArray(r) ? r.length : undefined }),
|
|
276
|
+
purgeAccount: (args, r) => ({ event: 'account.purge', detail: { account: accOf(args[0]), ...r } }),
|
|
277
|
+
};
|