@bsb/config-vault 9.6.9 → 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;IAmQtB,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"}
@@ -122,7 +122,7 @@ export class VaultHttpServer {
122
122
  app.use('/api/groups', defineEventHandler(async (event) => {
123
123
  const user = await this.requireUser(event);
124
124
  const body = await readBody(event);
125
- 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
126
  }));
127
127
  app.use('/api/profiles/update', defineEventHandler(async (event) => {
128
128
  const user = await this.requireUser(event);
@@ -141,6 +141,21 @@ export class VaultHttpServer {
141
141
  const body = await readBody(event);
142
142
  return this.options.vault.createProfile(user.userId, String(body.groupId ?? ''), String(body.name ?? 'default'));
143
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
+ }));
144
159
  app.use('/api/plugins', defineEventHandler(async (event) => {
145
160
  const user = await this.requireUser(event);
146
161
  const body = await readBody(event);
@@ -162,7 +177,7 @@ export class VaultHttpServer {
162
177
  const config = parseJsonObject(body.config);
163
178
  if (!config)
164
179
  throw new Error('Config must be a JSON object');
165
- await this.options.vault.saveDraft(user.userId, String(body.profileId ?? ''), config);
180
+ await this.options.vault.saveProfileDraft(user.userId, String(body.profileId ?? ''), config);
166
181
  return { success: true };
167
182
  }));
168
183
  app.use('/api/publish', defineEventHandler(async (event) => {
@@ -170,16 +185,21 @@ export class VaultHttpServer {
170
185
  const body = await readBody(event);
171
186
  return this.options.vault.publishDraft(user.userId, String(body.profileId ?? ''));
172
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
+ }));
173
196
  app.use('/api/runtime-keys', defineEventHandler(async (event) => {
174
197
  const user = await this.requireUser(event);
175
198
  const body = await readBody(event);
176
- return this.options.vault.createRuntimeKey(user.userId, {
199
+ return this.options.vault.createProfileRuntimeKey(user.userId, {
177
200
  name: String(body.name ?? ''),
178
- applicationId: String(body.applicationId ?? ''),
179
- groupId: String(body.groupId ?? ''),
180
201
  profileId: String(body.profileId ?? ''),
181
202
  containerName: body.containerName === undefined ? null : String(body.containerName),
182
- configPluginId: String(body.configPluginId ?? 'config-vault'),
183
203
  });
184
204
  }));
185
205
  app.use('/applications', defineEventHandler(async (event) => {
@@ -192,21 +212,45 @@ export class VaultHttpServer {
192
212
  const dashboard = await this.options.vault.dashboard();
193
213
  return this.page('Deployments', deploymentsPage(dashboard), 'deployments');
194
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
+ }));
195
228
  app.use('/configs', defineEventHandler(async (event) => {
196
229
  await this.requireUser(event);
197
230
  const dashboard = await this.options.vault.dashboard();
198
- 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');
199
233
  }));
200
234
  app.use('/runtime-keys', defineEventHandler(async (event) => {
201
235
  await this.requireUser(event);
202
236
  const query = getQuery(event);
203
237
  const dashboard = await this.options.vault.dashboard();
204
- 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');
205
247
  }));
206
248
  app.use('/plugins', defineEventHandler(async (event) => {
207
249
  await this.requireUser(event);
250
+ const query = getQuery(event);
208
251
  const dashboard = await this.options.vault.dashboard();
209
- 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');
210
254
  }));
211
255
  app.use('/profile', defineEventHandler(async (event) => {
212
256
  const session = await this.requireUser(event);
@@ -295,6 +339,7 @@ function html(title, body, active, authenticated) {
295
339
  .form-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:12px}
296
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}
297
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}
298
343
  .muted{color:var(--muted)}.danger{color:var(--danger)}.ok{color:var(--ok)}
299
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}
300
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}
@@ -312,8 +357,6 @@ function nav(active) {
312
357
  ['overview', 'Overview', '/'],
313
358
  ['applications', 'Applications', '/applications'],
314
359
  ['deployments', 'Deployments', '/deployments'],
315
- ['configs', 'Configs', '/configs'],
316
- ['runtime-keys', 'Runtime Keys', '/runtime-keys'],
317
360
  ['plugins', 'Plugins', '/plugins'],
318
361
  ['profile', 'Profile', '/profile'],
319
362
  ];
@@ -439,11 +482,11 @@ function overviewPage(data) {
439
482
  return `<div class="page-head"><div><h1>Overview</h1><p class="muted">Current Vault inventory and deployment configuration status.</p></div></div>
440
483
  <div class="grid">
441
484
  ${metric('Applications', data.applications.length)}
442
- ${metric('Service Groups', data.groups.length)}
485
+ ${metric('Deployments', data.groups.length)}
443
486
  ${metric('Deployment Profiles', data.profiles.length)}
444
- ${metric('Runtime Keys', data.runtimeKeys.length)}
487
+ ${metric('Container Keys', data.runtimeKeys.length)}
445
488
  </div>
446
- <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>`;
447
490
  }
448
491
  function applicationsPage(data) {
449
492
  return `<div class="page-head"><div><h1>Applications</h1><p class="muted">Create product or system boundaries for deployment profiles.</p></div></div>
@@ -460,66 +503,70 @@ function applicationsPage(data) {
460
503
  ${formScript()}`;
461
504
  }
462
505
  function deploymentsPage(data) {
463
- return `<div class="page-head"><div><h1>Deployments</h1><p class="muted">Model service groups and deployment profiles for containers.</p></div></div>
464
- <div class="grid">
465
- <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>
466
508
  <form data-api="/api/groups" data-redirect="/deployments">
467
509
  ${select('applicationId', 'Application', data.applications.map((x) => [x.id, x.name]))}
468
- ${input('name', 'Group Name', true)}
469
- <button>Create Group</button><p class="status"></p>
510
+ ${input('name', 'Deployment Name', true)}
511
+ <button>Create Deployment</button><p class="status"></p>
470
512
  </form>
471
513
  </section>
472
- <section><h2>Create Deployment Profile</h2>
473
- <form data-api="/api/profiles" data-redirect="/deployments">
474
- ${select('groupId', 'Service Group', data.groups.map((x) => [x.id, groupLabel(x, data)]))}
475
- ${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)}
476
534
  <button>Create Profile</button><p class="status"></p>
477
535
  </form>
478
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>
479
545
  </div>
480
- <section><h2>Service Groups</h2>${groupsTable(data)}</section>
481
- <section><h2>Deployment Profiles</h2>${profilesTable(data)}</section>
482
- ${formScript()}`;
483
- }
484
- function configsPage(data) {
485
- 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>
486
- <section><h2>Edit Draft</h2>
487
- <form data-api="/api/drafts" data-redirect="/configs">
488
- ${select('profileId', 'Deployment Profile', data.profiles.map((x) => [x.id, profileLabel(x, data)]))}
489
- <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>
490
550
  <button>Save Draft</button><p class="status"></p>
491
551
  </form>
492
- </section>
493
- <section><h2>Publish Draft</h2>
494
- <form data-api="/api/publish" data-redirect="/configs">
495
- ${select('profileId', 'Deployment Profile', data.profiles.map((x) => [x.id, profileLabel(x, data)]))}
496
- <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>
497
555
  </form>
498
556
  </section>
557
+ <section><h2>Container Keys</h2>${profileRuntimeKeyTable(data.runtimeKeys, data)}</section>
499
558
  ${formScript()}`;
500
559
  }
501
- function runtimeKeysPage(data, secret) {
502
- 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>
503
- ${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>` : ''}
504
- <section><h2>Create Runtime Key</h2>
505
- <form data-api="/api/runtime-keys" data-secret-redirect="/runtime-keys">
506
- <div class="form-grid">
507
- ${input('name', 'Name', true)}
508
- ${select('applicationId', 'Application', data.applications.map((x) => [x.id, x.name]))}
509
- ${select('groupId', 'Service Group', data.groups.map((x) => [x.id, groupLabel(x, data)]))}
510
- ${select('profileId', 'Deployment Profile', data.profiles.map((x) => [x.id, profileLabel(x, data)]))}
511
- ${input('containerName', 'Container Name')}
512
- ${input('configPluginId', 'Config Plugin', true, 'config-vault')}
513
- </div>
514
- <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>
515
566
  </form>
567
+ ${registry.length === 0 ? '<p class="muted">No registry results loaded.</p>' : registryTable(registry)}
516
568
  </section>
517
- <section><h2>Runtime Keys</h2>${runtimeKeyTable(data.runtimeKeys, data)}</section>
518
- ${formScript()}`;
519
- }
520
- function pluginsPage(data) {
521
- 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>
522
- <section><h2>Create Plugin</h2>
569
+ <section><h2>Private Plugin</h2>
523
570
  <form data-api="/api/plugins" data-redirect="/plugins">
524
571
  <div class="form-grid">
525
572
  ${input('org', 'Org', true, '_')}
@@ -537,6 +584,27 @@ function pluginsPage(data) {
537
584
  <section><h2>Catalog</h2>${table(data.plugins.map((x) => [x.pluginId, x.version, x.kind, x.source, x.packageName ?? '']))}</section>
538
585
  ${formScript()}`;
539
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
+ }
540
608
  function profilePage(data) {
541
609
  return `<div class="page-head"><div><h1>Profile</h1><p class="muted">Account security and admin authentication settings.</p></div></div>
542
610
  <section><h2>Account</h2>${table([[data.user.email, data.user.createdAt]])}</section>
@@ -555,11 +623,15 @@ function applicationsTable(data) {
555
623
  ${input('description', 'Description', false, app.description ?? '')}
556
624
  <button>Save</button><p class="status"></p>
557
625
  </form>
558
- </td><td class="actions">${deleteForm('/api/applications/delete', app.id, '/applications', 'Delete application and related groups, profiles, configs, and keys?')}</td></tr>`).join('')}</table>`;
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>`;
559
630
  }
560
631
  function groupsTable(data) {
561
632
  if (data.groups.length === 0)
562
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);
563
635
  return `<table>${data.groups.map((group) => `<tr><td>
564
636
  <form data-api="/api/groups/update" data-redirect="/deployments" class="inline-form">
565
637
  <input type="hidden" name="id" value="${escapeHtml(group.id)}">
@@ -567,7 +639,10 @@ function groupsTable(data) {
567
639
  ${input('name', 'Name', true, group.name)}
568
640
  <button>Save</button><p class="status"></p>
569
641
  </form>
570
- </td><td class="actions">${deleteForm('/api/groups/delete', group.id, '/deployments', 'Delete group and related profiles, configs, and keys?')}</td></tr>`).join('')}</table>`;
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>`;
571
646
  }
572
647
  function profilesTable(data) {
573
648
  if (data.profiles.length === 0)
@@ -575,12 +650,15 @@ function profilesTable(data) {
575
650
  return `<table>${data.profiles.map((profile) => `<tr><td>
576
651
  <form data-api="/api/profiles/update" data-redirect="/deployments" class="inline-form">
577
652
  <input type="hidden" name="id" value="${escapeHtml(profile.id)}">
578
- ${select('groupId', 'Service Group', selectedOptions(data.groups.map((x) => [x.id, groupLabel(x, data)]), profile.groupId))}
653
+ ${select('groupId', 'Deployment', selectedOptions(data.groups.map((x) => [x.id, groupLabel(x, data)]), profile.groupId))}
579
654
  ${input('name', 'Name', true, profile.name)}
580
655
  <span class="muted">${profile.activeVersionId ? 'published' : 'no published config'}</span>
581
656
  <button>Save</button><p class="status"></p>
582
657
  </form>
583
- </td><td class="actions">${deleteForm('/api/profiles/delete', profile.id, '/deployments', 'Delete deployment profile and related configs and keys?')}</td></tr>`).join('')}</table>`;
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>`;
584
662
  }
585
663
  function deleteForm(action, id, redirect, confirm) {
586
664
  return `<form data-api="${escapeHtml(action)}" data-redirect="${escapeHtml(redirect)}" data-confirm="${escapeHtml(confirm)}">
@@ -624,6 +702,31 @@ function runtimeKeyTable(keys, data) {
624
702
  key.revokedAt ?? 'active',
625
703
  ]));
626
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
+ }
627
730
  function shortId(value) {
628
731
  return value.length > 18 ? `${value.slice(0, 10)}...${value.slice(-6)}` : value;
629
732
  }
@@ -650,7 +753,10 @@ function formScript() {
650
753
  const result = await res.json();
651
754
  if (!res.ok) throw new Error(result.message || 'Save failed');
652
755
  if (form.dataset.secretRedirect && result.secret) {
653
- 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);
654
760
  return;
655
761
  }
656
762
  location.href = form.dataset.redirect || location.pathname;
@@ -717,29 +823,63 @@ function webauthnClientScript() {
717
823
  }
718
824
  `;
719
825
  }
720
- function apiForm(action, fields, textarea) {
721
- return `<form data-api="${action}" onsubmit="return submitJson(this)">
722
- ${fields.map((field) => `<input name="${field}" placeholder="${field}" ${field === 'configPluginId' ? 'value="config-vault"' : ''}>`).join('')}
723
- ${textarea ? `<textarea name="${textarea}" placeholder="${textarea} JSON"></textarea>` : ''}
724
- <button>Submit</button>
725
- </form>
726
- <script>
727
- async function submitJson(form){
728
- const data={}; for(const item of new FormData(form).entries()){data[item[0]]=item[1]}
729
- const csrf=document.cookie.split('; ').find(x=>x.startsWith('vault_csrf='))?.split('=')[1]||'';
730
- const res=await fetch(form.dataset.api,{method:'POST',headers:{'content-type':'application/json','x-csrf-token':csrf},body:JSON.stringify(data)});
731
- alert(JSON.stringify(await res.json(),null,2)); location.reload(); return false;
732
- }
733
- </script>`;
734
- }
735
- function pluginForm() {
736
- return apiForm('/api/plugins', ['org', 'name', 'pluginId', 'packageName', 'version', 'kind', 'source'], 'configSchema');
737
- }
738
826
  function table(rows) {
739
827
  if (rows.length === 0)
740
828
  return '<p class="muted">None</p>';
741
829
  return `<table>${rows.map((row) => `<tr>${row.map((cell) => `<td>${escapeHtml(cell)}</td>`).join('')}</tr>`).join('')}</table>`;
742
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
+ }
743
883
  function parseJsonObject(value) {
744
884
  if (typeof value === 'object' && value !== null && !Array.isArray(value))
745
885
  return value;