@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 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.29.0",
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": {
@@ -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
- isExpectedError(typedError.type as string | undefined, resolvedExpect)
535
- ) {
536
- request.log.debug(
537
- {err: error, method: methodName},
538
- 'gateway expected error',
539
- );
540
- } else {
541
- request.log.error(
542
- {err: error, method: methodName},
543
- 'gateway handler error',
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,
@@ -1,3 +1,4 @@
1
+ /// <reference types="vite/client" />
1
2
  import {realm} from '@feasibleone/blong/types';
2
3
 
3
4
  export default realm(blong => ({
@@ -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
- * Optional hooks applied while wrapping the shared knex instance.
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
- originalThen(
128
- (result: unknown) => {
129
- const processed = parseJsonResult(result);
130
- return typeof onFulfilled === 'function' ? onFulfilled(processed) : processed;
131
- },
132
- (reason: unknown) => {
133
- if (isDeadlock(reason)) options.onDeadlock?.(reason);
134
- if (typeof onRejected === 'function') return onRejected(reason);
135
- throw reason;
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`. Mirrors the
145
- * rejection interception in {@link wrapJsonBuilder} without the JSON column
146
- * handling, which does not apply to `raw` queries / stored-procedure calls.
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 attachDeadlockHook(thenable: any, options: WrapKnexOptions): any {
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
- originalThen(onFulfilled, (reason: unknown) => {
156
- if (isDeadlock(reason)) options.onDeadlock?.(reason);
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[]) => attachDeadlockHook(bound(...args), options);
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 request senders.
66
- const encryptPublic = async (params: {$http?: unknown}): Promise<{$http?: unknown}> => {
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
- (this.exec!(
103
- {
104
- path: '/rpc/login/token',
105
- method: 'POST',
106
- form: {
107
- grant_type: 'refresh_token',
108
- refresh_token: refreshToken,
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
- ) as Promise<{body?: unknown}>);
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
- readToken(result.body as IToken);
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
- clearTokens();
119
- this.error!(error);
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, done) {
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
- done();
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
- done(new Error('Authorization denied: no actions resolved'));
170
- return;
297
+ throw new Error('Authorization denied: no actions resolved');
171
298
  }
172
299
  if (!methodName) {
173
- done(); // no method configured — allow (backward compat)
174
- return;
300
+ return; // no method configured — allow (backward compat)
175
301
  }
176
302
  const requestedId = methodId(methodName);
177
- if (credentials.actions.includes(requestedId)) {
178
- done();
179
- } else {
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
- done(error);
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
  },