@bsb/config-vault 9.6.8 → 9.6.10
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/README.md +22 -10
- package/lib/plugins/service-config-vault/http-server.d.ts +1 -0
- package/lib/plugins/service-config-vault/http-server.d.ts.map +1 -1
- package/lib/plugins/service-config-vault/http-server.js +306 -79
- package/lib/plugins/service-config-vault/http-server.js.map +1 -1
- package/lib/plugins/service-config-vault/index.d.ts +3 -0
- package/lib/plugins/service-config-vault/index.d.ts.map +1 -1
- package/lib/plugins/service-config-vault/index.js +2 -0
- package/lib/plugins/service-config-vault/index.js.map +1 -1
- package/lib/plugins/service-config-vault/store.d.ts +13 -0
- package/lib/plugins/service-config-vault/store.d.ts.map +1 -1
- package/lib/plugins/service-config-vault/store.js +44 -0
- package/lib/plugins/service-config-vault/store.js.map +1 -1
- package/lib/plugins/service-config-vault/vault.d.ts +36 -1
- package/lib/plugins/service-config-vault/vault.d.ts.map +1 -1
- package/lib/plugins/service-config-vault/vault.js +85 -0
- package/lib/plugins/service-config-vault/vault.js.map +1 -1
- package/lib/schemas/config-vault.json +1 -1
- package/lib/schemas/config-vault.plugin.json +1 -1
- package/lib/schemas/service-config-vault.json +10 -2
- package/lib/schemas/service-config-vault.plugin.json +10 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -11,15 +11,14 @@ Runtime containers do not choose applications, groups, profiles, or versions. Th
|
|
|
11
11
|
|
|
12
12
|
## Runtime
|
|
13
13
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
apiSecret: vs_xxx
|
|
14
|
+
Runtime containers activate Vault as the BSB config plugin with env vars:
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
BSB_CONFIG_PLUGIN=config-vault
|
|
18
|
+
BSB_CONFIG_PLUGIN_PACKAGE=@bsb/config-vault
|
|
19
|
+
vaultUrl=https://vault.example.com
|
|
20
|
+
apiKeyId=vk_xxx
|
|
21
|
+
apiSecret=vs_xxx
|
|
23
22
|
```
|
|
24
23
|
|
|
25
24
|
When a container restarts, it pulls the active published version for the API key's bound deployment profile.
|
|
@@ -38,6 +37,7 @@ service-config-vault:
|
|
|
38
37
|
production: true
|
|
39
38
|
databaseUrl: postgres://vault:secret@postgres:5432/vault
|
|
40
39
|
masterKey: BASE64_32_BYTE_KEY
|
|
40
|
+
registryUrl: https://io.bsbcode.dev
|
|
41
41
|
```
|
|
42
42
|
|
|
43
43
|
`masterKey` must be a base64 encoded 32-byte key. Generate one with:
|
|
@@ -58,4 +58,16 @@ After enrollment, every admin login requires password, TOTP, and a browser passk
|
|
|
58
58
|
|
|
59
59
|
## Admin UI
|
|
60
60
|
|
|
61
|
-
Vault has
|
|
61
|
+
Vault has pages for Overview, Applications, Deployments, Plugins, and Profile. Deployment profiles own config drafts, publishing, and container key create/rotate flows.
|
|
62
|
+
|
|
63
|
+
When editing a profile config, enter only the profile body:
|
|
64
|
+
|
|
65
|
+
```json
|
|
66
|
+
{
|
|
67
|
+
"observable": {},
|
|
68
|
+
"events": {},
|
|
69
|
+
"services": {}
|
|
70
|
+
}
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
Vault wraps that body under the profile name internally. Container keys are generated from the deployment profile page and the UI shows the BSB container env vars once on creation or rotation.
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"http-server.d.ts","sourceRoot":"","sources":["../../../src/plugins/service-config-vault/http-server.ts"],"names":[],"mappings":"AAgBA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,WAAW,CAAC;AAC5C,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,YAAY,CAAC;AAG/C,MAAM,WAAW,gBAAgB;IAC/B,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;IACb,SAAS,EAAE,MAAM,CAAC;IAClB,UAAU,EAAE,OAAO,CAAC;IACpB,GAAG,EAAE,UAAU,CAAC;IAChB,KAAK,EAAE,YAAY,CAAC;CACrB;AAED,qBAAa,eAAe;IAC1B,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAmB;IAC3C,OAAO,CAAC,MAAM,CAAC,CAAS;gBAEZ,OAAO,EAAE,gBAAgB;IAI/B,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC;
|
|
1
|
+
{"version":3,"file":"http-server.d.ts","sourceRoot":"","sources":["../../../src/plugins/service-config-vault/http-server.ts"],"names":[],"mappings":"AAgBA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,WAAW,CAAC;AAC5C,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,YAAY,CAAC;AAG/C,MAAM,WAAW,gBAAgB;IAC/B,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;IACb,SAAS,EAAE,MAAM,CAAC;IAClB,WAAW,EAAE,MAAM,CAAC;IACpB,UAAU,EAAE,OAAO,CAAC;IACpB,GAAG,EAAE,UAAU,CAAC;IAChB,KAAK,EAAE,YAAY,CAAC;CACrB;AAED,qBAAa,eAAe;IAC1B,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAmB;IAC3C,OAAO,CAAC,MAAM,CAAC,CAAS;gBAEZ,OAAO,EAAE,gBAAgB;IAI/B,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC;IAyTtB,IAAI,IAAI,OAAO,CAAC,IAAI,CAAC;YAUb,WAAW;YAUX,gBAAgB;IAY9B,OAAO,CAAC,IAAI;CAGb"}
|
|
@@ -90,21 +90,72 @@ export class VaultHttpServer {
|
|
|
90
90
|
deleteCookie(event, 'vault_passkey_setup', { path: '/' });
|
|
91
91
|
return sendRedirect(event, '/login');
|
|
92
92
|
}));
|
|
93
|
+
app.use('/api/applications/update', defineEventHandler(async (event) => {
|
|
94
|
+
const user = await this.requireUser(event);
|
|
95
|
+
const body = await readBody(event);
|
|
96
|
+
await this.options.vault.updateApplication(user.userId, String(body.id ?? ''), String(body.name ?? ''), stringOrUndefined(body.description));
|
|
97
|
+
return { success: true };
|
|
98
|
+
}));
|
|
99
|
+
app.use('/api/applications/delete', defineEventHandler(async (event) => {
|
|
100
|
+
const user = await this.requireUser(event);
|
|
101
|
+
const body = await readBody(event);
|
|
102
|
+
await this.options.vault.deleteApplication(user.userId, String(body.id ?? ''));
|
|
103
|
+
return { success: true };
|
|
104
|
+
}));
|
|
93
105
|
app.use('/api/applications', defineEventHandler(async (event) => {
|
|
94
106
|
const user = await this.requireUser(event);
|
|
95
107
|
const body = await readBody(event);
|
|
96
108
|
return this.options.vault.createApplication(user.userId, String(body.name ?? ''), stringOrUndefined(body.description));
|
|
97
109
|
}));
|
|
110
|
+
app.use('/api/groups/update', defineEventHandler(async (event) => {
|
|
111
|
+
const user = await this.requireUser(event);
|
|
112
|
+
const body = await readBody(event);
|
|
113
|
+
await this.options.vault.updateGroup(user.userId, String(body.id ?? ''), String(body.applicationId ?? ''), String(body.name ?? ''));
|
|
114
|
+
return { success: true };
|
|
115
|
+
}));
|
|
116
|
+
app.use('/api/groups/delete', defineEventHandler(async (event) => {
|
|
117
|
+
const user = await this.requireUser(event);
|
|
118
|
+
const body = await readBody(event);
|
|
119
|
+
await this.options.vault.deleteGroup(user.userId, String(body.id ?? ''));
|
|
120
|
+
return { success: true };
|
|
121
|
+
}));
|
|
98
122
|
app.use('/api/groups', defineEventHandler(async (event) => {
|
|
99
123
|
const user = await this.requireUser(event);
|
|
100
124
|
const body = await readBody(event);
|
|
101
|
-
return this.options.vault.
|
|
125
|
+
return this.options.vault.createDeployment(user.userId, String(body.applicationId ?? ''), String(body.name ?? ''));
|
|
126
|
+
}));
|
|
127
|
+
app.use('/api/profiles/update', defineEventHandler(async (event) => {
|
|
128
|
+
const user = await this.requireUser(event);
|
|
129
|
+
const body = await readBody(event);
|
|
130
|
+
await this.options.vault.updateProfile(user.userId, String(body.id ?? ''), String(body.groupId ?? ''), String(body.name ?? ''));
|
|
131
|
+
return { success: true };
|
|
132
|
+
}));
|
|
133
|
+
app.use('/api/profiles/delete', defineEventHandler(async (event) => {
|
|
134
|
+
const user = await this.requireUser(event);
|
|
135
|
+
const body = await readBody(event);
|
|
136
|
+
await this.options.vault.deleteProfile(user.userId, String(body.id ?? ''));
|
|
137
|
+
return { success: true };
|
|
102
138
|
}));
|
|
103
139
|
app.use('/api/profiles', defineEventHandler(async (event) => {
|
|
104
140
|
const user = await this.requireUser(event);
|
|
105
141
|
const body = await readBody(event);
|
|
106
142
|
return this.options.vault.createProfile(user.userId, String(body.groupId ?? ''), String(body.name ?? 'default'));
|
|
107
143
|
}));
|
|
144
|
+
app.use('/api/plugins/import', defineEventHandler(async (event) => {
|
|
145
|
+
const user = await this.requireUser(event);
|
|
146
|
+
const body = await readBody(event);
|
|
147
|
+
return this.options.vault.createPlugin(user.userId, {
|
|
148
|
+
org: String(body.org ?? '_'),
|
|
149
|
+
name: String(body.name ?? ''),
|
|
150
|
+
pluginId: String(body.pluginId ?? body.name ?? ''),
|
|
151
|
+
packageName: body.packageName === undefined || body.packageName === '' ? null : String(body.packageName),
|
|
152
|
+
version: String(body.version ?? '0.0.0'),
|
|
153
|
+
kind: parseKind(body.kind),
|
|
154
|
+
source: 'registry',
|
|
155
|
+
configSchema: parseJsonObject(body.configSchema) ?? null,
|
|
156
|
+
eventSchema: parseJsonObject(body.eventSchema) ?? null,
|
|
157
|
+
});
|
|
158
|
+
}));
|
|
108
159
|
app.use('/api/plugins', defineEventHandler(async (event) => {
|
|
109
160
|
const user = await this.requireUser(event);
|
|
110
161
|
const body = await readBody(event);
|
|
@@ -126,7 +177,7 @@ export class VaultHttpServer {
|
|
|
126
177
|
const config = parseJsonObject(body.config);
|
|
127
178
|
if (!config)
|
|
128
179
|
throw new Error('Config must be a JSON object');
|
|
129
|
-
await this.options.vault.
|
|
180
|
+
await this.options.vault.saveProfileDraft(user.userId, String(body.profileId ?? ''), config);
|
|
130
181
|
return { success: true };
|
|
131
182
|
}));
|
|
132
183
|
app.use('/api/publish', defineEventHandler(async (event) => {
|
|
@@ -134,16 +185,21 @@ export class VaultHttpServer {
|
|
|
134
185
|
const body = await readBody(event);
|
|
135
186
|
return this.options.vault.publishDraft(user.userId, String(body.profileId ?? ''));
|
|
136
187
|
}));
|
|
188
|
+
app.use('/api/runtime-keys/rotate', defineEventHandler(async (event) => {
|
|
189
|
+
const user = await this.requireUser(event);
|
|
190
|
+
const body = await readBody(event);
|
|
191
|
+
return this.options.vault.rotateProfileRuntimeKey(user.userId, {
|
|
192
|
+
keyId: String(body.keyId ?? ''),
|
|
193
|
+
name: stringOrUndefined(body.name),
|
|
194
|
+
});
|
|
195
|
+
}));
|
|
137
196
|
app.use('/api/runtime-keys', defineEventHandler(async (event) => {
|
|
138
197
|
const user = await this.requireUser(event);
|
|
139
198
|
const body = await readBody(event);
|
|
140
|
-
return this.options.vault.
|
|
199
|
+
return this.options.vault.createProfileRuntimeKey(user.userId, {
|
|
141
200
|
name: String(body.name ?? ''),
|
|
142
|
-
applicationId: String(body.applicationId ?? ''),
|
|
143
|
-
groupId: String(body.groupId ?? ''),
|
|
144
201
|
profileId: String(body.profileId ?? ''),
|
|
145
202
|
containerName: body.containerName === undefined ? null : String(body.containerName),
|
|
146
|
-
configPluginId: String(body.configPluginId ?? 'config-vault'),
|
|
147
203
|
});
|
|
148
204
|
}));
|
|
149
205
|
app.use('/applications', defineEventHandler(async (event) => {
|
|
@@ -156,21 +212,45 @@ export class VaultHttpServer {
|
|
|
156
212
|
const dashboard = await this.options.vault.dashboard();
|
|
157
213
|
return this.page('Deployments', deploymentsPage(dashboard), 'deployments');
|
|
158
214
|
}));
|
|
215
|
+
app.use('/deployment', defineEventHandler(async (event) => {
|
|
216
|
+
await this.requireUser(event);
|
|
217
|
+
const query = getQuery(event);
|
|
218
|
+
const profileId = String(query.profileId ?? '');
|
|
219
|
+
if (!profileId)
|
|
220
|
+
return sendRedirect(event, '/deployments');
|
|
221
|
+
const profile = await this.options.vault.deploymentProfile(profileId);
|
|
222
|
+
return this.page('Deployment', deploymentDetailPage(profile, {
|
|
223
|
+
publicUrl: this.options.publicUrl,
|
|
224
|
+
keyId: String(query.keyId ?? ''),
|
|
225
|
+
secret: String(query.secret ?? ''),
|
|
226
|
+
}), 'deployments');
|
|
227
|
+
}));
|
|
159
228
|
app.use('/configs', defineEventHandler(async (event) => {
|
|
160
229
|
await this.requireUser(event);
|
|
161
230
|
const dashboard = await this.options.vault.dashboard();
|
|
162
|
-
|
|
231
|
+
const firstProfile = dashboard.profiles[0];
|
|
232
|
+
return firstProfile ? sendRedirect(event, `/deployment?profileId=${encodeURIComponent(firstProfile.id)}`) : sendRedirect(event, '/deployments');
|
|
163
233
|
}));
|
|
164
234
|
app.use('/runtime-keys', defineEventHandler(async (event) => {
|
|
165
235
|
await this.requireUser(event);
|
|
166
236
|
const query = getQuery(event);
|
|
167
237
|
const dashboard = await this.options.vault.dashboard();
|
|
168
|
-
|
|
238
|
+
const firstProfile = dashboard.profiles[0];
|
|
239
|
+
if (!String(query.secret ?? '') && firstProfile) {
|
|
240
|
+
return sendRedirect(event, `/deployment?profileId=${encodeURIComponent(firstProfile.id)}`);
|
|
241
|
+
}
|
|
242
|
+
return this.page('Container Key', runtimeKeysPage(dashboard, {
|
|
243
|
+
publicUrl: this.options.publicUrl,
|
|
244
|
+
keyId: String(query.keyId ?? ''),
|
|
245
|
+
secret: String(query.secret ?? ''),
|
|
246
|
+
}), 'runtime-keys');
|
|
169
247
|
}));
|
|
170
248
|
app.use('/plugins', defineEventHandler(async (event) => {
|
|
171
249
|
await this.requireUser(event);
|
|
250
|
+
const query = getQuery(event);
|
|
172
251
|
const dashboard = await this.options.vault.dashboard();
|
|
173
|
-
|
|
252
|
+
const registry = await registrySearch(this.options.registryUrl, String(query.query ?? ''));
|
|
253
|
+
return this.page('Plugins', pluginsPage(dashboard, registry, String(query.query ?? '')), 'plugins');
|
|
174
254
|
}));
|
|
175
255
|
app.use('/profile', defineEventHandler(async (event) => {
|
|
176
256
|
const session = await this.requireUser(event);
|
|
@@ -259,6 +339,7 @@ function html(title, body, active, authenticated) {
|
|
|
259
339
|
.form-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:12px}
|
|
260
340
|
.metric{font-size:28px;font-weight:750}.page-head{display:flex;justify-content:space-between;align-items:flex-start;gap:16px;margin:0 0 18px}
|
|
261
341
|
.inline-form{display:flex;gap:10px;align-items:end;flex-wrap:wrap}.inline-form label{min-width:190px}
|
|
342
|
+
.tabs{display:flex;gap:8px;flex-wrap:wrap;margin:0 0 16px}.tabs a{padding:8px 10px;border:1px solid var(--line);border-radius:6px;text-decoration:none;color:#344054;background:#fff;font-weight:650}.tabs a.active{background:#eaf1ff;color:#155eef;border-color:#b8cdfd}
|
|
262
343
|
.muted{color:var(--muted)}.danger{color:var(--danger)}.ok{color:var(--ok)}
|
|
263
344
|
.auth{max-width:480px;margin:32px auto}.stack{display:flex;flex-direction:column;gap:12px}.actions{display:flex;gap:10px;align-items:center;flex-wrap:wrap}
|
|
264
345
|
.status{margin-top:12px;color:var(--muted);font-size:14px}.code{word-break:break-all;background:#f2f4f7;border:1px solid var(--line);border-radius:6px;padding:10px}
|
|
@@ -276,8 +357,6 @@ function nav(active) {
|
|
|
276
357
|
['overview', 'Overview', '/'],
|
|
277
358
|
['applications', 'Applications', '/applications'],
|
|
278
359
|
['deployments', 'Deployments', '/deployments'],
|
|
279
|
-
['configs', 'Configs', '/configs'],
|
|
280
|
-
['runtime-keys', 'Runtime Keys', '/runtime-keys'],
|
|
281
360
|
['plugins', 'Plugins', '/plugins'],
|
|
282
361
|
['profile', 'Profile', '/profile'],
|
|
283
362
|
];
|
|
@@ -403,11 +482,11 @@ function overviewPage(data) {
|
|
|
403
482
|
return `<div class="page-head"><div><h1>Overview</h1><p class="muted">Current Vault inventory and deployment configuration status.</p></div></div>
|
|
404
483
|
<div class="grid">
|
|
405
484
|
${metric('Applications', data.applications.length)}
|
|
406
|
-
${metric('
|
|
485
|
+
${metric('Deployments', data.groups.length)}
|
|
407
486
|
${metric('Deployment Profiles', data.profiles.length)}
|
|
408
|
-
${metric('
|
|
487
|
+
${metric('Container Keys', data.runtimeKeys.length)}
|
|
409
488
|
</div>
|
|
410
|
-
<section><h2>Recent
|
|
489
|
+
<section><h2>Recent Container Keys</h2>${runtimeKeyTable(data.runtimeKeys.slice(0, 8), data)}</section>`;
|
|
411
490
|
}
|
|
412
491
|
function applicationsPage(data) {
|
|
413
492
|
return `<div class="page-head"><div><h1>Applications</h1><p class="muted">Create product or system boundaries for deployment profiles.</p></div></div>
|
|
@@ -420,70 +499,74 @@ function applicationsPage(data) {
|
|
|
420
499
|
<button>Create Application</button><p class="status"></p>
|
|
421
500
|
</form>
|
|
422
501
|
</section>
|
|
423
|
-
<section><h2>Applications</h2>${
|
|
502
|
+
<section><h2>Applications</h2>${applicationsTable(data)}</section>
|
|
424
503
|
${formScript()}`;
|
|
425
504
|
}
|
|
426
505
|
function deploymentsPage(data) {
|
|
427
|
-
return `<div class="page-head"><div><h1>Deployments</h1><p class="muted">
|
|
428
|
-
<
|
|
429
|
-
<section><h2>Create Service Group</h2>
|
|
506
|
+
return `<div class="page-head"><div><h1>Deployments</h1><p class="muted">A deployment represents the container group that will receive one selected profile.</p></div></div>
|
|
507
|
+
<section><h2>Create Deployment</h2>
|
|
430
508
|
<form data-api="/api/groups" data-redirect="/deployments">
|
|
431
509
|
${select('applicationId', 'Application', data.applications.map((x) => [x.id, x.name]))}
|
|
432
|
-
${input('name', '
|
|
433
|
-
<button>Create
|
|
510
|
+
${input('name', 'Deployment Name', true)}
|
|
511
|
+
<button>Create Deployment</button><p class="status"></p>
|
|
434
512
|
</form>
|
|
435
513
|
</section>
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
514
|
+
<section><h2>Deployments</h2>${groupsTable(data)}</section>
|
|
515
|
+
<section><h2>Profiles</h2>${profilesTable(data)}</section>
|
|
516
|
+
${formScript()}`;
|
|
517
|
+
}
|
|
518
|
+
function runtimeKeysPage(data, credential) {
|
|
519
|
+
return `<div class="page-head"><div><h1>Container Key</h1><p class="muted">Use these env vars in the target BSB container.</p></div></div>
|
|
520
|
+
${credential.secret ? runtimeEnvBlock(credential) : ''}
|
|
521
|
+
<section><h2>Container Keys</h2>${runtimeKeyTable(data.runtimeKeys, data)}</section>`;
|
|
522
|
+
}
|
|
523
|
+
function deploymentDetailPage(data, credential) {
|
|
524
|
+
const draft = data.draft ?? { observable: {}, events: {}, services: {} };
|
|
525
|
+
const redirect = `/deployment?profileId=${encodeURIComponent(data.profile.id)}`;
|
|
526
|
+
return `<div class="page-head"><div><h1>${escapeHtml(data.group.name)}</h1><p class="muted">${escapeHtml(data.application.name)} / ${escapeHtml(data.profile.name)}</p></div><a class="button secondary" href="/deployments">Back</a></div>
|
|
527
|
+
<div class="tabs">${data.profiles.map((profile) => `<a class="${profile.id === data.profile.id ? 'active' : ''}" href="/deployment?profileId=${encodeURIComponent(profile.id)}">${escapeHtml(profile.name)}</a>`).join('')}</div>
|
|
528
|
+
${credential.secret ? runtimeEnvBlock(credential) : ''}
|
|
529
|
+
<div class="grid">
|
|
530
|
+
<section><h2>Create Profile</h2>
|
|
531
|
+
<form data-api="/api/profiles" data-redirect="${escapeHtml(redirect)}">
|
|
532
|
+
<input type="hidden" name="groupId" value="${escapeHtml(data.group.id)}">
|
|
533
|
+
${input('name', 'Profile Name', true)}
|
|
440
534
|
<button>Create Profile</button><p class="status"></p>
|
|
441
535
|
</form>
|
|
442
536
|
</section>
|
|
537
|
+
<section><h2>Container Key</h2>
|
|
538
|
+
<form data-api="/api/runtime-keys" data-secret-redirect="${escapeHtml(redirect)}">
|
|
539
|
+
<input type="hidden" name="profileId" value="${escapeHtml(data.profile.id)}">
|
|
540
|
+
${input('name', 'Key Name', true, `${data.group.name}-${data.profile.name}`)}
|
|
541
|
+
${input('containerName', 'Container Name')}
|
|
542
|
+
<button>Create Key</button><p class="status"></p>
|
|
543
|
+
</form>
|
|
544
|
+
</section>
|
|
443
545
|
</div>
|
|
444
|
-
<section><h2>
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
}
|
|
448
|
-
function configsPage(data) {
|
|
449
|
-
return `<div class="page-head"><div><h1>Configs</h1><p class="muted">Save draft runtime config for a deployment profile, then publish it when ready.</p></div></div>
|
|
450
|
-
<section><h2>Edit Draft</h2>
|
|
451
|
-
<form data-api="/api/drafts" data-redirect="/configs">
|
|
452
|
-
${select('profileId', 'Deployment Profile', data.profiles.map((x) => [x.id, profileLabel(x, data)]))}
|
|
453
|
-
<label>Config JSON</label><textarea name="config" required placeholder='{"default":{"observable":{},"events":{},"services":{}}}'></textarea>
|
|
546
|
+
<section><h2>Profile Config</h2>
|
|
547
|
+
<form data-api="/api/drafts" data-redirect="${escapeHtml(redirect)}">
|
|
548
|
+
<input type="hidden" name="profileId" value="${escapeHtml(data.profile.id)}">
|
|
549
|
+
<label>Config JSON</label><textarea name="config" required>${escapeHtml(JSON.stringify(draft, null, 2))}</textarea>
|
|
454
550
|
<button>Save Draft</button><p class="status"></p>
|
|
455
551
|
</form>
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
${select('profileId', 'Deployment Profile', data.profiles.map((x) => [x.id, profileLabel(x, data)]))}
|
|
460
|
-
<button>Publish Active Version</button><p class="status"></p>
|
|
552
|
+
<form data-api="/api/publish" data-redirect="${escapeHtml(redirect)}">
|
|
553
|
+
<input type="hidden" name="profileId" value="${escapeHtml(data.profile.id)}">
|
|
554
|
+
<button class="secondary">Publish Draft</button><p class="status"></p>
|
|
461
555
|
</form>
|
|
462
556
|
</section>
|
|
557
|
+
<section><h2>Container Keys</h2>${profileRuntimeKeyTable(data.runtimeKeys, data)}</section>
|
|
463
558
|
${formScript()}`;
|
|
464
559
|
}
|
|
465
|
-
function
|
|
466
|
-
return `<div class="page-head"><div><h1>
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
<
|
|
471
|
-
${input('name', 'Name', true)}
|
|
472
|
-
${select('applicationId', 'Application', data.applications.map((x) => [x.id, x.name]))}
|
|
473
|
-
${select('groupId', 'Service Group', data.groups.map((x) => [x.id, groupLabel(x, data)]))}
|
|
474
|
-
${select('profileId', 'Deployment Profile', data.profiles.map((x) => [x.id, profileLabel(x, data)]))}
|
|
475
|
-
${input('containerName', 'Container Name')}
|
|
476
|
-
${input('configPluginId', 'Config Plugin', true, 'config-vault')}
|
|
477
|
-
</div>
|
|
478
|
-
<button>Create Runtime Key</button><p class="status"></p>
|
|
560
|
+
function pluginsPage(data, registry, query) {
|
|
561
|
+
return `<div class="page-head"><div><h1>Plugin Catalog</h1><p class="muted">Import registry plugins for config authoring, or create private plugin entries manually.</p></div></div>
|
|
562
|
+
<section><h2>Registry Search</h2>
|
|
563
|
+
<form method="get" action="/plugins" class="inline-form">
|
|
564
|
+
${input('query', 'Search', false, query)}
|
|
565
|
+
<button>Search Registry</button>
|
|
479
566
|
</form>
|
|
567
|
+
${registry.length === 0 ? '<p class="muted">No registry results loaded.</p>' : registryTable(registry)}
|
|
480
568
|
</section>
|
|
481
|
-
<section><h2>
|
|
482
|
-
${formScript()}`;
|
|
483
|
-
}
|
|
484
|
-
function pluginsPage(data) {
|
|
485
|
-
return `<div class="page-head"><div><h1>Plugin Catalog</h1><p class="muted">Register public, private, or uploaded plugin schemas for config authoring.</p></div></div>
|
|
486
|
-
<section><h2>Create Plugin</h2>
|
|
569
|
+
<section><h2>Private Plugin</h2>
|
|
487
570
|
<form data-api="/api/plugins" data-redirect="/plugins">
|
|
488
571
|
<div class="form-grid">
|
|
489
572
|
${input('org', 'Org', true, '_')}
|
|
@@ -501,6 +584,27 @@ function pluginsPage(data) {
|
|
|
501
584
|
<section><h2>Catalog</h2>${table(data.plugins.map((x) => [x.pluginId, x.version, x.kind, x.source, x.packageName ?? '']))}</section>
|
|
502
585
|
${formScript()}`;
|
|
503
586
|
}
|
|
587
|
+
function registryTable(items) {
|
|
588
|
+
return `<table>${items.map((item) => `<tr>
|
|
589
|
+
<td>${escapeHtml(item.pluginId)}</td>
|
|
590
|
+
<td>${escapeHtml(item.version)}</td>
|
|
591
|
+
<td>${escapeHtml(item.kind)}</td>
|
|
592
|
+
<td>${escapeHtml(item.packageName ?? '')}</td>
|
|
593
|
+
<td>
|
|
594
|
+
<form data-api="/api/plugins/import" data-redirect="/plugins">
|
|
595
|
+
<input type="hidden" name="org" value="${escapeHtml(item.org)}">
|
|
596
|
+
<input type="hidden" name="name" value="${escapeHtml(item.name)}">
|
|
597
|
+
<input type="hidden" name="pluginId" value="${escapeHtml(item.pluginId)}">
|
|
598
|
+
<input type="hidden" name="packageName" value="${escapeHtml(item.packageName ?? '')}">
|
|
599
|
+
<input type="hidden" name="version" value="${escapeHtml(item.version)}">
|
|
600
|
+
<input type="hidden" name="kind" value="${escapeHtml(item.kind)}">
|
|
601
|
+
<input type="hidden" name="configSchema" value="${escapeHtml(JSON.stringify(item.configSchema ?? {}))}">
|
|
602
|
+
<input type="hidden" name="eventSchema" value="${escapeHtml(JSON.stringify(item.eventSchema ?? {}))}">
|
|
603
|
+
<button class="secondary">Import</button><p class="status"></p>
|
|
604
|
+
</form>
|
|
605
|
+
</td>
|
|
606
|
+
</tr>`).join('')}</table>`;
|
|
607
|
+
}
|
|
504
608
|
function profilePage(data) {
|
|
505
609
|
return `<div class="page-head"><div><h1>Profile</h1><p class="muted">Account security and admin authentication settings.</p></div></div>
|
|
506
610
|
<section><h2>Account</h2>${table([[data.user.email, data.user.createdAt]])}</section>
|
|
@@ -509,6 +613,59 @@ function profilePage(data) {
|
|
|
509
613
|
<p><a class="button" href="/passkeys/setup">Add Passkey</a></p>
|
|
510
614
|
</section>`;
|
|
511
615
|
}
|
|
616
|
+
function applicationsTable(data) {
|
|
617
|
+
if (data.applications.length === 0)
|
|
618
|
+
return '<p class="muted">None</p>';
|
|
619
|
+
return `<table>${data.applications.map((app) => `<tr><td>
|
|
620
|
+
<form data-api="/api/applications/update" data-redirect="/applications" class="inline-form">
|
|
621
|
+
<input type="hidden" name="id" value="${escapeHtml(app.id)}">
|
|
622
|
+
${input('name', 'Name', true, app.name)}
|
|
623
|
+
${input('description', 'Description', false, app.description ?? '')}
|
|
624
|
+
<button>Save</button><p class="status"></p>
|
|
625
|
+
</form>
|
|
626
|
+
</td><td class="actions">
|
|
627
|
+
<a class="button secondary" href="/deployments">Deployments</a>
|
|
628
|
+
${deleteForm('/api/applications/delete', app.id, '/applications', 'Delete application and related deployments, profiles, configs, and keys?')}
|
|
629
|
+
</td></tr>`).join('')}</table>`;
|
|
630
|
+
}
|
|
631
|
+
function groupsTable(data) {
|
|
632
|
+
if (data.groups.length === 0)
|
|
633
|
+
return '<p class="muted">None</p>';
|
|
634
|
+
const defaultProfileFor = (groupId) => data.profiles.find((profile) => profile.groupId === groupId && profile.name === 'default') ?? data.profiles.find((profile) => profile.groupId === groupId);
|
|
635
|
+
return `<table>${data.groups.map((group) => `<tr><td>
|
|
636
|
+
<form data-api="/api/groups/update" data-redirect="/deployments" class="inline-form">
|
|
637
|
+
<input type="hidden" name="id" value="${escapeHtml(group.id)}">
|
|
638
|
+
${select('applicationId', 'Application', selectedOptions(data.applications.map((x) => [x.id, x.name]), group.applicationId))}
|
|
639
|
+
${input('name', 'Name', true, group.name)}
|
|
640
|
+
<button>Save</button><p class="status"></p>
|
|
641
|
+
</form>
|
|
642
|
+
</td><td class="actions">
|
|
643
|
+
${defaultProfileFor(group.id) ? `<a class="button secondary" href="/deployment?profileId=${encodeURIComponent(defaultProfileFor(group.id).id)}">Open</a>` : ''}
|
|
644
|
+
${deleteForm('/api/groups/delete', group.id, '/deployments', 'Delete deployment and related profiles, configs, and keys?')}
|
|
645
|
+
</td></tr>`).join('')}</table>`;
|
|
646
|
+
}
|
|
647
|
+
function profilesTable(data) {
|
|
648
|
+
if (data.profiles.length === 0)
|
|
649
|
+
return '<p class="muted">None</p>';
|
|
650
|
+
return `<table>${data.profiles.map((profile) => `<tr><td>
|
|
651
|
+
<form data-api="/api/profiles/update" data-redirect="/deployments" class="inline-form">
|
|
652
|
+
<input type="hidden" name="id" value="${escapeHtml(profile.id)}">
|
|
653
|
+
${select('groupId', 'Deployment', selectedOptions(data.groups.map((x) => [x.id, groupLabel(x, data)]), profile.groupId))}
|
|
654
|
+
${input('name', 'Name', true, profile.name)}
|
|
655
|
+
<span class="muted">${profile.activeVersionId ? 'published' : 'no published config'}</span>
|
|
656
|
+
<button>Save</button><p class="status"></p>
|
|
657
|
+
</form>
|
|
658
|
+
</td><td class="actions">
|
|
659
|
+
<a class="button secondary" href="/deployment?profileId=${encodeURIComponent(profile.id)}">Open</a>
|
|
660
|
+
${deleteForm('/api/profiles/delete', profile.id, '/deployments', 'Delete deployment profile and related configs and keys?')}
|
|
661
|
+
</td></tr>`).join('')}</table>`;
|
|
662
|
+
}
|
|
663
|
+
function deleteForm(action, id, redirect, confirm) {
|
|
664
|
+
return `<form data-api="${escapeHtml(action)}" data-redirect="${escapeHtml(redirect)}" data-confirm="${escapeHtml(confirm)}">
|
|
665
|
+
<input type="hidden" name="id" value="${escapeHtml(id)}">
|
|
666
|
+
<button class="secondary" type="submit">Delete</button><p class="status"></p>
|
|
667
|
+
</form>`;
|
|
668
|
+
}
|
|
512
669
|
function metric(label, value) {
|
|
513
670
|
return `<section><p class="muted">${escapeHtml(label)}</p><div class="metric">${value}</div></section>`;
|
|
514
671
|
}
|
|
@@ -518,9 +675,16 @@ function input(name, label, required = false, value = '') {
|
|
|
518
675
|
function select(name, label, options) {
|
|
519
676
|
const body = options.length === 0
|
|
520
677
|
? '<option value="">Create the required parent record first</option>'
|
|
521
|
-
: options.map(([value, optionLabel]) =>
|
|
678
|
+
: options.map(([value, optionLabel]) => {
|
|
679
|
+
const selected = optionLabel.endsWith('\u0000selected');
|
|
680
|
+
const labelText = selected ? optionLabel.slice(0, -9) : optionLabel;
|
|
681
|
+
return `<option value="${escapeHtml(value)}" ${selected ? 'selected' : ''}>${escapeHtml(labelText)}</option>`;
|
|
682
|
+
}).join('');
|
|
522
683
|
return `<label>${escapeHtml(label)}<select name="${escapeHtml(name)}" ${options.length === 0 ? 'disabled' : 'required'}>${body}</select></label>`;
|
|
523
684
|
}
|
|
685
|
+
function selectedOptions(options, selectedValue) {
|
|
686
|
+
return options.map(([value, label]) => [value, value === selectedValue ? `${label}\u0000selected` : label]);
|
|
687
|
+
}
|
|
524
688
|
function groupLabel(group, data) {
|
|
525
689
|
const app = data.applications.find((candidate) => candidate.id === group.applicationId);
|
|
526
690
|
return `${app?.name ?? 'Unknown'} / ${group.name}`;
|
|
@@ -538,6 +702,31 @@ function runtimeKeyTable(keys, data) {
|
|
|
538
702
|
key.revokedAt ?? 'active',
|
|
539
703
|
]));
|
|
540
704
|
}
|
|
705
|
+
function profileRuntimeKeyTable(keys, data) {
|
|
706
|
+
if (keys.length === 0)
|
|
707
|
+
return '<p class="muted">No container keys created for this profile.</p>';
|
|
708
|
+
return `<table>${keys.map((key) => `<tr>
|
|
709
|
+
<td>${escapeHtml(key.name)}</td>
|
|
710
|
+
<td>${escapeHtml(key.id)}</td>
|
|
711
|
+
<td>${escapeHtml(data.profile.name)}</td>
|
|
712
|
+
<td>${escapeHtml(key.containerName ?? '')}</td>
|
|
713
|
+
<td>${escapeHtml(key.revokedAt ?? 'active')}</td>
|
|
714
|
+
<td>${key.revokedAt ? '' : `<form data-api="/api/runtime-keys/rotate" data-secret-redirect="/deployment?profileId=${escapeHtml(data.profile.id)}"><input type="hidden" name="keyId" value="${escapeHtml(key.id)}"><button class="secondary">Rotate</button><p class="status"></p></form>`}</td>
|
|
715
|
+
</tr>`).join('')}</table>`;
|
|
716
|
+
}
|
|
717
|
+
function runtimeEnvBlock(credential) {
|
|
718
|
+
const env = [
|
|
719
|
+
'BSB_CONFIG_PLUGIN=config-vault',
|
|
720
|
+
'BSB_CONFIG_PLUGIN_PACKAGE=@bsb/config-vault',
|
|
721
|
+
`vaultUrl=${credential.publicUrl}`,
|
|
722
|
+
`apiKeyId=${credential.keyId}`,
|
|
723
|
+
`apiSecret=${credential.secret}`,
|
|
724
|
+
].join('\n');
|
|
725
|
+
return `<section><h2>Container Environment</h2>
|
|
726
|
+
<p class="muted">Shown once. Add these env vars to the BSB container that should load this deployment profile.</p>
|
|
727
|
+
<pre class="code"><code>${escapeHtml(env)}</code></pre>
|
|
728
|
+
</section>`;
|
|
729
|
+
}
|
|
541
730
|
function shortId(value) {
|
|
542
731
|
return value.length > 18 ? `${value.slice(0, 10)}...${value.slice(-6)}` : value;
|
|
543
732
|
}
|
|
@@ -546,6 +735,7 @@ function formScript() {
|
|
|
546
735
|
document.querySelectorAll('form[data-api]').forEach((form) => {
|
|
547
736
|
form.addEventListener('submit', async (event) => {
|
|
548
737
|
event.preventDefault();
|
|
738
|
+
if (form.dataset.confirm && !confirm(form.dataset.confirm)) return;
|
|
549
739
|
const status = form.querySelector('.status');
|
|
550
740
|
if (status) { status.textContent = 'Saving...'; status.className = 'status'; }
|
|
551
741
|
try {
|
|
@@ -563,7 +753,10 @@ function formScript() {
|
|
|
563
753
|
const result = await res.json();
|
|
564
754
|
if (!res.ok) throw new Error(result.message || 'Save failed');
|
|
565
755
|
if (form.dataset.secretRedirect && result.secret) {
|
|
566
|
-
|
|
756
|
+
const joiner = form.dataset.secretRedirect.includes('?') ? '&' : '?';
|
|
757
|
+
location.href = form.dataset.secretRedirect
|
|
758
|
+
+ joiner + 'keyId=' + encodeURIComponent(result.keyId || result.id || '')
|
|
759
|
+
+ '&secret=' + encodeURIComponent(result.secret);
|
|
567
760
|
return;
|
|
568
761
|
}
|
|
569
762
|
location.href = form.dataset.redirect || location.pathname;
|
|
@@ -630,29 +823,63 @@ function webauthnClientScript() {
|
|
|
630
823
|
}
|
|
631
824
|
`;
|
|
632
825
|
}
|
|
633
|
-
function apiForm(action, fields, textarea) {
|
|
634
|
-
return `<form data-api="${action}" onsubmit="return submitJson(this)">
|
|
635
|
-
${fields.map((field) => `<input name="${field}" placeholder="${field}" ${field === 'configPluginId' ? 'value="config-vault"' : ''}>`).join('')}
|
|
636
|
-
${textarea ? `<textarea name="${textarea}" placeholder="${textarea} JSON"></textarea>` : ''}
|
|
637
|
-
<button>Submit</button>
|
|
638
|
-
</form>
|
|
639
|
-
<script>
|
|
640
|
-
async function submitJson(form){
|
|
641
|
-
const data={}; for(const item of new FormData(form).entries()){data[item[0]]=item[1]}
|
|
642
|
-
const csrf=document.cookie.split('; ').find(x=>x.startsWith('vault_csrf='))?.split('=')[1]||'';
|
|
643
|
-
const res=await fetch(form.dataset.api,{method:'POST',headers:{'content-type':'application/json','x-csrf-token':csrf},body:JSON.stringify(data)});
|
|
644
|
-
alert(JSON.stringify(await res.json(),null,2)); location.reload(); return false;
|
|
645
|
-
}
|
|
646
|
-
</script>`;
|
|
647
|
-
}
|
|
648
|
-
function pluginForm() {
|
|
649
|
-
return apiForm('/api/plugins', ['org', 'name', 'pluginId', 'packageName', 'version', 'kind', 'source'], 'configSchema');
|
|
650
|
-
}
|
|
651
826
|
function table(rows) {
|
|
652
827
|
if (rows.length === 0)
|
|
653
828
|
return '<p class="muted">None</p>';
|
|
654
829
|
return `<table>${rows.map((row) => `<tr>${row.map((cell) => `<td>${escapeHtml(cell)}</td>`).join('')}</tr>`).join('')}</table>`;
|
|
655
830
|
}
|
|
831
|
+
async function registrySearch(registryUrl, query) {
|
|
832
|
+
const url = new URL('/plugins', registryUrl);
|
|
833
|
+
url.searchParams.set('language', 'nodejs');
|
|
834
|
+
url.searchParams.set('limit', '20');
|
|
835
|
+
if (query.trim())
|
|
836
|
+
url.searchParams.set('query', query.trim());
|
|
837
|
+
try {
|
|
838
|
+
const response = await fetch(url, { headers: { accept: 'application/json' } });
|
|
839
|
+
if (!response.ok)
|
|
840
|
+
return [];
|
|
841
|
+
const parsed = await response.json();
|
|
842
|
+
return (Array.isArray(parsed.plugins) ? parsed.plugins : [])
|
|
843
|
+
.map(normalizeRegistryCandidate)
|
|
844
|
+
.filter((item) => item !== null);
|
|
845
|
+
}
|
|
846
|
+
catch {
|
|
847
|
+
return [];
|
|
848
|
+
}
|
|
849
|
+
}
|
|
850
|
+
function normalizeRegistryCandidate(input) {
|
|
851
|
+
if (typeof input !== 'object' || input === null || Array.isArray(input))
|
|
852
|
+
return null;
|
|
853
|
+
const value = input;
|
|
854
|
+
const org = stringField(value.org) ?? orgFromPackage(value.packageName ?? value.package) ?? '_';
|
|
855
|
+
const name = stringField(value.name) ?? stringField(value.pluginId) ?? stringField(value.id);
|
|
856
|
+
if (!name)
|
|
857
|
+
return null;
|
|
858
|
+
const packageName = stringField(value.packageName) ?? stringField(value.package) ?? null;
|
|
859
|
+
const pluginId = stringField(value.pluginId) ?? stringField(value.id) ?? name;
|
|
860
|
+
return {
|
|
861
|
+
org,
|
|
862
|
+
name,
|
|
863
|
+
pluginId,
|
|
864
|
+
packageName,
|
|
865
|
+
version: stringField(value.version) ?? '0.0.0',
|
|
866
|
+
kind: parseKind(value.kind ?? value.category ?? value.type),
|
|
867
|
+
configSchema: objectField(value.configSchema) ?? objectField(value.schema) ?? objectField(value.validationSchema) ?? null,
|
|
868
|
+
eventSchema: objectField(value.eventSchema) ?? objectField(value.events) ?? null,
|
|
869
|
+
};
|
|
870
|
+
}
|
|
871
|
+
function stringField(value) {
|
|
872
|
+
return typeof value === 'string' && value.length > 0 ? value : undefined;
|
|
873
|
+
}
|
|
874
|
+
function objectField(value) {
|
|
875
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value) ? value : null;
|
|
876
|
+
}
|
|
877
|
+
function orgFromPackage(value) {
|
|
878
|
+
if (typeof value !== 'string' || !value.startsWith('@'))
|
|
879
|
+
return undefined;
|
|
880
|
+
const [org] = value.split('/');
|
|
881
|
+
return org || undefined;
|
|
882
|
+
}
|
|
656
883
|
function parseJsonObject(value) {
|
|
657
884
|
if (typeof value === 'object' && value !== null && !Array.isArray(value))
|
|
658
885
|
return value;
|