@heswell/vuu-portal 0.0.0-stage → 2.1.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/195.js ADDED
@@ -0,0 +1,448 @@
1
+ import { ConfigFactory, EditSessionRpcHandler, ModuleFactory, Provider, TableDef, VUU_DEFAULT_COLUMNS, ViewPortDef, createConfiguredAuthProviders, createVuuServerApplication } from "@heswell/vuu-server";
2
+ import { MODULE_ADMIN_RPC, executeModuleAdminRpc, managedModuleColumnValues, managedModulePermissionValues, validateModuleConfig } from "@heswell/module-admin";
3
+ import node_fs from "node:fs";
4
+ import node_path from "node:path";
5
+ function moduleClientIdentifier(name) {
6
+ const kebab = name.replace(/([a-z0-9])([A-Z])/g, "$1-$2").replace(/[\s_]+/g, "-").toLowerCase();
7
+ return kebab.startsWith("vuu-") ? kebab : `vuu-${kebab}`;
8
+ }
9
+ function createModuleRegistry(tableContainer, user) {
10
+ return {
11
+ modules: selectModules(readModules(tableContainer.getTable("modules")), readModulePermissions(tableContainer.getTable("modulePermissions")), user.authorizations)
12
+ };
13
+ }
14
+ function readModules(table) {
15
+ return table.rows.map((row)=>{
16
+ const name = stringValue(table, row, "name");
17
+ const parentModuleId = numberValue(table, row, "parentModuleId");
18
+ return {
19
+ clientIdentifier: moduleClientIdentifier(name),
20
+ id: numberValue(table, row, "id"),
21
+ parentModuleId,
22
+ accessRole: "",
23
+ name,
24
+ title: stringValue(table, row, "title"),
25
+ description: stringValue(table, row, "description"),
26
+ version: numberValue(table, row, "version"),
27
+ enabled: booleanValue(table, row, "enabled"),
28
+ navLocation: 0 === parentModuleId ? stringValue(table, row, "location") : "",
29
+ path: stringValue(table, row, "path"),
30
+ mfComponent: stringValue(table, row, "mfComponent"),
31
+ mfScope: stringValue(table, row, "mfScope"),
32
+ mfUrl: stringValue(table, row, "mfUrl"),
33
+ ...navIcon(table, row)
34
+ };
35
+ });
36
+ }
37
+ function navIcon(table, row) {
38
+ const navIconUrl = row[columnIndex(table, "navIconUrl")];
39
+ return "string" == typeof navIconUrl && navIconUrl ? {
40
+ navIconUrl
41
+ } : {};
42
+ }
43
+ function readModulePermissions(table) {
44
+ return table.rows.map((row)=>({
45
+ moduleId: numberValue(table, row, "module_id"),
46
+ role: stringValue(table, row, "role")
47
+ }));
48
+ }
49
+ function selectModules(modules, modulePermissions, authorizations) {
50
+ const moduleRoles = new Map();
51
+ const permittedModuleRoles = new Map();
52
+ const roles = new Set(authorizations);
53
+ modulePermissions.forEach(({ moduleId, role })=>{
54
+ moduleRoles.set(moduleId, role);
55
+ if (roles.has(role)) permittedModuleRoles.set(moduleId, role);
56
+ });
57
+ const latestByName = new Map();
58
+ modules.forEach((module)=>{
59
+ const accessRole = accessRoleForModule(module, moduleRoles, permittedModuleRoles);
60
+ if (!module.enabled || !accessRole) return;
61
+ const moduleWithAccess = {
62
+ ...module,
63
+ accessRole
64
+ };
65
+ const current = latestByName.get(module.name);
66
+ if (!current || module.version > current.version || module.version === current.version && module.id > current.id) latestByName.set(module.name, moduleWithAccess);
67
+ });
68
+ const permittedModules = [
69
+ ...latestByName.values()
70
+ ];
71
+ const permittedIds = new Set(permittedModules.map(({ id })=>id));
72
+ return permittedModules.filter(({ parentModuleId })=>0 === parentModuleId || permittedIds.has(parentModuleId)).map(({ parentModuleId: _parentModuleId, ...module })=>module).sort((left, right)=>left.name.localeCompare(right.name));
73
+ }
74
+ function accessRoleForModule(module, moduleRoles, permittedModuleRoles) {
75
+ const ownRole = permittedModuleRoles.get(module.id);
76
+ if (ownRole) return ownRole;
77
+ if (0 !== module.parentModuleId && !moduleRoles.has(module.id)) return permittedModuleRoles.get(module.parentModuleId) ?? "";
78
+ return "";
79
+ }
80
+ function stringValue(table, row, column) {
81
+ const value = row[columnIndex(table, column)];
82
+ if ("string" != typeof value) throw new Error(`Expected ${table.name}.${column} to be a string`);
83
+ return value;
84
+ }
85
+ function numberValue(table, row, column) {
86
+ const value = row[columnIndex(table, column)];
87
+ if ("number" != typeof value) throw new Error(`Expected ${table.name}.${column} to be a number`);
88
+ return value;
89
+ }
90
+ function booleanValue(table, row, column) {
91
+ const value = row[columnIndex(table, column)];
92
+ if ("boolean" != typeof value) throw new Error(`Expected ${table.name}.${column} to be a boolean`);
93
+ return value;
94
+ }
95
+ function columnIndex(table, column) {
96
+ const index = table.tableDef.columns.findIndex(({ name })=>name === column);
97
+ if (-1 === index) throw new Error(`Table ${table.name} does not contain ${column}`);
98
+ return index;
99
+ }
100
+ const modulesTable = TableDef({
101
+ columns: [
102
+ {
103
+ name: "id",
104
+ dataType: "int"
105
+ },
106
+ {
107
+ name: "parentModuleId",
108
+ dataType: "int"
109
+ },
110
+ {
111
+ name: "name",
112
+ dataType: "string"
113
+ },
114
+ {
115
+ name: "title",
116
+ dataType: "string"
117
+ },
118
+ {
119
+ name: "description",
120
+ dataType: "string"
121
+ },
122
+ {
123
+ name: "version",
124
+ dataType: "int"
125
+ },
126
+ {
127
+ name: "enabled",
128
+ dataType: "boolean"
129
+ },
130
+ {
131
+ name: "location",
132
+ dataType: "string"
133
+ },
134
+ {
135
+ name: "path",
136
+ dataType: "string"
137
+ },
138
+ {
139
+ name: "mfComponent",
140
+ dataType: "string"
141
+ },
142
+ {
143
+ name: "mfScope",
144
+ dataType: "string"
145
+ },
146
+ {
147
+ name: "mfUrl",
148
+ dataType: "string"
149
+ },
150
+ {
151
+ name: "navIconUrl",
152
+ dataType: "string"
153
+ },
154
+ ...VUU_DEFAULT_COLUMNS
155
+ ],
156
+ keyField: "id",
157
+ name: "modules"
158
+ });
159
+ const modulePermissionsTable = TableDef({
160
+ columns: [
161
+ {
162
+ name: "id",
163
+ dataType: "int"
164
+ },
165
+ {
166
+ name: "module_id",
167
+ dataType: "int"
168
+ },
169
+ {
170
+ name: "role",
171
+ dataType: "string"
172
+ },
173
+ ...VUU_DEFAULT_COLUMNS
174
+ ],
175
+ keyField: "id",
176
+ name: "modulePermissions"
177
+ });
178
+ class ModuleDiscoveryProvider extends Provider {
179
+ rows;
180
+ constructor(table, rows){
181
+ super(table), this.rows = rows;
182
+ }
183
+ async load() {
184
+ for (const row of this.rows)this.table.insert(row, false);
185
+ }
186
+ }
187
+ const success = (data)=>({
188
+ type: "SUCCESS_RESULT",
189
+ data
190
+ });
191
+ const failure = (errorMessage)=>({
192
+ type: "ERROR_RESULT",
193
+ errorMessage
194
+ });
195
+ const toErrorMessage = (error)=>error instanceof Error ? error.message : String(error);
196
+ const DEFAULT_MODULE_ADMIN_ROLE = "module-admin-access";
197
+ class ModuleAdminService extends EditSessionRpcHandler {
198
+ state;
199
+ adminRole;
200
+ now;
201
+ constructor(tableContainer, state, { adminRole = DEFAULT_MODULE_ADMIN_ROLE, now = Date.now } = {}){
202
+ super(tableContainer), this.state = state;
203
+ this.adminRole = adminRole;
204
+ this.now = now;
205
+ this.registerRpc(MODULE_ADMIN_RPC.createModule, this.handleModuleAdminRpc("createModule"));
206
+ this.registerRpc(MODULE_ADMIN_RPC.updateModule, this.handleModuleAdminRpc("updateModule"));
207
+ this.registerRpc(MODULE_ADMIN_RPC.setModuleEnabled, this.handleModuleAdminRpc("setModuleEnabled"));
208
+ this.registerRpc(MODULE_ADMIN_RPC.deleteModule, this.handleModuleAdminRpc("deleteModule"));
209
+ }
210
+ handleModuleAdminRpc(rpcName) {
211
+ return async ({ ctx, namedParams })=>{
212
+ if (!ctx?.user?.authorizations?.includes(this.adminRole)) return failure(`Administering modules requires the ${this.adminRole} role`);
213
+ try {
214
+ const { modules, result } = executeModuleAdminRpc(this.state.modules, rpcName, namedParams, this.now);
215
+ this.state.replaceAndSave(modules);
216
+ this.syncTables(modules);
217
+ return success(result);
218
+ } catch (error) {
219
+ return failure(toErrorMessage(error));
220
+ }
221
+ };
222
+ }
223
+ syncTables(modules) {
224
+ syncTable(this.tableContainer.getTable("modules"), modules.map(managedModuleColumnValues));
225
+ syncTable(this.tableContainer.getTable("modulePermissions"), managedModulePermissionValues(modules));
226
+ }
227
+ }
228
+ function syncTable(table, values) {
229
+ const expectedKeys = new Set(values.map((row)=>String(row[table.schema.key])));
230
+ for (const row of [
231
+ ...table.rows
232
+ ]){
233
+ const key = String(row[table.indexOfKeyField]);
234
+ if (!expectedKeys.has(key)) table.delete(key);
235
+ }
236
+ for (const rowValues of values){
237
+ const row = table.tableDef.columns.map(({ name })=>rowValues[name] ?? "");
238
+ const key = String(row[table.indexOfKeyField]);
239
+ const existing = table.getRowAtKey(key, false);
240
+ if (existing) {
241
+ row[table.columnMap.vuuCreatedTimestamp] = existing[table.columnMap.vuuCreatedTimestamp];
242
+ table.update(table.rowIndexAtKey(key), row);
243
+ } else table.insert(row);
244
+ }
245
+ }
246
+ const yaml = ()=>{
247
+ if ("u" < typeof Bun || !Bun.YAML) throw new Error("@heswell/vuu-portal module files require the Bun runtime");
248
+ return Bun.YAML;
249
+ };
250
+ class InMemoryModuleStore {
251
+ modules;
252
+ constructor(modules = []){
253
+ this.modules = modules;
254
+ }
255
+ load() {
256
+ return cloneModules(this.modules);
257
+ }
258
+ save(modules) {
259
+ this.modules = cloneModules(modules);
260
+ }
261
+ }
262
+ class ModuleState {
263
+ store;
264
+ #modules;
265
+ constructor(store, modules){
266
+ this.store = store;
267
+ this.#modules = cloneModules(modules);
268
+ }
269
+ get modules() {
270
+ return cloneModules(this.#modules);
271
+ }
272
+ replaceAndSave(modules) {
273
+ const next = cloneModules(modules);
274
+ this.store.save(next);
275
+ this.#modules = next;
276
+ }
277
+ }
278
+ class YamlModuleStore {
279
+ filePath;
280
+ seedModules;
281
+ constructor(filePath, seedModules){
282
+ this.filePath = filePath;
283
+ this.seedModules = seedModules;
284
+ }
285
+ load() {
286
+ if (!node_fs.existsSync(this.filePath)) {
287
+ const seeded = cloneModules(this.seedModules());
288
+ assertValidManagedModules(seeded, this.filePath);
289
+ this.save(seeded);
290
+ return seeded;
291
+ }
292
+ const modules = readModulesFile(this.filePath).map((value, index)=>parseManagedModule(value, index));
293
+ assertValidManagedModules(modules, this.filePath);
294
+ return modules;
295
+ }
296
+ save(modules) {
297
+ const directory = node_path.dirname(this.filePath);
298
+ node_fs.mkdirSync(directory, {
299
+ recursive: true
300
+ });
301
+ const tempPath = node_path.join(directory, `.${node_path.basename(this.filePath)}.${process.pid}.${Date.now()}.tmp`);
302
+ const contents = yaml().stringify({
303
+ modules: cloneModules(modules)
304
+ }, null, 2);
305
+ try {
306
+ node_fs.writeFileSync(tempPath, contents, "utf8");
307
+ node_fs.renameSync(tempPath, this.filePath);
308
+ } catch (error) {
309
+ try {
310
+ if (node_fs.existsSync(tempPath)) node_fs.unlinkSync(tempPath);
311
+ } catch {}
312
+ throw error;
313
+ }
314
+ }
315
+ }
316
+ function createModuleState(store) {
317
+ return new ModuleState(store, store.load());
318
+ }
319
+ function loadDefaultModules(filePath, timestamp) {
320
+ const modules = readModulesFile(filePath).map((value, index)=>parseManagedModule(isRecord(value) ? {
321
+ ...value,
322
+ created: timestamp,
323
+ updated: timestamp
324
+ } : value, index));
325
+ assertValidManagedModules(modules, filePath);
326
+ return modules;
327
+ }
328
+ function readModulesFile(filePath) {
329
+ const parsed = yaml().parse(node_fs.readFileSync(filePath, "utf8"));
330
+ if (!isRecord(parsed) || !Array.isArray(parsed.modules)) throw new Error(`Modules file '${filePath}' must contain a 'modules' list`);
331
+ return parsed.modules;
332
+ }
333
+ function cloneModules(modules) {
334
+ return modules.map((module)=>({
335
+ ...module
336
+ }));
337
+ }
338
+ const managedModuleKeys = [
339
+ "id",
340
+ "version",
341
+ "created",
342
+ "updated",
343
+ "parentModuleId",
344
+ "name",
345
+ "title",
346
+ "description",
347
+ "enabled",
348
+ "location",
349
+ "path",
350
+ "mfComponent",
351
+ "mfScope",
352
+ "mfUrl",
353
+ "navIconUrl",
354
+ "accessRole"
355
+ ];
356
+ const retiredModuleKeys = [
357
+ "vuuConnectionId",
358
+ "vuuWebsocketUrl",
359
+ "vuuRestUrl"
360
+ ];
361
+ function parseManagedModule(value, index) {
362
+ if (!isRecord(value)) throw new Error(`Modules file entry ${index} must be an object`);
363
+ for (const key of Object.keys(value))if (!managedModuleKeys.includes(key) && !retiredModuleKeys.includes(key)) throw new Error(`Modules file entry ${index} contains unknown field '${key}'`);
364
+ const module = {};
365
+ for (const key of managedModuleKeys){
366
+ const fieldValue = value[key];
367
+ if (void 0 === fieldValue) throw new Error(`Modules file entry ${index} is missing '${key}'`);
368
+ const expected = [
369
+ "id",
370
+ "version",
371
+ "created",
372
+ "updated",
373
+ "parentModuleId"
374
+ ].includes(key) ? "number" : "enabled" === key ? "boolean" : "string";
375
+ if (typeof fieldValue !== expected) throw new Error(`Modules file entry ${index} field '${key}' must be a ${expected}`);
376
+ if ("number" === expected && !Number.isInteger(fieldValue)) throw new Error(`Modules file entry ${index} field '${key}' must be an integer`);
377
+ module[key] = fieldValue;
378
+ }
379
+ return module;
380
+ }
381
+ function assertValidManagedModules(modules, filePath) {
382
+ const ids = new Set();
383
+ modules.forEach((module, index)=>{
384
+ if (ids.has(module.id)) throw new Error(`Modules file '${filePath}' contains duplicate id ${module.id}`);
385
+ ids.add(module.id);
386
+ const errors = validateModuleConfig(module, modules, module.id);
387
+ const message = formatValidationErrors(errors);
388
+ if (message) throw new Error(`Modules file '${filePath}' entry ${index} is invalid: ${message}`);
389
+ });
390
+ }
391
+ function formatValidationErrors(errors) {
392
+ return Object.entries(errors).filter(([, message])=>message).map(([field, message])=>`${field}: ${message}`).join("; ");
393
+ }
394
+ function isRecord(value) {
395
+ return "object" == typeof value && null !== value && !Array.isArray(value);
396
+ }
397
+ function loadModuleState(config) {
398
+ const filePath = config.getPath("vuu.portal.modulesFile", "modules.yaml");
399
+ const defaultsPath = config.getPath("vuu.portal.defaultModulesFile", "default-modules.yaml");
400
+ return createModuleState(new YamlModuleStore(filePath, ()=>loadDefaultModules(defaultsPath, Date.now())));
401
+ }
402
+ function loadModuleAdminRole(config) {
403
+ const role = config.getString("vuu.portal.moduleAdminRole", DEFAULT_MODULE_ADMIN_ROLE).trim();
404
+ if (!role) throw new Error("vuu.portal.moduleAdminRole must not be empty");
405
+ return role;
406
+ }
407
+ const ModuleDiscoveryModule = (state, { adminRole = DEFAULT_MODULE_ADMIN_ROLE } = {})=>{
408
+ const modules = state.modules;
409
+ const moduleRows = modules.map((module)=>valuesToRow(modulesTable, managedModuleColumnValues(module)));
410
+ const permissionRows = managedModulePermissionValues(modules).map((values)=>valuesToRow(modulePermissionsTable, values));
411
+ let service;
412
+ const getService = (tableContainer)=>service ??= new ModuleAdminService(tableContainer, state, {
413
+ adminRole
414
+ });
415
+ return ModuleFactory.withNameSpace("MODULE_DISCOVERY").addTable(modulesTable, (table)=>new ModuleDiscoveryProvider(table, moduleRows), (table, _provider, _providerContainer, tableContainer)=>ViewPortDef(columnsFor(table), getService(tableContainer))).addTable(modulePermissionsTable, (table)=>new ModuleDiscoveryProvider(table, permissionRows), (table, _provider, _providerContainer, tableContainer)=>ViewPortDef(columnsFor(table), getService(tableContainer))).asModule();
416
+ };
417
+ function columnsFor(table) {
418
+ return table.schema.columns.map(({ name, serverDataType: dataType }, index)=>({
419
+ name,
420
+ dataType,
421
+ index
422
+ }));
423
+ }
424
+ function valuesToRow(table, values) {
425
+ return table.columns.map(({ name })=>values[name] ?? "");
426
+ }
427
+ const PORTAL_DEFAULT_HTTPS_PORT = 8443;
428
+ const PORTAL_DEFAULT_WEBSOCKET_PATH = "/websocket-portal";
429
+ const PORTAL_DEFAULT_WEBSOCKET_PORT = 8091;
430
+ function createPortalApplication({ config, configFile } = {}) {
431
+ const portalConfig = config ?? ConfigFactory.load(configFile);
432
+ return createVuuServerApplication({
433
+ authProviders: createConfiguredAuthProviders(portalConfig),
434
+ config: portalConfig,
435
+ defaultHttpsPort: PORTAL_DEFAULT_HTTPS_PORT,
436
+ defaultWebSocketPath: PORTAL_DEFAULT_WEBSOCKET_PATH,
437
+ defaultWebSocketPort: PORTAL_DEFAULT_WEBSOCKET_PORT,
438
+ loginSuccessProvider: (user, tableContainer)=>({
439
+ moduleRegistry: createModuleRegistry(tableContainer, user)
440
+ }),
441
+ modules: [
442
+ ModuleDiscoveryModule(loadModuleState(portalConfig), {
443
+ adminRole: loadModuleAdminRole(portalConfig)
444
+ })
445
+ ]
446
+ });
447
+ }
448
+ export { DEFAULT_MODULE_ADMIN_ROLE, InMemoryModuleStore, ModuleDiscoveryModule, ModuleState, PORTAL_DEFAULT_WEBSOCKET_PATH, YamlModuleStore, createModuleRegistry, createModuleState, createPortalApplication, loadDefaultModules, loadModuleAdminRole, loadModuleState, moduleClientIdentifier };
package/README.md CHANGED
@@ -1,3 +1,112 @@
1
- # Temporary Holding Version
1
+ # @heswell/vuu-portal
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Vuu portal server: a [`@heswell/vuu-server`](../vuu-server) application that
4
+ hosts the `MODULE_DISCOVERY` module and returns each user's module registry in
5
+ the login response. Requires the [Bun](https://bun.sh) runtime to run the
6
+ server. The library exports can be imported under Node.js too.
7
+
8
+ ```sh
9
+ npm install @heswell/vuu-portal @heswell/vuu-server @heswell/vuu-table
10
+ # TypeScript consumers: the vuu-server declarations reference Bun types
11
+ npm install -D @types/bun
12
+ ```
13
+
14
+ `@heswell/vuu-server` and `@heswell/vuu-table` are peer dependencies, so the
15
+ portal shares the application's single copy of each. Two copies would mean two
16
+ module registries and failing `instanceof` checks. npm 7+ and bun install
17
+ missing peers automatically.
18
+
19
+ ## Entry points
20
+
21
+ | Import / command | Purpose |
22
+ | --- | --- |
23
+ | `@heswell/vuu-portal` | Library API. Importing it has no side effects. |
24
+ | `@heswell/vuu-portal/main` | `main(options?)`: creates and starts the portal. Importing it has no side effects. |
25
+ | `vuu-portal` bin | Starts the portal from a shell. |
26
+
27
+ ```ts
28
+ import {
29
+ createModuleRegistry,
30
+ createPortalApplication,
31
+ loadDefaultModules,
32
+ loadModuleAdminRole,
33
+ loadModuleState,
34
+ ModuleDiscoveryModule,
35
+ } from "@heswell/vuu-portal";
36
+ ```
37
+
38
+ Root exports:
39
+
40
+ - `createPortalApplication({ config?, configFile? })` creates the portal
41
+ application without starting it.
42
+ - `createModuleRegistry` and `moduleClientIdentifier`.
43
+ - `ModuleDiscoveryModule`, `loadModuleState`, `loadModuleAdminRole`.
44
+ - Module catalogue stores: `ModuleState`, `createModuleState`,
45
+ `YamlModuleStore`, `InMemoryModuleStore`, `loadDefaultModules` and the
46
+ `ModuleStore` type.
47
+ - `DEFAULT_MODULE_ADMIN_ROLE`, plus the `PORTAL_DEFAULT_*` fallback ports and
48
+ path.
49
+
50
+ ## Starting the portal
51
+
52
+ Everything is configured in `application.conf`. Point the portal at your own
53
+ conf and run the stock entry unchanged:
54
+
55
+ ```sh
56
+ # bin
57
+ bunx vuu-portal --config ./path/to/application.conf
58
+ VUU_CONFIG_FILE=./path/to/application.conf bunx vuu-portal
59
+
60
+ # from code
61
+ import { main } from "@heswell/vuu-portal/main";
62
+ await main({ configFile: "./path/to/application.conf" });
63
+ ```
64
+
65
+ The conf file is located in this order:
66
+
67
+ 1. `--config <path>` (bin) or `main({ configFile })`. Relative paths resolve
68
+ against the current directory.
69
+ 2. `$VUU_CONFIG_FILE`.
70
+ 3. `$VUU_APP`, which selects `packages/$VUU_APP/application.conf` under the
71
+ current directory.
72
+ 4. `./application.conf`.
73
+
74
+ In this repository, `npm run start:portal` starts it with
75
+ `VUU_APP=vuu-portal`, which uses [`application.conf`](./application.conf).
76
+
77
+ ### Settings
78
+
79
+ Any setting can be overridden from the environment with a HOCON-style
80
+ `${?VAR}` line in the conf, and the portal's own
81
+ [`application.conf`](./application.conf) already does this for the ports,
82
+ websocket path, auth mode, CORS origin, Keycloak URL and realm, and module
83
+ files (`VUU_HTTPS_PORT`, `VUU_WEBSOCKET_PORT`, `VUU_WEBSOCKET_PATH`,
84
+ `VUU_AUTH_MODE`, `VUU_CORS_ALLOWED_ORIGIN`, `VUU_KEYCLOAK_URL`,
85
+ `VUU_KEYCLOAK_REALM`, `VUU_PORTAL_MODULES_FILE`,
86
+ `VUU_PORTAL_DEFAULT_MODULES_FILE`, `VUU_LOG_LEVEL`). See the
87
+ [`@heswell/service-utils` README](../service-utils/README.md#environment-variable-substitution)
88
+ for the syntax.
89
+
90
+ | Key | Fallback when not set |
91
+ | --- | --- |
92
+ | `vuu.https.port` | `8443` |
93
+ | `vuu.websocket.port` | `8091` |
94
+ | `vuu.websocket.path` | `/websocket-portal` |
95
+ | `vuu.ssl`, `vuu.certPath`, `vuu.keyPath` | SSL disabled |
96
+ | `vuu.auth.*`, `vuu.keycloak.*` | See [`@heswell/vuu-server`](../vuu-server/README.md) |
97
+ | `vuu.portal.defaultModulesFile` | `default-modules.yaml` |
98
+ | `vuu.portal.modulesFile` | `modules.yaml` |
99
+ | `vuu.portal.moduleAdminRole` | `module-admin-access` |
100
+ | `vuu.log.level`, `vuu.log.levels.<logger>` | `info` (or `$VUU_LOG_LEVEL`), see [Logging](../service-utils/README.md#logging) |
101
+
102
+ ### Module catalogue
103
+
104
+ The portal's modules are defined in YAML, not in code. Relative file paths
105
+ are resolved against the directory that contains `application.conf`;
106
+ absolute paths and `~/` paths are also accepted.
107
+
108
+ - `vuu.portal.modulesFile` is the runtime state, edited through the module
109
+ admin. If it does not exist, it is created from the default modules file on
110
+ start.
111
+ - `vuu.portal.defaultModulesFile` is the seed catalogue. See
112
+ [`default-modules.yaml`](./default-modules.yaml) for the format.
package/bin.js ADDED
@@ -0,0 +1,27 @@
1
+ #!/usr/bin/env bun
2
+ import { main } from "./main.js";
3
+ const USAGE = `Usage: vuu-portal [--config <path/to/application.conf>]
4
+
5
+ Starts the Vuu portal server. All settings (ports, websocket path, auth,
6
+ module catalogue files, module admin role) come from application.conf.
7
+
8
+ Without --config the conf is located via $VUU_CONFIG_FILE, then $VUU_APP
9
+ (packages/<app>/application.conf), then ./application.conf.`;
10
+ const parseConfigFile = (args)=>{
11
+ let configFile;
12
+ for(let index = 0; index < args.length; index++){
13
+ const arg = args[index];
14
+ if ("--help" === arg || "-h" === arg) {
15
+ console.log(USAGE);
16
+ process.exit(0);
17
+ } else if ("--config" === arg || "-c" === arg) {
18
+ configFile = args[++index];
19
+ if (!configFile) throw new Error(`${arg} requires a path\n\n${USAGE}`);
20
+ } else if (arg.startsWith("--config=")) configFile = arg.slice(9);
21
+ else throw new Error(`Unknown argument '${arg}'\n\n${USAGE}`);
22
+ }
23
+ return configFile;
24
+ };
25
+ await main({
26
+ configFile: parseConfigFile(process.argv.slice(2))
27
+ });
package/index.js ADDED
@@ -0,0 +1,4 @@
1
+ var src_PORTAL_DEFAULT_HTTPS_PORT = 8443;
2
+ var src_PORTAL_DEFAULT_WEBSOCKET_PORT = 8091;
3
+ export { DEFAULT_MODULE_ADMIN_ROLE, InMemoryModuleStore, ModuleDiscoveryModule, ModuleState, PORTAL_DEFAULT_WEBSOCKET_PATH, YamlModuleStore, createModuleRegistry, createModuleState, createPortalApplication, loadDefaultModules, loadModuleAdminRole, loadModuleState, moduleClientIdentifier } from "./195.js";
4
+ export { src_PORTAL_DEFAULT_HTTPS_PORT as PORTAL_DEFAULT_HTTPS_PORT, src_PORTAL_DEFAULT_WEBSOCKET_PORT as PORTAL_DEFAULT_WEBSOCKET_PORT };
package/main.js ADDED
@@ -0,0 +1,9 @@
1
+ import { createPortalApplication } from "./195.js";
2
+ async function main(options) {
3
+ const application = createPortalApplication(options);
4
+ await application.start();
5
+ return application;
6
+ }
7
+ const src_main = main;
8
+ export default src_main;
9
+ export { main };
package/package.json CHANGED
@@ -1,6 +1,56 @@
1
1
  {
2
2
  "name": "@heswell/vuu-portal",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "2.1.0",
4
+ "description": "VUU portal server with remote module discovery",
5
+ "type": "module",
6
+ "main": "./index.js",
7
+ "module": "./index.js",
8
+ "types": "./src/index.d.ts",
9
+ "exports": {
10
+ ".": {
11
+ "types": "./src/index.d.ts",
12
+ "import": "./index.js"
13
+ },
14
+ "./main": {
15
+ "types": "./src/main.d.ts",
16
+ "import": "./main.js"
17
+ },
18
+ "./package.json": "./package.json"
19
+ },
20
+ "sideEffects": [
21
+ "./bin.js"
22
+ ],
23
+ "publishConfig": {
24
+ "access": "public"
25
+ },
26
+ "author": "heswell",
27
+ "license": "MIT",
28
+ "dependencies": {
29
+ "@heswell/module-admin": "^2.0.1"
30
+ },
31
+ "peerDependencies": {
32
+ "@heswell/vuu-server": "^3.1.0 || ^4.0.0",
33
+ "@heswell/vuu-table": "^1.0.1",
34
+ "@vuu-ui/vuu-protocol-types": "4.0.0-alpha.20"
35
+ },
36
+ "repository": {
37
+ "type": "git",
38
+ "url": "git+https://github.com/heswell/vuu-websocket.git",
39
+ "directory": "packages/vuu-portal"
40
+ },
41
+ "typesVersions": {
42
+ "*": {
43
+ "main": [
44
+ "./src/main.d.ts"
45
+ ]
46
+ }
47
+ },
48
+ "bin": {
49
+ "vuu-portal": "bin.js"
50
+ },
51
+ "files": [
52
+ "*.js",
53
+ "src/**/*.d.ts",
54
+ "README.md"
55
+ ]
56
+ }
@@ -0,0 +1,8 @@
1
+ import type { ModuleRegistry, TableContainer, VuuUser } from "@heswell/vuu-server";
2
+ /**
3
+ * Derives a module's own client identifier from its name, e.g.
4
+ * `userAdmin` -> `vuu-user-admin`. The UI keys each module's saved state by
5
+ * this value, so it must be unique per module.
6
+ */
7
+ export declare function moduleClientIdentifier(name: string): string;
8
+ export declare function createModuleRegistry(tableContainer: TableContainer, user: VuuUser): ModuleRegistry;
@@ -0,0 +1,21 @@
1
+ import { type Config, type VuuServerApplication } from "@heswell/vuu-server";
2
+ export declare const PORTAL_DEFAULT_HTTPS_PORT = 8443;
3
+ export declare const PORTAL_DEFAULT_WEBSOCKET_PATH = "/websocket-portal";
4
+ export declare const PORTAL_DEFAULT_WEBSOCKET_PORT = 8091;
5
+ export type PortalApplicationOptions = {
6
+ /** An already loaded config. Takes precedence over `configFile`. */
7
+ config?: Config;
8
+ /**
9
+ * Path to the portal's application.conf, resolved against the current
10
+ * directory. Defaults to ConfigFactory's lookup: $VUU_CONFIG_FILE, then
11
+ * $VUU_APP, then ./application.conf.
12
+ */
13
+ configFile?: string;
14
+ };
15
+ /**
16
+ * Creates (but does not start) the portal server, configured entirely by
17
+ * application.conf: ports, websocket path, auth, the module catalogue files
18
+ * (vuu.portal.modulesFile / vuu.portal.defaultModulesFile, resolved relative
19
+ * to the conf file) and the module admin role (vuu.portal.moduleAdminRole).
20
+ */
21
+ export declare function createPortalApplication({ config, configFile, }?: PortalApplicationOptions): VuuServerApplication;
package/src/bin.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env bun
2
+ export {};
package/src/index.d.ts ADDED
@@ -0,0 +1,5 @@
1
+ export { createModuleRegistry, moduleClientIdentifier, } from "./ModuleRegistry.js";
2
+ export { loadModuleAdminRole, loadModuleState, ModuleDiscoveryModule, ModuleState, } from "./modules/ModuleDiscovery/ModuleDiscoveryModule.js";
3
+ export { createModuleState, InMemoryModuleStore, loadDefaultModules, type ModuleStore, YamlModuleStore, } from "./modules/ModuleDiscovery/ModuleStore.js";
4
+ export { DEFAULT_MODULE_ADMIN_ROLE } from "./modules/ModuleDiscovery/ModuleAdminService.js";
5
+ export { createPortalApplication, PORTAL_DEFAULT_HTTPS_PORT, PORTAL_DEFAULT_WEBSOCKET_PATH, PORTAL_DEFAULT_WEBSOCKET_PORT, type PortalApplicationOptions, } from "./PortalApplication.js";
package/src/main.d.ts ADDED
@@ -0,0 +1,8 @@
1
+ import { type PortalApplicationOptions } from "./PortalApplication.js";
2
+ /**
3
+ * Creates and starts the portal application from application.conf (see
4
+ * createPortalApplication). Importing this module has no side effects; use
5
+ * the `vuu-portal` bin to start the portal from a shell.
6
+ */
7
+ export declare function main(options?: PortalApplicationOptions): Promise<import("@heswell/vuu-server").VuuServerApplication>;
8
+ export default main;
@@ -0,0 +1,16 @@
1
+ import { EditSessionRpcHandler, type TableContainer } from "@heswell/vuu-server";
2
+ import type { ModuleState } from "./ModuleStore.js";
3
+ export declare const DEFAULT_MODULE_ADMIN_ROLE = "module-admin-access";
4
+ export interface ModuleAdminServiceOptions {
5
+ /** Role a user must hold to create, update, enable/disable or delete modules. */
6
+ adminRole?: string;
7
+ now?: () => number;
8
+ }
9
+ export declare class ModuleAdminService extends EditSessionRpcHandler {
10
+ private readonly state;
11
+ private readonly adminRole;
12
+ private readonly now;
13
+ constructor(tableContainer: TableContainer, state: ModuleState, { adminRole, now }?: ModuleAdminServiceOptions);
14
+ private handleModuleAdminRpc;
15
+ private syncTables;
16
+ }
@@ -0,0 +1,8 @@
1
+ import type { Config } from "@heswell/vuu-server";
2
+ import { ModuleState } from "./ModuleStore.js";
3
+ export { InMemoryModuleStore, ModuleState, YamlModuleStore, createModuleState, loadDefaultModules, } from "./ModuleStore.js";
4
+ export declare function loadModuleState(config: Pick<Config, "getPath">): ModuleState;
5
+ export declare function loadModuleAdminRole(config: Pick<Config, "getString">): string;
6
+ export declare const ModuleDiscoveryModule: (state: ModuleState, { adminRole }?: {
7
+ adminRole?: string;
8
+ }) => import("@heswell/vuu-server").Module;
@@ -0,0 +1,7 @@
1
+ import { Table } from "@heswell/vuu-table";
2
+ import { Provider } from "@heswell/vuu-server";
3
+ export declare class ModuleDiscoveryProvider extends Provider {
4
+ private readonly rows;
5
+ constructor(table: Table, rows: Array<Array<string | number | boolean>>);
6
+ load(): Promise<void>;
7
+ }
@@ -0,0 +1 @@
1
+ export { ModuleAdminService as ModuleDiscoveryService } from "./ModuleAdminService.js";
@@ -0,0 +1,3 @@
1
+ import { TableDef } from "@heswell/vuu-server";
2
+ export declare const modulesTable: TableDef;
3
+ export declare const modulePermissionsTable: TableDef;
@@ -0,0 +1,66 @@
1
+ import { type ManagedModule } from "@heswell/module-admin";
2
+ export interface ModuleStore {
3
+ load(): ManagedModule[];
4
+ save(modules: readonly ManagedModule[]): void;
5
+ }
6
+ export declare class InMemoryModuleStore implements ModuleStore {
7
+ private modules;
8
+ constructor(modules?: readonly ManagedModule[]);
9
+ load(): {
10
+ parentModuleId: number;
11
+ name: string;
12
+ title: string;
13
+ description: string;
14
+ enabled: boolean;
15
+ location: string;
16
+ path: string;
17
+ mfComponent: string;
18
+ mfScope: string;
19
+ mfUrl: string;
20
+ navIconUrl: string;
21
+ accessRole: string;
22
+ id: number;
23
+ version: number;
24
+ created: number;
25
+ updated: number;
26
+ }[];
27
+ save(modules: readonly ManagedModule[]): void;
28
+ }
29
+ export declare class ModuleState {
30
+ #private;
31
+ private readonly store;
32
+ constructor(store: ModuleStore, modules: readonly ManagedModule[]);
33
+ get modules(): {
34
+ parentModuleId: number;
35
+ name: string;
36
+ title: string;
37
+ description: string;
38
+ enabled: boolean;
39
+ location: string;
40
+ path: string;
41
+ mfComponent: string;
42
+ mfScope: string;
43
+ mfUrl: string;
44
+ navIconUrl: string;
45
+ accessRole: string;
46
+ id: number;
47
+ version: number;
48
+ created: number;
49
+ updated: number;
50
+ }[];
51
+ replaceAndSave(modules: readonly ManagedModule[]): void;
52
+ }
53
+ export declare class YamlModuleStore implements ModuleStore {
54
+ private readonly filePath;
55
+ private readonly seedModules;
56
+ constructor(filePath: string, seedModules: () => readonly ManagedModule[]);
57
+ load(): ManagedModule[];
58
+ save(modules: readonly ManagedModule[]): void;
59
+ }
60
+ export declare function createModuleState(store: ModuleStore): ModuleState;
61
+ /**
62
+ * Reads the default module catalog used to seed `modules.yaml`. Entries have
63
+ * the same fields as `modules.yaml`, except `created` and `updated`, which are
64
+ * set to `timestamp`.
65
+ */
66
+ export declare function loadDefaultModules(filePath: string, timestamp: number): ManagedModule[];