@feasibleone/blong-gogo 1.39.0 → 1.40.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,18 @@
1
1
  # Changelog
2
2
 
3
+ ## [1.40.0](https://github.com/feasibleone/blong/compare/blong-gogo-v1.39.0...blong-gogo-v1.40.0) (2026-10-13)
4
+
5
+
6
+ ### Features
7
+
8
+ * blong-kustomize improvements ([0a07e50](https://github.com/feasibleone/blong/commit/0a07e50275208a3fd8a23476e0abe9334ec3822d))
9
+ * blong-kustomize operator UI ([2599dab](https://github.com/feasibleone/blong/commit/2599dab9c5ac734c9949f9af9b11fcb181eb8c7f))
10
+
11
+
12
+ ### Bug Fixes
13
+
14
+ * flaky tests ([226d4a6](https://github.com/feasibleone/blong/commit/226d4a6c1bb912e2fff0919bda975ba487d3e060))
15
+
3
16
  ## [1.39.0](https://github.com/feasibleone/blong/compare/blong-gogo-v1.38.1...blong-gogo-v1.39.0) (2026-10-11)
4
17
 
5
18
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@feasibleone/blong-gogo",
3
- "version": "1.39.0",
3
+ "version": "1.40.0",
4
4
  "repository": {
5
5
  "url": "git+https://github.com/feasibleone/blong.git"
6
6
  },
@@ -83,7 +83,7 @@
83
83
  "yaml": "^2.8.3"
84
84
  },
85
85
  "devDependencies": {
86
- "@feasibleone/blong-dev": "1.9.0",
86
+ "@feasibleone/blong-dev": "1.10.0",
87
87
  "@rushstack/heft": "^1.2.6",
88
88
  "@rushstack/heft-lint-plugin": "^1.2.6",
89
89
  "@rushstack/heft-typescript-plugin": "^1.3.1",
@@ -39,6 +39,28 @@ export interface IGatewayCodec {
39
39
  export interface IConfig {
40
40
  client?: {tls?: {ca?: string | string[]; key?: string; cert?: string; crl?: string}};
41
41
  gateway?: object;
42
+ /**
43
+ * Whether a method must *not* be carried by the gateway this config names: `true` for every method,
44
+ * `false` for none, or a regular-expression *source* matched against the method name for the ones
45
+ * that belong here.
46
+ *
47
+ * The routing decision is made here — a gateway is where an *outside* caller reaches a process, and
48
+ * `gateway()` is what says whether a call is handed to one — so this is where a method that has to
49
+ * be answered at home is kept out of it. The name the framework needs it for is its own session
50
+ * check: `identity.checkInternal` is asked by the process that verified the token, from inside that
51
+ * verification, and a realm that answers its own login is one process. Routed through a gateway the
52
+ * call reached the gateway itself, was refused for a missing bearer and came back as a null the
53
+ * session step destructured (F-497) — a hardcoded name here until this setting replaced it, because
54
+ * a deployment that routes namespaces through a gateway is the one that knows which of its own
55
+ * methods have to stay home.
56
+ *
57
+ * A skipped method falls back to the ordinary path, and what that resolves to is the resolver's
58
+ * answer: `remote.canSkipSocket` decides whether the process answers it in-process or sends it to
59
+ * the namespace. The two settings compose rather than overlap — this one says "not through the
60
+ * gateway", that one says "answered here" — which is why the resolver asks its question only after
61
+ * this one has let the call through.
62
+ */
63
+ skipGateway?: boolean | string;
42
64
  protocol?: Protocol;
43
65
  host?: string;
44
66
  port?: string;
@@ -56,6 +78,14 @@ export default class GatewayCodecImpl implements IGatewayCodec {
56
78
  #config: IConfig;
57
79
  #tlsClient: object;
58
80
  #resolution: IResolution;
81
+ /**
82
+ * The `skipGateway` source as it was compiled, and the pattern — `null` for a source that did not
83
+ * compile.
84
+ *
85
+ * Kept beside the source for the reason the resolver keeps its own: `gateway()` runs for every
86
+ * outgoing call, and compiling the same expression per call is work that only the first call needs.
87
+ */
88
+ #skipGateway: {source: boolean | string; pattern: RegExp | null} | undefined;
59
89
 
60
90
  public verify!: IGatewayCodec['verify'];
61
91
 
@@ -170,8 +200,37 @@ export default class GatewayCodecImpl implements IGatewayCodec {
170
200
  this.verify = verify as IGatewayCodec['verify'];
171
201
  }
172
202
 
203
+ /**
204
+ * Whether this call is one the gateway must not carry ({@link IConfig.skipGateway}).
205
+ *
206
+ * A source that does not compile is treated as nothing matching rather than as every method: a typo
207
+ * in a config must not decide that no call is ever routed, and the alternative — refusing to start —
208
+ * would take a deployment down over a string. Nothing is logged here because this class has no
209
+ * logger: it is handed one by the caller that owns the logs, and the silent treatment is the same
210
+ * answer `Remote.canAnswerLocally` reports through its own.
211
+ */
212
+ private _skipsGateway(methodName: string): boolean {
213
+ const {skipGateway} = this.#config;
214
+ if (skipGateway === true) return true;
215
+ if (typeof skipGateway !== 'string') return false;
216
+ if (this.#skipGateway?.source !== skipGateway) {
217
+ let pattern: RegExp | null = null;
218
+ try {
219
+ pattern = new RegExp(skipGateway);
220
+ } catch {
221
+ pattern = null;
222
+ }
223
+ this.#skipGateway = {source: skipGateway, pattern};
224
+ }
225
+ return this.#skipGateway.pattern?.test(methodName) ?? false;
226
+ }
227
+
173
228
  public gateway($meta: IMeta, methodName: string = $meta.method!): object | void {
174
- if (this.#config.gateway && methodName !== 'identity.checkInternal') {
229
+ // A skipped method does not reach a gateway at all, not even one the caller asked for in
230
+ // `$meta.gateway`: the setting says this process answers the call, and what answers it is the
231
+ // resolver's business (see `IConfig.skipGateway`).
232
+ if (this._skipsGateway(methodName)) return undefined;
233
+ if (this.#config.gateway) {
175
234
  const gw = this.#config.gateway as Record<string, unknown>;
176
235
  const [prefix, method] = methodName.split('/');
177
236
  if (method) {
package/src/Remote.ts CHANGED
@@ -24,7 +24,18 @@ const errorMap: IErrorMap = {
24
24
 
25
25
  export default class Remote extends Internal implements IRemote {
26
26
  #config: {
27
- canSkipSocket: boolean;
27
+ /**
28
+ * Whether a call may be answered in this process instead of through the transport: `true` for
29
+ * every method, `false` for none, or a regular-expression *source* matched against the method
30
+ * name for the ones that belong here ({@link canAnswerLocally}).
31
+ *
32
+ * A configured gateway still wins, and deliberately: a method a gateway is declared for is not
33
+ * this process's to answer, and a deployment that wants an exception says so on the other side
34
+ * — `GatewayCodec`'s `skipGateway` keeps the method out of the gateway, and this is what the
35
+ * fallback then resolves by. Asking the same question in two places would make the two settings
36
+ * disagreeable; asking it here only when nothing else claims the call keeps them composable.
37
+ */
38
+ canSkipSocket: boolean | string;
28
39
  requireMeta: 'trace' | 'debug' | 'warn' | 'error' | 'fatal' | 'info' | true;
29
40
  } = {
30
41
  canSkipSocket: false,
@@ -36,12 +47,21 @@ export default class Remote extends Internal implements IRemote {
36
47
  #errors: Errors<typeof errorMap>;
37
48
  #local: ILocal;
38
49
  #platform: IPlatformApi;
50
+ /**
51
+ * The compiled pattern of each `canSkipSocket` source seen, `null` for one that does not compile.
52
+ *
53
+ * Per source rather than one slot, because a reload replaces the config and a process may see more
54
+ * than one value in its life; the map is small enough that keeping the earlier ones costs nothing.
55
+ */
56
+ #skipPatterns = new Map<string, RegExp | null>();
39
57
 
40
58
  #brokerRequest: RemoteMethod;
41
59
  #brokerPublish: RemoteMethod;
42
60
 
43
61
  public constructor(
44
- config: {logLevel?: Parameters<ILog['logger']>[0]},
62
+ // Declared rather than read off the caller's own interface, because a type that names only
63
+ // `logLevel` refuses the value the loader actually passes — `canSkipSocket` among them.
64
+ config: {logLevel?: Parameters<ILog['logger']>[0]; canSkipSocket?: boolean | string},
45
65
  {
46
66
  log,
47
67
  error,
@@ -202,9 +222,10 @@ export default class Remote extends Internal implements IRemote {
202
222
  if (!fn) {
203
223
  // don't try skipping socket if there is a gateway configured
204
224
  if (methodName && !this.gateway?.($applyMeta, methodName)) {
205
- if (this.#config.canSkipSocket) fn = this._findMethod(methodName, methodType);
225
+ if (this.canAnswerLocally(methodName))
226
+ fn = this._findMethod(methodName, methodType);
206
227
  if (fn) unpack = true;
207
- else if (methodName && this.#config.canSkipSocket) {
228
+ else if (this.canAnswerLocally(methodName)) {
208
229
  // Visibility: under canSkipSocket every call is expected
209
230
  // to resolve locally. A missing method (e.g. a derived
210
231
  // handler name that doesn't match a registered file —
@@ -368,7 +389,7 @@ export default class Remote extends Internal implements IRemote {
368
389
  mtid = $meta.mtid;
369
390
  if (mtid === 'discard') return true;
370
391
  const handler =
371
- this.#config.canSkipSocket &&
392
+ this.canAnswerLocally($meta.method) &&
372
393
  this._findMethod($meta.method!, mtid === 'request' ? 'request' : 'publish');
373
394
  if (handler) {
374
395
  return Promise.resolve(handler(...params));
@@ -392,6 +413,43 @@ export default class Remote extends Internal implements IRemote {
392
413
  return this._getPath(name).split('.').pop()!;
393
414
  }
394
415
 
416
+ /**
417
+ * Whether a method may be answered in this process rather than through the transport.
418
+ *
419
+ * `true` and `false` are the two ends — a process that carries everything answers everything, one
420
+ * that carries only its own realm answers nothing — and the string is the middle: a
421
+ * regular-expression *source*, read against the method name, for a process that serves some of what
422
+ * it can reach and has to send the rest out. The pattern is compiled once per value, because a
423
+ * reload replaces the config.
424
+ *
425
+ * A source that does not compile is not a reason to refuse every call: it is reported once and
426
+ * treated as nothing matching, which leaves the process dialling as it would have with `false`.
427
+ *
428
+ * The caller decides *when* to ask ({@link _getMethod} asks only where no gateway claims the call),
429
+ * so nothing here knows about gateways: this is the local half of the question and the routing half
430
+ * lives in the codec that actually routes.
431
+ */
432
+ protected canAnswerLocally(methodName: string | undefined): boolean {
433
+ const {canSkipSocket} = this.#config;
434
+ if (canSkipSocket === true) return true;
435
+ if (typeof canSkipSocket !== 'string' || !methodName) return false;
436
+ const cached = this.#skipPatterns.get(canSkipSocket);
437
+ if (cached !== undefined) return cached ? cached.test(methodName) : false;
438
+ try {
439
+ const pattern = new RegExp(canSkipSocket);
440
+ this.#skipPatterns.set(canSkipSocket, pattern);
441
+ return pattern.test(methodName);
442
+ } catch (error) {
443
+ this.#skipPatterns.set(canSkipSocket, null);
444
+ this.log?.warn?.({
445
+ $meta: {mtid: 'event', method: 'remote.canSkipSocket'},
446
+ message: `canSkipSocket is not a regular expression: ${canSkipSocket}`,
447
+ error,
448
+ });
449
+ return false;
450
+ }
451
+ }
452
+
395
453
  private _findMethod(methodName: string, type: 'request' | 'publish'): RemoteMethod {
396
454
  methodName = this._getPath(methodName);
397
455
  const key = ['ports', methodName, type].join('.');
package/src/RpcClient.ts CHANGED
@@ -90,7 +90,17 @@ export default class RpcClientImpl extends RemoteImpl implements IRpcClient {
90
90
  'http',
91
91
  '8091',
92
92
  this.#errors,
93
- this.sender('request'),
93
+ // The session step's own call — the one the codec makes while it verifies a caller's token
94
+ // (`identity.checkInternal`) — is asked through the *resolver* like every other call this
95
+ // process makes, which is what lets a process answer it itself. Handed the raw transport it
96
+ // could only ever dial: the routing question would be answered (the codec keeps the method
97
+ // out of a gateway, `skipGateway`) and then the call left for an `identity` namespace that a
98
+ // deployment answering its own login does not publish — refused for a missing bearer when it
99
+ // reached a gateway, and `ENOTFOUND` when it reached nothing (F-494, F-497). Asked through
100
+ // the resolver, `remote.canSkipSocket` decides: the process that verified the token answers
101
+ // the question, and a deployment that runs an identity service still reaches it.
102
+ (msg: unknown, $meta: unknown) =>
103
+ this.remote(($meta as IMeta)?.method ?? 'identity.checkInternal')(msg, $meta),
94
104
  this.#resolution,
95
105
  );
96
106
  }
package/src/busGateway.ts CHANGED
@@ -35,6 +35,37 @@ export default ({
35
35
  encrypt: string;
36
36
  }>;
37
37
  }) => {
38
+ /**
39
+ * What could not be reached, in the message rather than in a cause nobody prints.
40
+ *
41
+ * `fetch failed` is all a `TypeError` from the transport says, and the method and the
42
+ * address it was fetching are not in it: diagnosing an unreachable namespace came down
43
+ * to reading the process's DNS names out of a log line that did not carry them (F-494).
44
+ * Every transport failure in this file therefore leaves through here, with the method,
45
+ * the address and the code the resolver or the socket returned.
46
+ */
47
+ const unreachable = async <T>(
48
+ target: string,
49
+ method: string,
50
+ call: () => Promise<T>,
51
+ ): Promise<T> => {
52
+ try {
53
+ return await call();
54
+ } catch (error) {
55
+ const cause = (error as {cause?: {code?: string; hostname?: string; syscall?: string}})
56
+ ?.cause;
57
+ const detail = cause
58
+ ? [cause.code, cause.syscall, cause.hostname].filter(Boolean).join(' ')
59
+ : '';
60
+ throw new Error(
61
+ `gateway codec could not reach ${target} for '${method}': ` +
62
+ `${(error as Error)?.message ?? String(error)}` +
63
+ `${detail ? ` (${detail})` : ''}`,
64
+ {cause: error as Error},
65
+ );
66
+ }
67
+ };
68
+
38
69
  const localCache: Record<
39
70
  string,
40
71
  {
@@ -193,10 +224,12 @@ export default ({
193
224
  const cache = (localCache[url] = localCache[url] || {});
194
225
 
195
226
  if (localKeys && !cache.remoteKeys) {
196
- const body = await get(
197
- `${url}/rpc/login/.well-known/mle`,
198
- errors['bus.jsonRpcHttp'],
199
- errors['bus.jsonRpcEmpty'],
227
+ const body = await unreachable(`${url}/rpc/login/.well-known/mle`, method, () =>
228
+ get(
229
+ `${url}/rpc/login/.well-known/mle`,
230
+ errors['bus.jsonRpcHttp'],
231
+ errors['bus.jsonRpcEmpty'],
232
+ ),
200
233
  );
201
234
  if (body.sign && body.encrypt) cache.remoteKeys = body;
202
235
  }
@@ -230,7 +263,10 @@ export default ({
230
263
  return codec;
231
264
  }
232
265
 
233
- if (!cache.auth) await login(cache, url, username, password, channel);
266
+ if (!cache.auth)
267
+ await unreachable(`${url}/rpc/login/identity`, method, () =>
268
+ login(cache, url, username, password, channel),
269
+ );
234
270
 
235
271
  const exp = Date.now();
236
272
 
@@ -176,7 +176,28 @@ export default handler<{
176
176
 
177
177
  return {
178
178
  async ready() {
179
- let mleKey = null; // isBrowser && JSON.parse(window.localStorage.getItem('mle-jose') || 'null');
179
+ /**
180
+ * The pair is kept across a reload, which is what the write below has always assumed.
181
+ *
182
+ * A caller's session is opened with the pair it presented when it logged in — the realm
183
+ * that answers the session hands those two public keys back — and every later call is
184
+ * verified against the signing half of it. A reload that minted a new pair therefore
185
+ * signed with a key the session had never seen, and the gateway answered
186
+ * `Decryption failed` with `ERR_JWS_SIGNATURE_VERIFICATION_FAILED` underneath, on the
187
+ * first call of every page that logs in by reloading (F-497). The value is written below
188
+ * and was never read back, which is the shape of a half-finished feature rather than a
189
+ * decision: keeping it is now deliberate — a private JWK in `localStorage` is readable by
190
+ * any script on this origin, and the alternative is a session re-opened after every
191
+ * reload, which needs the caller's keys to travel with the call instead (D-530).
192
+ */
193
+ let mleKey = null;
194
+ if (isBrowser) {
195
+ try {
196
+ mleKey = JSON.parse(window.localStorage.getItem('mle-jose') || 'null');
197
+ } catch {
198
+ mleKey = null;
199
+ }
200
+ }
180
201
  if (!mleKey) {
181
202
  const {body: {sign, encrypt} = {}}: {body?: {sign?: unknown; encrypt?: unknown}} =
182
203
  (await (this as {exec?(...params: unknown[]): Promise<unknown>}).exec!(
@@ -143,7 +143,7 @@ test('a realm the suite declares is loaded', async t => {
143
143
  try {
144
144
  await withAmbientResolution(dirname(fileURLToPath(url)), async () => {
145
145
  const registry = await load(suite(url) as never, 'declared', {}, ['cli']);
146
- t.ok(registry.describe?.().realms.includes('server'), 'the declared realm is loaded');
146
+ t.ok(registry.describe?.().realms.includes('srv'), 'the declared realm is loaded');
147
147
  });
148
148
  } finally {
149
149
  dispose();
@@ -164,7 +164,7 @@ test('a trimmed manifest does not un-declare a realm', async t => {
164
164
  ['cli'],
165
165
  );
166
166
  t.ok(
167
- registry.describe?.().realms.includes('server'),
167
+ registry.describe?.().realms.includes('srv'),
168
168
  'the file beside the suite still counts',
169
169
  );
170
170
  });
package/src/lib.ts CHANGED
@@ -240,16 +240,39 @@ export async function requestGet(
240
240
  protocol: string,
241
241
  tls: Record<string, unknown> | undefined,
242
242
  ): Promise<unknown> {
243
- const response = await ky.get(url, {
244
- ...tls,
245
- ...(headers && {
246
- headers: {
247
- 'x-forwarded-proto': headers['x-forwarded-proto'] || protocol,
248
- 'x-forwarded-host': headers['x-forwarded-host'] || headers.host,
249
- },
250
- }),
251
- throwHttpErrors: false,
252
- });
243
+ let response;
244
+ try {
245
+ response = await ky.get(url, {
246
+ ...tls,
247
+ ...(headers && {
248
+ headers: {
249
+ // Every header the caller named, not only the two forwarded-for ones. Dropping
250
+ // the rest silently dropped the `authorization` an issuer's discovery is read
251
+ // with, and the API server answered 401 for a request that presented nothing —
252
+ // a failure with no trace of the cause it was given (F-494).
253
+ ...(Object.fromEntries(
254
+ Object.entries(headers).filter(([, value]) => value !== undefined),
255
+ ) as Record<string, string>),
256
+ 'x-forwarded-proto': headers['x-forwarded-proto'] || protocol,
257
+ 'x-forwarded-host': headers['x-forwarded-host'] || headers.host,
258
+ },
259
+ }),
260
+ throwHttpErrors: false,
261
+ });
262
+ } catch (error) {
263
+ // Name what could not be fetched. A transport failure reaches the log as `fetch failed`
264
+ // and nothing else, so an unreachable dependency was diagnosed by reading DNS names out of
265
+ // the process the hard way (F-494); the address and the resolver's code belong in the
266
+ // message, where whoever reads the failure is already looking.
267
+ const cause = (error as {cause?: {code?: string; hostname?: string; syscall?: string}})
268
+ ?.cause;
269
+ const detail = cause ? [cause.code, cause.syscall, cause.hostname].filter(Boolean).join(' ') : '';
270
+ throw new Error(
271
+ `could not reach ${url}: ${(error as Error)?.message ?? String(error)}` +
272
+ `${detail ? ` (${detail})` : ''}`,
273
+ {cause: error as Error},
274
+ );
275
+ }
253
276
 
254
277
  const responseText = await response.text();
255
278
  let body: {[key: string]: unknown} | undefined;
package/src/load.ts CHANGED
@@ -1360,7 +1360,7 @@ export default async function loadRealm<T extends TSchema>(
1360
1360
  */
1361
1361
  intents?: readonly string[];
1362
1362
  }> = [
1363
- {name: 'server', specifier: '@feasibleone/blong-server/server.ts'},
1363
+ {name: 'srv', specifier: '@feasibleone/blong-server/server.ts'},
1364
1364
  {name: 'login', specifier: '@feasibleone/blong-login/server.ts'},
1365
1365
  {name: 'core', specifier: '@feasibleone/blong-core/server.ts'},
1366
1366
  {name: 'access', specifier: '@feasibleone/blong-access/server.ts'},
package/src/oidc.ts CHANGED
@@ -1,7 +1,22 @@
1
1
  import type {Errors} from '@feasibleone/blong';
2
2
  import {createLocalJWKSet, decodeJwt, decodeProtectedHeader, jwtVerify} from 'jose';
3
+ import {readFileSync} from 'node:fs';
3
4
  import {requestGet as get, loginService} from './lib.ts';
4
5
 
6
+ /**
7
+ * The headers an issuer's own endpoints are read with.
8
+ *
9
+ * A cluster's discovery document and key set are not anonymous: the API server answers 401 unless
10
+ * the caller presents an identity it knows, so an issuer that names its credentials by file is read
11
+ * with them (`rpc.oidcHttp: OpenID returned HTTP error 401` is what an uncredentialed read looks
12
+ * like). The file is read per fetch rather than cached, because what lives in a projected
13
+ * service-account volume is rotated by the kubelet and the process is meant to pick the new one up.
14
+ */
15
+ const issuerHeaders = (config: {tokenFile?: string} | undefined): Record<string, string> => {
16
+ if (!config?.tokenFile) return {};
17
+ return {authorization: `Bearer ${readFileSync(config.tokenFile, 'utf8').trim()}`};
18
+ };
19
+
5
20
  export default ({
6
21
  issuers,
7
22
  tls = {},
@@ -55,9 +70,12 @@ export default ({
55
70
  ) => Promise<{protocol: string; hostname: string; port: number}>,
56
71
  );
57
72
  issuer = `${loginProtocol}://${hostname}:${port}/rpc/login/.well-known/openid-configuration`;
58
- } else {
59
- headers = undefined;
60
73
  }
74
+ // The headers are kept for every issuer, not only for `blong-login`. A configured issuer
75
+ // is read with whatever the caller of this function brought, and for a cluster issuer that
76
+ // is the process's own credential: discarding it here turned the API server's answer into
77
+ // `rpc.oidcHttp: 401` for a request that presented nothing, with nothing in the message to
78
+ // say the credential had been dropped on the way (F-494).
61
79
  return await get(issuer, errorOidcHttp, errorOidcEmpty, headers, protocol ?? '', tls);
62
80
  }
63
81
 
@@ -155,6 +173,7 @@ export default ({
155
173
  {
156
174
  ...((await openIdConfig(
157
175
  configuration || issuerUrl(issuerId, url),
176
+ issuerHeaders(rest as {tokenFile?: string}),
158
177
  )) as object),
159
178
  audience,
160
179
  issuerId,
@@ -220,11 +239,12 @@ export default ({
220
239
  }
221
240
 
222
241
  async function jwks(issuerId: string) {
242
+ const config = await issuerConfig(issuerId);
223
243
  return get(
224
- (await issuerConfig(issuerId)).jwks_uri,
244
+ config.jwks_uri,
225
245
  errorOidcHttp,
226
246
  errorOidcEmpty,
227
- {},
247
+ issuerHeaders(config as {tokenFile?: string}),
228
248
  '',
229
249
  tls,
230
250
  );
@@ -295,7 +315,21 @@ export default ({
295
315
  } catch (error) {
296
316
  throw errorInvalid({params: {message: (error as Error).message}, cause: error});
297
317
  }
298
- if (session && audience === 'blong' && !decoded.ses) await session(decoded);
318
+ /**
319
+ * The session step, for the issuers that ask for one.
320
+ *
321
+ * It is what hands a caller its session — an actor, a session id and the MLE key pair its
322
+ * later calls are decrypted with — by asking the `identity` namespace, and the framework's
323
+ * own issuer asks for it by name. An issuer may ask too (`session: true`), which is how a
324
+ * realm that answers its own login keeps the framework's session contract without running
325
+ * the framework's login realm: it answers `identity.checkInternal` itself. An issuer that
326
+ * asks for nothing gets nothing — a cluster issuer answers for the caller by its signature
327
+ * alone — and asking an `identity` service that does not exist is what made a verified
328
+ * token fail with `ENOTFOUND identity.<namespace>.svc.cluster.local` (F-494).
329
+ */
330
+ const wantsSession =
331
+ (config as {session?: boolean})?.session ?? decoded.iss === 'blong-login';
332
+ if (session && wantsSession && !decoded.ses) await session(decoded);
299
333
  return {
300
334
  ...decoded,
301
335
  config,
@@ -0,0 +1,76 @@
1
+ import {
2
+ type IErrorFactory,
3
+ type ILocal,
4
+ type ILog,
5
+ type IPlatformApi,
6
+ } from '@feasibleone/blong/types';
7
+ import tap from 'tap';
8
+
9
+ import RemoteImpl from './Remote.ts';
10
+
11
+ /**
12
+ * `canSkipSocket` in its three shapes each: all, none, and the pattern in between.
13
+ *
14
+ * A process that carries a whole suite answers most of what it can reach, and one that carries a single
15
+ * realm has no reason to answer anything — but a process that serves some of what it can reach and has
16
+ * to send the rest out is neither: the pattern is the middle, read against the method name, and as a
17
+ * *source* rather than a `RegExp` because a config is data.
18
+ *
19
+ * The check is `protected` so this can pin it: the alternative is a transport, and a test that dials
20
+ * localhost asserts on a machine's state rather than on the code. What it does *not* pin is when the
21
+ * question is asked — a configured gateway claims the call first, and a method kept out of the gateway
22
+ * is the codec's business (`skipGateway.test.ts`) — which is why nothing here is about gateways.
23
+ */
24
+ class Probe extends RemoteImpl {
25
+ public answers(methodName: string | undefined): boolean {
26
+ return this.canAnswerLocally(methodName);
27
+ }
28
+ }
29
+
30
+ const build = (canSkipSocket: boolean | string): Probe =>
31
+ new Probe(
32
+ {canSkipSocket},
33
+ {
34
+ log: {logger: () => ({warn: () => undefined})} as unknown as ILog,
35
+ error: {register: () => ({})} as unknown as IErrorFactory,
36
+ local: {get: () => undefined, register: () => undefined} as unknown as ILocal,
37
+ platform: {} as unknown as IPlatformApi,
38
+ },
39
+ );
40
+
41
+ tap.test('canSkipSocket=true answers any method', async t => {
42
+ const probe = build(true);
43
+ t.equal(probe.answers('kustomize.deployment.find'), true, 'a realm method');
44
+ t.equal(probe.answers('subject.object.schema'), true, 'another namespace');
45
+ t.equal(probe.answers(undefined), true, 'and the end of the scale is not a pattern');
46
+ });
47
+
48
+ tap.test('canSkipSocket=false answers none', async t => {
49
+ const probe = build(false);
50
+ t.equal(probe.answers('kustomize.deployment.find'), false, 'a realm method travels');
51
+ t.equal(probe.answers(undefined), false, 'and a nameless call travels');
52
+ });
53
+
54
+ tap.test('canSkipSocket as a pattern answers what it matches', async t => {
55
+ const probe = build('^(kustomize|srv)\\.');
56
+ t.equal(probe.answers('kustomize.deployment.find'), true, 'the realm serves this one');
57
+ t.equal(probe.answers('srv.db.queryBuilder'), true, 'and this one');
58
+ t.equal(
59
+ probe.answers('access.user.find'),
60
+ false,
61
+ 'while another namespace is sent out, which is what the pattern is for',
62
+ );
63
+ t.equal(probe.answers(undefined), false, 'and a nameless call travels');
64
+ });
65
+
66
+ tap.test('canSkipSocket that is not a regular expression answers none', async t => {
67
+ // `(` on its own throws when it is compiled. Reported, and treated as nothing matching: a typo in
68
+ // a config must not decide where every call goes.
69
+ const probe = build('(');
70
+ t.equal(probe.answers('kustomize.deployment.find'), false, 'the call travels');
71
+ t.equal(
72
+ probe.answers('kustomize.deployment.find'),
73
+ false,
74
+ 'and keeps travelling, not throwing',
75
+ );
76
+ });
@@ -0,0 +1,112 @@
1
+ import type {IMeta} from '@feasibleone/blong/types';
2
+ import tap from 'tap';
3
+
4
+ import GatewayCodecImpl from './GatewayCodec.ts';
5
+
6
+ /**
7
+ * `skipGateway` in its three shapes, pinned where the routing decision is made.
8
+ *
9
+ * A gateway is where an *outside* caller reaches a process, and `gateway()` is what answers whether a
10
+ * call is handed to one. A method that has to be answered at home therefore has to be kept out *here*:
11
+ * the resolver asks its own question (`remote.canSkipSocket`, `remoteCanSkip.test.ts`) only after this
12
+ * one has let the call through, which is the order that lets a deployment route a namespace through a
13
+ * gateway and still keep one of its own methods in-process. The framework's own session check is that
14
+ * method — `identity.checkInternal`, asked by the process that verified the token, from inside the
15
+ * verification — and it was a hardcoded name here until this setting replaced it (F-497).
16
+ *
17
+ * The codec is built for real rather than mocked: what is pinned is the answer of the public method, and
18
+ * a stub of the class would let the two disagree. Its OIDC client discovers nothing until a token is
19
+ * verified, so building one costs no network.
20
+ */
21
+ const build = (config: {skipGateway?: boolean | string; gateway?: object}) =>
22
+ new GatewayCodecImpl(
23
+ config,
24
+ 'http',
25
+ '8091',
26
+ {register: () => ({})} as never,
27
+ async () => undefined,
28
+ undefined as never,
29
+ );
30
+
31
+ /** The address the configured gateway answers at, as `gateway()` hands it back. */
32
+ const GATEWAY = {
33
+ deploy: {protocol: 'http', hostname: 'the-gateway', port: '8080'},
34
+ identity: {protocol: 'http', hostname: 'the-gateway', port: '8080'},
35
+ };
36
+
37
+ tap.test('nothing is skipped unless it is asked for', async t => {
38
+ const codec = build({gateway: GATEWAY});
39
+ t.same(
40
+ codec.gateway({method: 'identity.checkInternal'} as IMeta),
41
+ {...GATEWAY.identity, method: 'identity.checkInternal'},
42
+ 'a namespace routed through the gateway still is',
43
+ );
44
+ t.equal(
45
+ codec.gateway({method: 'remote.unknown.nothing'} as IMeta),
46
+ undefined,
47
+ 'and one the gateway does not name is not routed at all',
48
+ );
49
+ });
50
+
51
+ tap.test('skipGateway=true keeps every call at home', async t => {
52
+ const codec = build({gateway: GATEWAY, skipGateway: true});
53
+ t.equal(
54
+ codec.gateway({method: 'kustomize.deployment.find'} as IMeta),
55
+ undefined,
56
+ 'even where the gateway names the namespace',
57
+ );
58
+ t.equal(
59
+ codec.gateway({method: 'identity.checkInternal'} as IMeta),
60
+ undefined,
61
+ 'and the session',
62
+ );
63
+ });
64
+
65
+ tap.test('skipGateway as a pattern skips what it matches', async t => {
66
+ const codec = build({gateway: GATEWAY, skipGateway: '^identity\\.'});
67
+ t.equal(
68
+ codec.gateway({method: 'identity.checkInternal'} as IMeta),
69
+ undefined,
70
+ 'the method the framework asks of the process that verified the token',
71
+ );
72
+ t.same(
73
+ codec.gateway({method: 'deploy.kustomize.deployment.find'} as IMeta),
74
+ {...GATEWAY.deploy, method: 'deploy.kustomize.deployment.find'},
75
+ 'while the rest still goes through the gateway it is configured for',
76
+ );
77
+ });
78
+
79
+ tap.test('the setting beats a gateway the caller asked for', async t => {
80
+ // `$meta.gateway` is how a caller routes a call itself. A method named in `skipGateway` does not
81
+ // take that route either: the setting says this process answers it, and a caller's preference is
82
+ // not a reason for the session check to leave the process that holds the handshake.
83
+ const codec = build({skipGateway: 'identity.checkInternal'});
84
+ t.equal(
85
+ codec.gateway({
86
+ method: 'identity.checkInternal',
87
+ gateway: {hostname: 'somewhere-else'},
88
+ } as IMeta),
89
+ undefined,
90
+ 'the skip wins',
91
+ );
92
+ t.same(
93
+ codec.gateway({method: 'other.method', gateway: {hostname: 'somewhere-else'}} as IMeta),
94
+ {hostname: 'somewhere-else', method: 'other.method'},
95
+ 'and a call it does not name keeps the route it was given',
96
+ );
97
+ });
98
+
99
+ tap.test('a skip that is not a regular expression skips nothing', async t => {
100
+ // `(` throws when it is compiled, and a broken pattern must not decide that no call is ever routed:
101
+ // treated as nothing matching, which leaves the gateway doing what it did.
102
+ const codec = build({gateway: GATEWAY, skipGateway: '('});
103
+ t.same(
104
+ codec.gateway({method: 'identity.checkInternal'} as IMeta),
105
+ {...GATEWAY.identity, method: 'identity.checkInternal'},
106
+ 'the call is routed as it would have been',
107
+ );
108
+ t.ok(
109
+ codec.gateway({method: 'deploy.kustomize.deployment.find'} as IMeta),
110
+ 'and keeps routing, not throwing',
111
+ );
112
+ });