@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 CHANGED
@@ -11,15 +11,14 @@ Runtime containers do not choose applications, groups, profiles, or versions. Th
11
11
 
12
12
  ## Runtime
13
13
 
14
- ```yaml
15
- config-vault:
16
- plugin: config-vault
17
- package: "@bsb/config-vault"
18
- enabled: true
19
- config:
20
- vaultUrl: https://vault.example.com
21
- apiKeyId: vk_xxx
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 separate pages for Overview, Applications, Deployments, Configs, Runtime Keys, Plugins, and Profile. Passkey accounts are managed from Profile; first-login passkey enrollment is only separate because it happens before an authenticated session exists.
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.
@@ -4,6 +4,7 @@ export interface VaultHttpOptions {
4
4
  host: string;
5
5
  port: number;
6
6
  publicUrl: string;
7
+ registryUrl: string;
7
8
  production: boolean;
8
9
  obs: Observable;
9
10
  vault: VaultService;
@@ -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;IAoNtB,IAAI,IAAI,OAAO,CAAC,IAAI,CAAC;YAUb,WAAW;YAUX,gBAAgB;IAY9B,OAAO,CAAC,IAAI;CAGb"}
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.createGroup(user.userId, String(body.applicationId ?? ''), String(body.name ?? ''));
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.saveDraft(user.userId, String(body.profileId ?? ''), config);
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.createRuntimeKey(user.userId, {
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
- return this.page('Configs', configsPage(dashboard), 'configs');
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
- return this.page('Runtime Keys', runtimeKeysPage(dashboard, String(query.secret ?? '')), 'runtime-keys');
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
- return this.page('Plugins', pluginsPage(dashboard), 'plugins');
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('Service Groups', data.groups.length)}
485
+ ${metric('Deployments', data.groups.length)}
407
486
  ${metric('Deployment Profiles', data.profiles.length)}
408
- ${metric('Runtime Keys', data.runtimeKeys.length)}
487
+ ${metric('Container Keys', data.runtimeKeys.length)}
409
488
  </div>
410
- <section><h2>Recent Runtime Keys</h2>${runtimeKeyTable(data.runtimeKeys.slice(0, 8), data)}</section>`;
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>${table(data.applications.map((x) => [x.name, x.description ?? '', x.id]))}</section>
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">Model service groups and deployment profiles for containers.</p></div></div>
428
- <div class="grid">
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', 'Group Name', true)}
433
- <button>Create Group</button><p class="status"></p>
510
+ ${input('name', 'Deployment Name', true)}
511
+ <button>Create Deployment</button><p class="status"></p>
434
512
  </form>
435
513
  </section>
436
- <section><h2>Create Deployment Profile</h2>
437
- <form data-api="/api/profiles" data-redirect="/deployments">
438
- ${select('groupId', 'Service Group', data.groups.map((x) => [x.id, groupLabel(x, data)]))}
439
- ${input('name', 'Profile Name', true, 'default')}
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>Service Groups</h2>${table(data.groups.map((x) => [groupLabel(x, data), x.id]))}</section>
445
- <section><h2>Deployment Profiles</h2>${table(data.profiles.map((x) => [profileLabel(x, data), x.activeVersionId ? 'published' : 'no published config', x.id]))}</section>
446
- ${formScript()}`;
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
- </section>
457
- <section><h2>Publish Draft</h2>
458
- <form data-api="/api/publish" data-redirect="/configs">
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 runtimeKeysPage(data, secret) {
466
- return `<div class="page-head"><div><h1>Runtime Keys</h1><p class="muted">Bind a container credential to one application, service group, deployment profile, and config plugin.</p></div></div>
467
- ${secret ? `<section><h2>Runtime Secret</h2><p class="muted">Shown once. Store it in the container environment now.</p><p class="code"><code>${escapeHtml(secret)}</code></p></section>` : ''}
468
- <section><h2>Create Runtime Key</h2>
469
- <form data-api="/api/runtime-keys" data-secret-redirect="/runtime-keys">
470
- <div class="form-grid">
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>Runtime Keys</h2>${runtimeKeyTable(data.runtimeKeys, data)}</section>
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]) => `<option value="${escapeHtml(value)}">${escapeHtml(optionLabel)}</option>`).join('');
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
- location.href = form.dataset.secretRedirect + '?secret=' + encodeURIComponent(result.secret);
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;