@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 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
- await autoRun({cwd: process.cwd(), target: argv._[0]});
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
- await autoRun({cwd: process.cwd(), target: argv._[0]});
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.21.0",
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": "tap src/ConfigRuntime.test.ts src/lib.test.ts --allow-incomplete-coverage"
87
+ "ci-unit": "../common/run-coverage.sh",
88
+ "ci-coverage": "../common/run-coverage.sh report"
88
89
  }
89
90
  }
@@ -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 ((this.log as {error?: (...args: unknown[]) => void})?.error) {
181
- if (error.type && $meta?.expect?.includes?.(error.type)) return;
182
- if ($meta) error.method = $meta.method;
183
- (this.log as {error: (...args: unknown[]) => void}).error(error);
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
- ...(expect && {expect: ([] as string[]).concat(expect)}),
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
- request.log.error(
487
- {err: error, method: methodName},
488
- 'gateway handler error',
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 {id: clientUpdateId, ...clientUpdateData} = handleParams;
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
@@ -1,5 +1,5 @@
1
1
  import assert from 'node:assert';
2
- import {test} from 'node:test';
2
+ import {test} from 'tap';
3
3
 
4
4
  import jose from './jose.ts';
5
5
 
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', ...minimist(process.argv.slice(2))._],
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(serverDef: SolutionFactory, name: string): Promise<void> {
11
- const platform = await load(serverDef as unknown as Parameters<typeof load>[0], name, name, [
12
- 'microservice',
13
- 'integration',
14
- 'dev',
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: {cwd: string; target?: string}): Promise<void> {
33
- const {cwd, target} = options;
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, ['microservice', 'integration', 'dev']),
52
- load(browserDef, name, name, ['microservice', 'integration', 'dev']),
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}. ` +