@feasibleone/blong-gogo 1.29.0 → 1.30.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 +15 -0
- package/package.json +2 -1
- package/src/AdapterBase.ts +1 -0
- package/src/Gateway.ts +32 -12
- package/src/adapter/browser.ts +1 -0
- package/src/adapter/schema/knex/json.test.ts +198 -0
- package/src/adapter/schema/knex/json.ts +136 -22
- package/src/adapter/schema/knex/types.ts +35 -0
- package/src/adapter/server/knex-deadlock.test.ts +73 -1
- package/src/adapter/server/knex.ts +41 -0
- package/src/codec/adapter/mle/ready.ts +89 -17
- package/src/globals.d.ts +0 -6
- package/src/jwt.ts +160 -14
- package/src/load.ts +5 -0
- package/template/index.browser.ts +4 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,20 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [1.30.0](https://github.com/feasibleone/blong/compare/blong-gogo-v1.29.0...blong-gogo-v1.30.0) (2026-08-21)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Features
|
|
7
|
+
|
|
8
|
+
* access sessions and audit ([694349f](https://github.com/feasibleone/blong/commit/694349f7e70973b984a30f696d2b6e892b55ec02))
|
|
9
|
+
* improve MySQL handling in CI ([4770eec](https://github.com/feasibleone/blong/commit/4770eec3a24cd38cd1931da1c553e0a804ca58dd))
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
### Bug Fixes
|
|
13
|
+
|
|
14
|
+
* api gateway wiring ([cbdb743](https://github.com/feasibleone/blong/commit/cbdb7439a997925ca6a4ba07e046ad2809d37218))
|
|
15
|
+
* blong-access-mock, silent error, expected error ([dc00429](https://github.com/feasibleone/blong/commit/dc00429a63a973996d6e94359830cc52d149b995))
|
|
16
|
+
* build ([fc98bed](https://github.com/feasibleone/blong/commit/fc98beda0e7ba800682bd663eeebd4408ce04bf1))
|
|
17
|
+
|
|
3
18
|
## [1.29.0](https://github.com/feasibleone/blong/compare/blong-gogo-v1.28.0...blong-gogo-v1.29.0) (2026-08-19)
|
|
4
19
|
|
|
5
20
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@feasibleone/blong-gogo",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.30.0",
|
|
4
4
|
"repository": {
|
|
5
5
|
"url": "git+https://github.com/feasibleone/blong.git"
|
|
6
6
|
},
|
|
@@ -87,6 +87,7 @@
|
|
|
87
87
|
"playwright": "^1.60.0",
|
|
88
88
|
"tap": "^21.6.3",
|
|
89
89
|
"typescript": "^6.0.3",
|
|
90
|
+
"vite": "^8.2.1",
|
|
90
91
|
"@feasibleone/blong-dev": "1.2.0"
|
|
91
92
|
},
|
|
92
93
|
"scripts": {
|
package/src/AdapterBase.ts
CHANGED
|
@@ -183,6 +183,7 @@ export class AdapterBase<T, C extends IContext> implements AdapterHandlerContext
|
|
|
183
183
|
|
|
184
184
|
error(error: ITypedError, $meta: IMeta): ITypedError {
|
|
185
185
|
if ($meta) error.method = $meta.method;
|
|
186
|
+
if (error.silent) return error;
|
|
186
187
|
if (isExpectedError(error.type, $meta?.expect)) {
|
|
187
188
|
(this.log as {debug?: (...args: unknown[]) => void})?.debug?.(error);
|
|
188
189
|
return error;
|
package/src/Gateway.ts
CHANGED
|
@@ -78,6 +78,17 @@ interface IConfig extends IConfigMLE {
|
|
|
78
78
|
* then enforces that every request's methodId is in the allowed list.
|
|
79
79
|
*/
|
|
80
80
|
authorize?: string;
|
|
81
|
+
/**
|
|
82
|
+
* Optional access-check audit. When set, the gateway records every
|
|
83
|
+
* authorization decision (allow/deny) at the access-check point into the
|
|
84
|
+
* configured handler, plus sanitised DML context for access.* write
|
|
85
|
+
* methods. `exclude` lists methodId patterns (exact or `prefix*`) that
|
|
86
|
+
* must not be audited. Best-effort — audit failures never block requests.
|
|
87
|
+
*/
|
|
88
|
+
audit?: {
|
|
89
|
+
handler: string;
|
|
90
|
+
exclude?: string[];
|
|
91
|
+
};
|
|
81
92
|
errorFields: unknown[];
|
|
82
93
|
jwt: {
|
|
83
94
|
cache: object;
|
|
@@ -171,6 +182,7 @@ export default class Gateway extends Internal implements IGateway {
|
|
|
171
182
|
debug: false,
|
|
172
183
|
expectedErrors: false,
|
|
173
184
|
authorize: undefined,
|
|
185
|
+
audit: undefined,
|
|
174
186
|
errorFields: [],
|
|
175
187
|
jwt: {
|
|
176
188
|
cache: {},
|
|
@@ -369,6 +381,8 @@ export default class Gateway extends Internal implements IGateway {
|
|
|
369
381
|
...(value.bundle !== undefined && {bundle: value.bundle}),
|
|
370
382
|
...(value.creditCost !== undefined && {creditCost: value.creditCost}),
|
|
371
383
|
...(value.meter !== undefined && {meter: value.meter}),
|
|
384
|
+
...(value.audit !== undefined && {audit: value.audit}),
|
|
385
|
+
...(value.skipAuthorize && {skipAuthorize: true}),
|
|
372
386
|
},
|
|
373
387
|
schema: Type && {
|
|
374
388
|
...('body' in value
|
|
@@ -530,18 +544,23 @@ export default class Gateway extends Internal implements IGateway {
|
|
|
530
544
|
httpResponse?: unknown;
|
|
531
545
|
[key: string]: unknown;
|
|
532
546
|
};
|
|
533
|
-
if (
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
)
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
547
|
+
if (!typedError.silent) {
|
|
548
|
+
if (
|
|
549
|
+
isExpectedError(
|
|
550
|
+
typedError.type as string | undefined,
|
|
551
|
+
resolvedExpect,
|
|
552
|
+
)
|
|
553
|
+
) {
|
|
554
|
+
request.log.debug?.(
|
|
555
|
+
{err: error, method: methodName},
|
|
556
|
+
'gateway expected error',
|
|
557
|
+
);
|
|
558
|
+
} else {
|
|
559
|
+
request.log.error?.(
|
|
560
|
+
{err: error, method: methodName},
|
|
561
|
+
'gateway handler error',
|
|
562
|
+
);
|
|
563
|
+
}
|
|
545
564
|
}
|
|
546
565
|
this._applyMeta(
|
|
547
566
|
reply
|
|
@@ -614,6 +633,7 @@ export default class Gateway extends Internal implements IGateway {
|
|
|
614
633
|
errors: this.#errors,
|
|
615
634
|
audience: this.#config.jwt.audience,
|
|
616
635
|
authorize: this.#config.authorize,
|
|
636
|
+
audit: this.#config.audit,
|
|
617
637
|
local: this.#local,
|
|
618
638
|
methodId: methodId,
|
|
619
639
|
methodParts: methodParts,
|
package/src/adapter/browser.ts
CHANGED
|
@@ -11,9 +11,11 @@ import {test} from 'tap';
|
|
|
11
11
|
|
|
12
12
|
import {
|
|
13
13
|
isDeadlock,
|
|
14
|
+
isRetryableConnectionError,
|
|
14
15
|
parseJsonResult,
|
|
15
16
|
parseJsonRow,
|
|
16
17
|
stringifyJsonValues,
|
|
18
|
+
withConnectionRetry,
|
|
17
19
|
wrapJsonBuilder,
|
|
18
20
|
wrapKnex,
|
|
19
21
|
} from './json.ts';
|
|
@@ -216,6 +218,173 @@ test('wrapKnex transaction() wraps the resolved trx (promise form)', async t =>
|
|
|
216
218
|
t.end();
|
|
217
219
|
});
|
|
218
220
|
|
|
221
|
+
const CONN_LOST = {
|
|
222
|
+
code: 'PROTOCOL_CONNECTION_LOST',
|
|
223
|
+
fatal: true,
|
|
224
|
+
sql: 'UPDATE `t` SET `x` = 1',
|
|
225
|
+
sqlMessage: 'Connection lost: The server closed the connection.',
|
|
226
|
+
message: 'Connection lost: The server closed the connection.',
|
|
227
|
+
};
|
|
228
|
+
|
|
229
|
+
const DUP_ENTRY = {errno: 1062, code: 'ER_DUP_ENTRY', message: 'Duplicate entry'};
|
|
230
|
+
|
|
231
|
+
const BOOM = new Error('boom');
|
|
232
|
+
|
|
233
|
+
test('isRetryableConnectionError detects transient connection errors only', t => {
|
|
234
|
+
t.equal(
|
|
235
|
+
isRetryableConnectionError({code: 'PROTOCOL_CONNECTION_LOST', fatal: true}),
|
|
236
|
+
true,
|
|
237
|
+
'PROTOCOL_CONNECTION_LOST with fatal flag',
|
|
238
|
+
);
|
|
239
|
+
t.equal(isRetryableConnectionError({fatal: true}), true, 'fatal flag alone');
|
|
240
|
+
t.equal(isRetryableConnectionError({code: 'ECONNRESET'}), true, 'ECONNRESET');
|
|
241
|
+
t.equal(isRetryableConnectionError({code: 'ER_CON_COUNT_ERROR'}), true, 'too many connections');
|
|
242
|
+
t.equal(
|
|
243
|
+
isRetryableConnectionError({code: 'ER_LOCK_DEADLOCK', errno: 1213}),
|
|
244
|
+
false,
|
|
245
|
+
'deadlock is not a connection error',
|
|
246
|
+
);
|
|
247
|
+
t.equal(isRetryableConnectionError(DUP_ENTRY), false, 'constraint violation');
|
|
248
|
+
t.equal(isRetryableConnectionError(BOOM), false, 'plain error');
|
|
249
|
+
t.equal(isRetryableConnectionError(null), false, 'null');
|
|
250
|
+
t.equal(isRetryableConnectionError('PROTOCOL_CONNECTION_LOST'), false, 'string');
|
|
251
|
+
t.end();
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
test('withConnectionRetry runs once when retry is disabled (default)', async t => {
|
|
255
|
+
let calls = 0;
|
|
256
|
+
const run = async () => {
|
|
257
|
+
calls += 1;
|
|
258
|
+
throw CONN_LOST;
|
|
259
|
+
};
|
|
260
|
+
await t.rejects(withConnectionRetry(run, {}), CONN_LOST, 'error propagates');
|
|
261
|
+
t.equal(calls, 1, 'exactly one attempt');
|
|
262
|
+
t.end();
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
test('withConnectionRetry retries transient connection errors and recovers', async t => {
|
|
266
|
+
let calls = 0;
|
|
267
|
+
const run = async () => {
|
|
268
|
+
calls += 1;
|
|
269
|
+
if (calls < 3) throw CONN_LOST;
|
|
270
|
+
return 'ok';
|
|
271
|
+
};
|
|
272
|
+
const result = await withConnectionRetry(run, {
|
|
273
|
+
retry: {enabled: true, maxRetries: 3, backoffMs: 1},
|
|
274
|
+
});
|
|
275
|
+
t.equal(result, 'ok');
|
|
276
|
+
t.equal(calls, 3, 'first attempt + two retries');
|
|
277
|
+
t.end();
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
test('withConnectionRetry does not retry non-connection errors', async t => {
|
|
281
|
+
let calls = 0;
|
|
282
|
+
const run = async () => {
|
|
283
|
+
calls += 1;
|
|
284
|
+
throw DUP_ENTRY;
|
|
285
|
+
};
|
|
286
|
+
await t.rejects(
|
|
287
|
+
withConnectionRetry(run, {retry: {enabled: true, maxRetries: 3, backoffMs: 1}}),
|
|
288
|
+
DUP_ENTRY,
|
|
289
|
+
);
|
|
290
|
+
t.equal(calls, 1, 'constraint violations are not retried');
|
|
291
|
+
t.end();
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
test('withConnectionRetry gives up after maxRetries and surfaces the error', async t => {
|
|
295
|
+
let calls = 0;
|
|
296
|
+
const run = async () => {
|
|
297
|
+
calls += 1;
|
|
298
|
+
throw CONN_LOST;
|
|
299
|
+
};
|
|
300
|
+
await t.rejects(
|
|
301
|
+
withConnectionRetry(run, {retry: {enabled: true, maxRetries: 2, backoffMs: 1}}),
|
|
302
|
+
CONN_LOST,
|
|
303
|
+
'final error surfaces',
|
|
304
|
+
);
|
|
305
|
+
t.equal(calls, 3, 'first attempt + maxRetries retries');
|
|
306
|
+
t.end();
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
test('wrapJsonBuilder recovers a transient connection error via clone re-execution', async t => {
|
|
310
|
+
const builder = makeFlakyBuilder(2, CONN_LOST);
|
|
311
|
+
const wrapped = wrapJsonBuilder(builder, {
|
|
312
|
+
retry: {enabled: true, maxRetries: 5, backoffMs: 1},
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
const result = await wrapped.then((value: unknown) => value);
|
|
316
|
+
t.same(result, {ok: 3}, 'query succeeds after two dropped connections');
|
|
317
|
+
t.equal(builder.attempts(), 3, 'original + two clone re-executions');
|
|
318
|
+
t.end();
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
test('wrapJsonBuilder reports connection errors via onConnectionError', async t => {
|
|
322
|
+
const reported: unknown[] = [];
|
|
323
|
+
const builder = makeFlakyBuilder(1, CONN_LOST);
|
|
324
|
+
const wrapped = wrapJsonBuilder(builder, {
|
|
325
|
+
retry: {enabled: true, maxRetries: 3, backoffMs: 1},
|
|
326
|
+
onConnectionError: error => reported.push(error),
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
const result = await wrapped.then((value: unknown) => value);
|
|
330
|
+
t.same(result, {ok: 2}, 'recovers');
|
|
331
|
+
t.same(reported, [CONN_LOST], 'the dropped connection was reported');
|
|
332
|
+
t.end();
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
test('wrapJsonBuilder does not retry when retry is disabled', async t => {
|
|
336
|
+
const reported: unknown[] = [];
|
|
337
|
+
const builder = makeFlakyBuilder(1, CONN_LOST);
|
|
338
|
+
const wrapped = wrapJsonBuilder(builder, {
|
|
339
|
+
onConnectionError: error => reported.push(error),
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
await t.rejects(Promise.resolve(wrapped.then(null, null)), CONN_LOST, 'error propagates');
|
|
343
|
+
t.equal(builder.attempts(), 1, 'exactly one attempt');
|
|
344
|
+
t.same(reported, [CONN_LOST], 'connection error still reported for diagnostics');
|
|
345
|
+
t.end();
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
test('wrapJsonBuilder never retries non-connection errors', async t => {
|
|
349
|
+
const builder = makeFlakyBuilder(1, BOOM);
|
|
350
|
+
const wrapped = wrapJsonBuilder(builder, {
|
|
351
|
+
retry: {enabled: true, maxRetries: 3, backoffMs: 1},
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
await t.rejects(Promise.resolve(wrapped.then(null, null)), /boom/, 'error propagates');
|
|
355
|
+
t.equal(builder.attempts(), 1, 'plain errors are not retried');
|
|
356
|
+
t.end();
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
test('wrapKnex raw() retries transient connection errors by re-invoking raw', async t => {
|
|
360
|
+
let calls = 0;
|
|
361
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
362
|
+
const fakeKnex = (() => undefined) as any;
|
|
363
|
+
fakeKnex.raw = () => {
|
|
364
|
+
calls += 1;
|
|
365
|
+
const ok = calls > 1;
|
|
366
|
+
return {
|
|
367
|
+
then(
|
|
368
|
+
onFulfilled?: (value: unknown) => unknown,
|
|
369
|
+
onRejected?: (reason: unknown) => unknown,
|
|
370
|
+
) {
|
|
371
|
+
return ok
|
|
372
|
+
? Promise.resolve({rows: [{n: calls}]}).then(onFulfilled, onRejected)
|
|
373
|
+
: Promise.reject(CONN_LOST).then(onFulfilled, onRejected);
|
|
374
|
+
},
|
|
375
|
+
};
|
|
376
|
+
};
|
|
377
|
+
const wrapped = wrapKnex(fakeKnex, {
|
|
378
|
+
retry: {enabled: true, maxRetries: 3, backoffMs: 1},
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
const raw = wrapped.raw('CALL something()');
|
|
382
|
+
const result = await Promise.resolve(raw.then((value: unknown) => value));
|
|
383
|
+
t.same(result, {rows: [{n: 2}]}, 'raw re-invoked on retry');
|
|
384
|
+
t.equal(calls, 2, 'knex.raw called twice');
|
|
385
|
+
t.end();
|
|
386
|
+
});
|
|
387
|
+
|
|
219
388
|
/** A minimal knex-like builder whose `then` rejects with `error`. */
|
|
220
389
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
221
390
|
function makeRejectingBuilder(error: unknown): any {
|
|
@@ -241,3 +410,32 @@ function makeRejectingThenable(error: unknown): any {
|
|
|
241
410
|
},
|
|
242
411
|
};
|
|
243
412
|
}
|
|
413
|
+
|
|
414
|
+
/**
|
|
415
|
+
* A knex-like builder whose first `failures` `then` calls reject with `error`
|
|
416
|
+
* and whose later calls resolve. `clone()` returns a fresh builder sharing the
|
|
417
|
+
* same attempt counter, mirroring how the retry path re-runs a cloned builder.
|
|
418
|
+
*/
|
|
419
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
420
|
+
function makeFlakyBuilder(failures: number, error: unknown): any {
|
|
421
|
+
let attempts = 0;
|
|
422
|
+
const make = () => ({
|
|
423
|
+
insert(..._args: unknown[]) {
|
|
424
|
+
return this;
|
|
425
|
+
},
|
|
426
|
+
update(..._args: unknown[]) {
|
|
427
|
+
return this;
|
|
428
|
+
},
|
|
429
|
+
then(onFulfilled?: (value: unknown) => unknown, onRejected?: (reason: unknown) => unknown) {
|
|
430
|
+
attempts += 1;
|
|
431
|
+
return attempts <= failures
|
|
432
|
+
? Promise.reject(error).then(onFulfilled, onRejected)
|
|
433
|
+
: Promise.resolve({ok: attempts}).then(onFulfilled, onRejected);
|
|
434
|
+
},
|
|
435
|
+
clone() {
|
|
436
|
+
return make();
|
|
437
|
+
},
|
|
438
|
+
attempts: () => attempts,
|
|
439
|
+
});
|
|
440
|
+
return make();
|
|
441
|
+
}
|
|
@@ -17,6 +17,8 @@
|
|
|
17
17
|
* `access_credential.credentialParamsJSON`.
|
|
18
18
|
*/
|
|
19
19
|
|
|
20
|
+
import {type IKnexRetryOptions} from './types.ts';
|
|
21
|
+
|
|
20
22
|
/** Column names ending in `JSON` are treated as JSON documents. */
|
|
21
23
|
const JSON_COLUMN = /JSON$/;
|
|
22
24
|
|
|
@@ -36,11 +38,78 @@ export function isDeadlock(error: unknown): boolean {
|
|
|
36
38
|
}
|
|
37
39
|
|
|
38
40
|
/**
|
|
39
|
-
*
|
|
41
|
+
* Error codes that indicate a *transient* MySQL connection failure worth
|
|
42
|
+
* retrying — the server closed the socket, the pool hit the server's
|
|
43
|
+
* `max_connections`, or the TCP link was reset. `fatal: true` is the generic
|
|
44
|
+
* marker mysql2 sets for errors that invalidate the connection (it is set on
|
|
45
|
+
* `PROTOCOL_CONNECTION_LOST`, the error observed intermittently in CI).
|
|
46
|
+
*/
|
|
47
|
+
const RETRYABLE_CONNECTION_CODES = new Set([
|
|
48
|
+
'PROTOCOL_CONNECTION_LOST',
|
|
49
|
+
'PROTOCOL_ENQUEUE_AFTER_FATAL_ERROR',
|
|
50
|
+
'ECONNRESET',
|
|
51
|
+
'ETIMEDOUT',
|
|
52
|
+
'ER_CON_COUNT_ERROR', // Too many connections (server max_connections)
|
|
53
|
+
'ER_SERVER_SHUTDOWN', // Server shutting down
|
|
54
|
+
'ER_ABORTING_CONNECTION',
|
|
55
|
+
]);
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Whether a query error is a transient connection failure the adapter may
|
|
59
|
+
* retry. Only connection-level failures qualify — deadlocks, constraint
|
|
60
|
+
* violations and ordinary SQL errors are never retried here.
|
|
61
|
+
*/
|
|
62
|
+
export function isRetryableConnectionError(error: unknown): boolean {
|
|
63
|
+
if (!error || typeof error !== 'object') return false;
|
|
64
|
+
const {code, fatal} = error as {code?: unknown; fatal?: unknown};
|
|
65
|
+
return fatal === true || (typeof code === 'string' && RETRYABLE_CONNECTION_CODES.has(code));
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Optional hooks and behaviour applied while wrapping the shared knex instance.
|
|
40
70
|
*/
|
|
41
71
|
export interface WrapKnexOptions {
|
|
42
72
|
/** Invoked when a query rejects with a deadlock error, before the caller's rejection handler. */
|
|
43
73
|
onDeadlock?: (error: unknown) => void;
|
|
74
|
+
/**
|
|
75
|
+
* Invoked when a query rejects with a transient connection error (before the
|
|
76
|
+
* caller's rejection handler). Fired on every failed attempt — including when
|
|
77
|
+
* retry is disabled — so connection drops can be logged for diagnostics.
|
|
78
|
+
*/
|
|
79
|
+
onConnectionError?: (error: unknown) => void;
|
|
80
|
+
/**
|
|
81
|
+
* Opt-in retry of transient connection errors. Non-prod only — disabled by
|
|
82
|
+
* default; enable via the `knex.retry` config block (see `srv.db`).
|
|
83
|
+
*/
|
|
84
|
+
retry?: IKnexRetryOptions;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Run `run()` once; if it rejects with a transient connection error and retry
|
|
89
|
+
* is enabled, re-run it (up to `maxRetries`) with linear backoff
|
|
90
|
+
* (`backoffMs * attempt`). Between attempts the knex pool re-establishes a
|
|
91
|
+
* healthy connection, so a query that hit a dead socket usually succeeds on the
|
|
92
|
+
* next attempt. When retry is disabled (default) the query runs exactly once.
|
|
93
|
+
*/
|
|
94
|
+
export async function withConnectionRetry<T>(
|
|
95
|
+
run: () => Promise<T>,
|
|
96
|
+
options: WrapKnexOptions,
|
|
97
|
+
): Promise<T> {
|
|
98
|
+
const retry = options.retry;
|
|
99
|
+
if (!retry?.enabled) return run();
|
|
100
|
+
const maxRetries = retry.maxRetries ?? 3;
|
|
101
|
+
const backoffMs = retry.backoffMs ?? 250;
|
|
102
|
+
let attempt = 0;
|
|
103
|
+
for (;;) {
|
|
104
|
+
try {
|
|
105
|
+
return await run();
|
|
106
|
+
} catch (error) {
|
|
107
|
+
if (!isRetryableConnectionError(error)) throw error;
|
|
108
|
+
if (attempt >= maxRetries) throw error;
|
|
109
|
+
attempt += 1;
|
|
110
|
+
await new Promise(resolve => setTimeout(resolve, backoffMs * attempt));
|
|
111
|
+
}
|
|
112
|
+
}
|
|
44
113
|
}
|
|
45
114
|
|
|
46
115
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
@@ -96,7 +165,8 @@ export function parseJsonResult(result: unknown): unknown {
|
|
|
96
165
|
* Only `insert`, `update`, and `then` are intercepted — all other builder
|
|
97
166
|
* behaviour is untouched. Chained calls keep working because knex builder
|
|
98
167
|
* methods return the same instance. The `then` rejection path is also
|
|
99
|
-
* intercepted to detect MySQL deadlocks and report them via `options.onDeadlock
|
|
168
|
+
* intercepted to detect MySQL deadlocks and report them via `options.onDeadlock`,
|
|
169
|
+
* and to retry transient connection errors via `options.retry`.
|
|
100
170
|
*/
|
|
101
171
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
102
172
|
export function wrapJsonBuilder(builder: any, options: WrapKnexOptions = {}): any {
|
|
@@ -123,40 +193,83 @@ export function wrapJsonBuilder(builder: any, options: WrapKnexOptions = {}): an
|
|
|
123
193
|
builder.then = (
|
|
124
194
|
onFulfilled?: ((value: unknown) => unknown) | null,
|
|
125
195
|
onRejected?: ((reason: unknown) => unknown) | null,
|
|
126
|
-
) =>
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
196
|
+
) => {
|
|
197
|
+
// Each attempt runs the query again. The first attempt executes the
|
|
198
|
+
// original builder via its captured `then`; retries re-run a fresh
|
|
199
|
+
// clone (knex builders are single-shot) whose `then` is the original
|
|
200
|
+
// prototype method — re-acquiring a healthy connection from the pool.
|
|
201
|
+
let attempt = 0;
|
|
202
|
+
const execute = () => {
|
|
203
|
+
const cloneable = typeof builder.clone === 'function';
|
|
204
|
+
const target = attempt === 0 || !cloneable ? null : builder.clone();
|
|
205
|
+
const thenFn = target ? target.then.bind(target) : originalThen;
|
|
206
|
+
attempt += 1;
|
|
207
|
+
return new Promise<unknown>((resolve, reject) => {
|
|
208
|
+
thenFn(
|
|
209
|
+
(result: unknown) => {
|
|
210
|
+
const processed = parseJsonResult(result);
|
|
211
|
+
resolve(
|
|
212
|
+
typeof onFulfilled === 'function' ? onFulfilled(processed) : processed,
|
|
213
|
+
);
|
|
214
|
+
},
|
|
215
|
+
(reason: unknown) => {
|
|
216
|
+
if (isDeadlock(reason)) options.onDeadlock?.(reason);
|
|
217
|
+
if (isRetryableConnectionError(reason)) options.onConnectionError?.(reason);
|
|
218
|
+
reject(reason);
|
|
219
|
+
},
|
|
220
|
+
);
|
|
221
|
+
});
|
|
222
|
+
};
|
|
223
|
+
return withConnectionRetry(execute, options).then(undefined, (reason: unknown) => {
|
|
224
|
+
if (typeof onRejected === 'function') return onRejected(reason);
|
|
225
|
+
throw reason;
|
|
226
|
+
});
|
|
227
|
+
};
|
|
138
228
|
|
|
139
229
|
return builder;
|
|
140
230
|
}
|
|
141
231
|
|
|
142
232
|
/**
|
|
143
233
|
* Intercept the `then` rejection path of a thenable (e.g. a knex `Raw` result)
|
|
144
|
-
* so MySQL deadlocks are reported via `options.onDeadlock
|
|
145
|
-
*
|
|
146
|
-
*
|
|
234
|
+
* so MySQL deadlocks are reported via `options.onDeadlock` and transient
|
|
235
|
+
* connection errors are reported via `options.onConnectionError` / retried via
|
|
236
|
+
* `options.retry`. `rerun()` re-creates the query (e.g. re-invoking
|
|
237
|
+
* `knex.raw(...)` with the same args) so a connection failure can be retried.
|
|
238
|
+
* Mirrors the rejection interception in {@link wrapJsonBuilder} without the JSON
|
|
239
|
+
* column handling, which does not apply to `raw` queries / stored-procedure calls.
|
|
147
240
|
*/
|
|
148
241
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
149
|
-
function
|
|
242
|
+
function attachQueryHook(thenable: any, options: WrapKnexOptions, rerun: () => any): any {
|
|
150
243
|
const originalThen = thenable.then.bind(thenable);
|
|
151
244
|
thenable.then = (
|
|
152
245
|
onFulfilled?: ((value: unknown) => unknown) | null,
|
|
153
246
|
onRejected?: ((reason: unknown) => unknown) | null,
|
|
154
|
-
) =>
|
|
155
|
-
|
|
156
|
-
|
|
247
|
+
) => {
|
|
248
|
+
// The first attempt runs the original thenable via its captured `then`;
|
|
249
|
+
// retries re-invoke `rerun()` to obtain a fresh thenable backed by a new
|
|
250
|
+
// query (and, for raw, a freshly acquired pool connection).
|
|
251
|
+
let attempt = 0;
|
|
252
|
+
const execute = () => {
|
|
253
|
+
const target = attempt === 0 ? null : rerun();
|
|
254
|
+
const thenFn = target ? target.then.bind(target) : originalThen;
|
|
255
|
+
attempt += 1;
|
|
256
|
+
return new Promise<unknown>((resolve, reject) => {
|
|
257
|
+
thenFn(
|
|
258
|
+
(result: unknown) =>
|
|
259
|
+
resolve(typeof onFulfilled === 'function' ? onFulfilled(result) : result),
|
|
260
|
+
(reason: unknown) => {
|
|
261
|
+
if (isDeadlock(reason)) options.onDeadlock?.(reason);
|
|
262
|
+
if (isRetryableConnectionError(reason)) options.onConnectionError?.(reason);
|
|
263
|
+
reject(reason);
|
|
264
|
+
},
|
|
265
|
+
);
|
|
266
|
+
});
|
|
267
|
+
};
|
|
268
|
+
return withConnectionRetry(execute, options).then(undefined, (reason: unknown) => {
|
|
157
269
|
if (typeof onRejected === 'function') return onRejected(reason);
|
|
158
270
|
throw reason;
|
|
159
271
|
});
|
|
272
|
+
};
|
|
160
273
|
return thenable;
|
|
161
274
|
}
|
|
162
275
|
|
|
@@ -186,7 +299,8 @@ export function wrapKnex<T>(knex: T, options: WrapKnexOptions = {}): T {
|
|
|
186
299
|
const original = Reflect.get(target, prop, target);
|
|
187
300
|
if (typeof original !== 'function') return original;
|
|
188
301
|
const bound = original.bind(target);
|
|
189
|
-
return (...args: unknown[]) =>
|
|
302
|
+
return (...args: unknown[]) =>
|
|
303
|
+
attachQueryHook(bound(...args), options, () => bound(...args));
|
|
190
304
|
}
|
|
191
305
|
if (prop === 'transaction') {
|
|
192
306
|
const original = Reflect.get(target, prop, target);
|
|
@@ -135,9 +135,33 @@ export interface IKnexConnection {
|
|
|
135
135
|
user?: string;
|
|
136
136
|
password?: string;
|
|
137
137
|
database?: string;
|
|
138
|
+
/**
|
|
139
|
+
* mysql2 TCP keep-alive. When `true`, idle connections send keep-alive
|
|
140
|
+
* probes so a server/proxy/LB silently closing an idle socket is noticed
|
|
141
|
+
* promptly instead of surfacing later as a mid-query
|
|
142
|
+
* `PROTOCOL_CONNECTION_LOST`. Enabled for non-prod via the shared `srv.db`
|
|
143
|
+
* adapter (`core/blong-server/adapter/db.ts`).
|
|
144
|
+
*/
|
|
145
|
+
enableKeepAlive?: boolean;
|
|
138
146
|
[key: string]: unknown;
|
|
139
147
|
}
|
|
140
148
|
|
|
149
|
+
/**
|
|
150
|
+
* Opt-in retry of transient fatal connection errors (e.g. mysql2
|
|
151
|
+
* `PROTOCOL_CONNECTION_LOST`). Intended for non-prod environments (CI / dev)
|
|
152
|
+
* where a single dropped connection must not abort a whole test run. Disabled
|
|
153
|
+
* by default so production behaviour is unchanged — enable explicitly via the
|
|
154
|
+
* `knex.retry` config block (see `core/blong-server/adapter/db.ts`).
|
|
155
|
+
*/
|
|
156
|
+
export interface IKnexRetryOptions {
|
|
157
|
+
/** When `true`, queries that fail with a transient connection error are retried. Default `false`. */
|
|
158
|
+
enabled?: boolean;
|
|
159
|
+
/** Maximum number of retry attempts per query (excluding the first attempt). Default `3`. */
|
|
160
|
+
maxRetries?: number;
|
|
161
|
+
/** Base delay before the first retry (ms); each retry waits `backoffMs * attempt`. Default `250`. */
|
|
162
|
+
backoffMs?: number;
|
|
163
|
+
}
|
|
164
|
+
|
|
141
165
|
/** The `knex` config block of a DB adapter (adapter.knex / the shared `srv.db`). */
|
|
142
166
|
export interface IKnexConfig {
|
|
143
167
|
client?: string;
|
|
@@ -149,6 +173,17 @@ export interface IKnexConfig {
|
|
|
149
173
|
* `default` / `ci` / `prod`, where the database is provisioned externally.
|
|
150
174
|
*/
|
|
151
175
|
createDatabase?: boolean;
|
|
176
|
+
/**
|
|
177
|
+
* Knex/tarn pool options (passed through to the `knex` pool config, e.g.
|
|
178
|
+
* `maxConnectionLifetimeMillis` to recycle long-lived connections).
|
|
179
|
+
*/
|
|
180
|
+
pool?: {
|
|
181
|
+
/** Max lifetime of a pooled connection in ms before it is recycled. */
|
|
182
|
+
maxConnectionLifetimeMillis?: number;
|
|
183
|
+
[key: string]: unknown;
|
|
184
|
+
};
|
|
185
|
+
/** Opt-in retry of transient fatal connection errors — non-prod only. */
|
|
186
|
+
retry?: IKnexRetryOptions;
|
|
152
187
|
[key: string]: unknown;
|
|
153
188
|
}
|
|
154
189
|
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
import {test} from 'tap';
|
|
13
13
|
|
|
14
14
|
import {wrapKnex} from '../schema/knex/json.ts';
|
|
15
|
-
import {logKnexDeadlock} from './knex.ts';
|
|
15
|
+
import {logKnexConnectionError, logKnexDeadlock} from './knex.ts';
|
|
16
16
|
|
|
17
17
|
function makeLog(): {log: {error: (...a: unknown[]) => void}; calls: Array<{args: unknown[]}>} {
|
|
18
18
|
const calls: Array<{args: unknown[]}> = [];
|
|
@@ -97,6 +97,78 @@ test('wrapKnex onDeadlock wiring logs the deadlock (as wired in adapter start())
|
|
|
97
97
|
t.end();
|
|
98
98
|
});
|
|
99
99
|
|
|
100
|
+
// Shaped like a real mysql2 connection-lost error: `fatal: true` plus the
|
|
101
|
+
// `PROTOCOL_CONNECTION_LOST` code observed intermittently in CI.
|
|
102
|
+
const CONN_LOST = {
|
|
103
|
+
code: 'PROTOCOL_CONNECTION_LOST',
|
|
104
|
+
fatal: true,
|
|
105
|
+
sql: 'UPDATE `t` SET `x` = 1',
|
|
106
|
+
sqlMessage: 'Connection lost: The server closed the connection.',
|
|
107
|
+
message: 'Connection lost: The server closed the connection.',
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
const EXPECTED_CONN_ENTRY = {
|
|
111
|
+
err: CONN_LOST.message,
|
|
112
|
+
code: 'PROTOCOL_CONNECTION_LOST',
|
|
113
|
+
errno: undefined,
|
|
114
|
+
fatal: true,
|
|
115
|
+
sql: CONN_LOST.sql,
|
|
116
|
+
sqlMessage: CONN_LOST.sqlMessage,
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
test('logKnexConnectionError logs full connection-error details when config.debug is set', t => {
|
|
120
|
+
const {log, calls} = makeLog();
|
|
121
|
+
logKnexConnectionError({debug: true}, log, CONN_LOST);
|
|
122
|
+
|
|
123
|
+
t.equal(calls.length, 1, 'logged exactly once');
|
|
124
|
+
t.same(calls[0].args[0], EXPECTED_CONN_ENTRY, 'entry carries err, code, errno, fatal, sql');
|
|
125
|
+
t.equal(calls[0].args[1], 'knex connection error', 'message is "knex connection error"');
|
|
126
|
+
t.end();
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test('logKnexConnectionError logs when logLevel is debug', t => {
|
|
130
|
+
const {log, calls} = makeLog();
|
|
131
|
+
logKnexConnectionError({logLevel: 'debug'}, log, CONN_LOST);
|
|
132
|
+
t.equal(calls.length, 1, 'logged once');
|
|
133
|
+
t.end();
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test('logKnexConnectionError stays silent unless debug or logLevel debug', t => {
|
|
137
|
+
const {log, calls} = makeLog();
|
|
138
|
+
logKnexConnectionError({logLevel: 'info'}, log, CONN_LOST);
|
|
139
|
+
logKnexConnectionError({}, log, CONN_LOST);
|
|
140
|
+
t.same(calls, [], 'no log entries when gating is off');
|
|
141
|
+
t.end();
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test('logKnexConnectionError maps a plain Error to its message and tolerates a missing log', t => {
|
|
145
|
+
const {log, calls} = makeLog();
|
|
146
|
+
logKnexConnectionError({debug: true}, log, new Error('boom'));
|
|
147
|
+
t.equal((calls[0].args[0] as {err?: unknown}).err, 'boom', 'err is the error message');
|
|
148
|
+
t.doesNotThrow(() => logKnexConnectionError({debug: true}, undefined, CONN_LOST), 'no log');
|
|
149
|
+
t.doesNotThrow(
|
|
150
|
+
() => logKnexConnectionError({debug: true}, {}, CONN_LOST),
|
|
151
|
+
'log without error()',
|
|
152
|
+
);
|
|
153
|
+
t.end();
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
test('wrapKnex onConnectionError wiring logs the drop (as wired in adapter start())', async t => {
|
|
157
|
+
const {log, calls} = makeLog();
|
|
158
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
159
|
+
const fakeKnex = (() => makeRejectingBuilder(CONN_LOST)) as any;
|
|
160
|
+
const wrapped = wrapKnex(fakeKnex, {
|
|
161
|
+
onConnectionError: error => logKnexConnectionError({debug: true}, log, error),
|
|
162
|
+
});
|
|
163
|
+
const builder = wrapped('deadlock_demo');
|
|
164
|
+
|
|
165
|
+
await t.rejects(Promise.resolve(builder.then(null, null)), CONN_LOST, 'query still rejects');
|
|
166
|
+
t.equal(calls.length, 1, 'connection error logged once');
|
|
167
|
+
t.equal(calls[0].args[1], 'knex connection error', 'logged with the connection message');
|
|
168
|
+
t.same(calls[0].args[0], EXPECTED_CONN_ENTRY, 'full connection-error details logged');
|
|
169
|
+
t.end();
|
|
170
|
+
});
|
|
171
|
+
|
|
100
172
|
/** A minimal knex-like builder whose `then` rejects with `error`. */
|
|
101
173
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
102
174
|
function makeRejectingBuilder(error: unknown): any {
|
|
@@ -106,6 +106,41 @@ export function logKnexDeadlock(
|
|
|
106
106
|
);
|
|
107
107
|
}
|
|
108
108
|
|
|
109
|
+
/**
|
|
110
|
+
* Log transient MySQL connection-error details (including the offending query)
|
|
111
|
+
* in dev mode. Called from the `wrapKnex` `onConnectionError` hook whenever a
|
|
112
|
+
* query path through the wrapped knex fails with a fatal/connection error
|
|
113
|
+
* (`PROTOCOL_CONNECTION_LOST` and similar) — whether or not retry is enabled.
|
|
114
|
+
* This is the diagnostics breadcrumb for the intermittent CI connection drops:
|
|
115
|
+
* it records which query failed and how the pool was doing at that moment.
|
|
116
|
+
*/
|
|
117
|
+
export function logKnexConnectionError(
|
|
118
|
+
config: {debug?: boolean; logLevel?: string},
|
|
119
|
+
log: unknown,
|
|
120
|
+
error: unknown,
|
|
121
|
+
): void {
|
|
122
|
+
if (!config.debug && config.logLevel !== 'debug') return;
|
|
123
|
+
const err = error as {
|
|
124
|
+
message?: string;
|
|
125
|
+
code?: string;
|
|
126
|
+
errno?: number;
|
|
127
|
+
fatal?: boolean;
|
|
128
|
+
sql?: string;
|
|
129
|
+
sqlMessage?: string;
|
|
130
|
+
};
|
|
131
|
+
(log as {error?: (...args: unknown[]) => void})?.error?.(
|
|
132
|
+
{
|
|
133
|
+
err: err.message ?? err,
|
|
134
|
+
code: err.code,
|
|
135
|
+
errno: err.errno,
|
|
136
|
+
fatal: err.fatal,
|
|
137
|
+
sql: err.sql,
|
|
138
|
+
sqlMessage: err.sqlMessage,
|
|
139
|
+
},
|
|
140
|
+
'knex connection error',
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
|
|
109
144
|
export default adapter<IConfig>(({utError, schema: objectSchema}) => {
|
|
110
145
|
_errors ||= utError.register(errorMap);
|
|
111
146
|
|
|
@@ -165,6 +200,9 @@ export default adapter<IConfig>(({utError, schema: objectSchema}) => {
|
|
|
165
200
|
this.config.context = {
|
|
166
201
|
queryBuilder: wrapKnex(KnexLib(this.config.knex), {
|
|
167
202
|
onDeadlock: error => logKnexDeadlock(this.config, this.log, error),
|
|
203
|
+
onConnectionError: error =>
|
|
204
|
+
logKnexConnectionError(this.config, this.log, error),
|
|
205
|
+
retry: this.config.knex.retry,
|
|
168
206
|
}) as unknown as Knex,
|
|
169
207
|
};
|
|
170
208
|
super.connect();
|
|
@@ -341,6 +379,9 @@ export default adapter<IConfig>(({utError, schema: objectSchema}) => {
|
|
|
341
379
|
this.config.context = {
|
|
342
380
|
queryBuilder: wrapKnex(KnexLib(newKnexConfig as object), {
|
|
343
381
|
onDeadlock: error => logKnexDeadlock(this.config, this.log, error),
|
|
382
|
+
onConnectionError: error =>
|
|
383
|
+
logKnexConnectionError(this.config, this.log, error),
|
|
384
|
+
retry: (newKnexConfig as IKnexConfig).retry ?? this.config.knex.retry,
|
|
344
385
|
}) as unknown as Knex,
|
|
345
386
|
};
|
|
346
387
|
},
|
|
@@ -62,8 +62,11 @@ export default handler<{
|
|
|
62
62
|
|
|
63
63
|
// Public (auth: 'login') endpoints — pre-auth callers hold no blong token, so the MLE
|
|
64
64
|
// request is encrypted with the handshake keys carried in the JWE protected header.
|
|
65
|
-
// Shared by the loginTokenCreate / accessRegistrationAdd / loginTokenExchange
|
|
66
|
-
|
|
65
|
+
// Shared by the loginTokenCreate / accessRegistrationAdd / loginTokenExchange /
|
|
66
|
+
// loginTokenRefresh / loginTokenRestore request senders.
|
|
67
|
+
const encryptPublic = async (
|
|
68
|
+
params: {$http?: unknown} & Record<string, unknown>,
|
|
69
|
+
): Promise<{$http?: unknown} & Record<string, unknown>> => {
|
|
67
70
|
if (!jose) return params;
|
|
68
71
|
const {$http, ...rest} = params;
|
|
69
72
|
const encrypted = (await encrypt(rest, {
|
|
@@ -89,34 +92,74 @@ export default handler<{
|
|
|
89
92
|
refreshTokenExpire = 0;
|
|
90
93
|
}
|
|
91
94
|
|
|
95
|
+
/**
|
|
96
|
+
* Redeem a refresh token at `login.token.refresh` (auth: 'login' — MLE
|
|
97
|
+
* handshake keys, plain JSON-RPC response). On success the new access +
|
|
98
|
+
* refresh tokens are stored in memory. On refusal (revoked / inactive /
|
|
99
|
+
* expired session) the tokens are cleared so the next request surfaces a
|
|
100
|
+
* clean 401 to the caller, which the UI turns into a login prompt.
|
|
101
|
+
*/
|
|
92
102
|
async function refresh(this: {
|
|
93
103
|
exec?(...params: unknown[]): Promise<unknown>;
|
|
94
104
|
error?(error: unknown, $meta?: unknown): void;
|
|
95
|
-
}): Promise<void> {
|
|
105
|
+
}, opts: {force?: boolean} = {}): Promise<void> {
|
|
96
106
|
const now = Date.now();
|
|
97
|
-
if (token && tokenExpire < now) {
|
|
107
|
+
if (token && (opts.force || tokenExpire < now)) {
|
|
98
108
|
if (refreshToken && refreshTokenExpire > now) {
|
|
99
109
|
try {
|
|
100
110
|
pending =
|
|
101
111
|
pending ||
|
|
102
|
-
(
|
|
103
|
-
{
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
112
|
+
(async () => {
|
|
113
|
+
const params = await encryptPublic({refreshToken});
|
|
114
|
+
const result = (await this.exec!(
|
|
115
|
+
{
|
|
116
|
+
path: '/rpc/login/token/refresh',
|
|
117
|
+
method: 'POST',
|
|
118
|
+
responseType: 'json',
|
|
119
|
+
json: {
|
|
120
|
+
jsonrpc: '2.0',
|
|
121
|
+
id: 1,
|
|
122
|
+
method: 'login.token.refresh',
|
|
123
|
+
params,
|
|
124
|
+
},
|
|
109
125
|
},
|
|
110
|
-
|
|
111
|
-
{
|
|
112
|
-
|
|
126
|
+
{},
|
|
127
|
+
)) as {
|
|
128
|
+
statusCode?: number;
|
|
129
|
+
body?: {result?: IToken; error?: {type?: string; message?: string; statusCode?: number}};
|
|
130
|
+
};
|
|
131
|
+
return result;
|
|
132
|
+
})();
|
|
113
133
|
const result = await pending!;
|
|
114
134
|
if (pending !== null) pending = null;
|
|
115
|
-
|
|
135
|
+
const {body, statusCode} = result as {
|
|
136
|
+
statusCode?: number;
|
|
137
|
+
body?: {result?: IToken; error?: {type?: string; message?: string; statusCode?: number}};
|
|
138
|
+
};
|
|
139
|
+
// The gateway MLE-encrypts the response with the handshake
|
|
140
|
+
// keys, so decrypt the result before reading the token.
|
|
141
|
+
await decrypt(body as object, 'result');
|
|
142
|
+
if (body?.error || (statusCode != null && statusCode >= 400)) {
|
|
143
|
+
clearTokens();
|
|
144
|
+
const error = new Error(
|
|
145
|
+
body?.error?.message || 'Token refresh failed',
|
|
146
|
+
) as Error & {
|
|
147
|
+
type?: string;
|
|
148
|
+
statusCode?: number;
|
|
149
|
+
auth?: boolean;
|
|
150
|
+
};
|
|
151
|
+
error.type = body?.error?.type || 'rpc.refreshFailed';
|
|
152
|
+
error.statusCode = body?.error?.statusCode ?? statusCode ?? 401;
|
|
153
|
+
error.auth = true;
|
|
154
|
+
throw error;
|
|
155
|
+
}
|
|
156
|
+
readToken(body!.result as IToken);
|
|
116
157
|
} catch (error) {
|
|
117
158
|
pending = null;
|
|
118
|
-
|
|
119
|
-
|
|
159
|
+
// Keep auth-classified failures as-is; otherwise drop tokens
|
|
160
|
+
// so the next request reports a clean 401.
|
|
161
|
+
if (!(error as {auth?: boolean}).auth) clearTokens();
|
|
162
|
+
throw error;
|
|
120
163
|
}
|
|
121
164
|
} else clearTokens();
|
|
122
165
|
}
|
|
@@ -184,6 +227,12 @@ export default handler<{
|
|
|
184
227
|
$http.headers.authorization = 'Bearer ' + token;
|
|
185
228
|
}
|
|
186
229
|
if ($http && params) params.$http = $http;
|
|
230
|
+
// An unexpected 401 is surfaced as-is: the automatic pre-send
|
|
231
|
+
// `refresh()` above already renewed the token when it was close to
|
|
232
|
+
// expiry, so a 401 here means the session is genuinely unusable
|
|
233
|
+
// (e.g. revoked/closed server-side) — a forced renewal would only
|
|
234
|
+
// add a failing round-trip. The UI turns the 401 into a login
|
|
235
|
+
// prompt.
|
|
187
236
|
return super.send(params, $meta);
|
|
188
237
|
},
|
|
189
238
|
async receive(
|
|
@@ -214,6 +263,18 @@ export default handler<{
|
|
|
214
263
|
},
|
|
215
264
|
async loginTokenCreateResponseReceive(result: Response<{result: unknown}>, $meta: unknown) {
|
|
216
265
|
await decrypt(result.body, 'result');
|
|
266
|
+
if ((result.body as {error?: unknown})?.error) return super.receive(result, $meta);
|
|
267
|
+
readToken(result.body.result as IToken);
|
|
268
|
+
return super.receive(result, $meta);
|
|
269
|
+
},
|
|
270
|
+
// Explicit token renewal (auth: 'login') — the same path the automatic
|
|
271
|
+
// `refresh()` uses; feeds the new tokens into memory.
|
|
272
|
+
async loginTokenRefreshRequestSend(params: {$http?: unknown}, $meta: unknown) {
|
|
273
|
+
return super.send(await encryptPublic(params), $meta);
|
|
274
|
+
},
|
|
275
|
+
async loginTokenRefreshResponseReceive(result: Response<{result: unknown}>, $meta: unknown) {
|
|
276
|
+
await decrypt(result.body, 'result');
|
|
277
|
+
if ((result.body as {error?: unknown})?.error) return super.receive(result, $meta);
|
|
217
278
|
readToken(result.body.result as IToken);
|
|
218
279
|
return super.receive(result, $meta);
|
|
219
280
|
},
|
|
@@ -224,5 +285,16 @@ export default handler<{
|
|
|
224
285
|
async loginTokenExchangeRequestSend(params: {$http?: unknown}, $meta: unknown) {
|
|
225
286
|
return super.send(await encryptPublic(params), $meta);
|
|
226
287
|
},
|
|
288
|
+
// Session restore (auth: 'login') — exchanges the path-scoped HttpOnly
|
|
289
|
+
// cookie for fresh tokens; the response feeds the same readToken path.
|
|
290
|
+
async loginTokenRestoreRequestSend(params: {$http?: unknown}, $meta: unknown) {
|
|
291
|
+
return super.send(await encryptPublic(params), $meta);
|
|
292
|
+
},
|
|
293
|
+
async loginTokenRestoreResponseReceive(result: Response<{result: unknown}>, $meta: unknown) {
|
|
294
|
+
await decrypt(result.body, 'result');
|
|
295
|
+
if ((result.body as {error?: unknown})?.error) return super.receive(result, $meta);
|
|
296
|
+
readToken(result.body.result as IToken);
|
|
297
|
+
return super.receive(result, $meta);
|
|
298
|
+
},
|
|
227
299
|
};
|
|
228
300
|
});
|
package/src/globals.d.ts
CHANGED
|
@@ -57,12 +57,6 @@ declare module 'ut-dns-discovery' {
|
|
|
57
57
|
export default discovery;
|
|
58
58
|
}
|
|
59
59
|
|
|
60
|
-
// Vite / esbuild glob-import support
|
|
61
|
-
interface ImportMeta {
|
|
62
|
-
glob(patterns: string | string[]): Record<string, () => Promise<unknown>>;
|
|
63
|
-
glob<T>(patterns: string | string[], options?: object): Record<string, () => Promise<T>>;
|
|
64
|
-
}
|
|
65
|
-
|
|
66
60
|
declare module 'picomatch' {
|
|
67
61
|
function picomatch(glob: string | string[], options?: object): (path: string) => boolean;
|
|
68
62
|
export default picomatch;
|
package/src/jwt.ts
CHANGED
|
@@ -19,17 +19,140 @@ declare module 'fastify' {
|
|
|
19
19
|
permissionMap?: Buffer;
|
|
20
20
|
actorId?: string | number;
|
|
21
21
|
sessionId?: string;
|
|
22
|
+
/** Audit record id of the access-check audit for this request (set by `recordAccessAudit`). */
|
|
23
|
+
auditId?: string;
|
|
22
24
|
/** Allowed action methodIds — populated by the authorize handler. */
|
|
23
25
|
actions?: string[];
|
|
24
26
|
};
|
|
25
27
|
};
|
|
26
28
|
}
|
|
27
29
|
interface FastifyReply {
|
|
28
|
-
unstate: (name: string) => this;
|
|
30
|
+
unstate: (name: string, options?: unknown) => this;
|
|
29
31
|
state: (name: string, value: string, options: unknown) => this;
|
|
30
32
|
}
|
|
31
33
|
interface FastifyContextConfig {
|
|
32
34
|
methodName?: string;
|
|
35
|
+
audit?: boolean;
|
|
36
|
+
skipAuthorize?: boolean;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Methods that must never be recorded by the access-check audit — they are
|
|
42
|
+
* part of the access-control machinery itself and would recurse.
|
|
43
|
+
*/
|
|
44
|
+
const AUDIT_HARD_EXCLUDE = ['access.audit.record', 'access.authorization.list'];
|
|
45
|
+
|
|
46
|
+
/** Entity tables whose DML carries a sanitised detail summary in the audit. */
|
|
47
|
+
const AUDIT_DML_ENTITIES = [
|
|
48
|
+
'user',
|
|
49
|
+
'role',
|
|
50
|
+
'capability',
|
|
51
|
+
'action',
|
|
52
|
+
'credential',
|
|
53
|
+
'access',
|
|
54
|
+
'policy',
|
|
55
|
+
'flow',
|
|
56
|
+
];
|
|
57
|
+
const AUDIT_WRITE_OPS = ['add', 'edit', 'remove', 'insert', 'update', 'delete', 'merge'];
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Sanitised DML context for `access.*` write methods — the entity plus its
|
|
61
|
+
* id/name keys only. Full params (credentials, hashes, descriptions) are
|
|
62
|
+
* deliberately never recorded.
|
|
63
|
+
*/
|
|
64
|
+
function auditDetail(methodName: string, params: unknown): object | undefined {
|
|
65
|
+
const [subject, object, operation] = String(methodName).split('.');
|
|
66
|
+
if (
|
|
67
|
+
subject !== 'access' ||
|
|
68
|
+
!AUDIT_DML_ENTITIES.includes(object) ||
|
|
69
|
+
!AUDIT_WRITE_OPS.includes(operation)
|
|
70
|
+
) {
|
|
71
|
+
return undefined;
|
|
72
|
+
}
|
|
73
|
+
const p = (params && typeof params === 'object' ? params : {}) as Record<string, unknown>;
|
|
74
|
+
const summary: Record<string, unknown> = {entity: object};
|
|
75
|
+
for (const [key, value] of Object.entries(p)) {
|
|
76
|
+
if (
|
|
77
|
+
/Id$|Name$|Key$/.test(key) &&
|
|
78
|
+
(typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean')
|
|
79
|
+
) {
|
|
80
|
+
summary[key] = value;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return summary;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Best-effort recording of an access-control decision at the gateway access
|
|
88
|
+
* check. Resolves the configured audit handler (like `authorize`) and hands
|
|
89
|
+
* it a single audit entry with the actor/session/method/outcome plus sanitised
|
|
90
|
+
* DML context. The inserted record key is exposed on `request.auth.credentials.
|
|
91
|
+
* auditId` (→ `$meta.auth.auditId`). Callers await it but must never let an
|
|
92
|
+
* audit failure fail the request.
|
|
93
|
+
*/
|
|
94
|
+
async function recordAccessAudit(
|
|
95
|
+
request: FastifyRequest,
|
|
96
|
+
audit: {handler: string; exclude?: string[]},
|
|
97
|
+
methodName: string,
|
|
98
|
+
allowed: boolean,
|
|
99
|
+
statusCode: number,
|
|
100
|
+
local: ILocal,
|
|
101
|
+
methodIdFn: (name: string) => string,
|
|
102
|
+
methodPartsFn: (name: string) => string,
|
|
103
|
+
): Promise<void> {
|
|
104
|
+
// Route-level opt-out (e.g. operations that audit themselves).
|
|
105
|
+
if (request.routeOptions.config.audit === false) return;
|
|
106
|
+
const methodIdName = methodIdFn(methodName);
|
|
107
|
+
if (AUDIT_HARD_EXCLUDE.includes(methodIdName)) return;
|
|
108
|
+
const excluded = (audit.exclude ?? []).some(pattern => {
|
|
109
|
+
if (pattern.endsWith('*')) return methodIdName.startsWith(pattern.slice(0, -1));
|
|
110
|
+
return methodIdName === methodIdFn(pattern);
|
|
111
|
+
});
|
|
112
|
+
if (excluded) return;
|
|
113
|
+
|
|
114
|
+
const handlerName = methodPartsFn(audit.handler);
|
|
115
|
+
const reqName = `ports.${handlerName.split('.', 1)[0]}.request`;
|
|
116
|
+
const handler = local.get(reqName);
|
|
117
|
+
if (!handler) return;
|
|
118
|
+
|
|
119
|
+
const credentials = (request.auth?.credentials ?? {}) as {
|
|
120
|
+
actorId?: string;
|
|
121
|
+
sessionId?: string;
|
|
122
|
+
};
|
|
123
|
+
const forwarded = (request.headers['x-forwarded-for'] ?? '') as string | string[];
|
|
124
|
+
const ipAddress =
|
|
125
|
+
([] as string[]).concat(forwarded)[0]?.split(',')[0] || request.socket.remoteAddress || '';
|
|
126
|
+
const params = (request.body as {params?: unknown} | undefined)?.params;
|
|
127
|
+
const detail = auditDetail(methodName, params);
|
|
128
|
+
const result = (await handler.method(
|
|
129
|
+
{
|
|
130
|
+
audit: [
|
|
131
|
+
{
|
|
132
|
+
actorId: credentials.actorId,
|
|
133
|
+
sessionId: credentials.sessionId,
|
|
134
|
+
actionName: methodName,
|
|
135
|
+
isSuccess: allowed,
|
|
136
|
+
statusCode,
|
|
137
|
+
ipAddress,
|
|
138
|
+
...(detail && {detail}),
|
|
139
|
+
},
|
|
140
|
+
],
|
|
141
|
+
},
|
|
142
|
+
{
|
|
143
|
+
method: handlerName,
|
|
144
|
+
mtid: 'request',
|
|
145
|
+
auth: credentials,
|
|
146
|
+
ipAddress,
|
|
147
|
+
httpRequest: {url: request.raw?.url || request.url},
|
|
148
|
+
},
|
|
149
|
+
)) as {auditIds?: string[]} | undefined;
|
|
150
|
+
// Expose the inserted audit record key on the request auth — the gateway's
|
|
151
|
+
// `_meta()` spreads `req.auth.credentials` into the handler `$meta.auth`, so
|
|
152
|
+
// the audited handler sees it as `$meta.auth.auditId`.
|
|
153
|
+
const auditId = result?.auditIds?.[0];
|
|
154
|
+
if (auditId && request.auth) {
|
|
155
|
+
request.auth.credentials = {...request.auth.credentials, auditId};
|
|
33
156
|
}
|
|
34
157
|
}
|
|
35
158
|
|
|
@@ -39,6 +162,7 @@ export default fp<{
|
|
|
39
162
|
verify: IGatewayCodec['verify'];
|
|
40
163
|
errors: Errors<object>;
|
|
41
164
|
authorize?: string;
|
|
165
|
+
audit?: {handler: string; exclude?: string[]};
|
|
42
166
|
local?: ILocal;
|
|
43
167
|
methodId?: (name: string) => string;
|
|
44
168
|
methodParts?: (name: string) => string;
|
|
@@ -51,6 +175,7 @@ export default fp<{
|
|
|
51
175
|
verify,
|
|
52
176
|
errors,
|
|
53
177
|
authorize,
|
|
178
|
+
audit,
|
|
54
179
|
local,
|
|
55
180
|
methodId,
|
|
56
181
|
methodParts,
|
|
@@ -143,14 +268,18 @@ export default fp<{
|
|
|
143
268
|
|
|
144
269
|
// Authorization hook: check the called method against allowed actions
|
|
145
270
|
if (authorize && local && methodId) {
|
|
146
|
-
fastify.addHook('preHandler', function (request, _reply
|
|
271
|
+
fastify.addHook('preHandler', async function (request, _reply) {
|
|
147
272
|
// Routes with auth: false or auth: 'login' don't go through bearer auth,
|
|
148
273
|
// so credentials have no actions. Skip the authorization check.
|
|
149
274
|
if (
|
|
150
275
|
!request.routeOptions.config.auth ||
|
|
151
276
|
request.routeOptions.config.auth === 'login'
|
|
152
277
|
) {
|
|
153
|
-
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
280
|
+
// Self-service methods (e.g. logout) are bearer-authenticated but
|
|
281
|
+
// operate only on the caller's own session — no RBAC action needed.
|
|
282
|
+
if (request.routeOptions.config.skipAuthorize) {
|
|
154
283
|
return;
|
|
155
284
|
}
|
|
156
285
|
// The authorize handler itself must be accessible without authorization,
|
|
@@ -160,36 +289,53 @@ export default fp<{
|
|
|
160
289
|
if (authorize && methodName && methodParts) {
|
|
161
290
|
const normalizedMethod = methodParts(authorize);
|
|
162
291
|
if (methodName === normalizedMethod) {
|
|
163
|
-
done();
|
|
164
292
|
return;
|
|
165
293
|
}
|
|
166
294
|
}
|
|
167
295
|
const credentials = request.auth?.credentials;
|
|
168
296
|
if (!credentials?.actions) {
|
|
169
|
-
|
|
170
|
-
return;
|
|
297
|
+
throw new Error('Authorization denied: no actions resolved');
|
|
171
298
|
}
|
|
172
299
|
if (!methodName) {
|
|
173
|
-
|
|
174
|
-
return;
|
|
300
|
+
return; // no method configured — allow (backward compat)
|
|
175
301
|
}
|
|
176
302
|
const requestedId = methodId(methodName);
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
303
|
+
const allowed = credentials.actions.includes(requestedId);
|
|
304
|
+
// Record the access decision along with the access check — applies to
|
|
305
|
+
// every operation controlled through it. Best-effort (an audit failure
|
|
306
|
+
// must never fail the request) but awaited so the inserted `auditId`
|
|
307
|
+
// lands on `request.auth.credentials.auditId` (→ `$meta.auth.auditId`)
|
|
308
|
+
// before the business handler runs.
|
|
309
|
+
if (audit && methodName && local && methodId && methodParts) {
|
|
310
|
+
try {
|
|
311
|
+
await recordAccessAudit(
|
|
312
|
+
request,
|
|
313
|
+
audit,
|
|
314
|
+
methodName,
|
|
315
|
+
allowed,
|
|
316
|
+
allowed ? 200 : 403,
|
|
317
|
+
local,
|
|
318
|
+
methodId,
|
|
319
|
+
methodParts,
|
|
320
|
+
);
|
|
321
|
+
} catch {
|
|
322
|
+
// ignore — audit is best-effort
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
if (!allowed) {
|
|
180
326
|
const error = new Error(
|
|
181
327
|
`Authorization denied: method "${methodName}" not allowed`,
|
|
182
328
|
) as Error & {statusCode: number};
|
|
183
329
|
error.statusCode = 403;
|
|
184
|
-
|
|
330
|
+
throw error;
|
|
185
331
|
}
|
|
186
332
|
});
|
|
187
333
|
}
|
|
188
334
|
|
|
189
335
|
await fastify.register(cookie, {});
|
|
190
336
|
fastify.decorateRequest('auth');
|
|
191
|
-
fastify.decorateReply('unstate', function (name: string) {
|
|
192
|
-
return this.clearCookie(name);
|
|
337
|
+
fastify.decorateReply('unstate', function (name: string, options?: unknown) {
|
|
338
|
+
return this.clearCookie(name, options as {path?: string});
|
|
193
339
|
});
|
|
194
340
|
(fastify.decorateReply as (name: string, fn: unknown) => void)(
|
|
195
341
|
'state',
|
package/src/load.ts
CHANGED
|
@@ -575,6 +575,11 @@ export default async function loadRealm<T extends TSchema>(
|
|
|
575
575
|
deps: ['log'],
|
|
576
576
|
load: () => import(/* @vite-ignore */ './Gateway' + extension),
|
|
577
577
|
},
|
|
578
|
+
{
|
|
579
|
+
name: 'apiGateway',
|
|
580
|
+
deps: ['log', 'gateway', 'local'],
|
|
581
|
+
load: () => import(/* @vite-ignore */ './ApiGateway' + extension),
|
|
582
|
+
},
|
|
578
583
|
{
|
|
579
584
|
name: 'restFs',
|
|
580
585
|
deps: ['log', 'gateway'],
|
|
@@ -17,6 +17,9 @@ export default browser(blong => ({
|
|
|
17
17
|
async function ui() {
|
|
18
18
|
return import('@feasibleone/blong-browser/browser.ts');
|
|
19
19
|
},
|
|
20
|
+
async function login() {
|
|
21
|
+
return import('@feasibleone/blong-login/browser.ts');
|
|
22
|
+
},
|
|
20
23
|
async function $subject() {
|
|
21
24
|
return import('./browser.ts');
|
|
22
25
|
},
|
|
@@ -30,6 +33,7 @@ export default browser(blong => ({
|
|
|
30
33
|
},
|
|
31
34
|
},
|
|
32
35
|
},
|
|
36
|
+
login: {},
|
|
33
37
|
$subject: {},
|
|
34
38
|
},
|
|
35
39
|
},
|