@feasibleone/blong-gogo 1.30.0 → 1.31.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 +7 -0
- package/package.json +1 -1
- package/src/adapter/server/k8s.ts +90 -0
- package/src/adapter/server/kafka.ts +229 -28
- package/src/adapter/server/knex.ts +45 -0
- package/src/adapter/server/mongodb.ts +59 -5
- package/src/adapter/server/redis.ts +33 -5
- package/src/adapter/server/s3.ts +18 -2
- package/src/adapter/server/vault.ts +97 -5
- package/template/playwright.config.ts +1 -5
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [1.31.0](https://github.com/feasibleone/blong/compare/blong-gogo-v1.30.0...blong-gogo-v1.31.0) (2026-09-07)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Features
|
|
7
|
+
|
|
8
|
+
* blong-commander ([#163](https://github.com/feasibleone/blong/issues/163)) ([b7fe11b](https://github.com/feasibleone/blong/commit/b7fe11b44b1d1ee0e14e4be96523d403b59311c2))
|
|
9
|
+
|
|
3
10
|
## [1.30.0](https://github.com/feasibleone/blong/compare/blong-gogo-v1.29.0...blong-gogo-v1.30.0) (2026-08-21)
|
|
4
11
|
|
|
5
12
|
|
package/package.json
CHANGED
|
@@ -43,6 +43,51 @@ const errorMap: IErrorMap = {
|
|
|
43
43
|
|
|
44
44
|
let _errors: Errors<typeof errorMap>;
|
|
45
45
|
|
|
46
|
+
/**
|
|
47
|
+
* Commander explorer categories for namespaced resources. Each category groups
|
|
48
|
+
* the resource types the adapter can list (`{ns}.<resource>.find`). The
|
|
49
|
+
* category / resource levels are synthetic navigation (no cluster calls).
|
|
50
|
+
*/
|
|
51
|
+
const CATEGORIES: Array<{name: string; label: string; resources: Array<{type: string; label: string}>}> = [
|
|
52
|
+
{
|
|
53
|
+
name: 'workloads',
|
|
54
|
+
label: 'Workloads',
|
|
55
|
+
resources: [
|
|
56
|
+
{type: 'deployment', label: 'Deployments'},
|
|
57
|
+
{type: 'replicaset', label: 'ReplicaSets'},
|
|
58
|
+
{type: 'daemonset', label: 'DaemonSets'},
|
|
59
|
+
{type: 'statefulset', label: 'StatefulSets'},
|
|
60
|
+
{type: 'pod', label: 'Pods'},
|
|
61
|
+
],
|
|
62
|
+
},
|
|
63
|
+
{
|
|
64
|
+
name: 'networking',
|
|
65
|
+
label: 'Networking',
|
|
66
|
+
resources: [
|
|
67
|
+
{type: 'service', label: 'Services'},
|
|
68
|
+
{type: 'ingress', label: 'Ingresses'},
|
|
69
|
+
{type: 'networkpolicy', label: 'NetworkPolicies'},
|
|
70
|
+
],
|
|
71
|
+
},
|
|
72
|
+
{
|
|
73
|
+
name: 'storage',
|
|
74
|
+
label: 'Storage',
|
|
75
|
+
resources: [
|
|
76
|
+
{type: 'persistentvolume', label: 'PersistentVolumes'},
|
|
77
|
+
{type: 'persistentvolumeclaim', label: 'PersistentVolumeClaims'},
|
|
78
|
+
{type: 'storageclass', label: 'StorageClasses'},
|
|
79
|
+
],
|
|
80
|
+
},
|
|
81
|
+
{
|
|
82
|
+
name: 'configuration',
|
|
83
|
+
label: 'Configuration',
|
|
84
|
+
resources: [
|
|
85
|
+
{type: 'configmap', label: 'ConfigMaps'},
|
|
86
|
+
{type: 'secret', label: 'Secrets'},
|
|
87
|
+
],
|
|
88
|
+
},
|
|
89
|
+
];
|
|
90
|
+
|
|
46
91
|
export default adapter<IConfig>(({utError}) => {
|
|
47
92
|
_errors ||= utError.register(errorMap);
|
|
48
93
|
|
|
@@ -318,6 +363,29 @@ export default adapter<IConfig>(({utError}) => {
|
|
|
318
363
|
};
|
|
319
364
|
|
|
320
365
|
try {
|
|
366
|
+
// Commander explorer navigation levels (synthetic, no cluster calls):
|
|
367
|
+
// `{ns}.category.list` → the resource categories
|
|
368
|
+
// `{ns}.resource.list` → the resource types within a category
|
|
369
|
+
if (_resourceType === 'category' && operation === 'list') {
|
|
370
|
+
const ns =
|
|
371
|
+
(!Array.isArray(params) && params.namespace) ||
|
|
372
|
+
this.config.k8s.namespace ||
|
|
373
|
+
'default';
|
|
374
|
+
return {items: CATEGORIES.map(c => ({category: c.name, label: c.label, namespace: ns}))};
|
|
375
|
+
}
|
|
376
|
+
if (_resourceType === 'resource' && operation === 'list') {
|
|
377
|
+
const category =
|
|
378
|
+
!Array.isArray(params) ? (params.category as string | undefined) : undefined;
|
|
379
|
+
const ns =
|
|
380
|
+
(!Array.isArray(params) && params.namespace) ||
|
|
381
|
+
this.config.k8s.namespace ||
|
|
382
|
+
'default';
|
|
383
|
+
const cat = CATEGORIES.find(c => c.name === category);
|
|
384
|
+
const resources = cat?.resources ?? [];
|
|
385
|
+
return {
|
|
386
|
+
items: resources.map(r => ({resourceType: r.type, label: r.label, namespace: ns})),
|
|
387
|
+
};
|
|
388
|
+
}
|
|
321
389
|
switch (operation) {
|
|
322
390
|
case 'get': {
|
|
323
391
|
// Get single resource
|
|
@@ -351,6 +419,28 @@ export default adapter<IConfig>(({utError}) => {
|
|
|
351
419
|
...(continueToken && {continue: continueToken}),
|
|
352
420
|
});
|
|
353
421
|
}
|
|
422
|
+
case 'log': {
|
|
423
|
+
// Read pod container logs (`{ns}.pod.log`)
|
|
424
|
+
if (Array.isArray(params)) {
|
|
425
|
+
throw this.error(_errors['k8s.invalid'](), $meta);
|
|
426
|
+
}
|
|
427
|
+
if (resourceType !== 'pod') {
|
|
428
|
+
throw this.error(_errors['k8s.invalid'](), $meta);
|
|
429
|
+
}
|
|
430
|
+
const {name, container, tailLines, sinceSeconds, follow = false} = params;
|
|
431
|
+
if (!name) {
|
|
432
|
+
throw this.error(_errors['k8s.missingKey']({key: 'name'}), $meta);
|
|
433
|
+
}
|
|
434
|
+
const result = await this.config.context.coreV1Api!.readNamespacedPodLog({
|
|
435
|
+
name: name as string,
|
|
436
|
+
namespace,
|
|
437
|
+
container: container as string | undefined,
|
|
438
|
+
follow: follow as boolean,
|
|
439
|
+
tailLines: tailLines as number | undefined,
|
|
440
|
+
sinceSeconds: sinceSeconds as number | undefined,
|
|
441
|
+
});
|
|
442
|
+
return {logs: result};
|
|
443
|
+
}
|
|
354
444
|
case 'create':
|
|
355
445
|
case 'add': {
|
|
356
446
|
// Create resource
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import {adapter} from '@feasibleone/blong/types';
|
|
2
|
-
import Kafka from 'node-rdkafka';
|
|
1
|
+
import {adapter, type IMeta} from '@feasibleone/blong/types';
|
|
2
|
+
import Kafka, {type Message} from 'node-rdkafka';
|
|
3
3
|
import {Duplex} from 'stream';
|
|
4
4
|
|
|
5
5
|
type KafkaConfig = ConstructorParameters<typeof Kafka.KafkaConsumer>[0];
|
|
@@ -18,6 +18,18 @@ export interface IConfig {
|
|
|
18
18
|
codec?: {
|
|
19
19
|
new (config: object): CodecInstance;
|
|
20
20
|
};
|
|
21
|
+
/**
|
|
22
|
+
* Operation mode.
|
|
23
|
+
* - `'stream'` (default): a produce/consume message adapter — every triple
|
|
24
|
+
* is routed through the Kafka stream (the request is encoded and produced
|
|
25
|
+
* to `consume.topics`, the response is consumed + decoded). This is the
|
|
26
|
+
* original design for message round-trips.
|
|
27
|
+
* - `'admin'`: an introspection adapter — triples are routed to `exec`
|
|
28
|
+
* (like the API adapters: `super.connect()` → `handle()` → `exec`), so
|
|
29
|
+
* `{ns}.topic.list` (broker metadata) and `{ns}.topic.find` (message
|
|
30
|
+
* reads via one-off consumers) are reachable. No produce/consume stream.
|
|
31
|
+
*/
|
|
32
|
+
mode?: 'stream' | 'admin';
|
|
21
33
|
}
|
|
22
34
|
|
|
23
35
|
export default adapter<IConfig>(() => {
|
|
@@ -30,6 +42,13 @@ export default adapter<IConfig>(() => {
|
|
|
30
42
|
isConnected(): boolean;
|
|
31
43
|
once(event: 'ready', cb: () => void): void;
|
|
32
44
|
assignments(): {partition: number; topic: string; offset: number}[];
|
|
45
|
+
getMetadata(
|
|
46
|
+
opts: {timeout: number},
|
|
47
|
+
cb: (
|
|
48
|
+
err: Error | null,
|
|
49
|
+
data?: {topics?: Array<{name: string; partitions: unknown[]}>},
|
|
50
|
+
) => void,
|
|
51
|
+
): void;
|
|
33
52
|
};
|
|
34
53
|
})
|
|
35
54
|
| null = null;
|
|
@@ -49,7 +68,12 @@ export default adapter<IConfig>(() => {
|
|
|
49
68
|
async start() {
|
|
50
69
|
const result = await super.start();
|
|
51
70
|
|
|
52
|
-
|
|
71
|
+
const isAdmin = this.config.mode === 'admin';
|
|
72
|
+
// The codec encodes/decodes the Kafka stream message format. Admin
|
|
73
|
+
// (introspection) mode routes triples to `exec` (plain object
|
|
74
|
+
// responses), so the codec must NOT be applied there — a stream
|
|
75
|
+
// codec would try `msg.value.toString()` on the exec result.
|
|
76
|
+
if (this.config.codec && !isAdmin) {
|
|
53
77
|
codec = new this.config.codec({});
|
|
54
78
|
this.encode = (...params) => codec!.encode(...params);
|
|
55
79
|
this.decode = (...params) => codec!.decode(...params);
|
|
@@ -57,11 +81,24 @@ export default adapter<IConfig>(() => {
|
|
|
57
81
|
codec = null;
|
|
58
82
|
}
|
|
59
83
|
|
|
60
|
-
|
|
84
|
+
const groupId = this.config.consume.groupId;
|
|
85
|
+
const startedAt = Date.now();
|
|
86
|
+
const consumerConfig = {
|
|
87
|
+
...this.config.connection,
|
|
88
|
+
'group.id': groupId,
|
|
89
|
+
};
|
|
90
|
+
this.log?.info?.(
|
|
61
91
|
{
|
|
62
|
-
|
|
63
|
-
|
|
92
|
+
groupId,
|
|
93
|
+
topics: this.config.consume.topics,
|
|
94
|
+
sessionTimeoutMs: consumerConfig['session.timeout.ms'],
|
|
95
|
+
broker: consumerConfig['metadata.broker.list'],
|
|
64
96
|
},
|
|
97
|
+
'kafka consumer creating',
|
|
98
|
+
);
|
|
99
|
+
|
|
100
|
+
consumerStream = Kafka.KafkaConsumer.createReadStream(
|
|
101
|
+
consumerConfig,
|
|
65
102
|
{
|
|
66
103
|
'auto.offset.reset': 'earliest',
|
|
67
104
|
},
|
|
@@ -83,49 +120,93 @@ export default adapter<IConfig>(() => {
|
|
|
83
120
|
poll = setInterval(() => {
|
|
84
121
|
if (consumerStream!.consumer.assignments().length > 0) {
|
|
85
122
|
cleanup();
|
|
123
|
+
this.log?.info?.(
|
|
124
|
+
{groupId, elapsedMs: Date.now() - startedAt},
|
|
125
|
+
'kafka consumer assigned',
|
|
126
|
+
);
|
|
86
127
|
resolve();
|
|
87
128
|
}
|
|
88
129
|
}, 200);
|
|
89
130
|
safety = setTimeout(() => {
|
|
90
131
|
cleanup();
|
|
132
|
+
this.log?.warn?.(
|
|
133
|
+
{
|
|
134
|
+
groupId,
|
|
135
|
+
elapsedMs: Date.now() - startedAt,
|
|
136
|
+
hint: 'stale group members from an earlier abrupt shutdown keep the rebalance waiting up to session.timeout.ms',
|
|
137
|
+
},
|
|
138
|
+
'kafka consumer assignment timed out',
|
|
139
|
+
);
|
|
140
|
+
// Best-effort graceful close BEFORE failing start: a
|
|
141
|
+
// consumer that joined the group but never got an
|
|
142
|
+
// assignment would otherwise linger as a stale group
|
|
143
|
+
// member (no LeaveGroup), blocking the NEXT rebalance
|
|
144
|
+
// for up to session.timeout.ms too. destroy() →
|
|
145
|
+
// close() → disconnect() sends LeaveGroup.
|
|
146
|
+
try {
|
|
147
|
+
consumerStream?.destroy();
|
|
148
|
+
} catch {
|
|
149
|
+
// ignore — the process is already failing start
|
|
150
|
+
}
|
|
91
151
|
reject(new Error('Kafka assignment timeout'));
|
|
92
152
|
}, 30000);
|
|
93
153
|
};
|
|
94
154
|
if (consumerStream!.consumer.isConnected()) {
|
|
155
|
+
this.log?.info?.({groupId}, 'kafka consumer already connected');
|
|
95
156
|
startPolling();
|
|
96
157
|
} else {
|
|
97
|
-
|
|
158
|
+
this.log?.info?.({groupId}, 'kafka consumer connecting, waiting for ready');
|
|
159
|
+
consumerStream!.consumer.once('ready', () => {
|
|
160
|
+
this.log?.info?.(
|
|
161
|
+
{groupId, elapsedMs: Date.now() - startedAt},
|
|
162
|
+
'kafka consumer ready',
|
|
163
|
+
);
|
|
164
|
+
startPolling();
|
|
165
|
+
});
|
|
98
166
|
}
|
|
99
167
|
});
|
|
100
168
|
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
{}
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
169
|
+
if (this.config.mode === 'admin') {
|
|
170
|
+
// Admin/introspection mode — route triples to `exec`
|
|
171
|
+
// (`super.connect()` → `handle()` → `findHandler(method) ||
|
|
172
|
+
// imported['exec']`). The consumer stays connected for broker
|
|
173
|
+
// metadata (`{ns}.topic.list` uses `getMetadata`); the
|
|
174
|
+
// produce/consume Duplex is not built and no messages are
|
|
175
|
+
// consumed by this adapter instance.
|
|
176
|
+
super.connect();
|
|
177
|
+
} else {
|
|
178
|
+
producerStream = Kafka.Producer.createWriteStream(
|
|
179
|
+
{
|
|
180
|
+
...this.config.connection,
|
|
181
|
+
},
|
|
182
|
+
{},
|
|
183
|
+
{
|
|
184
|
+
objectMode: true,
|
|
185
|
+
},
|
|
186
|
+
);
|
|
110
187
|
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
188
|
+
// Build a custom Duplex that writes to the Kafka producer and
|
|
189
|
+
// receives messages from the Kafka consumer via 'data' events.
|
|
190
|
+
// Duplex.from({readable, writable}) is not used because it does
|
|
191
|
+
// not reliably forward object-mode events from the inner readable.
|
|
192
|
+
stream = new Duplex({objectMode: true, read() {}});
|
|
193
|
+
stream.write = (chunk, ...args) =>
|
|
194
|
+
(producerStream!.write as (...a: unknown[]) => boolean)(chunk, ...args);
|
|
118
195
|
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
196
|
+
consumerStream!.on('data', msg => stream?.push(msg));
|
|
197
|
+
consumerStream!.on('end', () => stream?.push(null));
|
|
198
|
+
consumerStream!.on('error', err => stream?.destroy(err as Error));
|
|
122
199
|
|
|
123
|
-
|
|
200
|
+
super.connect(stream);
|
|
201
|
+
}
|
|
124
202
|
|
|
125
203
|
return result;
|
|
126
204
|
},
|
|
127
205
|
|
|
128
206
|
async stop(...params: unknown[]) {
|
|
207
|
+
const groupId = this.config.consume.groupId;
|
|
208
|
+
const stopStartedAt = Date.now();
|
|
209
|
+
this.log?.info?.({groupId}, 'kafka adapter stop');
|
|
129
210
|
const awaitClose = (
|
|
130
211
|
s: {once(e: 'close', cb: () => void): void} | null,
|
|
131
212
|
disconnect: () => void,
|
|
@@ -134,9 +215,27 @@ export default adapter<IConfig>(() => {
|
|
|
134
215
|
!s
|
|
135
216
|
? Promise.resolve()
|
|
136
217
|
: new Promise<void>(resolve => {
|
|
137
|
-
const t = setTimeout(
|
|
218
|
+
const t = setTimeout(() => {
|
|
219
|
+
this.log?.warn?.(
|
|
220
|
+
{
|
|
221
|
+
groupId,
|
|
222
|
+
waitMs: ms,
|
|
223
|
+
elapsedMs: Date.now() - stopStartedAt,
|
|
224
|
+
hint: 'consumer/producer did not emit close — the group member was not gracefully removed',
|
|
225
|
+
},
|
|
226
|
+
'kafka close timed out',
|
|
227
|
+
);
|
|
228
|
+
resolve();
|
|
229
|
+
}, ms);
|
|
138
230
|
s.once('close', () => {
|
|
139
231
|
clearTimeout(t);
|
|
232
|
+
this.log?.info?.(
|
|
233
|
+
{
|
|
234
|
+
groupId,
|
|
235
|
+
elapsedMs: Date.now() - stopStartedAt,
|
|
236
|
+
},
|
|
237
|
+
'kafka consumer/producer closed (LeaveGroup sent)',
|
|
238
|
+
);
|
|
140
239
|
resolve();
|
|
141
240
|
});
|
|
142
241
|
disconnect();
|
|
@@ -156,6 +255,10 @@ export default adapter<IConfig>(() => {
|
|
|
156
255
|
20000,
|
|
157
256
|
),
|
|
158
257
|
]);
|
|
258
|
+
this.log?.info?.(
|
|
259
|
+
{groupId, elapsedMs: Date.now() - stopStartedAt},
|
|
260
|
+
'kafka adapter stop complete',
|
|
261
|
+
);
|
|
159
262
|
} finally {
|
|
160
263
|
stream = null;
|
|
161
264
|
codec = null;
|
|
@@ -165,5 +268,103 @@ export default adapter<IConfig>(() => {
|
|
|
165
268
|
}
|
|
166
269
|
return result;
|
|
167
270
|
},
|
|
271
|
+
|
|
272
|
+
async exec(
|
|
273
|
+
params: Record<string, unknown>,
|
|
274
|
+
$meta: IMeta,
|
|
275
|
+
): Promise<unknown> {
|
|
276
|
+
const {method} = $meta;
|
|
277
|
+
const [, object, operation] = method!.split('.');
|
|
278
|
+
if (object === 'topic') {
|
|
279
|
+
switch (operation) {
|
|
280
|
+
case 'list': {
|
|
281
|
+
// `{ns}.topic.list` — enumerate topics from broker metadata
|
|
282
|
+
if (!consumerStream) {
|
|
283
|
+
throw new Error('Kafka consumer not connected');
|
|
284
|
+
}
|
|
285
|
+
const metadata = await new Promise<{
|
|
286
|
+
topics: Array<{name: string; partitions: unknown[]}>;
|
|
287
|
+
}>((resolve, reject) => {
|
|
288
|
+
consumerStream!.consumer.getMetadata(
|
|
289
|
+
{timeout: 5000, allTopics: true},
|
|
290
|
+
(err, data) => (err ? reject(err) : resolve(data ?? {topics: []})),
|
|
291
|
+
);
|
|
292
|
+
});
|
|
293
|
+
return {
|
|
294
|
+
items: (metadata.topics ?? [])
|
|
295
|
+
.filter(t => !t.name.startsWith('__'))
|
|
296
|
+
.map(t => ({
|
|
297
|
+
topic: t.name,
|
|
298
|
+
partitionCount: t.partitions?.length ?? 0,
|
|
299
|
+
})),
|
|
300
|
+
};
|
|
301
|
+
}
|
|
302
|
+
case 'find': {
|
|
303
|
+
// `{ns}.topic.find` — read a batch of messages from a topic.
|
|
304
|
+
// A fresh consumer group with `auto.offset.reset: earliest`
|
|
305
|
+
// reads existing messages from the beginning (exploration);
|
|
306
|
+
// partitions are surfaced as message metadata, not tree nodes.
|
|
307
|
+
const topic = params.topic as string;
|
|
308
|
+
const limit = (params.limit as number) ?? 50;
|
|
309
|
+
if (!topic) {
|
|
310
|
+
throw new Error('Missing topic param');
|
|
311
|
+
}
|
|
312
|
+
const consumer = new Kafka.KafkaConsumer(
|
|
313
|
+
{
|
|
314
|
+
...this.config.connection,
|
|
315
|
+
'group.id': `blong-commander-${Date.now()}`,
|
|
316
|
+
},
|
|
317
|
+
{'auto.offset.reset': 'earliest'},
|
|
318
|
+
);
|
|
319
|
+
// A fresh group must complete the rebalance (group join +
|
|
320
|
+
// partition assignment) before the first consume returns.
|
|
321
|
+
consumer.setDefaultConsumeTimeout(3000);
|
|
322
|
+
// Always RESOLVE to a (possibly empty) batch — a rebalance
|
|
323
|
+
// stall or broker hiccup must surface as an empty topic, not
|
|
324
|
+
// as a malformed/empty RPC response ("JSON RPC response
|
|
325
|
+
// without response and error").
|
|
326
|
+
const messages = await new Promise<Message[] | undefined>(resolve => {
|
|
327
|
+
const timer = setTimeout(() => {
|
|
328
|
+
try {
|
|
329
|
+
consumer.disconnect();
|
|
330
|
+
} catch {
|
|
331
|
+
// ignore
|
|
332
|
+
}
|
|
333
|
+
resolve(undefined);
|
|
334
|
+
}, 15000);
|
|
335
|
+
const done = (msgs: Message[] | undefined) => {
|
|
336
|
+
clearTimeout(timer);
|
|
337
|
+
try {
|
|
338
|
+
consumer.disconnect();
|
|
339
|
+
} catch {
|
|
340
|
+
// ignore
|
|
341
|
+
}
|
|
342
|
+
resolve(msgs);
|
|
343
|
+
};
|
|
344
|
+
consumer.on('ready', () => {
|
|
345
|
+
consumer.subscribe([topic]);
|
|
346
|
+
consumer.consume(limit, (err, msgs) => {
|
|
347
|
+
if (err) return done(undefined);
|
|
348
|
+
done(msgs);
|
|
349
|
+
});
|
|
350
|
+
});
|
|
351
|
+
consumer.on('event.error', () => done(undefined));
|
|
352
|
+
consumer.connect();
|
|
353
|
+
});
|
|
354
|
+
return {
|
|
355
|
+
items: (messages ?? []).map(m => ({
|
|
356
|
+
topic: m.topic,
|
|
357
|
+
partition: m.partition,
|
|
358
|
+
offset: m.offset,
|
|
359
|
+
key: m.key?.toString(),
|
|
360
|
+
value: m.value?.toString(),
|
|
361
|
+
timestamp: m.timestamp,
|
|
362
|
+
})),
|
|
363
|
+
};
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
throw new Error(`Unknown kafka operation: ${object}.${operation}`);
|
|
368
|
+
},
|
|
168
369
|
};
|
|
169
370
|
});
|
|
@@ -407,6 +407,51 @@ export default adapter<IConfig>(({utError, schema: objectSchema}) => {
|
|
|
407
407
|
this as unknown as {_dropdownList(s: string): Promise<unknown>}
|
|
408
408
|
)._dropdownList(subject);
|
|
409
409
|
}
|
|
410
|
+
// Structure discovery for the commander explorer:
|
|
411
|
+
// `{ns}.schema.list` — databases/schemas on the server
|
|
412
|
+
// `{ns}.table.list` — tables in a schema (params.schema)
|
|
413
|
+
if (object === 'schema' && operation === 'list') {
|
|
414
|
+
const qb = this.config.context.queryBuilder!;
|
|
415
|
+
const rows = await qb
|
|
416
|
+
.select('SCHEMA_NAME as schemaName')
|
|
417
|
+
.from('information_schema.schemata')
|
|
418
|
+
.orderBy('SCHEMA_NAME');
|
|
419
|
+
return {items: rows as Array<{schemaName: string}>};
|
|
420
|
+
}
|
|
421
|
+
if (object === 'table' && operation === 'list') {
|
|
422
|
+
const qb = this.config.context.queryBuilder!;
|
|
423
|
+
// When no schema is given, scope to the adapter's own database so
|
|
424
|
+
// the explorer lists the app's tables only — not every schema the
|
|
425
|
+
// connection can see (information_schema, mysql, other realms' DBs).
|
|
426
|
+
const schema = (params as Record<string, unknown>).schema as string | undefined;
|
|
427
|
+
const database =
|
|
428
|
+
schema ?? (this.config.knex?.connection as {database?: string} | undefined)?.database;
|
|
429
|
+
let query = qb
|
|
430
|
+
.select('TABLE_NAME as tableName', 'TABLE_TYPE as tableType')
|
|
431
|
+
.from('information_schema.tables')
|
|
432
|
+
.orderBy('TABLE_NAME');
|
|
433
|
+
if (database) query = query.where('TABLE_SCHEMA', database);
|
|
434
|
+
const rows = await query;
|
|
435
|
+
// Drop leftover template/placeholder tables (e.g. `$subject_$object`
|
|
436
|
+
// from an unresolved schema seed) — they are not real, queryable
|
|
437
|
+
// tables and clicking them would surface a "table doesn't exist".
|
|
438
|
+
const junk = /[\${}]/;
|
|
439
|
+
// Only the tables this subject owns (`{subject}_*`) are reachable
|
|
440
|
+
// via the generic `{subject}.{object}` CRUD triples; strip the
|
|
441
|
+
// `{subject}_` prefix so `access.{tableName}.find` resolves to the
|
|
442
|
+
// actual table (`access.user.find` → `access_user`), instead of
|
|
443
|
+
// double-prefixing (`access.access_user.find` → `access_access_user`).
|
|
444
|
+
const prefix = `${subject}_`;
|
|
445
|
+
return {
|
|
446
|
+
items: (rows as Array<{tableName: string; tableType: string}>)
|
|
447
|
+
.filter(row => !junk.test(row.tableName))
|
|
448
|
+
.filter(row => row.tableName.startsWith(prefix))
|
|
449
|
+
.map(row => ({
|
|
450
|
+
tableName: row.tableName.slice(prefix.length),
|
|
451
|
+
tableType: row.tableType,
|
|
452
|
+
})),
|
|
453
|
+
};
|
|
454
|
+
}
|
|
410
455
|
const table = `${subject}_${object}`;
|
|
411
456
|
switch (operation) {
|
|
412
457
|
case 'get': {
|
|
@@ -70,12 +70,18 @@ export default adapter<IConfig>(({utError}) => {
|
|
|
70
70
|
const {method} = $meta;
|
|
71
71
|
const [, _table, operation] = method!.split('.');
|
|
72
72
|
let table = _table;
|
|
73
|
+
let dbName: string | undefined;
|
|
74
|
+
// `{ns}.collection.*` triples carry `{database, collection}`; the
|
|
75
|
+
// collection is the table, the database selects the DB (and must NOT
|
|
76
|
+
// leak into the WHERE filter — previously docs were filtered by a
|
|
77
|
+
// literal `database` field, yielding empty lists).
|
|
73
78
|
if (!Array.isArray(params) && _table === 'collection') {
|
|
74
|
-
const {collection, ...rest} = params;
|
|
79
|
+
const {collection, database, ...rest} = params;
|
|
75
80
|
if (collection) {
|
|
76
81
|
table = collection;
|
|
77
82
|
params = rest;
|
|
78
83
|
}
|
|
84
|
+
dbName = database as string | undefined;
|
|
79
85
|
}
|
|
80
86
|
const key = table.split(/\W/, 1)[0] + 'Id';
|
|
81
87
|
switch (operation) {
|
|
@@ -86,8 +92,8 @@ export default adapter<IConfig>(({utError}) => {
|
|
|
86
92
|
}
|
|
87
93
|
const nonArrayParams = params as Record<string, unknown>;
|
|
88
94
|
const {select = '*', sort, [key]: _id, ...where} = nonArrayParams;
|
|
89
|
-
|
|
90
|
-
.mongodb!.db()
|
|
95
|
+
const doc = await this.config.context
|
|
96
|
+
.mongodb!.db(dbName)
|
|
91
97
|
.collection(table)
|
|
92
98
|
.findOne(
|
|
93
99
|
{
|
|
@@ -108,6 +114,11 @@ export default adapter<IConfig>(({utError}) => {
|
|
|
108
114
|
sort: sort as import('mongodb').Sort | undefined,
|
|
109
115
|
},
|
|
110
116
|
);
|
|
117
|
+
// Surface a string `id` (mongo's `_id` is an ObjectId object,
|
|
118
|
+
// dropped by the commander's scalar-only flattening).
|
|
119
|
+
return doc
|
|
120
|
+
? {...doc, id: String((doc as {_id?: unknown})._id ?? '')}
|
|
121
|
+
: doc;
|
|
111
122
|
}
|
|
112
123
|
case 'find': {
|
|
113
124
|
// find multiple documents
|
|
@@ -115,8 +126,8 @@ export default adapter<IConfig>(({utError}) => {
|
|
|
115
126
|
throw this.error(_errors['mongodb.invalid'](), $meta);
|
|
116
127
|
}
|
|
117
128
|
const {select = '*', order, limit, offset, [key]: _id, ...where} = params;
|
|
118
|
-
|
|
119
|
-
.mongodb!.db()
|
|
129
|
+
const docs = await this.config.context
|
|
130
|
+
.mongodb!.db(dbName)
|
|
120
131
|
.collection(table)
|
|
121
132
|
.find(
|
|
122
133
|
{
|
|
@@ -152,6 +163,8 @@ export default adapter<IConfig>(({utError}) => {
|
|
|
152
163
|
},
|
|
153
164
|
)
|
|
154
165
|
.toArray();
|
|
166
|
+
// Surface a string `id` for the commander explorer rows.
|
|
167
|
+
return docs.map(doc => ({...doc, id: String((doc as {_id?: unknown})._id ?? '')}));
|
|
155
168
|
}
|
|
156
169
|
case 'add': {
|
|
157
170
|
// add single document
|
|
@@ -234,6 +247,47 @@ export default adapter<IConfig>(({utError}) => {
|
|
|
234
247
|
.mongodb!.db()
|
|
235
248
|
.collection(table)
|
|
236
249
|
.deleteMany(params as Filter<BSON.Document>);
|
|
250
|
+
case 'list': {
|
|
251
|
+
// Enumeration for the commander explorer:
|
|
252
|
+
// `{ns}.database.list` → databases on the server
|
|
253
|
+
// `{ns}.collection.list` → collections in a database (params.database)
|
|
254
|
+
if (Array.isArray(params)) {
|
|
255
|
+
throw this.error(_errors['mongodb.invalid'](), $meta);
|
|
256
|
+
}
|
|
257
|
+
if (_table === 'database') {
|
|
258
|
+
const result = await this.config.context
|
|
259
|
+
.mongodb!.db()
|
|
260
|
+
.admin()
|
|
261
|
+
.listDatabases();
|
|
262
|
+
return {
|
|
263
|
+
items:
|
|
264
|
+
result.databases?.map(db => ({
|
|
265
|
+
database: db.name,
|
|
266
|
+
sizeOnDisk: db.sizeOnDisk,
|
|
267
|
+
empty: db.empty,
|
|
268
|
+
})) ?? [],
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
if (_table === 'collection') {
|
|
272
|
+
const database = (params as Record<string, unknown>).database as
|
|
273
|
+
| string
|
|
274
|
+
| undefined;
|
|
275
|
+
const collections = await this.config.context
|
|
276
|
+
.mongodb!.db(database)
|
|
277
|
+
.listCollections()
|
|
278
|
+
.toArray();
|
|
279
|
+
return {
|
|
280
|
+
items: collections.map(c => ({
|
|
281
|
+
collection: c.name,
|
|
282
|
+
type: c.type,
|
|
283
|
+
// thread the DB so deeper levels can resolve
|
|
284
|
+
// `{parent.database}`
|
|
285
|
+
database,
|
|
286
|
+
})),
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
throw this.error(_errors['mongodb.invalid'](), $meta);
|
|
290
|
+
}
|
|
237
291
|
}
|
|
238
292
|
throw this.error(_errors['mongodb.generic'](), $meta);
|
|
239
293
|
},
|
|
@@ -1,7 +1,6 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type {Adapter, IMeta} from '@feasibleone/blong/types';
|
|
2
2
|
import {adapter, type Errors, type IErrorMap} from '@feasibleone/blong/types';
|
|
3
|
-
import Redis from 'ioredis';
|
|
4
|
-
import {Cluster} from 'ioredis';
|
|
3
|
+
import Redis, {Cluster} from 'ioredis';
|
|
5
4
|
|
|
6
5
|
export interface IConfig {
|
|
7
6
|
/**
|
|
@@ -49,6 +48,7 @@ export interface IRedisClient {
|
|
|
49
48
|
hincrby(key: string, field: string, increment: number): Promise<number>;
|
|
50
49
|
hdel(key: string, ...fields: string[]): Promise<number>;
|
|
51
50
|
eval(script: string, numKeys: number, ...keysAndArgs: unknown[]): Promise<unknown>;
|
|
51
|
+
scan(cursor: string, ...args: unknown[]): Promise<[string, string[]]>;
|
|
52
52
|
quit(): Promise<unknown>;
|
|
53
53
|
}
|
|
54
54
|
|
|
@@ -121,6 +121,20 @@ export default adapter<IConfig>(({utError}) => {
|
|
|
121
121
|
expired: (await redis.expire(params.keyName as string, params.seconds as number)) === 1,
|
|
122
122
|
}),
|
|
123
123
|
ttl: async params => ({ttl: await redis.ttl(params.keyName as string)}),
|
|
124
|
+
list: async params => {
|
|
125
|
+
const pattern = (params.pattern as string) ?? '*';
|
|
126
|
+
const count = (params.count as number) ?? 100;
|
|
127
|
+
const limit = (params.limit as number) ?? 1000;
|
|
128
|
+
const cursor = (params.cursor as string) ?? '0';
|
|
129
|
+
const keyNames: string[] = [];
|
|
130
|
+
let next = cursor;
|
|
131
|
+
do {
|
|
132
|
+
const [newCursor, batch] = await redis.scan(next, 'MATCH', pattern, 'COUNT', count);
|
|
133
|
+
keyNames.push(...batch);
|
|
134
|
+
next = newCursor;
|
|
135
|
+
} while (next !== '0' && keyNames.length < limit);
|
|
136
|
+
return {items: keyNames.slice(0, limit).map(keyName => ({keyName})), cursor: next};
|
|
137
|
+
},
|
|
124
138
|
};
|
|
125
139
|
|
|
126
140
|
// Generic hash operations: redis.hash.getAll|get|set|incrBy|del
|
|
@@ -185,6 +199,15 @@ export default adapter<IConfig>(({utError}) => {
|
|
|
185
199
|
} catch {
|
|
186
200
|
// Best-effort: a lazy client that never connected may reject quit().
|
|
187
201
|
}
|
|
202
|
+
try {
|
|
203
|
+
// `quit()` waits for the QUIT round-trip and can leave the socket
|
|
204
|
+
// open when the client is mid-connect/reconnect (the commander tap
|
|
205
|
+
// test observed a lingering 6379 socket after stop). `disconnect()`
|
|
206
|
+
// force-closes without waiting, guaranteeing the handle is released.
|
|
207
|
+
(redis as {disconnect?: () => void})?.disconnect?.();
|
|
208
|
+
} catch {
|
|
209
|
+
// ignore
|
|
210
|
+
}
|
|
188
211
|
return super.stop();
|
|
189
212
|
},
|
|
190
213
|
/**
|
|
@@ -194,8 +217,7 @@ export default adapter<IConfig>(({utError}) => {
|
|
|
194
217
|
async configChanged(diff: Map<string, {prev: unknown; next: unknown}>, next: unknown) {
|
|
195
218
|
const redisChanged = Array.from(diff.keys()).some(
|
|
196
219
|
(key: string) =>
|
|
197
|
-
key === this.config.id + '.redis' ||
|
|
198
|
-
key.startsWith(this.config.id + '.redis.'),
|
|
220
|
+
key === this.config.id + '.redis' || key.startsWith(this.config.id + '.redis.'),
|
|
199
221
|
);
|
|
200
222
|
if (!redisChanged) return;
|
|
201
223
|
const newAdapterConfig = (next as Record<string, unknown>)?.[this.config.id] as
|
|
@@ -217,6 +239,12 @@ export default adapter<IConfig>(({utError}) => {
|
|
|
217
239
|
const operation = parts[2];
|
|
218
240
|
try {
|
|
219
241
|
await ensureConnected();
|
|
242
|
+
// `{ns}.database.list` — enumerate the logical databases this
|
|
243
|
+
// source exposes (the configured db index).
|
|
244
|
+
if (object === 'database' && operation === 'list') {
|
|
245
|
+
const db = (this.config as {redis?: IConfig}).redis?.db ?? 0;
|
|
246
|
+
return {items: [{db}]};
|
|
247
|
+
}
|
|
220
248
|
const ops =
|
|
221
249
|
object === 'key'
|
|
222
250
|
? keyOps
|
package/src/adapter/server/s3.ts
CHANGED
|
@@ -3,6 +3,7 @@ import {
|
|
|
3
3
|
DeleteObjectCommand,
|
|
4
4
|
GetObjectCommand,
|
|
5
5
|
HeadObjectCommand,
|
|
6
|
+
ListBucketsCommand,
|
|
6
7
|
ListObjectsV2Command,
|
|
7
8
|
PutObjectCommand,
|
|
8
9
|
S3Client,
|
|
@@ -89,7 +90,7 @@ export default adapter<IConfig>(({utError}) => {
|
|
|
89
90
|
$meta: IMeta,
|
|
90
91
|
) {
|
|
91
92
|
const {method} = $meta;
|
|
92
|
-
const [, , operation] = method!.split('.');
|
|
93
|
+
const [, object, operation] = method!.split('.');
|
|
93
94
|
let bucket: string | undefined;
|
|
94
95
|
let actualParams = params;
|
|
95
96
|
|
|
@@ -99,7 +100,10 @@ export default adapter<IConfig>(({utError}) => {
|
|
|
99
100
|
actualParams = rest;
|
|
100
101
|
}
|
|
101
102
|
|
|
102
|
-
|
|
103
|
+
// Bucket enumeration (`s3.bucket.list`) does not need a target bucket.
|
|
104
|
+
const needsBucket =
|
|
105
|
+
!(object === 'bucket' && (operation === 'list' || operation === 'find'));
|
|
106
|
+
if (!bucket && !this.config.bucket?.Bucket && needsBucket) {
|
|
103
107
|
throw this.error(_errors['s3.missingBucket'](), $meta);
|
|
104
108
|
}
|
|
105
109
|
|
|
@@ -203,6 +207,18 @@ export default adapter<IConfig>(({utError}) => {
|
|
|
203
207
|
if (Array.isArray(actualParams)) {
|
|
204
208
|
throw this.error(_errors['s3.invalid'](), $meta);
|
|
205
209
|
}
|
|
210
|
+
// `s3.bucket.list` — enumerate buckets on the endpoint
|
|
211
|
+
if (object === 'bucket') {
|
|
212
|
+
const command = new ListBucketsCommand({});
|
|
213
|
+
const response = await this.config.context.s3!.send(command);
|
|
214
|
+
return {
|
|
215
|
+
items:
|
|
216
|
+
response.Buckets?.map(b => ({
|
|
217
|
+
bucket: b.Name,
|
|
218
|
+
creationDate: b.CreationDate,
|
|
219
|
+
})) ?? [],
|
|
220
|
+
};
|
|
221
|
+
}
|
|
206
222
|
const {prefix, maxKeys = 1000} = actualParams;
|
|
207
223
|
|
|
208
224
|
const command = new ListObjectsV2Command({
|
|
@@ -32,6 +32,25 @@ const errorMap: IErrorMap = {
|
|
|
32
32
|
|
|
33
33
|
let _errors: Errors<typeof errorMap>;
|
|
34
34
|
|
|
35
|
+
// KV v2 secret-engine mount paths (trailing slash, e.g. `secret/`), discovered
|
|
36
|
+
// from `vault.mount.list`. KV v2 stores the actual secrets under `metadata/`
|
|
37
|
+
// (listing) / `data/` (reading) instead of the mount root.
|
|
38
|
+
const kv2Mounts = new Set<string>();
|
|
39
|
+
|
|
40
|
+
/** Collapse doubled separators from `{parent.path}/{key}` joins. */
|
|
41
|
+
function normalizePath(path: string): string {
|
|
42
|
+
return path.replace(/\/{2,}/g, '/');
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** The KV v2 mount a path belongs to (e.g. `secret/`), or null. */
|
|
46
|
+
function kv2MountFor(path: string): string | null {
|
|
47
|
+
const normalized = normalizePath(path);
|
|
48
|
+
for (const mount of kv2Mounts) {
|
|
49
|
+
if (normalized === mount || normalized.startsWith(mount)) return mount;
|
|
50
|
+
}
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
|
|
35
54
|
async function authenticateVault(this: {config: IConfig}): Promise<void> {
|
|
36
55
|
const {authMethod, roleId, secretId, username, password} = this.config.vault;
|
|
37
56
|
|
|
@@ -155,9 +174,27 @@ export default adapter<IConfig>(({utError}) => {
|
|
|
155
174
|
throw this.error(_errors['vault.missingPath'](), $meta);
|
|
156
175
|
}
|
|
157
176
|
|
|
177
|
+
// KV v2: secrets are read from `<mount>data/<name>`, and the
|
|
178
|
+
// secret fields are wrapped under `data`.
|
|
179
|
+
let readPath = normalizePath(secretPath);
|
|
180
|
+
const mount = kv2MountFor(readPath);
|
|
181
|
+
if (mount && !readPath.startsWith(`${mount}data/`)) {
|
|
182
|
+
const name = readPath.slice(mount.length);
|
|
183
|
+
readPath = `${mount}data/${name}`;
|
|
184
|
+
}
|
|
185
|
+
|
|
158
186
|
try {
|
|
159
|
-
const result = await this.config.context.vault!.read(
|
|
160
|
-
|
|
187
|
+
const result = await this.config.context.vault!.read(readPath);
|
|
188
|
+
const payload = result.data;
|
|
189
|
+
if (
|
|
190
|
+
payload &&
|
|
191
|
+
typeof payload === 'object' &&
|
|
192
|
+
!Array.isArray(payload) &&
|
|
193
|
+
'data' in payload
|
|
194
|
+
) {
|
|
195
|
+
return (payload as {data: unknown}).data;
|
|
196
|
+
}
|
|
197
|
+
return payload;
|
|
161
198
|
} catch (error: unknown) {
|
|
162
199
|
throw this.error(
|
|
163
200
|
(error as {response?: {statusCode?: number}})?.response?.statusCode === 404
|
|
@@ -220,19 +257,74 @@ export default adapter<IConfig>(({utError}) => {
|
|
|
220
257
|
if (Array.isArray(actualParams)) {
|
|
221
258
|
throw this.error(_errors['vault.invalid'](), $meta);
|
|
222
259
|
}
|
|
260
|
+
// `vault.mount.list` — enumerate mounted secret engines
|
|
261
|
+
if (resource === 'mount') {
|
|
262
|
+
try {
|
|
263
|
+
const result = await this.config.context.vault!.mounts();
|
|
264
|
+
kv2Mounts.clear();
|
|
265
|
+
const items = Object.entries(result?.data ?? {}).map(
|
|
266
|
+
([path, cfg]: [string, unknown]) => {
|
|
267
|
+
const c = cfg as {
|
|
268
|
+
type?: string;
|
|
269
|
+
options?: {version?: number};
|
|
270
|
+
};
|
|
271
|
+
if (c?.type === 'kv' && (c.options?.version ?? 0) === 2) {
|
|
272
|
+
kv2Mounts.add(path);
|
|
273
|
+
}
|
|
274
|
+
return {
|
|
275
|
+
path,
|
|
276
|
+
...(typeof cfg === 'object' && cfg !== null
|
|
277
|
+
? (cfg as Record<string, unknown>)
|
|
278
|
+
: {}),
|
|
279
|
+
};
|
|
280
|
+
},
|
|
281
|
+
);
|
|
282
|
+
return {items};
|
|
283
|
+
} catch (error: unknown) {
|
|
284
|
+
throw this.error(_errors['vault.generic'](error), $meta);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
223
287
|
if (!secretPath) {
|
|
224
288
|
throw this.error(_errors['vault.missingPath'](), $meta);
|
|
225
289
|
}
|
|
226
290
|
|
|
291
|
+
// KV v2: a mount root (or its `data/` marker) lists the actual
|
|
292
|
+
// secrets through `metadata/` — never the raw mount root.
|
|
293
|
+
const originalPath = normalizePath(secretPath);
|
|
294
|
+
let listPath = originalPath;
|
|
295
|
+
const mount = kv2MountFor(listPath);
|
|
296
|
+
if (mount) {
|
|
297
|
+
const trimmedMount = mount.replace(/\/+$/, '');
|
|
298
|
+
const trimmedList = listPath.replace(/\/+$/, '');
|
|
299
|
+
if (trimmedList === trimmedMount || trimmedList === `${trimmedMount}/data`) {
|
|
300
|
+
listPath = `${trimmedMount}/metadata/`;
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
227
304
|
try {
|
|
228
|
-
const result = await this.config.context.vault!.list(
|
|
229
|
-
|
|
305
|
+
const result = await this.config.context.vault!.list(listPath);
|
|
306
|
+
const data = (result.data ?? {}) as {keys?: string[]};
|
|
307
|
+
// Keep the native Vault `keys` shape (relied on by the
|
|
308
|
+
// integration tests) and additionally expose commander-style
|
|
309
|
+
// `items` rows (consistent with `vault.mount.list`) so the
|
|
310
|
+
// generic explorer can render the secrets as a table. Keys
|
|
311
|
+
// ending in `/` are sub-path (directory) markers, not leaf
|
|
312
|
+
// secrets — clicking one would 404 ("Vault Secret Not
|
|
313
|
+
// Found"), so they are filtered out. Rows carry the MOUNT
|
|
314
|
+
// path so the deeper level's `{parent.path}/{key}` open
|
|
315
|
+
// resolves to `<mount>/<name>`.
|
|
316
|
+
const keys = (data.keys ?? []).filter(key => !key.endsWith('/'));
|
|
317
|
+
return {
|
|
318
|
+
...data,
|
|
319
|
+
keys,
|
|
320
|
+
items: keys.map(key => ({key, path: originalPath})),
|
|
321
|
+
};
|
|
230
322
|
} catch (error: unknown) {
|
|
231
323
|
if (
|
|
232
324
|
(error as {response?: {statusCode?: number}})?.response?.statusCode ===
|
|
233
325
|
404
|
|
234
326
|
) {
|
|
235
|
-
return {keys: []};
|
|
327
|
+
return {keys: [], items: []};
|
|
236
328
|
}
|
|
237
329
|
throw this.error(_errors['vault.generic'](error), $meta);
|
|
238
330
|
}
|
|
@@ -1,7 +1,3 @@
|
|
|
1
1
|
import {defineBlongConfig} from '@feasibleone/blong-browser/playwright/config';
|
|
2
2
|
|
|
3
|
-
export default defineBlongConfig(
|
|
4
|
-
// Adjust these ports if they clash with another locally-running realm.
|
|
5
|
-
backendPort: 9003,
|
|
6
|
-
frontendPort: 9103,
|
|
7
|
-
});
|
|
3
|
+
export default defineBlongConfig();
|