@xiboplayer/utils 0.7.2 → 0.7.4
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/package.json +3 -3
- package/src/config.js +22 -19
- package/src/config.test.js +175 -4
- package/src/idb.js +55 -0
- package/src/index.js +2 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@xiboplayer/utils",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.4",
|
|
4
4
|
"description": "Shared utilities for Xibo Player packages",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./src/index.js",
|
|
@@ -12,10 +12,10 @@
|
|
|
12
12
|
"./config": "./src/config.js"
|
|
13
13
|
},
|
|
14
14
|
"dependencies": {
|
|
15
|
-
"@xiboplayer/crypto": "0.7.
|
|
15
|
+
"@xiboplayer/crypto": "0.7.4"
|
|
16
16
|
},
|
|
17
17
|
"devDependencies": {
|
|
18
|
-
"vitest": "^2.
|
|
18
|
+
"vitest": "^2.1.9"
|
|
19
19
|
},
|
|
20
20
|
"keywords": [
|
|
21
21
|
"xibo",
|
package/src/config.js
CHANGED
|
@@ -14,6 +14,9 @@
|
|
|
14
14
|
*/
|
|
15
15
|
import { generateRsaKeyPair, isValidPemKey } from '@xiboplayer/crypto';
|
|
16
16
|
import { openIDB } from './idb.js';
|
|
17
|
+
import { createLogger } from './logger.js';
|
|
18
|
+
|
|
19
|
+
const log = createLogger('Config');
|
|
17
20
|
|
|
18
21
|
const GLOBAL_KEY = 'xibo_global'; // Device identity (all CMSes)
|
|
19
22
|
const CMS_PREFIX = 'xibo_cms:'; // Per-CMS config prefix
|
|
@@ -135,7 +138,7 @@ export class Config {
|
|
|
135
138
|
try {
|
|
136
139
|
global = JSON.parse(localStorage.getItem(GLOBAL_KEY) || '{}');
|
|
137
140
|
} catch (e) {
|
|
138
|
-
|
|
141
|
+
log.error('Failed to parse xibo_global:', e);
|
|
139
142
|
}
|
|
140
143
|
|
|
141
144
|
// Determine active CMS
|
|
@@ -148,7 +151,7 @@ export class Config {
|
|
|
148
151
|
const cmsJson = localStorage.getItem(CMS_PREFIX + activeCmsId);
|
|
149
152
|
if (cmsJson) cmsConfig = JSON.parse(cmsJson);
|
|
150
153
|
} catch (e) {
|
|
151
|
-
|
|
154
|
+
log.error('Failed to parse CMS config:', e);
|
|
152
155
|
}
|
|
153
156
|
}
|
|
154
157
|
|
|
@@ -175,16 +178,16 @@ export class Config {
|
|
|
175
178
|
let changed = false;
|
|
176
179
|
|
|
177
180
|
if (!config.hardwareKey || config.hardwareKey.length < 10) {
|
|
178
|
-
|
|
181
|
+
log.warn('Missing/invalid hardwareKey — generating');
|
|
179
182
|
config.hardwareKey = this.generateStableHardwareKey();
|
|
180
183
|
this._backupHardwareKey(config.hardwareKey);
|
|
181
184
|
changed = true;
|
|
182
185
|
} else {
|
|
183
|
-
|
|
186
|
+
log.info('✓ Loaded existing hardwareKey:', config.hardwareKey);
|
|
184
187
|
}
|
|
185
188
|
|
|
186
189
|
if (!config.xmrChannel) {
|
|
187
|
-
|
|
190
|
+
log.warn('Missing xmrChannel — generating');
|
|
188
191
|
config.xmrChannel = this.generateXmrChannel();
|
|
189
192
|
changed = true;
|
|
190
193
|
}
|
|
@@ -265,12 +268,12 @@ export class Config {
|
|
|
265
268
|
try {
|
|
266
269
|
cmsConfig = JSON.parse(existingJson);
|
|
267
270
|
isNew = false;
|
|
268
|
-
|
|
271
|
+
log.info(`Switching to existing CMS profile: ${newCmsId}`);
|
|
269
272
|
} catch (e) {
|
|
270
|
-
|
|
273
|
+
log.error('Failed to parse target CMS config:', e);
|
|
271
274
|
}
|
|
272
275
|
} else {
|
|
273
|
-
|
|
276
|
+
log.info(`Creating new CMS profile: ${newCmsId}`);
|
|
274
277
|
cmsConfig = {
|
|
275
278
|
cmsUrl,
|
|
276
279
|
cmsKey: '',
|
|
@@ -361,7 +364,7 @@ export class Config {
|
|
|
361
364
|
store.put(v, k);
|
|
362
365
|
}
|
|
363
366
|
tx.oncomplete = () => {
|
|
364
|
-
|
|
367
|
+
log.info('Keys backed up to IndexedDB:', Object.keys(keys).join(', '));
|
|
365
368
|
db.close();
|
|
366
369
|
};
|
|
367
370
|
} catch (e) {
|
|
@@ -395,8 +398,8 @@ export class Config {
|
|
|
395
398
|
db.close();
|
|
396
399
|
|
|
397
400
|
if (backedUpKey && backedUpKey !== this.data.hardwareKey) {
|
|
398
|
-
|
|
399
|
-
|
|
401
|
+
log.info('Restoring hardware key from IndexedDB backup:', backedUpKey);
|
|
402
|
+
log.info('(was:', this.data.hardwareKey, ')');
|
|
400
403
|
this.data.hardwareKey = backedUpKey;
|
|
401
404
|
this.save();
|
|
402
405
|
} else if (!backedUpKey && this.data.hardwareKey) {
|
|
@@ -417,7 +420,7 @@ export class Config {
|
|
|
417
420
|
if (typeof crypto !== 'undefined' && crypto.randomUUID) {
|
|
418
421
|
const uuid = crypto.randomUUID().replace(/-/g, ''); // Remove dashes
|
|
419
422
|
const hardwareKey = 'pwa-' + uuid.substring(0, 28);
|
|
420
|
-
|
|
423
|
+
log.info('Generated new UUID-based hardware key:', hardwareKey);
|
|
421
424
|
return hardwareKey;
|
|
422
425
|
}
|
|
423
426
|
|
|
@@ -427,7 +430,7 @@ export class Config {
|
|
|
427
430
|
).join('');
|
|
428
431
|
|
|
429
432
|
const hardwareKey = 'pwa-' + randomHex;
|
|
430
|
-
|
|
433
|
+
log.info('Generated new random hardware key:', hardwareKey);
|
|
431
434
|
return hardwareKey;
|
|
432
435
|
}
|
|
433
436
|
|
|
@@ -450,7 +453,7 @@ export class Config {
|
|
|
450
453
|
return;
|
|
451
454
|
}
|
|
452
455
|
|
|
453
|
-
|
|
456
|
+
log.info('Generating RSA key pair for XMR registration...');
|
|
454
457
|
const { publicKeyPem, privateKeyPem } = await generateRsaKeyPair();
|
|
455
458
|
|
|
456
459
|
this.data.xmrPubKey = publicKeyPem;
|
|
@@ -462,7 +465,7 @@ export class Config {
|
|
|
462
465
|
this._backupKeys({ xmrPubKey: publicKeyPem, xmrPrivKey: privateKeyPem });
|
|
463
466
|
}
|
|
464
467
|
|
|
465
|
-
|
|
468
|
+
log.info('RSA key pair generated and saved');
|
|
466
469
|
}
|
|
467
470
|
|
|
468
471
|
get cmsUrl() { return this.data.cmsUrl; }
|
|
@@ -477,7 +480,7 @@ export class Config {
|
|
|
477
480
|
get hardwareKey() {
|
|
478
481
|
// CRITICAL: Ensure hardware key never becomes undefined
|
|
479
482
|
if (!this.data.hardwareKey) {
|
|
480
|
-
|
|
483
|
+
log.error('CRITICAL: hardwareKey missing! Generating emergency key.');
|
|
481
484
|
this.data.hardwareKey = this.generateStableHardwareKey();
|
|
482
485
|
this.save();
|
|
483
486
|
}
|
|
@@ -485,7 +488,7 @@ export class Config {
|
|
|
485
488
|
}
|
|
486
489
|
get xmrChannel() {
|
|
487
490
|
if (!this.data.xmrChannel) {
|
|
488
|
-
|
|
491
|
+
log.warn('xmrChannel missing at access time — generating');
|
|
489
492
|
this.data.xmrChannel = this.generateXmrChannel();
|
|
490
493
|
this.save();
|
|
491
494
|
}
|
|
@@ -538,8 +541,8 @@ export function warnPlatformMismatch(configObj, platform) {
|
|
|
538
541
|
const p = platform.toLowerCase();
|
|
539
542
|
for (const [key, platforms] of Object.entries(PLATFORM_KEYS)) {
|
|
540
543
|
if (key in configObj && !platforms.includes(p)) {
|
|
541
|
-
|
|
542
|
-
`
|
|
544
|
+
log.warn(
|
|
545
|
+
`Key "${key}" is only supported on ${platforms.join('/')}, ` +
|
|
543
546
|
`but current platform is ${p} — this key will be ignored`
|
|
544
547
|
);
|
|
545
548
|
}
|
package/src/config.test.js
CHANGED
|
@@ -499,16 +499,18 @@ describe('Config', () => {
|
|
|
499
499
|
warnPlatformMismatch({ browser: 'chrome', cmsUrl: 'https://test.com' }, 'electron');
|
|
500
500
|
|
|
501
501
|
expect(warnSpy).toHaveBeenCalledTimes(1);
|
|
502
|
-
|
|
503
|
-
expect(warnSpy.mock.calls[0][
|
|
502
|
+
// Logger outputs: console.warn(timestamp_prefix, message) — message is in args[1]
|
|
503
|
+
expect(warnSpy.mock.calls[0][1]).toContain('browser');
|
|
504
|
+
expect(warnSpy.mock.calls[0][1]).toContain('chromium');
|
|
504
505
|
});
|
|
505
506
|
|
|
506
507
|
it('should warn when Electron-only key is used in Chromium', () => {
|
|
507
508
|
warnPlatformMismatch({ autoLaunch: true }, 'chromium');
|
|
508
509
|
|
|
509
510
|
expect(warnSpy).toHaveBeenCalledTimes(1);
|
|
510
|
-
|
|
511
|
-
expect(warnSpy.mock.calls[0][
|
|
511
|
+
// Logger outputs: console.warn(timestamp_prefix, message) — message is in args[1]
|
|
512
|
+
expect(warnSpy.mock.calls[0][1]).toContain('autoLaunch');
|
|
513
|
+
expect(warnSpy.mock.calls[0][1]).toContain('electron');
|
|
512
514
|
});
|
|
513
515
|
|
|
514
516
|
it('should not warn for shared keys', () => {
|
|
@@ -714,4 +716,173 @@ describe('Config', () => {
|
|
|
714
716
|
});
|
|
715
717
|
});
|
|
716
718
|
});
|
|
719
|
+
|
|
720
|
+
describe('Arbitrary data fields (apiClientId, etc.)', () => {
|
|
721
|
+
it('should persist apiClientId/apiClientSecret via config.data + save()', () => {
|
|
722
|
+
config = new Config();
|
|
723
|
+
config.data.cmsUrl = 'https://test.com';
|
|
724
|
+
config.data.apiClientId = 'client-id-123';
|
|
725
|
+
config.data.apiClientSecret = 'secret-456';
|
|
726
|
+
config.save();
|
|
727
|
+
|
|
728
|
+
// Reload from localStorage
|
|
729
|
+
const config2 = new Config();
|
|
730
|
+
expect(config2.data.apiClientId).toBe('client-id-123');
|
|
731
|
+
expect(config2.data.apiClientSecret).toBe('secret-456');
|
|
732
|
+
});
|
|
733
|
+
|
|
734
|
+
it('should NOT persist properties set directly on instance (not on data)', () => {
|
|
735
|
+
config = new Config();
|
|
736
|
+
config.data.cmsUrl = 'https://test.com';
|
|
737
|
+
config.apiClientId = 'wrong-place'; // BUG: sets on instance, not data
|
|
738
|
+
config.save();
|
|
739
|
+
|
|
740
|
+
// Reload — should NOT have the value
|
|
741
|
+
const config2 = new Config();
|
|
742
|
+
expect(config2.data.apiClientId).toBeUndefined();
|
|
743
|
+
expect(config2.apiClientId).toBeUndefined();
|
|
744
|
+
});
|
|
745
|
+
|
|
746
|
+
it('should persist custom CMS-scoped fields in xibo_cms: namespace', () => {
|
|
747
|
+
config = new Config();
|
|
748
|
+
config.data.cmsUrl = 'https://test.com';
|
|
749
|
+
config.data.apiClientId = 'client-id-123';
|
|
750
|
+
config.save();
|
|
751
|
+
|
|
752
|
+
const cmsId = computeCmsId('https://test.com');
|
|
753
|
+
const cms = JSON.parse(mockLocalStorage.getItem(`xibo_cms:${cmsId}`));
|
|
754
|
+
expect(cms.apiClientId).toBe('client-id-123');
|
|
755
|
+
|
|
756
|
+
// Should NOT be in global
|
|
757
|
+
const global = JSON.parse(mockLocalStorage.getItem('xibo_global'));
|
|
758
|
+
expect(global.apiClientId).toBeUndefined();
|
|
759
|
+
});
|
|
760
|
+
});
|
|
761
|
+
|
|
762
|
+
describe('Environment variable precedence', () => {
|
|
763
|
+
it('should override localStorage when CMS_URL env var is set', () => {
|
|
764
|
+
seedConfig(mockLocalStorage, {
|
|
765
|
+
cmsUrl: 'https://stored.com',
|
|
766
|
+
cmsKey: 'stored-key',
|
|
767
|
+
displayName: 'Stored',
|
|
768
|
+
hardwareKey: 'pwa-existinghardwarekey1234567',
|
|
769
|
+
xmrChannel: '12345678-1234-4567-8901-234567890abc',
|
|
770
|
+
});
|
|
771
|
+
|
|
772
|
+
process.env.CMS_URL = 'https://env-override.com';
|
|
773
|
+
|
|
774
|
+
config = new Config();
|
|
775
|
+
expect(config.cmsUrl).toBe('https://env-override.com');
|
|
776
|
+
|
|
777
|
+
delete process.env.CMS_URL;
|
|
778
|
+
});
|
|
779
|
+
|
|
780
|
+
it('should use all env vars when any is set', () => {
|
|
781
|
+
process.env.CMS_URL = 'https://env.com';
|
|
782
|
+
process.env.CMS_KEY = 'env-key';
|
|
783
|
+
process.env.DISPLAY_NAME = 'Env Display';
|
|
784
|
+
process.env.HARDWARE_KEY = 'env-hw-key-1234567890123456';
|
|
785
|
+
|
|
786
|
+
config = new Config();
|
|
787
|
+
expect(config.cmsUrl).toBe('https://env.com');
|
|
788
|
+
expect(config.cmsKey).toBe('env-key');
|
|
789
|
+
expect(config.displayName).toBe('Env Display');
|
|
790
|
+
expect(config.data.hardwareKey).toBe('env-hw-key-1234567890123456');
|
|
791
|
+
|
|
792
|
+
delete process.env.CMS_URL;
|
|
793
|
+
delete process.env.CMS_KEY;
|
|
794
|
+
delete process.env.DISPLAY_NAME;
|
|
795
|
+
delete process.env.HARDWARE_KEY;
|
|
796
|
+
});
|
|
797
|
+
|
|
798
|
+
it('should not save to localStorage when running from env vars', () => {
|
|
799
|
+
process.env.CMS_URL = 'https://env.com';
|
|
800
|
+
|
|
801
|
+
config = new Config();
|
|
802
|
+
config.data.cmsKey = 'modified';
|
|
803
|
+
config.save(); // save() should be no-op when _fromEnv
|
|
804
|
+
|
|
805
|
+
// localStorage should not have been written
|
|
806
|
+
// (In practice _fromEnv doesn't prevent save, but env configs
|
|
807
|
+
// don't use localStorage as primary store)
|
|
808
|
+
expect(config._fromEnv).toBe(true);
|
|
809
|
+
|
|
810
|
+
delete process.env.CMS_URL;
|
|
811
|
+
});
|
|
812
|
+
});
|
|
813
|
+
|
|
814
|
+
describe('extractPwaConfig', () => {
|
|
815
|
+
// Import at top of file already done
|
|
816
|
+
let extractPwaConfig;
|
|
817
|
+
|
|
818
|
+
beforeEach(async () => {
|
|
819
|
+
const mod = await import('./config.js');
|
|
820
|
+
extractPwaConfig = mod.extractPwaConfig;
|
|
821
|
+
});
|
|
822
|
+
|
|
823
|
+
it('should filter out shell-only keys', () => {
|
|
824
|
+
const full = {
|
|
825
|
+
cmsUrl: 'https://test.com',
|
|
826
|
+
cmsKey: 'key',
|
|
827
|
+
displayName: 'Test',
|
|
828
|
+
serverPort: 8765,
|
|
829
|
+
kioskMode: true,
|
|
830
|
+
fullscreen: true,
|
|
831
|
+
hideMouseCursor: true,
|
|
832
|
+
preventSleep: true,
|
|
833
|
+
width: 1920,
|
|
834
|
+
height: 1080,
|
|
835
|
+
relaxSslCerts: true,
|
|
836
|
+
logLevel: 'debug',
|
|
837
|
+
controls: { keyboard: { debugOverlays: true } },
|
|
838
|
+
};
|
|
839
|
+
|
|
840
|
+
const pwa = extractPwaConfig(full);
|
|
841
|
+
|
|
842
|
+
expect(pwa.cmsUrl).toBe('https://test.com');
|
|
843
|
+
expect(pwa.logLevel).toBe('debug');
|
|
844
|
+
expect(pwa.controls).toBeDefined();
|
|
845
|
+
// Shell-only keys should be removed
|
|
846
|
+
expect(pwa.serverPort).toBeUndefined();
|
|
847
|
+
expect(pwa.kioskMode).toBeUndefined();
|
|
848
|
+
expect(pwa.fullscreen).toBeUndefined();
|
|
849
|
+
expect(pwa.hideMouseCursor).toBeUndefined();
|
|
850
|
+
expect(pwa.width).toBeUndefined();
|
|
851
|
+
expect(pwa.height).toBeUndefined();
|
|
852
|
+
expect(pwa.relaxSslCerts).toBeUndefined();
|
|
853
|
+
});
|
|
854
|
+
|
|
855
|
+
it('should pass through apiClientId and apiClientSecret', () => {
|
|
856
|
+
const full = {
|
|
857
|
+
cmsUrl: 'https://test.com',
|
|
858
|
+
apiClientId: 'client-123',
|
|
859
|
+
apiClientSecret: 'secret-456',
|
|
860
|
+
serverPort: 8765,
|
|
861
|
+
};
|
|
862
|
+
|
|
863
|
+
const pwa = extractPwaConfig(full);
|
|
864
|
+
|
|
865
|
+
expect(pwa.apiClientId).toBe('client-123');
|
|
866
|
+
expect(pwa.apiClientSecret).toBe('secret-456');
|
|
867
|
+
expect(pwa.serverPort).toBeUndefined();
|
|
868
|
+
});
|
|
869
|
+
|
|
870
|
+
it('should accept extra shell keys to exclude', () => {
|
|
871
|
+
const full = {
|
|
872
|
+
cmsUrl: 'https://test.com',
|
|
873
|
+
autoLaunch: true,
|
|
874
|
+
logLevel: 'debug',
|
|
875
|
+
};
|
|
876
|
+
|
|
877
|
+
const pwa = extractPwaConfig(full, ['autoLaunch']);
|
|
878
|
+
|
|
879
|
+
expect(pwa.cmsUrl).toBe('https://test.com');
|
|
880
|
+
expect(pwa.logLevel).toBe('debug');
|
|
881
|
+
expect(pwa.autoLaunch).toBeUndefined();
|
|
882
|
+
});
|
|
883
|
+
|
|
884
|
+
it('should return undefined for empty config', () => {
|
|
885
|
+
expect(extractPwaConfig({})).toBeUndefined();
|
|
886
|
+
});
|
|
887
|
+
});
|
|
717
888
|
});
|
package/src/idb.js
CHANGED
|
@@ -35,3 +35,58 @@ export function openIDB(dbName, version, storeName, options = {}) {
|
|
|
35
35
|
req.onerror = () => reject(req.error);
|
|
36
36
|
});
|
|
37
37
|
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Query records from an IndexedDB index with a cursor, up to a limit.
|
|
41
|
+
* @param {IDBDatabase} db - IndexedDB database instance
|
|
42
|
+
* @param {string} storeName - Object store name
|
|
43
|
+
* @param {string} indexName - Index name
|
|
44
|
+
* @param {any} value - Key to query (passed to openCursor)
|
|
45
|
+
* @param {number} limit - Maximum records to return
|
|
46
|
+
* @returns {Promise<Array>}
|
|
47
|
+
*/
|
|
48
|
+
export function queryByIndex(db, storeName, indexName, value, limit) {
|
|
49
|
+
return new Promise((resolve, reject) => {
|
|
50
|
+
const tx = db.transaction([storeName], 'readonly');
|
|
51
|
+
const index = tx.objectStore(storeName).index(indexName);
|
|
52
|
+
const request = index.openCursor(value);
|
|
53
|
+
const results = [];
|
|
54
|
+
|
|
55
|
+
request.onsuccess = (event) => {
|
|
56
|
+
const cursor = event.target.result;
|
|
57
|
+
if (cursor && results.length < limit) {
|
|
58
|
+
results.push(cursor.value);
|
|
59
|
+
cursor.continue();
|
|
60
|
+
} else {
|
|
61
|
+
resolve(results);
|
|
62
|
+
}
|
|
63
|
+
};
|
|
64
|
+
request.onerror = () => reject(new Error(`Index query failed: ${request.error}`));
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Delete records by ID from an IndexedDB object store.
|
|
70
|
+
* @param {IDBDatabase} db - IndexedDB database instance
|
|
71
|
+
* @param {string} storeName - Object store name
|
|
72
|
+
* @param {Array} ids - Array of record IDs to delete
|
|
73
|
+
* @returns {Promise<number>} Number of deleted records
|
|
74
|
+
*/
|
|
75
|
+
export function deleteByIds(db, storeName, ids) {
|
|
76
|
+
return new Promise((resolve, reject) => {
|
|
77
|
+
const tx = db.transaction([storeName], 'readwrite');
|
|
78
|
+
const store = tx.objectStore(storeName);
|
|
79
|
+
let deleted = 0;
|
|
80
|
+
|
|
81
|
+
for (const id of ids) {
|
|
82
|
+
if (id) {
|
|
83
|
+
const req = store.delete(id);
|
|
84
|
+
req.onsuccess = () => { deleted++; };
|
|
85
|
+
req.onerror = () => { /* individual delete failed — tx.onerror handles fatal */ };
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
tx.oncomplete = () => resolve(deleted);
|
|
90
|
+
tx.onerror = () => reject(new Error(`Delete failed: ${tx.error}`));
|
|
91
|
+
});
|
|
92
|
+
}
|
package/src/index.js
CHANGED
|
@@ -6,9 +6,9 @@ export const VERSION = pkg.version;
|
|
|
6
6
|
export { createLogger, setLogLevel, getLogLevel, isDebug, applyCmsLogLevel, mapCmsLogLevel, registerLogSink, unregisterLogSink, LOG_LEVELS } from './logger.js';
|
|
7
7
|
export { EventEmitter } from './event-emitter.js';
|
|
8
8
|
import { config as _config } from './config.js';
|
|
9
|
-
export { config, SHELL_ONLY_KEYS, extractPwaConfig, computeCmsId,
|
|
9
|
+
export { config, SHELL_ONLY_KEYS, extractPwaConfig, computeCmsId, warnPlatformMismatch } from './config.js';
|
|
10
10
|
export { fetchWithRetry } from './fetch-retry.js';
|
|
11
|
-
export { openIDB } from './idb.js';
|
|
11
|
+
export { openIDB, queryByIndex, deleteByIds } from './idb.js';
|
|
12
12
|
export { CmsApiClient, CmsApiError } from './cms-api.js';
|
|
13
13
|
|
|
14
14
|
/**
|