@feasibleone/blong-gogo 1.34.0 → 1.35.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.
@@ -0,0 +1,264 @@
1
+ /**
2
+ * The framework's server-side logger, backed by `@feasibleone/semantic-log`.
3
+ *
4
+ * It is the alternative to `Log` (pino) and implements the same `ILog` contract,
5
+ * so `Internal`'s `this.log`, `api.createLog` and every existing call site are
6
+ * unaffected by which one is selected. The emitter's own log — construction, one
7
+ * logger per component, retention, the cluster sink and the lifecycle — is
8
+ * `LogBase` in `@feasibleone/semantic-log/emitter`; what is left here is what is
9
+ * blong's, and there are three things:
10
+ *
11
+ * - **The envelope.** The framework's call sites pass `{$meta: {mtid, method}}`,
12
+ * and its call records carry the same envelope (`callTrace.ts`), so both are
13
+ * read here — an envelope is not something semantic-log could know about.
14
+ * - **The level contract.** `logger(level, bindings)` binds *downwards*: the
15
+ * original implementation uses a switch with no `break`s, so asking for `info`
16
+ * binds `info` through `fatal` and leaves `trace`/`debug` undefined. Call sites
17
+ * depend on that: a `this.log?.debug?.()` at info level is a silent no-op rather
18
+ * than a call. `ILog.logger` also promises `progress`, which is blong-lib's.
19
+ * - **The call gate.** Whether a flow records its calls is the framework's
20
+ * configuration and its `blong grant` tokens (`callTrace.ts`), read at the
21
+ * boundary that mints a flow rather than here.
22
+ *
23
+ * ## Why this *holds* a base rather than extending one
24
+ *
25
+ * `LogBase` is what blong would extend, except that it cannot: the loader decides
26
+ * that a module is a component by testing `prototype instanceof Internal`
27
+ * (`load.ts`), and semantic-log's side of the world knows nothing of `Internal` —
28
+ * nor should it. So the framework's class extends `Internal` as every other
29
+ * component does, and delegates to a `LogBase` it owns. The hooks the base would
30
+ * otherwise ask a subclass for are options, which is what makes that possible.
31
+ *
32
+ * The emitter is reached through `@feasibleone/semantic-log/emitter`, not the
33
+ * package root: the root re-exports the cluster service, which imports `fastify`,
34
+ * and this module is on a path `loadBrowser.ts` shares. Keeping the service out of
35
+ * the import graph is what stops a browser bundle from trying to resolve a server
36
+ * HTTP framework.
37
+ *
38
+ * ## `fatal` does not exit
39
+ *
40
+ * The emitter's `fatal` writes a record and then calls `exit`, because its own
41
+ * process-failure hooks install it that way. The framework's `fatal` has always
42
+ * meant "log this at fatal level" — pino ends nothing — and `LogBase` leaves `exit`
43
+ * a no-op for exactly that reason. Installing the emitter's failure capture is a
44
+ * separate decision, and not one this class makes for every process that logs.
45
+ */
46
+
47
+ import {merge, withProgress} from '@feasibleone/blong-lib';
48
+ import {Internal, type ICallLog, type ILog, type ILogger} from '@feasibleone/blong/types';
49
+ import {
50
+ LogBase,
51
+ resolveHome,
52
+ type ClusterOptions,
53
+ type Format,
54
+ type LevelName,
55
+ type LoggerFace,
56
+ type PayloadStore,
57
+ type RecordStore,
58
+ type RetentionOptions,
59
+ } from '@feasibleone/semantic-log/emitter';
60
+ import type {Level, Logger as PinoLogger} from 'pino';
61
+ import {callRecord, configureCallTrace, type CallTraceConfig} from './callTrace.ts';
62
+ import {toLogCall} from './semanticRecord.ts';
63
+
64
+ export {resolveHome};
65
+
66
+ /**
67
+ * The threshold's type, taken from the contract rather than from pino.
68
+ *
69
+ * `ILog.logger`'s first parameter is pino's level, and the framework's own type
70
+ * file imports that name without re-exporting it — so the parameter is named
71
+ * through the interface that owns it, as `types.ts` itself does elsewhere.
72
+ */
73
+ type LogLevel = Parameters<ILog['logger']>[0];
74
+
75
+ /** What a suite or realm may configure under `log` when `impl` is `semantic`. */
76
+ export interface SemanticLogConfig {
77
+ /** Threshold. Records below it are not emitted at all. */
78
+ level?: LogLevel;
79
+ /** `human` (one readable line) or `json` (one JSON object per record). */
80
+ format?: Format;
81
+ /** ANSI colour in human format. Opt-in, as the emitter's own option is. */
82
+ color?: boolean;
83
+ /** The service name carried on every record; the framework's own by default. */
84
+ service?: string;
85
+ /**
86
+ * Retain records on disk so a printed reference resolves later. The directory is
87
+ * the framework's log cache, which the pino transport also writes — one store,
88
+ * shared, so a `semantic-log://record/<id>` reference resolves whichever
89
+ * implementation emitted it.
90
+ */
91
+ cache?: RetentionOptions;
92
+ /**
93
+ * The call channel: the framework's own account of the calls this process
94
+ * makes and answers, and the switch that decides whether it is produced.
95
+ *
96
+ * `enabled` and `off` are the framework's decision (see `callTrace.ts`) —
97
+ * `off` names the entry methods whose flows are too frequent to record. This
98
+ * implementation adds `stdout`: the call records reach the retention store
99
+ * and the cluster service whether or not it is set, and setting it is what
100
+ * puts them on a terminal as well.
101
+ */
102
+ calls?: CallTraceConfig;
103
+ /**
104
+ * The semantic-log cluster service: where records go to be assembled into
105
+ * templates, flows, diagrams and incidents. `LogBase` owns what each field
106
+ * means — the framework's own concern here is only that a development run may
107
+ * start one and a deployment may point at one.
108
+ */
109
+ cluster?: ClusterOptions;
110
+ }
111
+
112
+ /**
113
+ * A pino level, as an emitter threshold.
114
+ *
115
+ * pino's vocabulary has one name the emitter does not: `silent`. It means "no
116
+ * records at all", which is a threshold above every level, so it is translated
117
+ * rather than passed through — a component configured silent must not fall back
118
+ * to the root's threshold and start emitting again, which is exactly what
119
+ * happened while `child` ignored this option.
120
+ */
121
+ function thresholdOf(level: Level | 'silent' | number | undefined): LevelName | number | undefined {
122
+ if (level === undefined) return undefined;
123
+ return level === 'silent' ? Number.POSITIVE_INFINITY : (level as LevelName | number);
124
+ }
125
+
126
+ export default class SemanticLog extends Internal implements ILog {
127
+ #config: SemanticLogConfig;
128
+ #base: LogBase;
129
+
130
+ public constructor(config: SemanticLogConfig) {
131
+ super();
132
+ const merged = merge<SemanticLogConfig>(
133
+ {level: 'info' as LogLevel, format: 'human' as Format, color: false},
134
+ config,
135
+ ) as SemanticLogConfig;
136
+ this.#config = merged;
137
+ this.#base = new LogBase({
138
+ service: merged.service ?? 'blong',
139
+ level: thresholdOf(merged.level),
140
+ format: merged.format,
141
+ color: merged.color,
142
+ cache: merged.cache,
143
+ // Only the printing switch is the base's business; `enabled`/`off` are
144
+ // the gate's, and are read where a flow is minted.
145
+ calls: merged.calls,
146
+ cluster: merged.cluster,
147
+ // Lazy on purpose: this module is on a path the browser bootstrap shares,
148
+ // and the service entry reaches `fastify`. A process with no cluster
149
+ // configured never loads it at all.
150
+ openCluster: async () =>
151
+ (await import('@feasibleone/semantic-log/service')).openCluster,
152
+ // The framework's envelope, on the record and on the call site's own
153
+ // arguments: `{$meta: {mtid, method}}` is what its readers match on.
154
+ envelope: ({leg, phase}) => callRecord(leg, phase),
155
+ translate: args => toLogCall(args),
156
+ });
157
+ }
158
+
159
+ /** What the emitter retains through, and how a retained record is read back. */
160
+ public get store(): RecordStore & PayloadStore {
161
+ return this.#base.store;
162
+ }
163
+
164
+ /**
165
+ * The address of the cluster service this process started, once it started one.
166
+ *
167
+ * The framework publishes it to the components that read the service — the realm
168
+ * whose pages ask it what it observed. It is the *bound* address rather than a
169
+ * configured one because the port is asked for, not named: two framework
170
+ * processes on one machine are ordinary (a dev server beside a test run, or CI
171
+ * running packages in parallel) and a fixed port leaves the second one with no
172
+ * service at all while its readers keep looking at the first one's.
173
+ */
174
+ public get clusterUrl(): string | undefined {
175
+ return this.#base.clusterUrl;
176
+ }
177
+
178
+ /**
179
+ * Install the call gate, then open the store and the cluster.
180
+ *
181
+ * The gate's configuration goes first: `enabled`/`off` decide whether a flow
182
+ * records, and both are read where a flow is minted, which is before the store
183
+ * can matter. The ambient scope the decision is carried in is semantic-log's
184
+ * own, reached by the vocabulary the server bootstrap attaches, so there is
185
+ * nothing to attach here.
186
+ */
187
+ public async init(): Promise<void> {
188
+ configureCallTrace(this.#config.calls);
189
+ await this.#base.init();
190
+ }
191
+
192
+ /** Flush and close everything the base opened. */
193
+ public async stop(): Promise<void> {
194
+ await this.#base.stop();
195
+ }
196
+
197
+ /**
198
+ * A pino-shaped child logger, with pino's `silent` translated first.
199
+ *
200
+ * Everything else about the child — the bindings, the component's name, the
201
+ * face — is the base's.
202
+ */
203
+ public child<T extends string>(
204
+ ...params: Parameters<PinoLogger<never>['child']>
205
+ ): PinoLogger<T> {
206
+ const [bindings, options] = params as unknown as [
207
+ Record<string, unknown> | undefined,
208
+ {level?: Level | 'silent' | number} | undefined,
209
+ ];
210
+ const level = thresholdOf(options?.level);
211
+ return this.#base.child(
212
+ bindings,
213
+ level === undefined ? undefined : {level},
214
+ ) as unknown as PinoLogger<T>;
215
+ }
216
+
217
+ public logger(
218
+ level: LogLevel = this.#config.level as LogLevel,
219
+ bindings: object,
220
+ ): ReturnType<ILog['logger']> {
221
+ const {name, ...rest} = bindings as {name?: string};
222
+ const face: LoggerFace = this.#base.componentFace(name, thresholdOf(level), rest);
223
+ const result: ILogger = {
224
+ trace: undefined,
225
+ debug: undefined,
226
+ info: undefined,
227
+ warn: undefined,
228
+ error: undefined,
229
+ fatal: undefined,
230
+ };
231
+ // The fall-through is the contract, not an oversight: see the class
232
+ // comment. Each case binds its own level and every one below it.
233
+ switch (level) {
234
+ case 'trace':
235
+ result.trace = face.trace;
236
+ case 'debug':
237
+ result.debug = face.debug;
238
+ case 'info':
239
+ result.info = face.info;
240
+ case 'warn':
241
+ result.warn = face.warn;
242
+ case 'error':
243
+ result.error = face.error;
244
+ case 'fatal':
245
+ result.fatal = face.fatal;
246
+ }
247
+ return {
248
+ ...result,
249
+ progress: (label, promise, options) => withProgress(result, label, promise, options),
250
+ calls: face.calls,
251
+ };
252
+ }
253
+
254
+ /**
255
+ * The framework's own account of the calls made through this log.
256
+ *
257
+ * Present on the log itself so a component that holds the log rather than a
258
+ * child logger (`Gateway`) reaches the same channel as a port that holds
259
+ * only its logger.
260
+ */
261
+ public get calls(): ICallLog {
262
+ return this.#base.callsChannel(undefined);
263
+ }
264
+ }
package/src/Watch.ts CHANGED
@@ -10,6 +10,7 @@ import {
10
10
  type IConfigRuntime,
11
11
  type IErrorFactory,
12
12
  type ILog,
13
+ type IMeta,
13
14
  type IModuleConfig,
14
15
  type IObjectSchema,
15
16
  type IPlatformApi,
@@ -21,6 +22,7 @@ import type {Dirent} from 'node:fs';
21
22
  import merge from 'ut-function.merge';
22
23
 
23
24
  import layerProxy from './layerProxy.ts';
25
+ import {runInFlow, withStep} from './semanticContext.ts';
24
26
 
25
27
  export interface IWatch {
26
28
  start: (realm: IRegistry, remote: IRemote, configOverride: object) => Promise<void>;
@@ -64,6 +66,16 @@ const isTest = (filename: string): boolean => /\.test\.[mc]?[tj]sx?$/i.test(file
64
66
 
65
67
  const prefixRE: RegExp = /(?:\d+-)?(.*)/;
66
68
 
69
+ /**
70
+ * The name a test group is displayed by: `test.meter.flow` reads as `meter flow`.
71
+ *
72
+ * The group's handler may name itself instead (`group("gateway meter flow")`), which
73
+ * is what the handler's own reader sees; this is the name to fall back to.
74
+ */
75
+ function groupName(method: string): string {
76
+ return method.replace(/^test\./, '').replace(/\./g, ' ');
77
+ }
78
+
67
79
  interface IConfig {
68
80
  enabled: boolean;
69
81
  test: string;
@@ -731,31 +743,53 @@ export default class Watch extends Internal implements IWatch {
731
743
  await import('./chain.ts')
732
744
  ).default(test as ITestContext, this.log);
733
745
 
734
- const steps = await Promise.all(
735
- ([] as string[]).concat(this.#config.test).map(async method => {
736
- const result = await remote.remote(method)({}, {});
737
- if (Array.isArray(result) && !('name' in result)) {
738
- Object.defineProperty(result, 'name', {
739
- value: method.replace(/^test\./, '').replace(/\./g, ' '),
740
- configurable: true,
741
- });
742
- }
743
- return result;
744
- }),
745
- );
746
+ const groups = ([] as string[]).concat(this.#config.test);
746
747
  let executed = 0;
747
748
  await withProgress(
748
749
  this.log,
749
750
  'run test groups',
750
- (async () => {
751
- await Promise.all(
752
- steps.map(async step => {
753
- await chain(step as Parameters<typeof chain>[0]);
754
- executed += 1;
755
- }),
756
- );
757
- })(),
758
- {getProgress: () => ({done: executed, total: steps.length})},
751
+ Promise.all(
752
+ groups.map(async method => {
753
+ // Nothing above a test group minted a flow, so each group is
754
+ // an entry of its own. The groups run in parallel, so one
755
+ // flow for the whole run would interleave unrelated
756
+ // scenarios into a single diagram; a flow per group gives
757
+ // one scenario per flow, which is what there is to draw.
758
+ //
759
+ // The flow wraps the *execution*, not just the dispatch: a
760
+ // group answers with its steps and they run afterwards, so a
761
+ // flow that ended with the dispatch covered nothing but the
762
+ // call that built the scenario - the calls the scenario made
763
+ // were then made in no flow at all, and the observed flow the
764
+ // run was supposed to leave behind never existed.
765
+ const meta: IMeta = {mtid: 'event', method};
766
+ await runInFlow(meta, method, async () => {
767
+ const steps = await remote.remote(method)({}, meta);
768
+ if (Array.isArray(steps) && !('name' in steps)) {
769
+ Object.defineProperty(steps, 'name', {
770
+ value: groupName(method),
771
+ configurable: true,
772
+ });
773
+ }
774
+ // The group's display name is also the flow's *phase*: a
775
+ // sequence that runs a whole scenario reads as one band, and
776
+ // the band is what tells a reader which scenario it is.
777
+ const named =
778
+ typeof steps === 'object' && steps !== null
779
+ ? (steps as {name?: unknown})
780
+ : undefined;
781
+ const phase =
782
+ typeof named?.name === 'string'
783
+ ? named.name
784
+ : groupName(method);
785
+ await withStep(phase, () =>
786
+ chain(steps as Parameters<typeof chain>[0]),
787
+ );
788
+ });
789
+ executed += 1;
790
+ }),
791
+ ),
792
+ {getProgress: () => ({done: executed, total: groups.length})},
759
793
  );
760
794
  } catch (error) {
761
795
  this.log?.error?.(error);
@@ -771,6 +805,20 @@ export default class Watch extends Internal implements IWatch {
771
805
  }
772
806
 
773
807
  public async test(framework: unknown): Promise<void> {
808
+ // `test()` settles only when the `start()`-registered listener calls `done`, and
809
+ // that listener is registered only when test groups are declared. Waiting for it
810
+ // unconditionally hangs the whole run with no output at all — a silent wait, which
811
+ // is exactly what a suite that loads a platform without test groups (a browser
812
+ // platform in a tap run, say) used to get. Report it and return: nothing to run is
813
+ // not an error, it just has to be said out loud.
814
+ const groups = ([] as string[]).concat(this.#config.test ?? []);
815
+ if (groups.length === 0) {
816
+ this.log?.info?.(
817
+ {$meta: {mtid: 'event', method: 'watch.test.none'}},
818
+ 'no test groups configured on this platform; nothing to run',
819
+ );
820
+ return;
821
+ }
774
822
  return new Promise<void>((resolve, reject) => {
775
823
  // this.#emit.emit('test', error => (error ? reject(error) : resolve()), framework);
776
824
  this.#emit.dispatchEvent(
@@ -0,0 +1,80 @@
1
+ /**
2
+ * The framework's half of call recording: the configuration it falls back to, the
3
+ * envelope a record carries, and the name a capability is asked for by.
4
+ *
5
+ * The switch itself and the phases are semantic-log's (`src/capability.ts`
6
+ * there), and the two are pinned together here rather than left to agree by
7
+ * convention — a capability name that drifted between packages would silence the
8
+ * channel without failing anything.
9
+ */
10
+ import {CALLS_CAPABILITY as SEMANTIC_CALLS_CAPABILITY} from '@feasibleone/semantic-log/capability';
11
+ import t from 'tap';
12
+ import {
13
+ CALLS_CAPABILITY,
14
+ callRecord,
15
+ callTraceConfig,
16
+ callsPermitted,
17
+ configureCallTrace,
18
+ } from './callTrace.ts';
19
+
20
+ t.teardown(() => configureCallTrace(undefined));
21
+
22
+ t.test('the capability name is the one semantic-log asks for', t => {
23
+ t.equal(CALLS_CAPABILITY, SEMANTIC_CALLS_CAPABILITY);
24
+ t.end();
25
+ });
26
+
27
+ t.test('the default is to record', t => {
28
+ configureCallTrace(undefined);
29
+ t.equal(callsPermitted('access.access.find'), true, 'nothing configured records');
30
+ t.same(callTraceConfig(), {}, 'and the configuration in force says so');
31
+ t.end();
32
+ });
33
+
34
+ t.test('an off pattern names a flow, and matches a namespace or an exact method', t => {
35
+ configureCallTrace({off: ['payment', 'ledger.entry.post']});
36
+ t.equal(callsPermitted('payment.transfer.prepare'), false, 'a namespace is opted out');
37
+ t.equal(callsPermitted('ledger.entry.post'), false, 'an exact entry is opted out');
38
+ t.equal(callsPermitted('ledger.entry.other'), true, 'a sibling of the exact entry is not');
39
+ t.equal(callsPermitted('access.access.find'), true, 'an unrelated flow is untouched');
40
+ t.end();
41
+ });
42
+
43
+ t.test('a glob pattern opts out a whole prefix', t => {
44
+ configureCallTrace({off: ['payment.*']});
45
+ t.equal(callsPermitted('payment.transfer.prepare'), false);
46
+ t.equal(callsPermitted('payment'), false, 'the namespace itself matches the pattern');
47
+ t.equal(callsPermitted('payments.transfer.prepare'), true, 'a longer name is not a match');
48
+ t.end();
49
+ });
50
+
51
+ t.test('enabled false is the blunt switch', t => {
52
+ configureCallTrace({enabled: false, off: ['access']});
53
+ t.equal(callsPermitted('access.access.find'), false);
54
+ t.equal(callsPermitted('anything.else'), false, 'and it is not limited to the patterns');
55
+ t.end();
56
+ });
57
+
58
+ t.test('a single off flag is read as a list, because the CLI writes one', t => {
59
+ // `--log.calls.off=payment` arrives as a string and the same flag twice as an
60
+ // array; a configuration that only worked when it was repeated would be a trap.
61
+ configureCallTrace({off: 'payment' as unknown as string[]});
62
+ t.equal(callsPermitted('payment.transfer.prepare'), false);
63
+ t.same(callTraceConfig().off, ['payment'], 'and it is normalised to a list');
64
+ t.end();
65
+ });
66
+
67
+ t.test('a phase record keeps the shape the ledger reads a leg from', t => {
68
+ t.same(callRecord('gateway.access.access.find', 'start'), {
69
+ $meta: {mtid: 'event', method: 'gateway.access.access.find.start'},
70
+ });
71
+ t.same(callRecord('a.b', 'received'), {$meta: {mtid: 'event', method: 'a.b.received'}});
72
+ t.end();
73
+ });
74
+
75
+ t.test('the stdout opt-in is read by the log implementation, not here', t => {
76
+ configureCallTrace({stdout: true});
77
+ t.equal(callTraceConfig().stdout, true, 'the framework passes it through untouched');
78
+ t.equal(callsPermitted('a.b'), true, 'and it says nothing about whether a flow records');
79
+ t.end();
80
+ });
@@ -0,0 +1,126 @@
1
+ /**
2
+ * Whether a flow's calls are recorded, and the record a phase produces.
3
+ *
4
+ * The *decision* is no longer made here. It is a capability — a per-execution
5
+ * switch — and semantic-log owns those (`@feasibleone/semantic-log/capability`):
6
+ * the switch lives in its ambient scope, travels with the identity in the `cap`
7
+ * field of `x-semantic-trace`, and is read back where a record would be produced.
8
+ * What this module keeps is everything that is the *framework's* rather than the
9
+ * emitter's:
10
+ *
11
+ * - the configuration the decision falls back to (`log.calls.*`), and the entry
12
+ * patterns a high-throughput flow is opted out by;
13
+ * - the envelope a call record carries — `$meta` with `mtid: 'event'` and a method
14
+ * naming the leg and the phase, which is what the ledger reads a call out of;
15
+ * - the header a grant token arrives in.
16
+ *
17
+ * The line between the two is worth stating, because it is the line between the
18
+ * packages: semantic-log knows what a call *is* (a leg, its target, its position,
19
+ * its phases), and blong knows how this runtime names things and what its records
20
+ * look like.
21
+ */
22
+
23
+ import type {CallPhase} from '@feasibleone/blong/types';
24
+
25
+ /**
26
+ * The capability that switches the call channel.
27
+ *
28
+ * The same name semantic-log's `createCallChannel` asks for — it is defined there
29
+ * too, and `callTrace.test.ts` pins the two together, because a value that had to
30
+ * agree across packages by convention is a value that would stop agreeing
31
+ * silently.
32
+ */
33
+ export const CALLS_CAPABILITY = 'calls';
34
+
35
+ /** The header a short-lived grant token arrives in. Never forwarded. */
36
+ export const GRANT_HEADER = 'x-blong-grant';
37
+
38
+ /**
39
+ * What the framework may be told about call recording.
40
+ *
41
+ * Declared here rather than in the log implementation because both of them honour
42
+ * it — the decision belongs to the framework, not to whichever logger a process
43
+ * happens to run.
44
+ */
45
+ export interface CallTraceConfig {
46
+ /** Produce call records at all. On unless this is `false`. */
47
+ enabled?: boolean;
48
+ /**
49
+ * Entry methods whose flows record nothing, matched against the name the flow
50
+ * was minted with (the gateway's method, e.g. `payment.transfer.prepare`).
51
+ *
52
+ * `payment` and `payment.*` both match the whole namespace; an exact name
53
+ * matches only that entry. The point is the high-throughput flows: recording
54
+ * them costs a record per call, and the picture they would draw is the one a
55
+ * reader already has.
56
+ */
57
+ off?: string[];
58
+ /**
59
+ * Print call records to stdout. Off unless asked for: the records are retained
60
+ * and sent to the cluster either way, and a person watching a terminal is
61
+ * reading the level log, not the call ledger.
62
+ */
63
+ stdout?: boolean;
64
+ }
65
+
66
+ let config: CallTraceConfig = {};
67
+
68
+ /** Install the configuration. Called by whichever log implementation loaded. */
69
+ export function configureCallTrace(next: CallTraceConfig | undefined): void {
70
+ if (next === undefined) {
71
+ config = {};
72
+ return;
73
+ }
74
+ // `off` normalised to a list, because the CLI is one of the two ways this is
75
+ // configured: `--log.calls.off=payment` arrives as a string and
76
+ // `--log.calls.off=payment --log.calls.off=ledger` as an array, and a
77
+ // configuration that only worked when it was repeated twice would be a trap.
78
+ const off = next.off as string | string[] | undefined;
79
+ config = {
80
+ ...next,
81
+ ...(off === undefined ? {} : {off: Array.isArray(off) ? off : [off]}),
82
+ };
83
+ }
84
+
85
+ /** The configuration in force, for a caller that has to explain it. */
86
+ export function callTraceConfig(): CallTraceConfig {
87
+ return config;
88
+ }
89
+
90
+ /** Does `pattern` name this entry method? */
91
+ function matches(pattern: string, entry: string): boolean {
92
+ if (pattern.length === 0) return false;
93
+ if (pattern === entry) return true;
94
+ // `payment` and `payment.*` mean the same thing — the whole namespace — and a
95
+ // bare name is what a configuration usually writes; the glob form exists so a
96
+ // pattern can end in `*` where that reads better.
97
+ const prefix = pattern.endsWith('.*') ? pattern.slice(0, -2) : pattern;
98
+ return entry === prefix || entry.startsWith(`${prefix}.`);
99
+ }
100
+
101
+ /**
102
+ * What the configuration says about an entry, when nothing else has decided.
103
+ *
104
+ * Only the configuration half of the decision: a capability already published for
105
+ * the flow settles the question before this is reached, and `semanticContext.ts`
106
+ * is where the two are put in order.
107
+ */
108
+ export function callsPermitted(entry: string): boolean {
109
+ if (config.enabled === false) {
110
+ return false;
111
+ }
112
+ return !(config.off ?? []).some(pattern => matches(pattern, entry));
113
+ }
114
+
115
+ /**
116
+ * The envelope a call record carries.
117
+ *
118
+ * What it was when these records were written through the level API — `$meta` with
119
+ * `mtid: 'event'` and a method naming the leg and the phase — so nothing downstream
120
+ * of the channel had to learn a new format, and the ledger still reads a call out
121
+ * of the same two fields. The line beside it belongs to semantic-log, which hands
122
+ * it to the writer on the event; nothing here names a phase in prose.
123
+ */
124
+ export function callRecord(leg: string, phase: CallPhase): Record<string, unknown> {
125
+ return {$meta: {mtid: 'event', method: `${leg}.${phase}`}};
126
+ }
package/src/cliHelp.ts CHANGED
@@ -17,6 +17,8 @@ Usage:
17
17
  blong <intent>... Run with only the given intents (e.g. integration)
18
18
  blong realm <name> [--object] Scaffold a new realm
19
19
  blong create realm <name> Scaffold a new realm
20
+ blong grant <capability> Mint a short-lived capability token
21
+ (e.g. blong grant calls --ttl=15m)
20
22
 
21
23
  Well-known intents:
22
24
  dev Development — verbose logs, hot reload
package/src/devKeys.ts ADDED
@@ -0,0 +1,38 @@
1
+ /**
2
+ * The framework's development-time gateway keys.
3
+ *
4
+ * Static, committed, and deliberately not secret: their job is that two
5
+ * processes started from the same checkout agree on the same key material, so a
6
+ * session survives a hot reload and a token minted by one command is the token a
7
+ * running server accepts. A deployment supplies its own through
8
+ * `GATEWAY_SIGN_KEY` / `GATEWAY_ENCRYPT_KEY` (see `Gateway.ts`), and this module
9
+ * is never consulted when they are set.
10
+ *
11
+ * They live here rather than inline in the `dev` intent because more than the
12
+ * loader needs them: `blong grant` mints a short-lived capability token with the
13
+ * same sign key the gateway verifies with, and a token minted with a key nobody
14
+ * else holds is a token that verifies nowhere.
15
+ */
16
+
17
+ /* cSpell:disable */
18
+ /** The `dev` intent's sign key (`ES384`, private: minting happens here too). */
19
+ export const devSignKey = {
20
+ kty: 'EC',
21
+ crv: 'P-384',
22
+ alg: 'ES384',
23
+ use: 'sig',
24
+ x: 'VlRkjgqRHJSk9WN8CaAqHn34BUMy9pgKQUAAW9MrOqh0yvCmJW7JTr6LUCbm9zfW',
25
+ y: '8eYxbAZrv-HZEc4LSgdEHeSp21zO3D8KrynMcVcNAmZKTf3RMkbkh1B26lePHQNz',
26
+ d: 'aj6BkYmpwkKRbmcO1LO6d__HX5bvkqcRjqadlX7plXlGfj1d42XiSUWa4c9xrxwt',
27
+ };
28
+ /** The `dev` intent's encrypt key (`ECDH-ES+A256KW`). */
29
+ export const devEncryptKey = {
30
+ kty: 'EC',
31
+ crv: 'P-384',
32
+ alg: 'ECDH-ES+A256KW',
33
+ use: 'enc',
34
+ x: '86IBoWsatO3Vky9CRMxmuYcfYoTY1Yr0D1sJGDgLlREMjbL9cIOHcBQnEaW52QJV',
35
+ y: 'fsKOmTuXaIRFXXteh7uU0Z8mncX4VsPhqaz9pMKMm8EktQlF7HBS_fYFdkLwqMMN',
36
+ d: 'rBY50TZzjONw_oYzWPqaR3DdoFwO-F9sWcmkOltrJHYnfbnTojNImX2xN1DhhC5-',
37
+ };
38
+ /* cSpell:enable */