@feasibleone/blong-gogo 1.21.0 → 1.23.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 +22 -0
- package/bin/blong-dev.ts +8 -1
- package/bin/blong.ts +8 -1
- package/package.json +3 -2
- package/src/AdapterBase.ts +6 -4
- package/src/Gateway.ts +30 -6
- package/src/adapter/server/keycloak.ts +11 -4
- package/src/expected-errors.test.ts +291 -0
- package/src/jose.test.ts +1 -1
- package/src/jose.ts +2 -2
- package/src/lib.ts +22 -0
- package/src/loadServer.ts +7 -1
- package/src/runServer.test.ts +114 -0
- package/src/runServer.ts +28 -12
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,27 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [1.23.0](https://github.com/feasibleone/blong/compare/blong-gogo-v1.22.0...blong-gogo-v1.23.0) (2026-05-14)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Features
|
|
7
|
+
|
|
8
|
+
* expected errors ([57ab05e](https://github.com/feasibleone/blong/commit/57ab05ed66b86405561f519e395e30f43b4fffa2))
|
|
9
|
+
|
|
10
|
+
## [1.22.0](https://github.com/feasibleone/blong/compare/blong-gogo-v1.21.0...blong-gogo-v1.22.0) (2026-05-14)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
### Features
|
|
14
|
+
|
|
15
|
+
* intents ([5b238b0](https://github.com/feasibleone/blong/commit/5b238b064be7e20a051e963f23762012ce96bcb9))
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
### Bug Fixes
|
|
19
|
+
|
|
20
|
+
* improve integration test coverage for keycloak, S3, and vault adapters ([#149](https://github.com/feasibleone/blong/issues/149)) ([453c9ac](https://github.com/feasibleone/blong/commit/453c9ac17bdde48c526d92e465aeeb6a6fd816bb))
|
|
21
|
+
* integration tests coverage report ([ce3ae79](https://github.com/feasibleone/blong/commit/ce3ae79e8056fab2d09d5f0779f24997773ba3c8))
|
|
22
|
+
* integration tests coverage report ([4215fc1](https://github.com/feasibleone/blong/commit/4215fc1a8620e3cf36d748f55aabb0c85917d2f2))
|
|
23
|
+
* integration tests coverage report ([bb8d3c7](https://github.com/feasibleone/blong/commit/bb8d3c7b0eb64c6be9d90d70ac1469c065139f31))
|
|
24
|
+
|
|
3
25
|
## [1.21.0](https://github.com/feasibleone/blong/compare/blong-gogo-v1.20.0...blong-gogo-v1.21.0) (2026-05-12)
|
|
4
26
|
|
|
5
27
|
|
package/bin/blong-dev.ts
CHANGED
|
@@ -1,8 +1,15 @@
|
|
|
1
1
|
#!/usr/bin/env -S node --watch --conditions=development --inspect
|
|
2
2
|
|
|
3
3
|
import minimist from 'minimist';
|
|
4
|
+
import {existsSync} from 'node:fs';
|
|
5
|
+
import {resolve} from 'node:path';
|
|
4
6
|
import {autoRun} from '../src/runServer.ts';
|
|
5
7
|
|
|
6
8
|
const argv: {_: string[]} = minimist(process.argv.slice(2));
|
|
7
9
|
|
|
8
|
-
|
|
10
|
+
// The first positional arg is an optional file/folder target; the rest are intents.
|
|
11
|
+
const [maybeTarget, ...rest] = argv._;
|
|
12
|
+
const target = maybeTarget && existsSync(resolve(maybeTarget)) ? maybeTarget : undefined;
|
|
13
|
+
const intents = target ? rest : argv._;
|
|
14
|
+
|
|
15
|
+
await autoRun({cwd: process.cwd(), target, intents});
|
package/bin/blong.ts
CHANGED
|
@@ -1,8 +1,15 @@
|
|
|
1
1
|
#!/usr/bin/env -S node
|
|
2
2
|
|
|
3
3
|
import minimist from 'minimist';
|
|
4
|
+
import {existsSync} from 'node:fs';
|
|
5
|
+
import {resolve} from 'node:path';
|
|
4
6
|
import {autoRun} from '../src/runServer.ts';
|
|
5
7
|
|
|
6
8
|
const argv: {_: string[]} = minimist(process.argv.slice(2));
|
|
7
9
|
|
|
8
|
-
|
|
10
|
+
// The first positional arg is an optional file/folder target; the rest are intents.
|
|
11
|
+
const [maybeTarget, ...rest] = argv._;
|
|
12
|
+
const target = maybeTarget && existsSync(resolve(maybeTarget)) ? maybeTarget : undefined;
|
|
13
|
+
const intents = target ? rest : argv._;
|
|
14
|
+
|
|
15
|
+
await autoRun({cwd: process.cwd(), target, intents});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@feasibleone/blong-gogo",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.23.0",
|
|
4
4
|
"repository": {
|
|
5
5
|
"url": "git+https://github.com/feasibleone/blong.git"
|
|
6
6
|
},
|
|
@@ -84,6 +84,7 @@
|
|
|
84
84
|
"build": "true",
|
|
85
85
|
"ci-lint": "tsc --noEmit",
|
|
86
86
|
"ci-publish": "node ../../common/scripts/install-run-rush-pnpm.js publish --access public --provenance",
|
|
87
|
-
"ci-unit": "
|
|
87
|
+
"ci-unit": "../common/run-coverage.sh",
|
|
88
|
+
"ci-coverage": "../common/run-coverage.sh report"
|
|
88
89
|
}
|
|
89
90
|
}
|
package/src/AdapterBase.ts
CHANGED
|
@@ -14,6 +14,7 @@ import PQueue from 'p-queue';
|
|
|
14
14
|
import merge from 'ut-function.merge';
|
|
15
15
|
|
|
16
16
|
import ConfigRuntime from './ConfigRuntime.ts';
|
|
17
|
+
import {isExpectedError} from './lib.ts';
|
|
17
18
|
import loop from './loop.ts';
|
|
18
19
|
|
|
19
20
|
const errorMap: IErrorMap = {
|
|
@@ -177,11 +178,12 @@ export class AdapterBase<T, C extends IContext> implements AdapterHandlerContext
|
|
|
177
178
|
}
|
|
178
179
|
|
|
179
180
|
error(error: ITypedError, $meta: IMeta): void {
|
|
180
|
-
if (
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
181
|
+
if ($meta) error.method = $meta.method;
|
|
182
|
+
if (isExpectedError(error.type, $meta?.expect)) {
|
|
183
|
+
(this.log as {debug?: (...args: unknown[]) => void})?.debug?.(error);
|
|
184
|
+
return;
|
|
184
185
|
}
|
|
186
|
+
(this.log as {error?: (...args: unknown[]) => void})?.error?.(error);
|
|
185
187
|
}
|
|
186
188
|
|
|
187
189
|
findValidation(): unknown {
|
package/src/Gateway.ts
CHANGED
|
@@ -20,7 +20,7 @@ import {v4} from 'uuid';
|
|
|
20
20
|
import type {IResolution} from './Resolution.ts';
|
|
21
21
|
import type {IRpcClient} from './RpcClient.ts';
|
|
22
22
|
import jwt from './jwt.ts';
|
|
23
|
-
import {methodParts, snakeToCamel} from './lib.ts';
|
|
23
|
+
import {isExpectedError, methodParts, snakeToCamel} from './lib.ts';
|
|
24
24
|
import type {IConfig as IConfigMLE} from './mle.ts';
|
|
25
25
|
import swagger from './swagger.ts';
|
|
26
26
|
|
|
@@ -60,6 +60,15 @@ interface IConfig extends IConfigMLE {
|
|
|
60
60
|
logLevel?: LevelWithSilent;
|
|
61
61
|
cors?: unknown;
|
|
62
62
|
debug?: boolean;
|
|
63
|
+
/**
|
|
64
|
+
* When true, the gateway accepts the `expect` field from JSON-RPC request
|
|
65
|
+
* bodies and uses it to suppress error-level log entries for declared
|
|
66
|
+
* expected errors (demoting them to `debug` level).
|
|
67
|
+
*
|
|
68
|
+
* Set to `true` in the `dev` intent. Must be `false` (the default) in
|
|
69
|
+
* production to prevent callers from suppressing audit-level error logs.
|
|
70
|
+
*/
|
|
71
|
+
expectedErrors?: boolean;
|
|
63
72
|
errorFields: unknown[];
|
|
64
73
|
jwt: {
|
|
65
74
|
cache: object;
|
|
@@ -147,6 +156,7 @@ export default class Gateway extends Internal implements IGateway {
|
|
|
147
156
|
encrypt: undefined,
|
|
148
157
|
},
|
|
149
158
|
debug: false,
|
|
159
|
+
expectedErrors: false,
|
|
150
160
|
errorFields: [],
|
|
151
161
|
jwt: {
|
|
152
162
|
cache: {},
|
|
@@ -418,6 +428,13 @@ export default class Gateway extends Internal implements IGateway {
|
|
|
418
428
|
timeout: false,
|
|
419
429
|
expect: undefined,
|
|
420
430
|
};
|
|
431
|
+
// Only honour `expect` from the request body when the gateway
|
|
432
|
+
// is configured to allow it. This prevents external callers
|
|
433
|
+
// from suppressing error-level logs in production.
|
|
434
|
+
const resolvedExpect =
|
|
435
|
+
this.#config.expectedErrors && expect
|
|
436
|
+
? ([] as string[]).concat(expect)
|
|
437
|
+
: undefined;
|
|
421
438
|
const methodName = isWildcard
|
|
422
439
|
? new URL(request.url, 'http://localhost').pathname
|
|
423
440
|
.slice(5)
|
|
@@ -432,7 +449,7 @@ export default class Gateway extends Internal implements IGateway {
|
|
|
432
449
|
...(timeout && {
|
|
433
450
|
timeout: this.#platform.timing.after(timeout as number),
|
|
434
451
|
}),
|
|
435
|
-
...(
|
|
452
|
+
...(resolvedExpect && {expect: resolvedExpect}),
|
|
436
453
|
...this._meta(request, pkg?.version, methodName.split('.')[0]),
|
|
437
454
|
};
|
|
438
455
|
const notfound = (): unknown =>
|
|
@@ -483,10 +500,17 @@ export default class Gateway extends Internal implements IGateway {
|
|
|
483
500
|
httpResponse?: unknown;
|
|
484
501
|
[key: string]: unknown;
|
|
485
502
|
};
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
503
|
+
if (isExpectedError(typedError.type as string | undefined, resolvedExpect)) {
|
|
504
|
+
request.log.debug(
|
|
505
|
+
{err: error, method: methodName},
|
|
506
|
+
'gateway expected error',
|
|
507
|
+
);
|
|
508
|
+
} else {
|
|
509
|
+
request.log.error(
|
|
510
|
+
{err: error, method: methodName},
|
|
511
|
+
'gateway handler error',
|
|
512
|
+
);
|
|
513
|
+
}
|
|
490
514
|
this._applyMeta(
|
|
491
515
|
reply
|
|
492
516
|
.header('x-envoy-decorator-operation', methodName)
|
|
@@ -249,7 +249,7 @@ export default adapter<IConfig>(({utError}) => {
|
|
|
249
249
|
|
|
250
250
|
case 'update':
|
|
251
251
|
case 'edit': {
|
|
252
|
-
const {id: userId, ...updateData} = handleParams;
|
|
252
|
+
const {id: userId, realm: _realm, ...updateData} = handleParams;
|
|
253
253
|
if (!userId) throw _errors['keycloak.missingKey']({key: 'id'});
|
|
254
254
|
return await client.users.update({id: userId as string}, updateData);
|
|
255
255
|
}
|
|
@@ -368,7 +368,7 @@ export default adapter<IConfig>(({utError}) => {
|
|
|
368
368
|
|
|
369
369
|
case 'update':
|
|
370
370
|
case 'edit': {
|
|
371
|
-
const {id: groupId, ...updateData} = handleParams;
|
|
371
|
+
const {id: groupId, realm: _realm, ...updateData} = handleParams;
|
|
372
372
|
if (!groupId) throw _errors['keycloak.missingKey']({key: 'id'});
|
|
373
373
|
return await client.groups.update({id: groupId as string}, updateData);
|
|
374
374
|
}
|
|
@@ -483,6 +483,7 @@ export default adapter<IConfig>(({utError}) => {
|
|
|
483
483
|
const {
|
|
484
484
|
roleName: updateRoleName,
|
|
485
485
|
clientUuid: updateClientUuid,
|
|
486
|
+
realm: _realm,
|
|
486
487
|
...updateData
|
|
487
488
|
} = handleParams;
|
|
488
489
|
if (!updateRoleName)
|
|
@@ -499,7 +500,7 @@ export default adapter<IConfig>(({utError}) => {
|
|
|
499
500
|
} else {
|
|
500
501
|
return await client.roles.updateByName(
|
|
501
502
|
{name: updateRoleName as string},
|
|
502
|
-
updateData,
|
|
503
|
+
{name: updateRoleName as string, ...updateData},
|
|
503
504
|
);
|
|
504
505
|
}
|
|
505
506
|
}
|
|
@@ -614,7 +615,11 @@ export default adapter<IConfig>(({utError}) => {
|
|
|
614
615
|
|
|
615
616
|
case 'update':
|
|
616
617
|
case 'edit': {
|
|
617
|
-
const {
|
|
618
|
+
const {
|
|
619
|
+
id: clientUpdateId,
|
|
620
|
+
realm: _realm,
|
|
621
|
+
...clientUpdateData
|
|
622
|
+
} = handleParams;
|
|
618
623
|
if (!clientUpdateId) throw _errors['keycloak.missingKey']({key: 'id'});
|
|
619
624
|
return await client.clients.update(
|
|
620
625
|
{id: clientUpdateId as string},
|
|
@@ -762,6 +767,8 @@ export default adapter<IConfig>(({utError}) => {
|
|
|
762
767
|
throw _errors['keycloak.invalid']();
|
|
763
768
|
}
|
|
764
769
|
} catch (error: unknown) {
|
|
770
|
+
// Re-throw already-typed blong errors without wrapping them
|
|
771
|
+
if (typeof (error as {type?: string}).type === 'string') throw error;
|
|
765
772
|
const keycloakError = error as {
|
|
766
773
|
response?: {status?: number; data?: {error?: string}};
|
|
767
774
|
};
|
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for the "expected errors" feature.
|
|
3
|
+
*
|
|
4
|
+
* Covers:
|
|
5
|
+
* - isExpectedError() matching rules (exact, array, wildcard)
|
|
6
|
+
* - AdapterBase.error() log-level demotion for expected errors
|
|
7
|
+
* - Gateway respects `expectedErrors` config flag
|
|
8
|
+
* - Propagation: expect travels with $meta through the handler chain
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import assert from 'node:assert';
|
|
12
|
+
import {describe, it} from 'node:test';
|
|
13
|
+
|
|
14
|
+
import {isExpectedError} from './lib.ts';
|
|
15
|
+
|
|
16
|
+
// ---------------------------------------------------------------------------
|
|
17
|
+
// isExpectedError() — matching rules
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
|
|
20
|
+
describe('isExpectedError — matching rules', () => {
|
|
21
|
+
it('returns false when error type is undefined', () => {
|
|
22
|
+
assert.strictEqual(isExpectedError(undefined, 'foo.bar'), false);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('returns false when expect is undefined', () => {
|
|
26
|
+
assert.strictEqual(isExpectedError('foo.bar', undefined), false);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('returns false when expect is an empty array', () => {
|
|
30
|
+
assert.strictEqual(isExpectedError('foo.bar', []), false);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('exact string — matches identical type', () => {
|
|
34
|
+
assert.strictEqual(isExpectedError('parking.invalidZone', 'parking.invalidZone'), true);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('exact string — does not match different type', () => {
|
|
38
|
+
assert.strictEqual(isExpectedError('parking.rateLimit', 'parking.invalidZone'), false);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('array — matches when type is in the list', () => {
|
|
42
|
+
assert.strictEqual(
|
|
43
|
+
isExpectedError('auth.unauthorized', ['parking.invalidZone', 'auth.unauthorized']),
|
|
44
|
+
true,
|
|
45
|
+
);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('array — does not match when type is absent from the list', () => {
|
|
49
|
+
assert.strictEqual(
|
|
50
|
+
isExpectedError('payment.failed', ['parking.invalidZone', 'auth.unauthorized']),
|
|
51
|
+
false,
|
|
52
|
+
);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('wildcard — matches any type with the given prefix', () => {
|
|
56
|
+
assert.strictEqual(isExpectedError('parking.invalidZone', 'parking.*'), true);
|
|
57
|
+
assert.strictEqual(isExpectedError('parking.rateLimit', 'parking.*'), true);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('wildcard — does not match a different namespace', () => {
|
|
61
|
+
assert.strictEqual(isExpectedError('auth.unauthorized', 'parking.*'), false);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('wildcard — does not match the prefix segment itself (no dot)', () => {
|
|
65
|
+
// 'parking' alone should NOT match 'parking.*' (requires the dot)
|
|
66
|
+
assert.strictEqual(isExpectedError('parking', 'parking.*'), false);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('array with wildcard — matches when a wildcard entry covers the type', () => {
|
|
70
|
+
assert.strictEqual(
|
|
71
|
+
isExpectedError('parking.invalidZone', ['auth.*', 'parking.*']),
|
|
72
|
+
true,
|
|
73
|
+
);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('wildcard at top of namespace — auth.*', () => {
|
|
77
|
+
assert.strictEqual(isExpectedError('auth.unauthorized', 'auth.*'), true);
|
|
78
|
+
assert.strictEqual(isExpectedError('auth.tokenExpired', 'auth.*'), true);
|
|
79
|
+
assert.strictEqual(isExpectedError('gateway.jwtMissingHeader', 'auth.*'), false);
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
// ---------------------------------------------------------------------------
|
|
84
|
+
// AdapterBase.error() — log-level demotion
|
|
85
|
+
// ---------------------------------------------------------------------------
|
|
86
|
+
|
|
87
|
+
describe('AdapterBase.error() — log-level demotion', () => {
|
|
88
|
+
/**
|
|
89
|
+
* Minimal stand-in for AdapterBase that exposes only the `error()` method
|
|
90
|
+
* under test. We copy the implementation verbatim so we are testing the
|
|
91
|
+
* real logic without spinning up a full adapter.
|
|
92
|
+
*/
|
|
93
|
+
function makeAdapter(log: Record<string, (...a: unknown[]) => void>) {
|
|
94
|
+
return {
|
|
95
|
+
log,
|
|
96
|
+
error(
|
|
97
|
+
error: {type?: string; method?: string},
|
|
98
|
+
$meta: {method?: string; expect?: string | string[]},
|
|
99
|
+
) {
|
|
100
|
+
if ($meta) error.method = $meta.method;
|
|
101
|
+
if (isExpectedError(error.type, $meta?.expect)) {
|
|
102
|
+
(this.log as Record<string, (...a: unknown[]) => void>).debug?.(error);
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
(this.log as Record<string, (...a: unknown[]) => void>).error?.(error);
|
|
106
|
+
},
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
it('logs at error level when expect is not set', () => {
|
|
111
|
+
const logged: {level: string; error: unknown}[] = [];
|
|
112
|
+
const adapter = makeAdapter({
|
|
113
|
+
error: e => logged.push({level: 'error', error: e}),
|
|
114
|
+
debug: e => logged.push({level: 'debug', error: e}),
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
adapter.error({type: 'parking.invalidZone'}, {method: 'parkingTest'});
|
|
118
|
+
|
|
119
|
+
assert.strictEqual(logged.length, 1);
|
|
120
|
+
assert.strictEqual(logged[0].level, 'error');
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it('logs at debug level and does not log at error when error is expected', () => {
|
|
124
|
+
const logged: {level: string; error: unknown}[] = [];
|
|
125
|
+
const adapter = makeAdapter({
|
|
126
|
+
error: e => logged.push({level: 'error', error: e}),
|
|
127
|
+
debug: e => logged.push({level: 'debug', error: e}),
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
adapter.error(
|
|
131
|
+
{type: 'parking.invalidZone'},
|
|
132
|
+
{method: 'parkingTest', expect: 'parking.invalidZone'},
|
|
133
|
+
);
|
|
134
|
+
|
|
135
|
+
assert.strictEqual(logged.length, 1);
|
|
136
|
+
assert.strictEqual(logged[0].level, 'debug');
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it('logs at debug level for wildcard match', () => {
|
|
140
|
+
const logged: {level: string; error: unknown}[] = [];
|
|
141
|
+
const adapter = makeAdapter({
|
|
142
|
+
error: e => logged.push({level: 'error', error: e}),
|
|
143
|
+
debug: e => logged.push({level: 'debug', error: e}),
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
adapter.error(
|
|
147
|
+
{type: 'parking.rateLimit'},
|
|
148
|
+
{method: 'parkingTest', expect: 'parking.*'},
|
|
149
|
+
);
|
|
150
|
+
|
|
151
|
+
assert.strictEqual(logged.length, 1);
|
|
152
|
+
assert.strictEqual(logged[0].level, 'debug');
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it('logs at error level when the error type does not match expect', () => {
|
|
156
|
+
const logged: {level: string; error: unknown}[] = [];
|
|
157
|
+
const adapter = makeAdapter({
|
|
158
|
+
error: e => logged.push({level: 'error', error: e}),
|
|
159
|
+
debug: e => logged.push({level: 'debug', error: e}),
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
adapter.error(
|
|
163
|
+
{type: 'auth.unauthorized'},
|
|
164
|
+
{method: 'someMethod', expect: 'parking.invalidZone'},
|
|
165
|
+
);
|
|
166
|
+
|
|
167
|
+
assert.strictEqual(logged.length, 1);
|
|
168
|
+
assert.strictEqual(logged[0].level, 'error');
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it('attaches $meta.method to the error object', () => {
|
|
172
|
+
const captured: unknown[] = [];
|
|
173
|
+
const adapter = makeAdapter({
|
|
174
|
+
error: e => captured.push(e),
|
|
175
|
+
debug: () => {},
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
const err: {type: string; method?: string} = {type: 'foo.bar'};
|
|
179
|
+
adapter.error(err, {method: 'someMethod'});
|
|
180
|
+
|
|
181
|
+
assert.strictEqual((captured[0] as typeof err).method, 'someMethod');
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it('attaches $meta.method even when the error is expected', () => {
|
|
185
|
+
const captured: unknown[] = [];
|
|
186
|
+
const adapter = makeAdapter({
|
|
187
|
+
error: () => {},
|
|
188
|
+
debug: e => captured.push(e),
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
const err: {type: string; method?: string} = {type: 'parking.invalidZone'};
|
|
192
|
+
adapter.error(err, {method: 'parkingTest', expect: 'parking.invalidZone'});
|
|
193
|
+
|
|
194
|
+
assert.strictEqual((captured[0] as typeof err).method, 'parkingTest');
|
|
195
|
+
});
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
// ---------------------------------------------------------------------------
|
|
199
|
+
// Gateway expectedErrors flag
|
|
200
|
+
// ---------------------------------------------------------------------------
|
|
201
|
+
|
|
202
|
+
describe('Gateway — expectedErrors config flag', () => {
|
|
203
|
+
/**
|
|
204
|
+
* Simulate the gateway route-handler logic that builds `resolvedExpect`.
|
|
205
|
+
* Returns the value of `resolvedExpect` that the gateway would compute.
|
|
206
|
+
*/
|
|
207
|
+
function buildResolvedExpect(
|
|
208
|
+
expectedErrorsEnabled: boolean,
|
|
209
|
+
expectFromBody: string | string[] | undefined,
|
|
210
|
+
): string[] | undefined {
|
|
211
|
+
return expectedErrorsEnabled && expectFromBody
|
|
212
|
+
? ([] as string[]).concat(expectFromBody)
|
|
213
|
+
: undefined;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
it('resolves expect when expectedErrors is true and expect is provided', () => {
|
|
217
|
+
const result = buildResolvedExpect(true, 'parking.invalidZone');
|
|
218
|
+
assert.deepStrictEqual(result, ['parking.invalidZone']);
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
it('resolves expect array when expectedErrors is true', () => {
|
|
222
|
+
const result = buildResolvedExpect(true, ['parking.invalidZone', 'auth.unauthorized']);
|
|
223
|
+
assert.deepStrictEqual(result, ['parking.invalidZone', 'auth.unauthorized']);
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
it('returns undefined when expectedErrors is false (production)', () => {
|
|
227
|
+
const result = buildResolvedExpect(false, 'parking.invalidZone');
|
|
228
|
+
assert.strictEqual(result, undefined);
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
it('returns undefined when expect is not provided even if expectedErrors is true', () => {
|
|
232
|
+
const result = buildResolvedExpect(true, undefined);
|
|
233
|
+
assert.strictEqual(result, undefined);
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
it('with resolvedExpect=undefined, isExpectedError always returns false', () => {
|
|
237
|
+
assert.strictEqual(isExpectedError('parking.invalidZone', undefined), false);
|
|
238
|
+
});
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
// ---------------------------------------------------------------------------
|
|
242
|
+
// Meta propagation — $meta.expect travels through handler chain
|
|
243
|
+
// ---------------------------------------------------------------------------
|
|
244
|
+
|
|
245
|
+
describe('$meta.expect propagation', () => {
|
|
246
|
+
/**
|
|
247
|
+
* Simulate a two-hop dispatch: caller → first handler → second handler.
|
|
248
|
+
* The second handler logs an error and the first handler propagates it.
|
|
249
|
+
* Verify that $meta.expect set by the caller controls log level at every hop.
|
|
250
|
+
*/
|
|
251
|
+
it('expect set by caller controls log level at every handler in the chain', () => {
|
|
252
|
+
const logged: {level: string; type: string}[] = [];
|
|
253
|
+
|
|
254
|
+
function logError(error: {type: string}, $meta: {expect?: string | string[]}) {
|
|
255
|
+
if (isExpectedError(error.type, $meta?.expect)) {
|
|
256
|
+
logged.push({level: 'debug', type: error.type});
|
|
257
|
+
} else {
|
|
258
|
+
logged.push({level: 'error', type: error.type});
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
const callerMeta = {method: 'callerMethod', expect: 'foo.someError'};
|
|
263
|
+
|
|
264
|
+
// Simulate two handler hops both receiving the same $meta
|
|
265
|
+
logError({type: 'foo.someError'}, callerMeta); // hop 1
|
|
266
|
+
logError({type: 'foo.someError'}, callerMeta); // hop 2
|
|
267
|
+
|
|
268
|
+
assert.strictEqual(logged.length, 2);
|
|
269
|
+
assert.ok(logged.every(l => l.level === 'debug'), 'all hops should log at debug');
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
it('an error type not in expect is still logged at error level on all hops', () => {
|
|
273
|
+
const logged: {level: string; type: string}[] = [];
|
|
274
|
+
|
|
275
|
+
function logError(error: {type: string}, $meta: {expect?: string | string[]}) {
|
|
276
|
+
if (isExpectedError(error.type, $meta?.expect)) {
|
|
277
|
+
logged.push({level: 'debug', type: error.type});
|
|
278
|
+
} else {
|
|
279
|
+
logged.push({level: 'error', type: error.type});
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
const callerMeta = {method: 'callerMethod', expect: 'foo.someError'};
|
|
284
|
+
|
|
285
|
+
logError({type: 'bar.otherError'}, callerMeta); // unexpected on hop 1
|
|
286
|
+
logError({type: 'bar.otherError'}, callerMeta); // unexpected on hop 2
|
|
287
|
+
|
|
288
|
+
assert.strictEqual(logged.length, 2);
|
|
289
|
+
assert.ok(logged.every(l => l.level === 'error'), 'unexpected errors stay at error level');
|
|
290
|
+
});
|
|
291
|
+
});
|
package/src/jose.test.ts
CHANGED
package/src/jose.ts
CHANGED
|
@@ -269,7 +269,7 @@ export default async function jose({sign, encrypt}: {sign?: KeySpec; encrypt?: K
|
|
|
269
269
|
? signEncrypt(
|
|
270
270
|
msg,
|
|
271
271
|
mlsk,
|
|
272
|
-
await importKey(key),
|
|
272
|
+
await importKey(await exportKey(key as JWK)),
|
|
273
273
|
protectedHeader!,
|
|
274
274
|
unprotectedHeader!,
|
|
275
275
|
options,
|
|
@@ -278,7 +278,7 @@ export default async function jose({sign, encrypt}: {sign?: KeySpec; encrypt?: K
|
|
|
278
278
|
decryptVerify: async (
|
|
279
279
|
msg: Parameters<typeof decryptVerify>[0],
|
|
280
280
|
key: Parameters<typeof importKey>[0],
|
|
281
|
-
) => (mlek ? decryptVerify(msg, await importKey(key), mlek!) : msg),
|
|
281
|
+
) => (mlek ? decryptVerify(msg, await importKey(await exportKey(key as JWK)), mlek!) : msg),
|
|
282
282
|
decrypt: (msg, options) =>
|
|
283
283
|
mlek ? decrypt(msg, mlek!, options as {complete?: unknown} | undefined) : msg,
|
|
284
284
|
verify: async (msg, key) => verify(msg, await importKey(key)),
|
package/src/lib.ts
CHANGED
|
@@ -66,6 +66,28 @@ export function parseAnnotatedKey(key: string): {
|
|
|
66
66
|
return {annotations, handlerName};
|
|
67
67
|
}
|
|
68
68
|
|
|
69
|
+
/**
|
|
70
|
+
* Returns true when an error type matches the `expect` declaration on `$meta`.
|
|
71
|
+
*
|
|
72
|
+
* Matching rules:
|
|
73
|
+
* - Exact string: `'foo.bar'` matches only `foo.bar`
|
|
74
|
+
* - Array of strings: any element that matches (exact or wildcard)
|
|
75
|
+
* - Wildcard prefix: `'foo.*'` matches any type starting with `foo.`
|
|
76
|
+
*/
|
|
77
|
+
export function isExpectedError(
|
|
78
|
+
errorType: string | undefined,
|
|
79
|
+
expect: string | string[] | undefined,
|
|
80
|
+
): boolean {
|
|
81
|
+
if (!errorType || !expect) return false;
|
|
82
|
+
const patterns = ([] as string[]).concat(expect);
|
|
83
|
+
return patterns.some(pattern => {
|
|
84
|
+
if (pattern.endsWith('.*')) {
|
|
85
|
+
return errorType.startsWith(pattern.slice(0, -1));
|
|
86
|
+
}
|
|
87
|
+
return errorType === pattern;
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
|
|
69
91
|
let loginCache: Promise<{protocol: string; hostname: string; port: number}> | null = null;
|
|
70
92
|
export async function loginService(
|
|
71
93
|
discovery: (service: string) => Promise<{protocol: string; hostname: string; port: number}>,
|
package/src/loadServer.ts
CHANGED
|
@@ -28,6 +28,12 @@ const loadConfig = async (parentConfig: string | object) => {
|
|
|
28
28
|
return {loadedConfig, configRuntime};
|
|
29
29
|
};
|
|
30
30
|
|
|
31
|
+
// Parse CLI intents: first positional arg may be a file/folder target — exclude it from intents.
|
|
32
|
+
const allPositional = minimist(process.argv.slice(2))._ as string[];
|
|
33
|
+
const [maybeTarget, ...rest] = allPositional;
|
|
34
|
+
const targetIsFile = Boolean(maybeTarget && existsSync(resolve(maybeTarget)));
|
|
35
|
+
const cliIntents = targetIsFile ? rest : allPositional;
|
|
36
|
+
|
|
31
37
|
export default load.bind(null, {
|
|
32
38
|
platform: 'server',
|
|
33
39
|
readdir: async (path: string) => readdir(path, {withFileTypes: true}),
|
|
@@ -46,5 +52,5 @@ export default load.bind(null, {
|
|
|
46
52
|
statSync,
|
|
47
53
|
watch,
|
|
48
54
|
timing: timing(hrtime),
|
|
49
|
-
configs: ['server', ...
|
|
55
|
+
configs: ['server', ...cliIntents],
|
|
50
56
|
});
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for CLI intent parsing logic in runServer.ts.
|
|
3
|
+
*
|
|
4
|
+
* These tests verify:
|
|
5
|
+
* - autoRun correctly separates file-path targets from intents
|
|
6
|
+
* - runPlatform uses DEFAULT_INTENTS when no intents are provided
|
|
7
|
+
* - DEFAULT_INTENTS matches the expected set
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import {test} from 'tap';
|
|
11
|
+
|
|
12
|
+
import {DEFAULT_INTENTS} from './runServer.ts';
|
|
13
|
+
|
|
14
|
+
// ---------------------------------------------------------------------------
|
|
15
|
+
// DEFAULT_INTENTS
|
|
16
|
+
// ---------------------------------------------------------------------------
|
|
17
|
+
|
|
18
|
+
test('DEFAULT_INTENTS contains the three baseline intents', async t => {
|
|
19
|
+
t.same([...DEFAULT_INTENTS], ['microservice', 'integration', 'dev']);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
// Intent extraction helper — replicated inline to avoid FS side-effects
|
|
24
|
+
// ---------------------------------------------------------------------------
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Mirrors the logic in bin/blong.ts:
|
|
28
|
+
* - first element is the target when existsSync returns true
|
|
29
|
+
* - remaining elements (or all elements when no target) are intents
|
|
30
|
+
*/
|
|
31
|
+
function extractIntents(
|
|
32
|
+
positional: string[],
|
|
33
|
+
fileExists: (path: string) => boolean,
|
|
34
|
+
): {target: string | undefined; intents: string[]} {
|
|
35
|
+
const [maybeTarget, ...rest] = positional;
|
|
36
|
+
const target = maybeTarget && fileExists(maybeTarget) ? maybeTarget : undefined;
|
|
37
|
+
const intents = target ? rest : positional;
|
|
38
|
+
return {target, intents};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// ---------------------------------------------------------------------------
|
|
42
|
+
// extractIntents — file-path as first positional
|
|
43
|
+
// ---------------------------------------------------------------------------
|
|
44
|
+
|
|
45
|
+
test('extractIntents — recognises existing file as target', async t => {
|
|
46
|
+
const {target, intents} = extractIntents(['./server.ts', 'integration'], () => true);
|
|
47
|
+
t.equal(target, './server.ts');
|
|
48
|
+
t.same(intents, ['integration']);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test('extractIntents — non-existent path is treated as an intent', async t => {
|
|
52
|
+
const {target, intents} = extractIntents(['integration', 'dev'], () => false);
|
|
53
|
+
t.equal(target, undefined);
|
|
54
|
+
t.same(intents, ['integration', 'dev']);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test('extractIntents — empty args yield no target and no intents', async t => {
|
|
58
|
+
const {target, intents} = extractIntents([], () => false);
|
|
59
|
+
t.equal(target, undefined);
|
|
60
|
+
t.same(intents, []);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test('extractIntents — single file target, no intents', async t => {
|
|
64
|
+
const {target, intents} = extractIntents(['/abs/path/server.ts'], () => true);
|
|
65
|
+
t.equal(target, '/abs/path/server.ts');
|
|
66
|
+
t.same(intents, []);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test('extractIntents — multiple intents with no target', async t => {
|
|
70
|
+
const {target, intents} = extractIntents(
|
|
71
|
+
['integration', 'microservice', 'debug'],
|
|
72
|
+
() => false,
|
|
73
|
+
);
|
|
74
|
+
t.equal(target, undefined);
|
|
75
|
+
t.same(intents, ['integration', 'microservice', 'debug']);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test('extractIntents — multiple intents after a valid target', async t => {
|
|
79
|
+
const {target, intents} = extractIntents(
|
|
80
|
+
['./index.ts', 'integration', 'debug'],
|
|
81
|
+
() => true,
|
|
82
|
+
);
|
|
83
|
+
t.equal(target, './index.ts');
|
|
84
|
+
t.same(intents, ['integration', 'debug']);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
// ---------------------------------------------------------------------------
|
|
88
|
+
// Intent resolution — default fallback when none provided
|
|
89
|
+
// ---------------------------------------------------------------------------
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Mirrors the intent-resolution logic in autoRun:
|
|
93
|
+
* cliIntents.length > 0 → use cliIntents
|
|
94
|
+
* otherwise → fall back to DEFAULT_INTENTS
|
|
95
|
+
*/
|
|
96
|
+
function resolveIntents(cliIntents: string[]): string[] {
|
|
97
|
+
return cliIntents.length > 0 ? cliIntents : [...DEFAULT_INTENTS];
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
test('resolveIntents — empty CLI intents fall back to defaults', async t => {
|
|
101
|
+
t.same(resolveIntents([]), [...DEFAULT_INTENTS]);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test('resolveIntents — provided intents are used as-is', async t => {
|
|
105
|
+
t.same(resolveIntents(['integration']), ['integration']);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test('resolveIntents — custom intent is passed through', async t => {
|
|
109
|
+
t.same(resolveIntents(['db']), ['db']);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test('resolveIntents — multiple intents preserved in order', async t => {
|
|
113
|
+
t.same(resolveIntents(['dev', 'debug']), ['dev', 'debug']);
|
|
114
|
+
});
|
package/src/runServer.ts
CHANGED
|
@@ -4,15 +4,21 @@ import {basename, resolve} from 'node:path';
|
|
|
4
4
|
import {analyzeFolder, synthesizeServerFromHandlers} from './folderAnalysis.ts';
|
|
5
5
|
import load from './loadServer.ts';
|
|
6
6
|
|
|
7
|
+
/** Default intents applied when none are provided on the CLI. */
|
|
8
|
+
export const DEFAULT_INTENTS = ['microservice', 'integration', 'dev'] as const;
|
|
9
|
+
|
|
7
10
|
/**
|
|
8
11
|
* Runs the standard platform lifecycle: start → test → (CI) stop.
|
|
12
|
+
*
|
|
13
|
+
* @param intents - Active intents that control which config blocks and layers are activated.
|
|
14
|
+
* Defaults to {@link DEFAULT_INTENTS} when omitted.
|
|
9
15
|
*/
|
|
10
|
-
export async function runPlatform(
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
]);
|
|
16
|
+
export async function runPlatform(
|
|
17
|
+
serverDef: SolutionFactory,
|
|
18
|
+
name: string,
|
|
19
|
+
intents: string[] = [...DEFAULT_INTENTS],
|
|
20
|
+
): Promise<void> {
|
|
21
|
+
const platform = await load(serverDef as unknown as Parameters<typeof load>[0], name, name, intents);
|
|
16
22
|
await platform.start!({});
|
|
17
23
|
await platform.test!(undefined);
|
|
18
24
|
if (process.env.CI) await platform.stop!();
|
|
@@ -28,9 +34,19 @@ export async function runPlatform(serverDef: SolutionFactory, name: string): Pro
|
|
|
28
34
|
* 4. `server.ts` alone — server-only suite or realm.
|
|
29
35
|
* 5. Folder contains handler files — synthesize a server suite on the fly.
|
|
30
36
|
* 6. Throws if nothing matched.
|
|
37
|
+
*
|
|
38
|
+
* @param options.intents - Active intents from the CLI (positional args after the optional target
|
|
39
|
+
* path). When empty, {@link DEFAULT_INTENTS} are used so that a plain `blong` invocation works
|
|
40
|
+
* out of the box.
|
|
31
41
|
*/
|
|
32
|
-
export async function autoRun(options: {
|
|
33
|
-
|
|
42
|
+
export async function autoRun(options: {
|
|
43
|
+
cwd: string;
|
|
44
|
+
target?: string;
|
|
45
|
+
intents?: string[];
|
|
46
|
+
}): Promise<void> {
|
|
47
|
+
const {cwd, target, intents: cliIntents} = options;
|
|
48
|
+
// Use CLI-supplied intents; fall back to defaults when the user passed none.
|
|
49
|
+
const intents = cliIntents && cliIntents.length > 0 ? cliIntents : [...DEFAULT_INTENTS];
|
|
34
50
|
|
|
35
51
|
if (target && existsSync(target)) {
|
|
36
52
|
(await import(target)).default(load);
|
|
@@ -48,19 +64,19 @@ export async function autoRun(options: {cwd: string; target?: string}): Promise<
|
|
|
48
64
|
const {default: serverDef} = await import(serverFile);
|
|
49
65
|
const {default: browserDef} = await import(browserFile);
|
|
50
66
|
const platforms: Awaited<ReturnType<typeof load>>[] = await Promise.all([
|
|
51
|
-
load(serverDef, name, name,
|
|
52
|
-
load(browserDef, name, name,
|
|
67
|
+
load(serverDef, name, name, intents),
|
|
68
|
+
load(browserDef, name, name, intents),
|
|
53
69
|
]);
|
|
54
70
|
for (const platform of platforms) await platform.start({});
|
|
55
71
|
await platforms[1].test!(undefined);
|
|
56
72
|
if (process.env.CI) for (const platform of platforms) await platform.stop();
|
|
57
73
|
} else if (existsSync(serverFile)) {
|
|
58
74
|
const {default: serverDef} = await import(serverFile);
|
|
59
|
-
await runPlatform(serverDef, name);
|
|
75
|
+
await runPlatform(serverDef, name, intents);
|
|
60
76
|
} else {
|
|
61
77
|
const analysis = await analyzeFolder(cwd);
|
|
62
78
|
if (analysis.kind === 'handlers' || analysis.kind === 'mixed') {
|
|
63
|
-
await runPlatform(await synthesizeServerFromHandlers(cwd, analysis), name);
|
|
79
|
+
await runPlatform(await synthesizeServerFromHandlers(cwd, analysis), name, intents);
|
|
64
80
|
} else {
|
|
65
81
|
throw new Error(
|
|
66
82
|
`No entry point found in ${cwd}. ` +
|