@simonsbs/keylore 1.0.0-rc4

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.
Files changed (81) hide show
  1. package/.env.example +64 -0
  2. package/LICENSE +176 -0
  3. package/NOTICE +5 -0
  4. package/README.md +424 -0
  5. package/bin/keylore-http.js +3 -0
  6. package/bin/keylore-stdio.js +3 -0
  7. package/data/auth-clients.json +54 -0
  8. package/data/catalog.json +53 -0
  9. package/data/policies.json +25 -0
  10. package/dist/adapters/adapter-registry.js +143 -0
  11. package/dist/adapters/aws-secrets-manager-adapter.js +99 -0
  12. package/dist/adapters/command-runner.js +17 -0
  13. package/dist/adapters/env-secret-adapter.js +42 -0
  14. package/dist/adapters/gcp-secret-manager-adapter.js +129 -0
  15. package/dist/adapters/local-secret-adapter.js +54 -0
  16. package/dist/adapters/onepassword-secret-adapter.js +83 -0
  17. package/dist/adapters/reference-utils.js +44 -0
  18. package/dist/adapters/types.js +1 -0
  19. package/dist/adapters/vault-secret-adapter.js +103 -0
  20. package/dist/app.js +132 -0
  21. package/dist/cli/args.js +51 -0
  22. package/dist/cli/run.js +483 -0
  23. package/dist/cli.js +18 -0
  24. package/dist/config.js +295 -0
  25. package/dist/domain/types.js +967 -0
  26. package/dist/http/admin-ui.js +3010 -0
  27. package/dist/http/server.js +1210 -0
  28. package/dist/index.js +40 -0
  29. package/dist/mcp/create-server.js +388 -0
  30. package/dist/mcp/stdio.js +7 -0
  31. package/dist/repositories/credential-repository.js +109 -0
  32. package/dist/repositories/interfaces.js +1 -0
  33. package/dist/repositories/json-file.js +20 -0
  34. package/dist/repositories/pg-access-token-repository.js +118 -0
  35. package/dist/repositories/pg-approval-repository.js +157 -0
  36. package/dist/repositories/pg-audit-log.js +62 -0
  37. package/dist/repositories/pg-auth-client-repository.js +98 -0
  38. package/dist/repositories/pg-authorization-code-repository.js +95 -0
  39. package/dist/repositories/pg-break-glass-repository.js +174 -0
  40. package/dist/repositories/pg-credential-repository.js +163 -0
  41. package/dist/repositories/pg-oauth-client-assertion-repository.js +25 -0
  42. package/dist/repositories/pg-policy-repository.js +62 -0
  43. package/dist/repositories/pg-refresh-token-repository.js +125 -0
  44. package/dist/repositories/pg-rotation-run-repository.js +127 -0
  45. package/dist/repositories/pg-tenant-repository.js +56 -0
  46. package/dist/repositories/policy-repository.js +24 -0
  47. package/dist/runtime/sandbox-runner.js +114 -0
  48. package/dist/services/access-fingerprint.js +13 -0
  49. package/dist/services/approval-service.js +148 -0
  50. package/dist/services/audit-log.js +38 -0
  51. package/dist/services/auth-context.js +43 -0
  52. package/dist/services/auth-secrets.js +14 -0
  53. package/dist/services/auth-service.js +784 -0
  54. package/dist/services/backup-service.js +610 -0
  55. package/dist/services/break-glass-service.js +207 -0
  56. package/dist/services/broker-service.js +557 -0
  57. package/dist/services/core-mode-service.js +154 -0
  58. package/dist/services/egress-policy.js +119 -0
  59. package/dist/services/local-secret-store.js +119 -0
  60. package/dist/services/maintenance-service.js +99 -0
  61. package/dist/services/notification-service.js +83 -0
  62. package/dist/services/policy-engine.js +85 -0
  63. package/dist/services/rate-limit-service.js +80 -0
  64. package/dist/services/rotation-service.js +271 -0
  65. package/dist/services/telemetry.js +149 -0
  66. package/dist/services/tenant-service.js +127 -0
  67. package/dist/services/trace-export-service.js +126 -0
  68. package/dist/services/trace-service.js +87 -0
  69. package/dist/storage/bootstrap.js +68 -0
  70. package/dist/storage/database.js +39 -0
  71. package/dist/storage/in-memory-database.js +40 -0
  72. package/dist/storage/migrations.js +27 -0
  73. package/migrations/001_init.sql +49 -0
  74. package/migrations/002_phase2_auth.sql +53 -0
  75. package/migrations/003_v05_operations.sql +9 -0
  76. package/migrations/004_v07_security.sql +28 -0
  77. package/migrations/005_v08_reviews.sql +11 -0
  78. package/migrations/006_v09_auth_trace_rotation.sql +51 -0
  79. package/migrations/007_v010_multi_tenant.sql +32 -0
  80. package/migrations/008_v011_auth_tenant_ops.sql +95 -0
  81. package/package.json +78 -0
@@ -0,0 +1,3010 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ const adminStyles = String.raw `
4
+ :root {
5
+ --canvas: #f4efe3;
6
+ --paper: rgba(255, 250, 241, 0.9);
7
+ --ink: #17211f;
8
+ --muted: #5d685f;
9
+ --line: rgba(23, 33, 31, 0.12);
10
+ --accent: #135d4a;
11
+ --accent-soft: rgba(19, 93, 74, 0.12);
12
+ --warning: #9d4b14;
13
+ --danger: #8f2d23;
14
+ --shadow: 0 24px 60px rgba(23, 33, 31, 0.12);
15
+ --radius: 18px;
16
+ --font-sans: "Avenir Next", "Trebuchet MS", Verdana, sans-serif;
17
+ --font-serif: "Iowan Old Style", "Palatino Linotype", "Book Antiqua", serif;
18
+ --font-mono: "IBM Plex Mono", "SFMono-Regular", "DejaVu Sans Mono", monospace;
19
+ }
20
+
21
+ * {
22
+ box-sizing: border-box;
23
+ }
24
+
25
+ html {
26
+ scroll-behavior: smooth;
27
+ }
28
+
29
+ body {
30
+ margin: 0;
31
+ min-height: 100vh;
32
+ color: var(--ink);
33
+ font-family: var(--font-sans);
34
+ background:
35
+ radial-gradient(circle at top left, rgba(182, 116, 42, 0.18), transparent 28%),
36
+ radial-gradient(circle at top right, rgba(19, 93, 74, 0.18), transparent 34%),
37
+ linear-gradient(180deg, #fcf6ea 0%, #efe5d1 100%);
38
+ }
39
+
40
+ body::before {
41
+ content: "";
42
+ position: fixed;
43
+ inset: 0;
44
+ pointer-events: none;
45
+ background-image:
46
+ linear-gradient(rgba(23, 33, 31, 0.03) 1px, transparent 1px),
47
+ linear-gradient(90deg, rgba(23, 33, 31, 0.03) 1px, transparent 1px);
48
+ background-size: 28px 28px;
49
+ mask-image: linear-gradient(180deg, rgba(0, 0, 0, 0.9), transparent 88%);
50
+ }
51
+
52
+ a {
53
+ color: inherit;
54
+ }
55
+
56
+ button,
57
+ input,
58
+ select,
59
+ textarea {
60
+ font: inherit;
61
+ }
62
+
63
+ .page-shell {
64
+ position: relative;
65
+ display: grid;
66
+ grid-template-columns: 290px minmax(0, 1fr);
67
+ min-height: 100vh;
68
+ }
69
+
70
+ .sidebar {
71
+ position: sticky;
72
+ top: 0;
73
+ align-self: start;
74
+ height: 100vh;
75
+ padding: 28px 22px;
76
+ border-right: 1px solid var(--line);
77
+ background: rgba(255, 248, 236, 0.78);
78
+ backdrop-filter: blur(18px);
79
+ }
80
+
81
+ .brand {
82
+ margin: 0 0 10px;
83
+ font-family: var(--font-serif);
84
+ font-size: 2rem;
85
+ line-height: 0.95;
86
+ }
87
+
88
+ .brand-subtitle,
89
+ .helper-copy,
90
+ .muted-copy {
91
+ margin: 0;
92
+ color: var(--muted);
93
+ line-height: 1.5;
94
+ }
95
+
96
+ .helper-copy {
97
+ font-size: 0.94rem;
98
+ }
99
+
100
+ .nav-group {
101
+ display: grid;
102
+ gap: 8px;
103
+ margin: 28px 0;
104
+ }
105
+
106
+ .sidebar-section-label {
107
+ margin: 18px 0 8px;
108
+ color: var(--muted);
109
+ font-size: 0.78rem;
110
+ font-weight: 700;
111
+ letter-spacing: 0.08em;
112
+ text-transform: uppercase;
113
+ }
114
+
115
+ .nav-button,
116
+ .button,
117
+ .button-secondary,
118
+ .button-danger {
119
+ appearance: none;
120
+ border: 0;
121
+ border-radius: 999px;
122
+ padding: 12px 16px;
123
+ cursor: pointer;
124
+ transition: transform 140ms ease, box-shadow 140ms ease, background 140ms ease;
125
+ }
126
+
127
+ .nav-button {
128
+ text-align: left;
129
+ background: transparent;
130
+ color: var(--ink);
131
+ }
132
+
133
+ .nav-button:hover,
134
+ .nav-button:focus-visible,
135
+ .button:hover,
136
+ .button:focus-visible,
137
+ .button-secondary:hover,
138
+ .button-secondary:focus-visible,
139
+ .button-danger:hover,
140
+ .button-danger:focus-visible {
141
+ transform: translateY(-1px);
142
+ box-shadow: 0 14px 28px rgba(23, 33, 31, 0.12);
143
+ }
144
+
145
+ .nav-button.is-active {
146
+ background: var(--accent-soft);
147
+ color: var(--accent);
148
+ font-weight: 600;
149
+ }
150
+
151
+ .button,
152
+ .button-secondary,
153
+ .button-danger {
154
+ font-weight: 600;
155
+ }
156
+
157
+ .button {
158
+ background: var(--accent);
159
+ color: #f8f5f0;
160
+ }
161
+
162
+ .button-secondary {
163
+ background: #f7f2e8;
164
+ color: var(--ink);
165
+ border: 1px solid var(--line);
166
+ }
167
+
168
+ .button-danger {
169
+ background: var(--danger);
170
+ color: #fff6f3;
171
+ }
172
+
173
+ .button[disabled],
174
+ .button-secondary[disabled],
175
+ .button-danger[disabled] {
176
+ cursor: progress;
177
+ opacity: 0.72;
178
+ transform: none;
179
+ box-shadow: none;
180
+ }
181
+
182
+ .content {
183
+ padding: 28px;
184
+ }
185
+
186
+ .hero {
187
+ display: grid;
188
+ grid-template-columns: minmax(0, 1fr) auto;
189
+ gap: 18px;
190
+ align-items: start;
191
+ padding: 24px 26px;
192
+ border: 1px solid rgba(19, 93, 74, 0.14);
193
+ border-radius: 28px;
194
+ background:
195
+ radial-gradient(circle at top left, rgba(19, 93, 74, 0.14), transparent 26%),
196
+ linear-gradient(135deg, rgba(255, 250, 241, 0.95), rgba(246, 236, 214, 0.9));
197
+ box-shadow: var(--shadow);
198
+ }
199
+
200
+ .eyebrow {
201
+ display: inline-flex;
202
+ align-items: center;
203
+ gap: 8px;
204
+ padding: 8px 12px;
205
+ border-radius: 999px;
206
+ background: rgba(19, 93, 74, 0.08);
207
+ color: var(--accent);
208
+ font-size: 0.78rem;
209
+ font-weight: 700;
210
+ letter-spacing: 0.08em;
211
+ text-transform: uppercase;
212
+ }
213
+
214
+ .hero h1,
215
+ .section-heading h2 {
216
+ margin: 10px 0 8px;
217
+ font-family: var(--font-serif);
218
+ font-size: clamp(2.2rem, 4vw, 3.4rem);
219
+ line-height: 0.95;
220
+ }
221
+
222
+ .hero-copy {
223
+ max-width: 720px;
224
+ margin: 0;
225
+ color: var(--muted);
226
+ font-size: 1rem;
227
+ line-height: 1.6;
228
+ }
229
+
230
+ .hero-meta {
231
+ display: grid;
232
+ gap: 10px;
233
+ min-width: 240px;
234
+ }
235
+
236
+ .meta-card,
237
+ .panel,
238
+ .metric-card {
239
+ border: 1px solid var(--line);
240
+ background: var(--paper);
241
+ border-radius: var(--radius);
242
+ box-shadow: var(--shadow);
243
+ }
244
+
245
+ .meta-card {
246
+ padding: 16px 18px;
247
+ }
248
+
249
+ .meta-label {
250
+ display: block;
251
+ color: var(--muted);
252
+ font-size: 0.82rem;
253
+ text-transform: uppercase;
254
+ letter-spacing: 0.08em;
255
+ }
256
+
257
+ .meta-value {
258
+ display: block;
259
+ margin-top: 8px;
260
+ font-family: var(--font-mono);
261
+ font-size: 0.9rem;
262
+ word-break: break-word;
263
+ }
264
+
265
+ .notice {
266
+ display: none;
267
+ margin: 20px 0 0;
268
+ padding: 14px 16px;
269
+ border-radius: 16px;
270
+ border: 1px solid var(--line);
271
+ }
272
+
273
+ .notice.is-visible {
274
+ display: block;
275
+ }
276
+
277
+ .notice.is-info {
278
+ background: rgba(19, 93, 74, 0.08);
279
+ color: var(--accent);
280
+ }
281
+
282
+ .notice.is-error {
283
+ background: rgba(143, 45, 35, 0.09);
284
+ color: var(--danger);
285
+ }
286
+
287
+ .notice.is-warning {
288
+ background: rgba(157, 75, 20, 0.1);
289
+ color: var(--warning);
290
+ }
291
+
292
+ .dashboard {
293
+ display: grid;
294
+ gap: 24px;
295
+ margin-top: 24px;
296
+ }
297
+
298
+ .step-grid {
299
+ display: grid;
300
+ grid-template-columns: repeat(4, minmax(0, 1fr));
301
+ gap: 14px;
302
+ }
303
+
304
+ .step-card {
305
+ padding: 16px;
306
+ border-radius: 16px;
307
+ border: 1px solid rgba(23, 33, 31, 0.08);
308
+ background: rgba(255, 255, 255, 0.7);
309
+ }
310
+
311
+ .step-card h3 {
312
+ margin: 10px 0 6px;
313
+ font-size: 1rem;
314
+ }
315
+
316
+ .step-card p {
317
+ margin: 0;
318
+ color: var(--muted);
319
+ line-height: 1.5;
320
+ }
321
+
322
+ .step-number {
323
+ display: inline-flex;
324
+ align-items: center;
325
+ justify-content: center;
326
+ width: 32px;
327
+ height: 32px;
328
+ border-radius: 999px;
329
+ background: rgba(19, 93, 74, 0.1);
330
+ color: var(--accent);
331
+ font-size: 0.84rem;
332
+ font-weight: 700;
333
+ }
334
+
335
+ .advanced-shell[hidden],
336
+ .advanced-nav[hidden] {
337
+ display: none;
338
+ }
339
+
340
+ .advanced-summary {
341
+ margin-top: 12px;
342
+ }
343
+
344
+ .section-heading {
345
+ display: flex;
346
+ justify-content: space-between;
347
+ gap: 16px;
348
+ align-items: center;
349
+ margin-bottom: 12px;
350
+ }
351
+
352
+ .section-heading h2 {
353
+ margin: 0;
354
+ font-size: 1.9rem;
355
+ }
356
+
357
+ .section-heading p {
358
+ margin: 6px 0 0;
359
+ color: var(--muted);
360
+ }
361
+
362
+ .panel {
363
+ padding: 20px;
364
+ }
365
+
366
+ .panel > details.disclosure:first-child {
367
+ margin-top: 0;
368
+ }
369
+
370
+ .panel-grid {
371
+ display: grid;
372
+ grid-template-columns: repeat(12, minmax(0, 1fr));
373
+ gap: 18px;
374
+ }
375
+
376
+ .span-12 {
377
+ grid-column: span 12;
378
+ }
379
+
380
+ .span-8 {
381
+ grid-column: span 8;
382
+ }
383
+
384
+ .span-7 {
385
+ grid-column: span 7;
386
+ }
387
+
388
+ .span-6 {
389
+ grid-column: span 6;
390
+ }
391
+
392
+ .span-5 {
393
+ grid-column: span 5;
394
+ }
395
+
396
+ .span-4 {
397
+ grid-column: span 4;
398
+ }
399
+
400
+ .span-3 {
401
+ grid-column: span 3;
402
+ }
403
+
404
+ .metric-grid {
405
+ display: grid;
406
+ grid-template-columns: repeat(4, minmax(0, 1fr));
407
+ gap: 16px;
408
+ }
409
+
410
+ .metric-card {
411
+ padding: 18px;
412
+ }
413
+
414
+ .metric-card strong {
415
+ display: block;
416
+ margin-top: 10px;
417
+ font-family: var(--font-serif);
418
+ font-size: 2rem;
419
+ }
420
+
421
+ .metric-card span {
422
+ color: var(--muted);
423
+ font-size: 0.9rem;
424
+ }
425
+
426
+ .form-grid {
427
+ display: grid;
428
+ grid-template-columns: repeat(2, minmax(0, 1fr));
429
+ gap: 14px;
430
+ }
431
+
432
+ .field,
433
+ .field-wide {
434
+ display: grid;
435
+ gap: 8px;
436
+ }
437
+
438
+ .field-wide {
439
+ grid-column: 1 / -1;
440
+ }
441
+
442
+ .field label,
443
+ .field-wide label {
444
+ font-size: 0.84rem;
445
+ color: var(--muted);
446
+ text-transform: uppercase;
447
+ letter-spacing: 0.08em;
448
+ }
449
+
450
+ .field input,
451
+ .field textarea,
452
+ .field select,
453
+ .field-wide textarea,
454
+ .field-wide input {
455
+ width: 100%;
456
+ padding: 12px 14px;
457
+ border: 1px solid rgba(23, 33, 31, 0.14);
458
+ border-radius: 14px;
459
+ background: rgba(255, 255, 255, 0.75);
460
+ color: var(--ink);
461
+ }
462
+
463
+ .field textarea,
464
+ .field-wide textarea {
465
+ min-height: 116px;
466
+ resize: vertical;
467
+ font-family: var(--font-mono);
468
+ font-size: 0.9rem;
469
+ line-height: 1.5;
470
+ }
471
+
472
+ .form-actions,
473
+ .panel-actions,
474
+ .toolbar {
475
+ display: flex;
476
+ flex-wrap: wrap;
477
+ gap: 12px;
478
+ align-items: center;
479
+ }
480
+
481
+ .panel-actions {
482
+ margin-top: 16px;
483
+ }
484
+
485
+ .toolbar {
486
+ justify-content: space-between;
487
+ }
488
+
489
+ .session-line {
490
+ display: flex;
491
+ flex-wrap: wrap;
492
+ gap: 10px;
493
+ align-items: center;
494
+ }
495
+
496
+ .pill {
497
+ display: inline-flex;
498
+ align-items: center;
499
+ gap: 8px;
500
+ padding: 8px 12px;
501
+ border-radius: 999px;
502
+ background: rgba(23, 33, 31, 0.05);
503
+ color: var(--muted);
504
+ font-size: 0.82rem;
505
+ }
506
+
507
+ .pill strong {
508
+ color: var(--ink);
509
+ font-weight: 700;
510
+ }
511
+
512
+ .state-ok,
513
+ .state-pending,
514
+ .state-denied,
515
+ .state-disabled,
516
+ .state-active,
517
+ .state-warning {
518
+ display: inline-flex;
519
+ align-items: center;
520
+ gap: 8px;
521
+ padding: 7px 11px;
522
+ border-radius: 999px;
523
+ font-size: 0.76rem;
524
+ font-weight: 700;
525
+ letter-spacing: 0.06em;
526
+ text-transform: uppercase;
527
+ }
528
+
529
+ .state-ok,
530
+ .state-active {
531
+ background: rgba(19, 93, 74, 0.1);
532
+ color: var(--accent);
533
+ }
534
+
535
+ .state-pending,
536
+ .state-warning {
537
+ background: rgba(157, 75, 20, 0.12);
538
+ color: var(--warning);
539
+ }
540
+
541
+ .state-denied,
542
+ .state-disabled {
543
+ background: rgba(143, 45, 35, 0.1);
544
+ color: var(--danger);
545
+ }
546
+
547
+ .list-stack,
548
+ .code-stack {
549
+ display: grid;
550
+ gap: 12px;
551
+ }
552
+
553
+ .list-card {
554
+ padding: 16px;
555
+ border-radius: 16px;
556
+ background: rgba(255, 255, 255, 0.72);
557
+ border: 1px solid rgba(23, 33, 31, 0.08);
558
+ }
559
+
560
+ .list-card h3 {
561
+ margin: 0 0 8px;
562
+ font-size: 1rem;
563
+ }
564
+
565
+ .list-card p {
566
+ margin: 0;
567
+ color: var(--muted);
568
+ line-height: 1.5;
569
+ }
570
+
571
+ .list-meta {
572
+ display: flex;
573
+ flex-wrap: wrap;
574
+ gap: 10px;
575
+ margin-top: 12px;
576
+ }
577
+
578
+ .mono {
579
+ font-family: var(--font-mono);
580
+ }
581
+
582
+ .table-wrap {
583
+ overflow: auto;
584
+ border-radius: 14px;
585
+ border: 1px solid rgba(23, 33, 31, 0.08);
586
+ }
587
+
588
+ table {
589
+ width: 100%;
590
+ border-collapse: collapse;
591
+ min-width: 640px;
592
+ background: rgba(255, 255, 255, 0.6);
593
+ }
594
+
595
+ th,
596
+ td {
597
+ padding: 12px 14px;
598
+ text-align: left;
599
+ border-bottom: 1px solid rgba(23, 33, 31, 0.08);
600
+ vertical-align: top;
601
+ }
602
+
603
+ th {
604
+ font-size: 0.8rem;
605
+ color: var(--muted);
606
+ text-transform: uppercase;
607
+ letter-spacing: 0.08em;
608
+ }
609
+
610
+ pre {
611
+ margin: 0;
612
+ padding: 16px;
613
+ overflow: auto;
614
+ border-radius: 16px;
615
+ border: 1px solid rgba(23, 33, 31, 0.08);
616
+ background: #fbf7ef;
617
+ color: #233230;
618
+ font-family: var(--font-mono);
619
+ font-size: 0.85rem;
620
+ line-height: 1.55;
621
+ }
622
+
623
+ .empty-state,
624
+ .error-state {
625
+ padding: 18px;
626
+ border-radius: 16px;
627
+ border: 1px dashed rgba(23, 33, 31, 0.18);
628
+ color: var(--muted);
629
+ background: rgba(255, 255, 255, 0.45);
630
+ }
631
+
632
+ .error-state {
633
+ color: var(--danger);
634
+ border-style: solid;
635
+ border-color: rgba(143, 45, 35, 0.18);
636
+ background: rgba(143, 45, 35, 0.04);
637
+ }
638
+
639
+ .panel-footnote {
640
+ margin-top: 12px;
641
+ color: var(--muted);
642
+ font-size: 0.9rem;
643
+ }
644
+
645
+ .disclosure {
646
+ margin-top: 16px;
647
+ padding: 16px;
648
+ border-radius: 16px;
649
+ border: 1px solid rgba(23, 33, 31, 0.08);
650
+ background: rgba(255, 255, 255, 0.5);
651
+ }
652
+
653
+ .disclosure summary {
654
+ cursor: pointer;
655
+ list-style: none;
656
+ font-weight: 700;
657
+ }
658
+
659
+ .disclosure summary::-webkit-details-marker {
660
+ display: none;
661
+ }
662
+
663
+ .disclosure[open] summary {
664
+ margin-bottom: 12px;
665
+ }
666
+
667
+ .stack-tight {
668
+ display: grid;
669
+ gap: 12px;
670
+ }
671
+
672
+ @media (max-width: 1120px) {
673
+ .page-shell {
674
+ grid-template-columns: 1fr;
675
+ }
676
+
677
+ .sidebar {
678
+ position: relative;
679
+ height: auto;
680
+ border-right: 0;
681
+ border-bottom: 1px solid var(--line);
682
+ }
683
+
684
+ .metric-grid {
685
+ grid-template-columns: repeat(2, minmax(0, 1fr));
686
+ }
687
+
688
+ .step-grid {
689
+ grid-template-columns: repeat(2, minmax(0, 1fr));
690
+ }
691
+
692
+ .span-8,
693
+ .span-7,
694
+ .span-6,
695
+ .span-5,
696
+ .span-4,
697
+ .span-3 {
698
+ grid-column: span 12;
699
+ }
700
+ }
701
+
702
+ @media (max-width: 720px) {
703
+ .content,
704
+ .sidebar {
705
+ padding: 20px;
706
+ }
707
+
708
+ .hero {
709
+ grid-template-columns: 1fr;
710
+ }
711
+
712
+ .step-grid,
713
+ .metric-grid,
714
+ .form-grid {
715
+ grid-template-columns: 1fr;
716
+ }
717
+ }
718
+ `;
719
+ const adminApp = String.raw `
720
+ const config = window.__KEYLORE_ADMIN_CONFIG__;
721
+ const state = {
722
+ baseUrl: config.baseUrl,
723
+ resource: config.baseUrl.replace(/\/$/, '') + '/v1',
724
+ localQuickstartEnabled: config.localQuickstartEnabled === true,
725
+ localAdminBootstrap: config.localAdminBootstrap || null,
726
+ token: '',
727
+ sessionClientId: '',
728
+ sessionScopes: '',
729
+ sessionTenantId: '',
730
+ busy: false,
731
+ data: {},
732
+ lastBackup: null,
733
+ lastClientSecret: null,
734
+ lastResponse: null,
735
+ lastCredentialTest: null,
736
+ lastCredentialTestContext: null,
737
+ lastCreatedCredentialId: '',
738
+ selectedCredentialId: '',
739
+ currentCredentialContext: null,
740
+ lastMcpConnection: null,
741
+ mcpToken: '',
742
+ advancedVisible: false,
743
+ credentialIdManuallyEdited: false
744
+ };
745
+
746
+ const storageKey = 'keylore-admin-session';
747
+
748
+ function escapeHtml(value) {
749
+ return String(value ?? '')
750
+ .replace(/&/g, '&')
751
+ .replace(/</g, '&lt;')
752
+ .replace(/>/g, '&gt;')
753
+ .replace(/"/g, '&quot;')
754
+ .replace(/'/g, '&#39;');
755
+ }
756
+
757
+ function byId(id) {
758
+ return document.getElementById(id);
759
+ }
760
+
761
+ function splitList(value) {
762
+ return String(value ?? '')
763
+ .split(/[\n,]/)
764
+ .map(function(item) {
765
+ return item.trim();
766
+ })
767
+ .filter(Boolean);
768
+ }
769
+
770
+ function formatDate(value) {
771
+ if (!value) {
772
+ return 'n/a';
773
+ }
774
+ const date = new Date(value);
775
+ if (Number.isNaN(date.getTime())) {
776
+ return String(value);
777
+ }
778
+ return new Intl.DateTimeFormat(undefined, {
779
+ year: 'numeric',
780
+ month: 'short',
781
+ day: '2-digit',
782
+ hour: '2-digit',
783
+ minute: '2-digit'
784
+ }).format(date);
785
+ }
786
+
787
+ function prettyJson(value) {
788
+ return JSON.stringify(value ?? {}, null, 2);
789
+ }
790
+
791
+ function defaultTestUrlForCredential(credential) {
792
+ if (!credential) {
793
+ return '';
794
+ }
795
+ if (credential.service === 'github') {
796
+ return 'https://api.github.com/rate_limit';
797
+ }
798
+ if (credential.allowedDomains && credential.allowedDomains.length > 0) {
799
+ return 'https://' + credential.allowedDomains[0];
800
+ }
801
+ return '';
802
+ }
803
+
804
+ function slugifyTokenKey(value) {
805
+ const normalized = String(value ?? '')
806
+ .trim()
807
+ .toLowerCase()
808
+ .replace(/[^a-z0-9]+/g, '-')
809
+ .replace(/^-+|-+$/g, '')
810
+ .slice(0, 48);
811
+ return normalized || 'token';
812
+ }
813
+
814
+ function firstPromptForClient(clientName) {
815
+ const credential = state.currentCredentialContext || selectedCredentialSummary() || visibleCredentials()[0];
816
+ if (!credential) {
817
+ return 'After you create a credential, ask ' + clientName + ': "Search KeyLore for the best credential for the target service, explain why you chose it, and use it through the broker without exposing the raw token."';
818
+ }
819
+
820
+ const domain = credential.allowedDomains && credential.allowedDomains.length > 0
821
+ ? credential.allowedDomains[0]
822
+ : 'target-service.example.com';
823
+ const targetUrl = defaultTestUrlForCredential(credential) || ('https://' + domain);
824
+ return 'Ask ' + clientName + ': "Search KeyLore for the best credential for ' + credential.service + ' on ' + domain + '. Explain why you chose it, then use it through KeyLore to fetch ' + targetUrl + ' without exposing the raw token."';
825
+ }
826
+
827
+ function mcpHttpTokenValue() {
828
+ return state.mcpToken || 'REPLACE_ME_MCP_TOKEN';
829
+ }
830
+
831
+ function codexStdioSnippet() {
832
+ return [
833
+ '[mcp_servers.keylore_stdio]',
834
+ 'command = "node"',
835
+ 'args = ["' + config.stdioEntryPath.replace(/\\/g, '\\\\') + '", "--transport", "stdio"]'
836
+ ].join('\n');
837
+ }
838
+
839
+ function codexHttpSnippet() {
840
+ return [
841
+ '[mcp_servers.keylore_http]',
842
+ 'url = "' + config.baseUrl.replace(/\/$/, '') + '/mcp"',
843
+ 'bearer_token_env_var = "KEYLORE_MCP_ACCESS_TOKEN"'
844
+ ].join('\n');
845
+ }
846
+
847
+ function geminiStdioSnippet() {
848
+ return prettyJson({
849
+ mcpServers: {
850
+ keylore_stdio: {
851
+ command: 'node',
852
+ args: [config.stdioEntryPath, '--transport', 'stdio']
853
+ }
854
+ }
855
+ });
856
+ }
857
+
858
+ function geminiHttpSnippet() {
859
+ return prettyJson({
860
+ mcpServers: {
861
+ keylore_http: {
862
+ httpUrl: config.baseUrl.replace(/\/$/, '') + '/mcp',
863
+ headers: {
864
+ Authorization: 'Bearer ' + mcpHttpTokenValue()
865
+ }
866
+ }
867
+ }
868
+ });
869
+ }
870
+
871
+ function genericHttpSnippet() {
872
+ return [
873
+ 'MCP endpoint: ' + config.baseUrl.replace(/\/$/, '') + '/mcp',
874
+ 'Authorization: Bearer ' + mcpHttpTokenValue()
875
+ ].join('\n');
876
+ }
877
+
878
+ function setNotice(kind, message) {
879
+ const node = byId('notice');
880
+ node.className = 'notice is-visible is-' + kind;
881
+ node.textContent = message;
882
+ }
883
+
884
+ function clearNotice() {
885
+ const node = byId('notice');
886
+ node.className = 'notice';
887
+ node.textContent = '';
888
+ }
889
+
890
+ function setBusy(value) {
891
+ state.busy = value;
892
+ document.body.dataset.busy = value ? 'true' : 'false';
893
+ document.querySelectorAll('[data-busy-label]').forEach(function(button) {
894
+ if (!(button instanceof HTMLButtonElement)) {
895
+ return;
896
+ }
897
+ button.disabled = value;
898
+ button.textContent = value ? button.dataset.busyLabel : button.dataset.idleLabel;
899
+ });
900
+ }
901
+
902
+ function persistSession() {
903
+ const payload = {
904
+ baseUrl: state.baseUrl,
905
+ resource: state.resource,
906
+ token: state.token,
907
+ sessionClientId: state.sessionClientId,
908
+ sessionScopes: state.sessionScopes,
909
+ sessionTenantId: state.sessionTenantId,
910
+ advancedVisible: state.advancedVisible
911
+ };
912
+ localStorage.setItem(storageKey, JSON.stringify(payload));
913
+ }
914
+
915
+ function loadPersistedSession() {
916
+ const raw = localStorage.getItem(storageKey);
917
+ if (!raw) {
918
+ return;
919
+ }
920
+
921
+ try {
922
+ const parsed = JSON.parse(raw);
923
+ state.baseUrl = parsed.baseUrl || state.baseUrl;
924
+ state.resource = parsed.resource || state.resource;
925
+ state.token = parsed.token || '';
926
+ state.sessionClientId = parsed.sessionClientId || '';
927
+ state.sessionScopes = parsed.sessionScopes || '';
928
+ state.sessionTenantId = parsed.sessionTenantId || '';
929
+ state.advancedVisible = parsed.advancedVisible === true;
930
+ } catch (_error) {
931
+ localStorage.removeItem(storageKey);
932
+ }
933
+ }
934
+
935
+ function clearSession() {
936
+ state.token = '';
937
+ state.sessionClientId = '';
938
+ state.sessionScopes = '';
939
+ state.sessionTenantId = '';
940
+ state.data = {};
941
+ state.lastBackup = null;
942
+ state.lastClientSecret = null;
943
+ state.lastResponse = null;
944
+ state.lastCredentialTest = null;
945
+ state.lastCredentialTestContext = null;
946
+ state.lastCreatedCredentialId = '';
947
+ state.selectedCredentialId = '';
948
+ state.currentCredentialContext = null;
949
+ state.lastMcpConnection = null;
950
+ state.mcpToken = '';
951
+ state.advancedVisible = false;
952
+ state.credentialIdManuallyEdited = false;
953
+ localStorage.removeItem(storageKey);
954
+ syncSessionFields();
955
+ renderAll();
956
+ }
957
+
958
+ function syncSessionFields() {
959
+ byId('base-url').value = state.baseUrl;
960
+ byId('resource').value = state.resource;
961
+ byId('session-client-id').textContent = state.sessionClientId || 'anonymous token';
962
+ byId('session-scopes').textContent = state.sessionScopes || 'not loaded';
963
+ byId('session-tenant').textContent = state.sessionTenantId || 'global operator';
964
+ byId('session-status').textContent = state.token ? 'Session active' : 'Not connected';
965
+ byId('session-status').className = state.token ? 'state-active' : 'state-warning';
966
+ byId('login-panel').hidden = !!state.token;
967
+ byId('dashboard').hidden = !state.token;
968
+ }
969
+
970
+ function renderAdvancedMode() {
971
+ const advancedNav = byId('advanced-nav');
972
+ const advancedShell = byId('advanced-shell');
973
+ const toggle = byId('advanced-toggle');
974
+ const summary = byId('advanced-summary');
975
+
976
+ if (!advancedNav || !advancedShell || !toggle || !summary) {
977
+ return;
978
+ }
979
+
980
+ advancedNav.hidden = !state.advancedVisible;
981
+ advancedShell.hidden = !state.advancedVisible;
982
+ toggle.textContent = state.advancedVisible ? 'Hide advanced controls' : 'Show advanced controls';
983
+ summary.innerHTML = state.advancedVisible
984
+ ? '<div class="panel-footnote">Advanced mode is open. Tenant management, OAuth clients, approvals, backups, audit, and system internals are available below.</div>'
985
+ : '<div class="panel-footnote">Advanced mode is optional. You can ignore tenants, approvals, backups, audit, and system internals until you move beyond the local core workflow.</div>';
986
+ }
987
+
988
+ function populateLoginDefaults(force) {
989
+ if (!state.localAdminBootstrap) {
990
+ return;
991
+ }
992
+
993
+ if (force || !byId('client-id').value.trim()) {
994
+ byId('client-id').value = state.localAdminBootstrap.clientId;
995
+ }
996
+ if (force || !byId('client-secret').value) {
997
+ byId('client-secret').value = state.localAdminBootstrap.clientSecret;
998
+ }
999
+ if (force || !byId('scope-input').value.trim()) {
1000
+ byId('scope-input').value = state.localAdminBootstrap.scopes.join(' ');
1001
+ }
1002
+ }
1003
+
1004
+ async function requestTokenFromCredentials() {
1005
+ const clientId = byId('client-id').value.trim();
1006
+ const clientSecret = byId('client-secret').value;
1007
+ const scopes = byId('scope-input').value.trim();
1008
+ const resource = byId('resource').value.trim();
1009
+
1010
+ const response = await fetch(state.baseUrl.replace(/\/$/, '') + '/oauth/token', {
1011
+ method: 'POST',
1012
+ headers: {
1013
+ 'content-type': 'application/x-www-form-urlencoded'
1014
+ },
1015
+ body: new URLSearchParams({
1016
+ grant_type: 'client_credentials',
1017
+ client_id: clientId,
1018
+ client_secret: clientSecret,
1019
+ scope: scopes,
1020
+ resource: resource
1021
+ })
1022
+ });
1023
+
1024
+ const payload = await response.json().catch(function() {
1025
+ return { error: 'Unable to parse token response.' };
1026
+ });
1027
+
1028
+ if (!response.ok) {
1029
+ throw new Error(payload.error || 'Failed to mint access token.');
1030
+ }
1031
+
1032
+ state.token = payload.access_token;
1033
+ state.sessionClientId = clientId;
1034
+ state.sessionScopes = payload.scope || scopes;
1035
+ state.resource = resource;
1036
+ state.lastResponse = payload;
1037
+ }
1038
+
1039
+ async function fetchJson(path, options) {
1040
+ const request = Object.assign({}, options || {});
1041
+ const headers = new Headers(request.headers || {});
1042
+ if (state.token) {
1043
+ headers.set('authorization', 'Bearer ' + state.token);
1044
+ }
1045
+ request.headers = headers;
1046
+
1047
+ const response = await fetch(state.baseUrl.replace(/\/$/, '') + path, request);
1048
+ const payload = await response.json().catch(function() {
1049
+ return { error: 'Unable to parse server response.' };
1050
+ });
1051
+ if (!response.ok) {
1052
+ throw new Error(payload.error || ('Request failed with status ' + response.status));
1053
+ }
1054
+ return payload;
1055
+ }
1056
+
1057
+ async function safeFetch(path, options) {
1058
+ try {
1059
+ return {
1060
+ ok: true,
1061
+ data: await fetchJson(path, options)
1062
+ };
1063
+ } catch (error) {
1064
+ return {
1065
+ ok: false,
1066
+ error: error instanceof Error ? error.message : String(error)
1067
+ };
1068
+ }
1069
+ }
1070
+
1071
+ function renderResultState(result, renderOk) {
1072
+ if (!result) {
1073
+ return '<div class="empty-state">No data requested yet.</div>';
1074
+ }
1075
+ if (!result.ok) {
1076
+ return '<div class="error-state">' + escapeHtml(result.error) + '</div>';
1077
+ }
1078
+ return renderOk(result.data);
1079
+ }
1080
+
1081
+ function renderOverview() {
1082
+ const ready = state.data.readyz && state.data.readyz.ok ? state.data.readyz.data : null;
1083
+ const credentialCount = state.data.credentials && state.data.credentials.ok
1084
+ ? state.data.credentials.data.credentials.length
1085
+ : ready ? ready.credentials : 'n/a';
1086
+ const tenantCount = state.data.tenants && state.data.tenants.ok ? state.data.tenants.data.tenants.length : 0;
1087
+ const clientCount = state.data.authClients && state.data.authClients.ok ? state.data.authClients.data.clients.length : 0;
1088
+ const pendingApprovals = state.data.approvals && state.data.approvals.ok
1089
+ ? state.data.approvals.data.approvals.filter(function(item) { return item.status === 'pending'; }).length
1090
+ : 0;
1091
+ const activeBreakGlass = state.data.breakGlass && state.data.breakGlass.ok
1092
+ ? state.data.breakGlass.data.requests.filter(function(item) { return item.status === 'active'; }).length
1093
+ : 0;
1094
+
1095
+ byId('overview-metrics').innerHTML = [
1096
+ '<article class="metric-card"><span>Credentials</span><strong>' + escapeHtml(String(credentialCount)) + '</strong></article>',
1097
+ '<article class="metric-card"><span>Tenants</span><strong>' + escapeHtml(String(tenantCount)) + '</strong></article>',
1098
+ '<article class="metric-card"><span>OAuth Clients</span><strong>' + escapeHtml(String(clientCount)) + '</strong></article>',
1099
+ '<article class="metric-card"><span>Pending Reviews</span><strong>' + escapeHtml(String(pendingApprovals + activeBreakGlass)) + '</strong></article>'
1100
+ ].join('');
1101
+
1102
+ byId('overview-ready').innerHTML = renderResultState(state.data.readyz, function(payload) {
1103
+ return '<pre>' + escapeHtml(prettyJson(payload)) + '</pre>';
1104
+ });
1105
+ byId('overview-last-response').innerHTML = state.lastResponse
1106
+ ? '<pre>' + escapeHtml(prettyJson(state.lastResponse)) + '</pre>'
1107
+ : '<div class="empty-state">Fresh token issues and admin actions are echoed here for quick operator inspection.</div>';
1108
+ }
1109
+
1110
+ function renderCoreJourney() {
1111
+ const node = byId('core-journey');
1112
+ if (!node) {
1113
+ return;
1114
+ }
1115
+
1116
+ const credentials = visibleCredentials();
1117
+ const hasCredential = credentials.length > 0;
1118
+ const hasTest = !!state.lastCredentialTest;
1119
+ const hasConnection = !!state.lastMcpConnection;
1120
+ const nextAction = !state.token
1121
+ ? 'Open a local admin session first.'
1122
+ : !hasCredential
1123
+ ? 'Create your first credential from a template.'
1124
+ : !hasTest
1125
+ ? 'Run Test Credential to verify the broker path.'
1126
+ : !hasConnection
1127
+ ? 'Open Connect your AI tool, copy a snippet, and try the first prompt.'
1128
+ : 'Restart your MCP client and try the suggested first prompt.';
1129
+
1130
+ node.innerHTML = [
1131
+ '<div class="section-heading"><div><h2 style="font-size:1.4rem;">What to do next</h2><p>Follow the short path. Everything else can wait until later.</p></div></div>',
1132
+ '<div class="step-grid">',
1133
+ '<article class="step-card"><span class="' + (state.token ? 'state-active' : 'state-warning') + '">Step 1</span><h3>Open a session</h3><p>Use local quickstart or manual sign-in so KeyLore can save and test tokens for you.</p><div class="panel-actions"><button class="button-secondary" type="button" data-nav-target="' + escapeHtml(state.token ? 'session-section' : 'login-panel') + '">Go there</button></div></article>',
1134
+ '<article class="step-card"><span class="' + (hasCredential ? 'state-active' : 'state-warning') + '">Step 2</span><h3>Save a token</h3><p>Pick a template, paste the token, and explain when the AI should use it.</p><div class="panel-actions"><button class="button-secondary" type="button" data-nav-target="credentials-section">Open tokens</button></div></article>',
1135
+ '<article class="step-card"><span class="' + (hasTest ? 'state-active' : 'state-warning') + '">Step 3</span><h3>Test it safely</h3><p>Run a brokered check to confirm the token works without exposing the secret.</p><div class="panel-actions"><button class="button-secondary" type="button" data-nav-target="credentials-section">Open test</button></div></article>',
1136
+ '<article class="step-card"><span class="' + (hasConnection ? 'state-active' : 'state-warning') + '">Step 4</span><h3>Connect your AI tool</h3><p>Copy the Codex or Gemini snippet, restart the tool, and try the suggested prompt.</p><div class="panel-actions"><button class="button-secondary" type="button" data-nav-target="connect-section">Open connect</button></div></article>',
1137
+ '</div>',
1138
+ '<p class="panel-footnote" style="margin-top:12px;"><strong>Next:</strong> ' + escapeHtml(nextAction) + '</p>'
1139
+ ].join('');
1140
+ }
1141
+
1142
+ function renderCredentials() {
1143
+ byId('credential-list').innerHTML = renderResultState(state.data.credentials, function(payload) {
1144
+ if (!payload.credentials.length) {
1145
+ return '<div class="empty-state">No credentials are visible yet. Start with a template, save a token, then use Test Credential as the next step.</div>';
1146
+ }
1147
+
1148
+ function renderCredentialCard(credential) {
1149
+ const nextStatus = credential.status === 'active' ? 'disabled' : 'active';
1150
+ const statusActionLabel = credential.status === 'active' ? 'Archive' : 'Restore';
1151
+ const isNew = credential.id === state.lastCreatedCredentialId;
1152
+ const isSelected = credential.id === state.selectedCredentialId;
1153
+ return [
1154
+ '<article class="list-card">',
1155
+ '<div class="toolbar"><div><h3>' + escapeHtml(credential.displayName) + '</h3><p class="mono"><strong>Token key:</strong> ' + escapeHtml(credential.id) + '</p></div><div class="panel-actions">' + (isNew ? '<span class="state-active">Just added</span>' : '') + (isSelected ? '<span class="state-ok">Selected</span>' : '') + '<span class="' + (credential.status === 'active' ? 'state-active' : 'state-disabled') + '">' + escapeHtml(credential.status) + '</span></div></div>',
1156
+ '<div class="list-meta">',
1157
+ '<span class="pill"><strong>Service</strong> ' + escapeHtml(credential.service) + '</span>',
1158
+ '<span class="pill"><strong>Scope</strong> ' + escapeHtml(credential.scopeTier) + '</span>',
1159
+ '<span class="pill"><strong>Sensitivity</strong> ' + escapeHtml(credential.sensitivity) + '</span>',
1160
+ '</div>',
1161
+ '<p class="panel-footnote">' + escapeHtml(credential.selectionNotes) + '</p>',
1162
+ '<p class="muted-copy mono">Domains: ' + escapeHtml(credential.allowedDomains.join(', ')) + '</p>',
1163
+ '<div class="panel-actions"><button class="button-secondary" type="button" data-credential-context-action="open" data-credential-context-id="' + escapeHtml(credential.id) + '">Edit AI notes</button></div>',
1164
+ '<details class="disclosure"><summary>More actions</summary><div class="panel-actions"><button class="button-secondary" type="button" data-credential-context-action="rename" data-credential-context-id="' + escapeHtml(credential.id) + '" data-credential-context-name="' + escapeHtml(credential.displayName) + '">Rename</button><button class="button-secondary" type="button" data-credential-context-action="retag" data-credential-context-id="' + escapeHtml(credential.id) + '" data-credential-context-tags="' + escapeHtml(credential.tags.join(', ')) + '">Retag</button><button class="button-secondary" type="button" data-credential-context-action="status" data-credential-context-id="' + escapeHtml(credential.id) + '" data-credential-context-status="' + escapeHtml(nextStatus) + '">' + statusActionLabel + '</button>' + (credential.owner === 'local' ? '<button class="button-danger" type="button" data-credential-context-action="delete" data-credential-context-id="' + escapeHtml(credential.id) + '" data-credential-context-name="' + escapeHtml(credential.displayName) + '">Delete</button>' : '') + '</div></details>',
1165
+ '</article>'
1166
+ ].join('');
1167
+ }
1168
+
1169
+ const ownCredentials = payload.credentials.filter(function(credential) {
1170
+ return credential.owner === 'local';
1171
+ }).sort(function(left, right) {
1172
+ if (left.id === state.lastCreatedCredentialId) {
1173
+ return -1;
1174
+ }
1175
+ if (right.id === state.lastCreatedCredentialId) {
1176
+ return 1;
1177
+ }
1178
+ if (left.id === state.selectedCredentialId) {
1179
+ return -1;
1180
+ }
1181
+ if (right.id === state.selectedCredentialId) {
1182
+ return 1;
1183
+ }
1184
+ return left.displayName.localeCompare(right.displayName);
1185
+ });
1186
+ const starterCredentials = payload.credentials.filter(function(credential) {
1187
+ return credential.owner !== 'local';
1188
+ }).sort(function(left, right) {
1189
+ return left.displayName.localeCompare(right.displayName);
1190
+ });
1191
+
1192
+ return [
1193
+ ownCredentials.length
1194
+ ? '<div class="stack-tight"><div><h3 style="margin:0 0 8px;">Your tokens</h3><p class="panel-footnote" style="margin-top:0;">These are the tokens you added yourself.</p></div>' + ownCredentials.map(renderCredentialCard).join('') + '</div>'
1195
+ : '<div class="empty-state">You have not added a token yet. When you save one, it will appear here with a clear token key.</div>',
1196
+ starterCredentials.length
1197
+ ? '<div class="stack-tight" style="margin-top:16px;"><div><h3 style="margin:0 0 8px;">Included examples</h3><p class="panel-footnote" style="margin-top:0;">These example entries ship with the local setup. They are not the tokens you just added.</p></div>' + starterCredentials.map(renderCredentialCard).join('') + '</div>'
1198
+ : ''
1199
+ ].join('');
1200
+ });
1201
+
1202
+ if (!state.data.credentials) {
1203
+ byId('credential-test-id').innerHTML = '<option value="">Load credentials first</option>';
1204
+ } else if (!state.data.credentials.ok) {
1205
+ byId('credential-test-id').innerHTML = '<option value="">Credentials unavailable</option>';
1206
+ } else {
1207
+ const ownCredentials = state.data.credentials.data.credentials.filter(function(credential) {
1208
+ return credential.owner === 'local';
1209
+ });
1210
+ const starterCredentials = state.data.credentials.data.credentials.filter(function(credential) {
1211
+ return credential.owner !== 'local';
1212
+ });
1213
+ byId('credential-test-id').innerHTML = [
1214
+ ownCredentials.length
1215
+ ? '<optgroup label="Your tokens">' + ownCredentials.map(function(credential) {
1216
+ return '<option value="' + escapeHtml(credential.id) + '">' + escapeHtml(credential.displayName + ' (' + credential.id + ')') + '</option>';
1217
+ }).join('') + '</optgroup>'
1218
+ : '',
1219
+ starterCredentials.length
1220
+ ? '<optgroup label="Included examples">' + starterCredentials.map(function(credential) {
1221
+ return '<option value="' + escapeHtml(credential.id) + '">' + escapeHtml(credential.displayName + ' (' + credential.id + ')') + '</option>';
1222
+ }).join('') + '</optgroup>'
1223
+ : ''
1224
+ ].join('');
1225
+ }
1226
+
1227
+ if (state.lastCredentialTest) {
1228
+ const lastCredential = visibleCredentials().find(function(credential) {
1229
+ return credential.id === state.lastCredentialTestContext?.credentialId;
1230
+ });
1231
+ const summary = [
1232
+ '<div class="list-card">',
1233
+ '<h3 style="margin:0 0 8px;">What this check did</h3>',
1234
+ '<p>KeyLore tried a real <span class="mono">http.get</span> using <strong>' + escapeHtml(lastCredential ? lastCredential.displayName : (state.lastCredentialTestContext?.credentialId || 'the selected token')) + '</strong> against <span class="mono">' + escapeHtml(state.lastCredentialTestContext?.targetUrl || 'the selected URL') + '</span>.</p>',
1235
+ '<div class="list-meta">',
1236
+ '<span class="' + (state.lastCredentialTest.decision === 'allowed' ? 'state-active' : state.lastCredentialTest.decision === 'approval_required' ? 'state-warning' : 'state-disabled') + '">' + escapeHtml(state.lastCredentialTest.decision.replace('_', ' ')) + '</span>',
1237
+ '<span class="pill"><strong>Reason</strong> ' + escapeHtml(state.lastCredentialTest.reason || 'n/a') + '</span>',
1238
+ state.lastCredentialTest.httpResult ? '<span class="pill"><strong>HTTP status</strong> ' + escapeHtml(String(state.lastCredentialTest.httpResult.status)) + '</span>' : '',
1239
+ '</div>',
1240
+ state.lastCredentialTest.httpResult ? '<pre style="margin-top:12px;">' + escapeHtml(state.lastCredentialTest.httpResult.bodyPreview) + '</pre>' : '',
1241
+ '<div class="panel-footnote">Success means the token, the target domain, and KeyLore policy all allowed the request. Failure means one of those checks blocked it.</div>',
1242
+ '</div>'
1243
+ ].join('');
1244
+ byId('credential-test-result').innerHTML = summary;
1245
+ } else {
1246
+ byId('credential-test-result').innerHTML = '<div class="empty-state">This check makes a real brokered <span class="mono">http.get</span> call with the selected token and URL. Use it to confirm the token, target domain, and policy all work together.</div>';
1247
+ }
1248
+
1249
+ renderCredentialContextManager();
1250
+ }
1251
+
1252
+ function visibleCredentials() {
1253
+ return state.data.credentials && state.data.credentials.ok
1254
+ ? state.data.credentials.data.credentials
1255
+ : [];
1256
+ }
1257
+
1258
+ function selectedCredentialSummary() {
1259
+ const credentials = visibleCredentials();
1260
+ return credentials.find(function(credential) {
1261
+ return credential.id === state.selectedCredentialId;
1262
+ });
1263
+ }
1264
+
1265
+ function credentialContextAssessment(payload) {
1266
+ const errors = [];
1267
+ const warnings = [];
1268
+ const notes = String(payload.selectionNotes || '');
1269
+ const normalizedNotes = notes.trim().toLowerCase();
1270
+ if (!notes.trim()) {
1271
+ errors.push('Selection notes are required. Explain when the agent should use this credential.');
1272
+ } else if (notes.trim().length < 16) {
1273
+ errors.push('Selection notes are too short. Add enough detail for the agent to distinguish this credential from others.');
1274
+ } else if (notes.trim().length < 40) {
1275
+ warnings.push('Selection notes are short. Add when-to-use guidance so the agent can choose this credential reliably.');
1276
+ }
1277
+
1278
+ if (!payload.allowedDomains || !payload.allowedDomains.length) {
1279
+ warnings.push('Allowed domains are empty. The agent-visible metadata should make the intended target explicit.');
1280
+ }
1281
+
1282
+ if (!payload.permittedOperations || !payload.permittedOperations.length) {
1283
+ warnings.push('Permitted operations are empty. The preview should make the intended read or write capability explicit.');
1284
+ }
1285
+
1286
+ if (/^(use when needed|general use|general purpose|for api|api token|token for api|default token|main token)$/i.test(normalizedNotes)) {
1287
+ errors.push('Selection notes are too vague. Say what the credential is for, when the agent should choose it, and what it should avoid.');
1288
+ }
1289
+
1290
+ if (/(gh[pousr]_[A-Za-z0-9_]+|github_pat_|sk-[A-Za-z0-9_-]+|AKIA[0-9A-Z]{16})/.test(notes)) {
1291
+ errors.push('Selection notes look like they may contain a secret. Keep raw tokens out of the agent-visible context.');
1292
+ }
1293
+
1294
+ return { errors: errors, warnings: warnings };
1295
+ }
1296
+
1297
+ function credentialGuidanceForTemplate() {
1298
+ const template = byId('credential-template').value;
1299
+ if (template === 'github-readonly') {
1300
+ return {
1301
+ good: 'Use for GitHub repository metadata, issues, pull requests, and rate-limit reads. Never use it for write operations.',
1302
+ avoid: 'GitHub token'
1303
+ };
1304
+ }
1305
+ if (template === 'github-write') {
1306
+ return {
1307
+ good: 'Use for GitHub workflows that need authenticated reads plus controlled writes such as issue comments, labels, or pull request updates. Prefer the read-only GitHub credential when writes are not needed.',
1308
+ avoid: 'Main GitHub token'
1309
+ };
1310
+ }
1311
+ if (template === 'npm-readonly') {
1312
+ return {
1313
+ good: 'Use for npm package metadata, dependency lookup, and registry read operations. Do not use it for publish workflows.',
1314
+ avoid: 'npm token'
1315
+ };
1316
+ }
1317
+ if (template === 'internal-service') {
1318
+ return {
1319
+ good: 'Use only for the listed internal service domain when the task explicitly targets that API. Avoid unrelated external services.',
1320
+ avoid: 'Internal token'
1321
+ };
1322
+ }
1323
+ return {
1324
+ good: 'Describe the target service, the intended domain, when the agent should choose this credential, and what kinds of actions it should avoid.',
1325
+ avoid: 'Use when needed'
1326
+ };
1327
+ }
1328
+
1329
+ function renderCredentialGuidance() {
1330
+ const node = byId('credential-guidance');
1331
+ if (!node) {
1332
+ return;
1333
+ }
1334
+ const guidance = credentialGuidanceForTemplate();
1335
+ node.innerHTML = [
1336
+ '<div class="panel-footnote"><strong>Good context:</strong> ' + escapeHtml(guidance.good) + '</div>',
1337
+ '<div class="panel-footnote"><strong>Avoid:</strong> ' + escapeHtml(guidance.avoid) + '</div>',
1338
+ '<div class="panel-footnote">Good notes should answer: what service is this for, when should the agent choose it, and what should it avoid doing?</div>'
1339
+ ].join('');
1340
+ }
1341
+
1342
+ function renderCredentialPreview() {
1343
+ const previewNode = byId('credential-mcp-preview');
1344
+ const warningNode = byId('credential-preview-warnings');
1345
+ if (!previewNode || !warningNode) {
1346
+ return;
1347
+ }
1348
+
1349
+ const payload = serializeCredentialForm();
1350
+ const preview = {
1351
+ result: {
1352
+ id: payload.credentialId || 'credential-id-preview',
1353
+ tenantId: payload.tenantId || 'default',
1354
+ displayName: payload.displayName || 'Credential Preview',
1355
+ service: payload.service || 'service',
1356
+ owner: payload.owner,
1357
+ scopeTier: payload.scopeTier,
1358
+ sensitivity: payload.sensitivity,
1359
+ allowedDomains: payload.allowedDomains,
1360
+ permittedOperations: payload.permittedOperations,
1361
+ expiresAt: payload.expiresAt || null,
1362
+ rotationPolicy: payload.rotationPolicy || 'Managed locally',
1363
+ lastValidatedAt: null,
1364
+ selectionNotes: payload.selectionNotes || '',
1365
+ tags: payload.tags,
1366
+ status: payload.status,
1367
+ },
1368
+ };
1369
+
1370
+ previewNode.innerHTML = '<pre>' + escapeHtml(prettyJson(preview)) + '</pre>';
1371
+ const assessment = credentialContextAssessment(payload);
1372
+ const messages = [];
1373
+ assessment.errors.forEach(function(message) {
1374
+ messages.push('<div class="error-state">' + escapeHtml(message) + '</div>');
1375
+ });
1376
+ assessment.warnings.forEach(function(message) {
1377
+ messages.push('<div class="panel-footnote">' + escapeHtml(message) + '</div>');
1378
+ });
1379
+ if (!messages.length) {
1380
+ messages.push('<div class="panel-footnote">This is the MCP-visible metadata shape. Secret storage details, binding refs, and raw token values do not appear here.</div>');
1381
+ }
1382
+ warningNode.innerHTML = messages.join('');
1383
+ renderCredentialGuidance();
1384
+ }
1385
+
1386
+ function renderContextPreview(previewNodeId, warningNodeId, payload, currentId) {
1387
+ const previewNode = byId(previewNodeId);
1388
+ const warningNode = byId(warningNodeId);
1389
+ if (!previewNode || !warningNode) {
1390
+ return;
1391
+ }
1392
+
1393
+ const preview = {
1394
+ credential: {
1395
+ id: currentId || state.selectedCredentialId || 'credential-id-preview',
1396
+ tenantId: 'default',
1397
+ displayName: payload.displayName || 'Credential Context',
1398
+ service: payload.service || 'service',
1399
+ owner: 'local',
1400
+ scopeTier: payload.scopeTier,
1401
+ sensitivity: payload.sensitivity,
1402
+ allowedDomains: payload.allowedDomains,
1403
+ permittedOperations: payload.permittedOperations,
1404
+ expiresAt: null,
1405
+ rotationPolicy: 'Managed separately',
1406
+ lastValidatedAt: null,
1407
+ selectionNotes: payload.selectionNotes || '',
1408
+ tags: payload.tags,
1409
+ status: payload.status || 'active',
1410
+ },
1411
+ };
1412
+
1413
+ previewNode.innerHTML = '<pre>' + escapeHtml(prettyJson(preview)) + '</pre>';
1414
+ const assessment = credentialContextAssessment(payload);
1415
+ const messages = [];
1416
+ assessment.errors.forEach(function(message) {
1417
+ messages.push('<div class="error-state">' + escapeHtml(message) + '</div>');
1418
+ });
1419
+ assessment.warnings.forEach(function(message) {
1420
+ messages.push('<div class="panel-footnote">' + escapeHtml(message) + '</div>');
1421
+ });
1422
+ if (!messages.length) {
1423
+ messages.push('<div class="panel-footnote">This preview is agent-facing metadata only. Secret bindings and raw tokens stay separate and are not editable here.</div>');
1424
+ }
1425
+ warningNode.innerHTML = messages.join('');
1426
+ }
1427
+
1428
+ function populateCredentialContextForm(credential) {
1429
+ byId('credential-context-id').value = credential.id;
1430
+ byId('credential-context-display-name').value = credential.displayName;
1431
+ byId('credential-context-service').value = credential.service;
1432
+ byId('credential-context-sensitivity').value = credential.sensitivity;
1433
+ byId('credential-context-status').value = credential.status;
1434
+ byId('credential-context-operations').value = credential.permittedOperations.includes('http.post')
1435
+ ? 'http.get,http.post'
1436
+ : 'http.get';
1437
+ byId('credential-context-domains').value = credential.allowedDomains.join(', ');
1438
+ byId('credential-context-notes').value = credential.selectionNotes;
1439
+ byId('credential-context-tags').value = credential.tags.join(', ');
1440
+ }
1441
+
1442
+ function serializeCredentialContextForm() {
1443
+ const operations = splitList(byId('credential-context-operations').value);
1444
+ return {
1445
+ displayName: byId('credential-context-display-name').value.trim(),
1446
+ service: byId('credential-context-service').value.trim(),
1447
+ scopeTier: operations.includes('http.post') ? 'read_write' : 'read_only',
1448
+ sensitivity: byId('credential-context-sensitivity').value,
1449
+ status: byId('credential-context-status').value,
1450
+ allowedDomains: splitList(byId('credential-context-domains').value),
1451
+ permittedOperations: operations.length ? operations : ['http.get'],
1452
+ selectionNotes: byId('credential-context-notes').value.trim(),
1453
+ tags: splitList(byId('credential-context-tags').value),
1454
+ };
1455
+ }
1456
+
1457
+ function renderCredentialContextManager() {
1458
+ const currentNode = byId('credential-context-current');
1459
+ const formNode = byId('credential-context-form');
1460
+ if (!currentNode || !formNode) {
1461
+ return;
1462
+ }
1463
+
1464
+ const selected = state.currentCredentialContext || selectedCredentialSummary();
1465
+ if (!selected) {
1466
+ currentNode.innerHTML = '<div class="empty-state">Select a credential from the list to inspect or edit its MCP-visible context. Secret storage stays out of this flow.</div>';
1467
+ formNode.hidden = true;
1468
+ return;
1469
+ }
1470
+
1471
+ state.selectedCredentialId = selected.id;
1472
+ state.currentCredentialContext = selected;
1473
+ currentNode.innerHTML = '<pre>' + escapeHtml(prettyJson({ credential: selected })) + '</pre>';
1474
+ formNode.hidden = false;
1475
+ populateCredentialContextForm(selected);
1476
+ renderContextPreview(
1477
+ 'credential-context-preview',
1478
+ 'credential-context-preview-warnings',
1479
+ serializeCredentialContextForm(),
1480
+ selected.id,
1481
+ );
1482
+ }
1483
+
1484
+ async function openCredentialContext(credentialId) {
1485
+ const result = await fetchJson('/v1/core/credentials/' + encodeURIComponent(credentialId) + '/context');
1486
+ state.selectedCredentialId = credentialId;
1487
+ state.currentCredentialContext = result.credential;
1488
+ renderCredentialContextManager();
1489
+ }
1490
+
1491
+ function renderConnect() {
1492
+ byId('codex-stdio-snippet').value = codexStdioSnippet();
1493
+ byId('codex-http-snippet').value = codexHttpSnippet();
1494
+ byId('gemini-stdio-snippet').value = geminiStdioSnippet();
1495
+ byId('gemini-http-snippet').value = geminiHttpSnippet();
1496
+ byId('generic-http-snippet').value = genericHttpSnippet();
1497
+ byId('mcp-token-export').value = "export KEYLORE_MCP_ACCESS_TOKEN='" + mcpHttpTokenValue() + "'";
1498
+ byId('connect-client-id').value = state.localAdminBootstrap ? state.localAdminBootstrap.clientId : (state.sessionClientId || '');
1499
+ if (state.localAdminBootstrap && !byId('connect-client-secret').value) {
1500
+ byId('connect-client-secret').value = state.localAdminBootstrap.clientSecret;
1501
+ }
1502
+ byId('connect-stdio-status').innerHTML = config.stdioAvailable
1503
+ ? '<div class="state-active">stdio entry is available at <span class="mono">' + escapeHtml(config.stdioEntryPath) + '</span></div>'
1504
+ : '<div class="error-state">The stdio entry point was not found at <span class="mono">' + escapeHtml(config.stdioEntryPath) + '</span>.</div>';
1505
+ byId('codex-first-prompt').value = firstPromptForClient('Codex');
1506
+ byId('gemini-first-prompt').value = firstPromptForClient('Gemini');
1507
+ byId('connect-result').innerHTML = state.lastMcpConnection
1508
+ ? '<pre>' + escapeHtml(prettyJson(state.lastMcpConnection)) + '</pre>'
1509
+ : '<div class="empty-state">For local use, copy a stdio snippet and restart your MCP client. For remote HTTP MCP, run the connection check here first.</div>';
1510
+ }
1511
+
1512
+ function renderTenants() {
1513
+ byId('tenant-list').innerHTML = renderResultState(state.data.tenants, function(payload) {
1514
+ if (!payload.tenants.length) {
1515
+ return '<div class="empty-state">No tenants are visible to this session.</div>';
1516
+ }
1517
+
1518
+ return [
1519
+ '<div class="table-wrap"><table><thead><tr><th>Tenant</th><th>Status</th><th>Counts</th><th>Actions</th></tr></thead><tbody>',
1520
+ payload.tenants.map(function(tenant) {
1521
+ const nextStatus = tenant.status === 'active' ? 'disabled' : 'active';
1522
+ return [
1523
+ '<tr>',
1524
+ '<td><strong>' + escapeHtml(tenant.displayName) + '</strong><div class="muted-copy mono">' + escapeHtml(tenant.tenantId) + '</div></td>',
1525
+ '<td><span class="' + (tenant.status === 'active' ? 'state-active' : 'state-disabled') + '">' + escapeHtml(tenant.status) + '</span></td>',
1526
+ '<td class="mono">credentials ' + escapeHtml(String(tenant.credentialCount)) + ' · clients ' + escapeHtml(String(tenant.authClientCount)) + ' · tokens ' + escapeHtml(String(tenant.activeTokenCount)) + '</td>',
1527
+ '<td><div class="panel-actions"><button class="button-secondary" data-tenant-action="toggle" data-tenant-id="' + escapeHtml(tenant.tenantId) + '" data-next-status="' + escapeHtml(nextStatus) + '">' + (nextStatus === 'active' ? 'Enable' : 'Disable') + '</button></div></td>',
1528
+ '</tr>'
1529
+ ].join('');
1530
+ }).join(''),
1531
+ '</tbody></table></div>'
1532
+ ].join('');
1533
+ });
1534
+ }
1535
+
1536
+ function renderAuthClients() {
1537
+ byId('auth-client-list').innerHTML = renderResultState(state.data.authClients, function(payload) {
1538
+ if (!payload.clients.length) {
1539
+ return '<div class="empty-state">No auth clients are visible to this session.</div>';
1540
+ }
1541
+
1542
+ return payload.clients.map(function(client) {
1543
+ const toggleAction = client.status === 'active' ? 'disable' : 'enable';
1544
+ const toggleLabel = client.status === 'active' ? 'Disable' : 'Enable';
1545
+ return [
1546
+ '<article class="list-card">',
1547
+ '<div class="toolbar"><div><h3>' + escapeHtml(client.displayName) + '</h3><p class="mono">' + escapeHtml(client.clientId) + '</p></div><span class="' + (client.status === 'active' ? 'state-active' : 'state-disabled') + '">' + escapeHtml(client.status) + '</span></div>',
1548
+ '<div class="list-meta">',
1549
+ '<span class="pill"><strong>Tenant</strong> ' + escapeHtml(client.tenantId) + '</span>',
1550
+ '<span class="pill"><strong>Auth</strong> ' + escapeHtml(client.tokenEndpointAuthMethod) + '</span>',
1551
+ '<span class="pill"><strong>Grants</strong> ' + escapeHtml(client.grantTypes.join(', ')) + '</span>',
1552
+ '</div>',
1553
+ '<p class="panel-footnote">Roles: ' + escapeHtml(client.roles.join(', ')) + '<br>Scopes: ' + escapeHtml(client.allowedScopes.join(', ')) + '</p>',
1554
+ '<div class="panel-actions">',
1555
+ '<button class="button-secondary" data-client-action="' + escapeHtml(toggleAction) + '" data-client-id="' + escapeHtml(client.clientId) + '">' + toggleLabel + '</button>',
1556
+ '<button class="button-secondary" data-client-action="rotate" data-client-id="' + escapeHtml(client.clientId) + '">Rotate secret</button>',
1557
+ '</div>',
1558
+ '</article>'
1559
+ ].join('');
1560
+ }).join('');
1561
+ });
1562
+
1563
+ byId('issued-secret').innerHTML = state.lastClientSecret
1564
+ ? '<pre>' + escapeHtml(prettyJson(state.lastClientSecret)) + '</pre>'
1565
+ : '<div class="empty-state">Generated or rotated client secrets are displayed here exactly once.</div>';
1566
+ }
1567
+
1568
+ function renderApprovals() {
1569
+ byId('approval-list').innerHTML = renderResultState(state.data.approvals, function(payload) {
1570
+ if (!payload.approvals.length) {
1571
+ return '<div class="empty-state">No approval requests are currently visible.</div>';
1572
+ }
1573
+
1574
+ return payload.approvals.map(function(approval) {
1575
+ const actions = approval.status === 'pending'
1576
+ ? '<div class="panel-actions"><button class="button" data-approval-action="approve" data-approval-id="' + escapeHtml(approval.id) + '">Approve</button><button class="button-danger" data-approval-action="deny" data-approval-id="' + escapeHtml(approval.id) + '">Deny</button></div>'
1577
+ : '';
1578
+ return [
1579
+ '<article class="list-card">',
1580
+ '<div class="toolbar"><div><h3>' + escapeHtml(approval.credentialId) + '</h3><p class="mono">' + escapeHtml(approval.id) + '</p></div><span class="' + (approval.status === 'approved' ? 'state-active' : approval.status === 'pending' ? 'state-pending' : 'state-disabled') + '">' + escapeHtml(approval.status) + '</span></div>',
1581
+ '<p>' + escapeHtml(approval.operation + ' → ' + approval.targetUrl) + '</p>',
1582
+ '<div class="list-meta"><span class="pill"><strong>Requested by</strong> ' + escapeHtml(approval.requestedBy) + '</span><span class="pill"><strong>Quorum</strong> ' + escapeHtml(String(approval.approvalCount)) + ' / ' + escapeHtml(String(approval.requiredApprovals)) + '</span><span class="pill"><strong>Expires</strong> ' + escapeHtml(formatDate(approval.expiresAt)) + '</span></div>',
1583
+ actions,
1584
+ '</article>'
1585
+ ].join('');
1586
+ }).join('');
1587
+ });
1588
+ }
1589
+
1590
+ function renderBreakGlass() {
1591
+ byId('breakglass-list').innerHTML = renderResultState(state.data.breakGlass, function(payload) {
1592
+ if (!payload.requests.length) {
1593
+ return '<div class="empty-state">No break-glass requests are currently visible.</div>';
1594
+ }
1595
+
1596
+ return payload.requests.map(function(request) {
1597
+ const actions = request.status === 'pending'
1598
+ ? '<div class="panel-actions"><button class="button" data-breakglass-action="approve" data-breakglass-id="' + escapeHtml(request.id) + '">Approve</button><button class="button-danger" data-breakglass-action="deny" data-breakglass-id="' + escapeHtml(request.id) + '">Deny</button></div>'
1599
+ : request.status === 'active'
1600
+ ? '<div class="panel-actions"><button class="button-danger" data-breakglass-action="revoke" data-breakglass-id="' + escapeHtml(request.id) + '">Revoke</button></div>'
1601
+ : '';
1602
+ return [
1603
+ '<article class="list-card">',
1604
+ '<div class="toolbar"><div><h3>' + escapeHtml(request.credentialId) + '</h3><p class="mono">' + escapeHtml(request.id) + '</p></div><span class="' + (request.status === 'active' ? 'state-active' : request.status === 'pending' ? 'state-pending' : 'state-disabled') + '">' + escapeHtml(request.status) + '</span></div>',
1605
+ '<p>' + escapeHtml(request.operation + ' → ' + request.targetUrl) + '</p>',
1606
+ '<div class="list-meta"><span class="pill"><strong>Requested by</strong> ' + escapeHtml(request.requestedBy) + '</span><span class="pill"><strong>Quorum</strong> ' + escapeHtml(String(request.approvalCount)) + ' / ' + escapeHtml(String(request.requiredApprovals)) + '</span></div>',
1607
+ actions,
1608
+ '</article>'
1609
+ ].join('');
1610
+ }).join('');
1611
+ });
1612
+ }
1613
+
1614
+ function renderAudit() {
1615
+ byId('audit-list').innerHTML = renderResultState(state.data.audit, function(payload) {
1616
+ if (!payload.events.length) {
1617
+ return '<div class="empty-state">No audit events are visible to this session.</div>';
1618
+ }
1619
+
1620
+ return [
1621
+ '<div class="table-wrap"><table><thead><tr><th>When</th><th>Type</th><th>Outcome</th><th>Principal</th><th>Action</th></tr></thead><tbody>',
1622
+ payload.events.map(function(event) {
1623
+ return [
1624
+ '<tr>',
1625
+ '<td class="mono">' + escapeHtml(formatDate(event.occurredAt)) + '</td>',
1626
+ '<td>' + escapeHtml(event.type) + '</td>',
1627
+ '<td><span class="' + (event.outcome === 'success' || event.outcome === 'allowed' ? 'state-active' : 'state-disabled') + '">' + escapeHtml(event.outcome) + '</span></td>',
1628
+ '<td>' + escapeHtml(event.principal) + '</td>',
1629
+ '<td><div><strong>' + escapeHtml(event.action) + '</strong><div class="muted-copy mono">' + escapeHtml(event.correlationId) + '</div></div></td>',
1630
+ '</tr>'
1631
+ ].join('');
1632
+ }).join(''),
1633
+ '</tbody></table></div>'
1634
+ ].join('');
1635
+ });
1636
+ }
1637
+
1638
+ function renderSystem() {
1639
+ byId('maintenance-status').innerHTML = renderResultState(state.data.maintenance, function(payload) {
1640
+ return '<pre>' + escapeHtml(prettyJson(payload.maintenance)) + '</pre>';
1641
+ });
1642
+
1643
+ byId('trace-exporter-status').innerHTML = renderResultState(state.data.exporter, function(payload) {
1644
+ return '<pre>' + escapeHtml(prettyJson(payload.exporter)) + '</pre>';
1645
+ });
1646
+
1647
+ byId('adapter-health').innerHTML = renderResultState(state.data.adapters, function(payload) {
1648
+ return '<pre>' + escapeHtml(prettyJson(payload.adapters)) + '</pre>';
1649
+ });
1650
+
1651
+ byId('recent-traces').innerHTML = renderResultState(state.data.traces, function(payload) {
1652
+ return payload.traces.length
1653
+ ? '<pre>' + escapeHtml(prettyJson(payload.traces)) + '</pre>'
1654
+ : '<div class="empty-state">No traces captured yet.</div>';
1655
+ });
1656
+
1657
+ byId('rotation-list').innerHTML = renderResultState(state.data.rotations, function(payload) {
1658
+ return payload.rotations.length
1659
+ ? '<pre>' + escapeHtml(prettyJson(payload.rotations)) + '</pre>'
1660
+ : '<div class="empty-state">No rotation runs are visible.</div>';
1661
+ });
1662
+ }
1663
+
1664
+ function renderBackups() {
1665
+ byId('backup-summary').innerHTML = state.lastBackup
1666
+ ? '<pre>' + escapeHtml(prettyJson(state.lastBackup.summary || state.lastBackup.backup || state.lastBackup)) + '</pre>'
1667
+ : '<div class="empty-state">Export a backup or inspect a pasted payload to populate this panel.</div>';
1668
+ }
1669
+
1670
+ function renderAll() {
1671
+ syncSessionFields();
1672
+ renderAdvancedMode();
1673
+ renderCoreJourney();
1674
+ renderCredentials();
1675
+ renderConnect();
1676
+ renderOverview();
1677
+ renderTenants();
1678
+ renderAuthClients();
1679
+ renderApprovals();
1680
+ renderBreakGlass();
1681
+ renderAudit();
1682
+ renderSystem();
1683
+ renderBackups();
1684
+ }
1685
+
1686
+ async function refreshDashboard() {
1687
+ if (!state.token) {
1688
+ return;
1689
+ }
1690
+
1691
+ setBusy(true);
1692
+ clearNotice();
1693
+ state.data.readyz = await safeFetch('/readyz');
1694
+ state.data.credentials = await safeFetch('/v1/catalog/credentials');
1695
+ state.data.tenants = await safeFetch('/v1/tenants');
1696
+ state.data.authClients = await safeFetch('/v1/auth/clients');
1697
+ state.data.approvals = await safeFetch('/v1/approvals');
1698
+ state.data.breakGlass = await safeFetch('/v1/break-glass');
1699
+ state.data.audit = await safeFetch('/v1/audit/events?limit=20');
1700
+ state.data.maintenance = await safeFetch('/v1/system/maintenance');
1701
+ state.data.exporter = await safeFetch('/v1/system/trace-exporter');
1702
+ state.data.adapters = await safeFetch('/v1/system/adapters');
1703
+ state.data.traces = await safeFetch('/v1/system/traces?limit=15');
1704
+ state.data.rotations = await safeFetch('/v1/system/rotations');
1705
+ if (state.data.authClients && state.data.authClients.ok) {
1706
+ const matchingClient = state.data.authClients.data.clients.find(function(client) {
1707
+ return client.clientId === state.sessionClientId;
1708
+ });
1709
+ if (matchingClient) {
1710
+ state.sessionTenantId = matchingClient.tenantId || state.sessionTenantId;
1711
+ persistSession();
1712
+ }
1713
+ }
1714
+ const credentials = visibleCredentials();
1715
+ if (state.selectedCredentialId) {
1716
+ state.currentCredentialContext = credentials.find(function(credential) {
1717
+ return credential.id === state.selectedCredentialId;
1718
+ }) || state.currentCredentialContext;
1719
+ }
1720
+ if (!state.selectedCredentialId && credentials.length > 0) {
1721
+ state.selectedCredentialId = credentials[0].id;
1722
+ state.currentCredentialContext = credentials[0];
1723
+ }
1724
+ renderAll();
1725
+ syncCredentialTestDefaults(false);
1726
+ setBusy(false);
1727
+ }
1728
+
1729
+ async function withAction(message, action) {
1730
+ setBusy(true);
1731
+ clearNotice();
1732
+ try {
1733
+ const result = await action();
1734
+ if (message) {
1735
+ setNotice('info', message);
1736
+ }
1737
+ state.lastResponse = result;
1738
+ await refreshDashboard();
1739
+ return result;
1740
+ } catch (error) {
1741
+ setBusy(false);
1742
+ setNotice('error', error instanceof Error ? error.message : String(error));
1743
+ throw error;
1744
+ }
1745
+ }
1746
+
1747
+ function serializeAuthClientForm() {
1748
+ return {
1749
+ clientId: byId('new-client-id').value.trim(),
1750
+ tenantId: byId('new-client-tenant').value.trim() || 'default',
1751
+ displayName: byId('new-client-name').value.trim(),
1752
+ roles: splitList(byId('new-client-roles').value),
1753
+ allowedScopes: splitList(byId('new-client-scopes').value),
1754
+ clientSecret: byId('new-client-secret').value.trim() || undefined,
1755
+ tokenEndpointAuthMethod: byId('new-client-auth-method').value,
1756
+ grantTypes: splitList(byId('new-client-grants').value),
1757
+ redirectUris: splitList(byId('new-client-redirects').value),
1758
+ jwks: undefined
1759
+ };
1760
+ }
1761
+
1762
+ function applyCredentialTemplate() {
1763
+ const template = byId('credential-template').value;
1764
+ state.credentialIdManuallyEdited = false;
1765
+ if (template === 'github-readonly') {
1766
+ byId('credential-name').value = 'GitHub Read-Only Token';
1767
+ byId('credential-service').value = 'github';
1768
+ byId('credential-operations').value = 'http.get';
1769
+ byId('credential-domains').value = 'api.github.com';
1770
+ byId('credential-notes').value = 'Use for GitHub repository metadata, issues, pull requests, and rate-limit reads. Never use it for write operations.';
1771
+ byId('credential-tags').value = 'github,readonly';
1772
+ byId('credential-sensitivity').value = 'high';
1773
+ renderCredentialPreview();
1774
+ return;
1775
+ }
1776
+
1777
+ if (template === 'github-write') {
1778
+ byId('credential-name').value = 'GitHub Write Token';
1779
+ byId('credential-service').value = 'github';
1780
+ byId('credential-operations').value = 'http.get,http.post';
1781
+ byId('credential-domains').value = 'api.github.com';
1782
+ byId('credential-notes').value = 'Use for GitHub workflows that need authenticated reads plus controlled writes such as issue comments, pull request updates, labels, or status changes. Prefer the read-only GitHub credential when writes are not required.';
1783
+ byId('credential-tags').value = 'github,write';
1784
+ byId('credential-sensitivity').value = 'critical';
1785
+ renderCredentialPreview();
1786
+ return;
1787
+ }
1788
+
1789
+ if (template === 'npm-readonly') {
1790
+ byId('credential-name').value = 'npm Read-Only Token';
1791
+ byId('credential-service').value = 'npm';
1792
+ byId('credential-operations').value = 'http.get';
1793
+ byId('credential-domains').value = 'registry.npmjs.org';
1794
+ byId('credential-notes').value = 'Use for npm package metadata, dependency lookup, and registry read operations. Do not use this credential for publish or package mutation workflows.';
1795
+ byId('credential-tags').value = 'npm,readonly';
1796
+ byId('credential-sensitivity').value = 'high';
1797
+ renderCredentialPreview();
1798
+ return;
1799
+ }
1800
+
1801
+ if (template === 'internal-service') {
1802
+ byId('credential-name').value = 'Internal Service Token';
1803
+ byId('credential-service').value = 'internal_api';
1804
+ byId('credential-operations').value = 'http.get,http.post';
1805
+ byId('credential-domains').value = 'internal.example.com';
1806
+ byId('credential-notes').value = 'Use only for the listed internal service domain when the task explicitly targets that service. Keep this credential scoped to the documented internal API workflow and avoid unrelated external APIs.';
1807
+ byId('credential-tags').value = 'internal,bearer';
1808
+ byId('credential-sensitivity').value = 'critical';
1809
+ renderCredentialPreview();
1810
+ return;
1811
+ }
1812
+
1813
+ if (template === 'generic-bearer') {
1814
+ byId('credential-id').value = '';
1815
+ byId('credential-name').value = '';
1816
+ byId('credential-service').value = '';
1817
+ byId('credential-operations').value = 'http.get';
1818
+ byId('credential-domains').value = '';
1819
+ byId('credential-notes').value = '';
1820
+ byId('credential-tags').value = '';
1821
+ byId('credential-sensitivity').value = 'moderate';
1822
+ renderCredentialPreview();
1823
+ }
1824
+
1825
+ syncCredentialIdFromName(true);
1826
+ }
1827
+
1828
+ function syncCredentialIdFromName(force) {
1829
+ if (state.credentialIdManuallyEdited && !force) {
1830
+ return;
1831
+ }
1832
+
1833
+ const name = byId('credential-name').value.trim();
1834
+ const template = byId('credential-template').value;
1835
+ const fallback = template === 'generic-bearer' ? 'token' : template.replace(/[^a-z0-9]+/g, '-');
1836
+ byId('credential-id').value = slugifyTokenKey(name || fallback) + '-local';
1837
+ renderCredentialPreview();
1838
+ }
1839
+
1840
+ function syncCredentialSourceFields() {
1841
+ const adapter = byId('credential-storage').value;
1842
+ byId('credential-secret-field').hidden = adapter !== 'local';
1843
+ byId('credential-env-ref-field').hidden = adapter !== 'env';
1844
+ renderCredentialPreview();
1845
+ }
1846
+
1847
+ function serializeCredentialForm() {
1848
+ const operations = splitList(byId('credential-operations').value);
1849
+ const adapter = byId('credential-storage').value;
1850
+ return {
1851
+ credentialId: byId('credential-id').value.trim(),
1852
+ displayName: byId('credential-name').value.trim(),
1853
+ service: byId('credential-service').value.trim(),
1854
+ owner: 'local',
1855
+ scopeTier: operations.includes('http.post') ? 'read_write' : 'read_only',
1856
+ sensitivity: byId('credential-sensitivity').value,
1857
+ allowedDomains: splitList(byId('credential-domains').value),
1858
+ permittedOperations: operations.length ? operations : ['http.get'],
1859
+ selectionNotes: byId('credential-notes').value.trim(),
1860
+ tags: splitList(byId('credential-tags').value),
1861
+ authType: 'bearer',
1862
+ headerName: 'Authorization',
1863
+ headerPrefix: 'Bearer ',
1864
+ secretSource: adapter === 'local'
1865
+ ? {
1866
+ adapter: 'local',
1867
+ secretValue: byId('credential-secret').value
1868
+ }
1869
+ : {
1870
+ adapter: 'env',
1871
+ ref: byId('credential-env-ref').value.trim()
1872
+ }
1873
+ };
1874
+ }
1875
+
1876
+ function syncCredentialTestDefaults(force) {
1877
+ const credentials = state.data.credentials && state.data.credentials.ok
1878
+ ? state.data.credentials.data.credentials
1879
+ : [];
1880
+ if (!credentials.length) {
1881
+ return;
1882
+ }
1883
+ const selectedId = byId('credential-test-id').value;
1884
+ const selected = credentials.find(function(credential) {
1885
+ return credential.id === selectedId;
1886
+ }) || credentials.find(function(credential) {
1887
+ return credential.id === state.lastCreatedCredentialId;
1888
+ }) || credentials.find(function(credential) {
1889
+ return credential.id === state.selectedCredentialId;
1890
+ }) || credentials[0];
1891
+ if (force || !byId('credential-test-id').value) {
1892
+ byId('credential-test-id').value = selected.id;
1893
+ }
1894
+ if (force || !byId('credential-test-url').value.trim()) {
1895
+ byId('credential-test-url').value = defaultTestUrlForCredential(selected);
1896
+ }
1897
+ }
1898
+
1899
+ async function handleLogin(event) {
1900
+ if (event) {
1901
+ event.preventDefault();
1902
+ }
1903
+ state.baseUrl = byId('base-url').value.trim().replace(/\/$/, '');
1904
+ state.resource = byId('resource').value.trim();
1905
+ const pastedToken = byId('pasted-token').value.trim();
1906
+
1907
+ setBusy(true);
1908
+ clearNotice();
1909
+
1910
+ try {
1911
+ if (pastedToken) {
1912
+ state.token = pastedToken;
1913
+ state.sessionClientId = byId('client-id').value.trim() || 'pasted-token';
1914
+ state.sessionScopes = byId('scope-input').value.trim() || 'unknown';
1915
+ } else {
1916
+ await requestTokenFromCredentials();
1917
+ }
1918
+ persistSession();
1919
+ renderAll();
1920
+ setNotice('info', 'Operator session established.');
1921
+ await refreshDashboard();
1922
+ } catch (error) {
1923
+ setBusy(false);
1924
+ setNotice('error', error instanceof Error ? error.message : String(error));
1925
+ }
1926
+ }
1927
+
1928
+ async function handleLocalQuickstartLogin() {
1929
+ setBusy(true);
1930
+ clearNotice();
1931
+
1932
+ try {
1933
+ const payload = await fetchJson('/v1/core/local-session', {
1934
+ method: 'POST'
1935
+ });
1936
+ state.token = payload.access_token;
1937
+ state.sessionClientId = payload.clientId || 'keylore-admin-local';
1938
+ state.sessionScopes = payload.scope || 'catalog:read';
1939
+ state.resource = payload.resource || (state.baseUrl.replace(/\/$/, '') + '/v1');
1940
+ persistSession();
1941
+ renderAll();
1942
+ setNotice('info', 'Local session established. Next: save a token.');
1943
+ await refreshDashboard();
1944
+ } catch (error) {
1945
+ setBusy(false);
1946
+ setNotice('error', error instanceof Error ? error.message : String(error));
1947
+ }
1948
+ }
1949
+
1950
+ async function handleCreateCredential(event) {
1951
+ event.preventDefault();
1952
+ const payload = serializeCredentialForm();
1953
+ const assessment = credentialContextAssessment(payload);
1954
+ if (assessment.errors.length) {
1955
+ renderCredentialPreview();
1956
+ setNotice('error', assessment.errors[0]);
1957
+ return;
1958
+ }
1959
+ const result = await withAction('Credential created. Next: run Test Credential or inspect the saved context.', async function() {
1960
+ try {
1961
+ return await fetchJson('/v1/core/credentials', {
1962
+ method: 'POST',
1963
+ headers: {
1964
+ 'content-type': 'application/json'
1965
+ },
1966
+ body: JSON.stringify(payload)
1967
+ });
1968
+ } catch (error) {
1969
+ const message = error instanceof Error ? error.message : String(error);
1970
+ if (message.includes('already exists')) {
1971
+ throw new Error('Token key "' + payload.credentialId + '" is already in use. Change the Token key field and save again.');
1972
+ }
1973
+ throw error;
1974
+ }
1975
+ });
1976
+ state.lastCreatedCredentialId = result.credential.id;
1977
+ state.selectedCredentialId = result.credential.id;
1978
+ state.currentCredentialContext = result.credential;
1979
+ state.credentialIdManuallyEdited = false;
1980
+ byId('credential-form').reset();
1981
+ byId('credential-template').value = 'github-readonly';
1982
+ byId('credential-storage').value = 'local';
1983
+ applyCredentialTemplate();
1984
+ syncCredentialSourceFields();
1985
+ renderCredentialPreview();
1986
+ syncCredentialTestDefaults(true);
1987
+ }
1988
+
1989
+ async function handleCredentialTest(event) {
1990
+ event.preventDefault();
1991
+ const credentialId = byId('credential-test-id').value.trim();
1992
+ const targetUrl = byId('credential-test-url').value.trim();
1993
+ const result = await withAction('Token check completed. Review the summary below, then connect your AI tool.', async function() {
1994
+ return fetchJson('/v1/access/request', {
1995
+ method: 'POST',
1996
+ headers: {
1997
+ 'content-type': 'application/json'
1998
+ },
1999
+ body: JSON.stringify({
2000
+ credentialId: credentialId,
2001
+ operation: 'http.get',
2002
+ targetUrl: targetUrl
2003
+ })
2004
+ });
2005
+ });
2006
+ state.lastCredentialTestContext = {
2007
+ credentialId: credentialId,
2008
+ targetUrl: targetUrl
2009
+ };
2010
+ state.lastCredentialTest = result;
2011
+ renderCredentials();
2012
+ }
2013
+
2014
+ async function handleCredentialContextAction(event) {
2015
+ if (!(event.target instanceof Element)) {
2016
+ return;
2017
+ }
2018
+ const button = event.target.closest('[data-credential-context-action]');
2019
+ if (!button) {
2020
+ return;
2021
+ }
2022
+
2023
+ try {
2024
+ const credentialId = button.dataset.credentialContextId;
2025
+ const action = button.dataset.credentialContextAction;
2026
+ if (action === 'open') {
2027
+ setBusy(true);
2028
+ clearNotice();
2029
+ await openCredentialContext(credentialId);
2030
+ setNotice('info', 'Loaded the current MCP-visible context. Secret storage remains separate.');
2031
+ setBusy(false);
2032
+ return;
2033
+ }
2034
+
2035
+ if (action === 'rename') {
2036
+ const nextName = window.prompt('New display name', button.dataset.credentialContextName || '');
2037
+ if (!nextName || !nextName.trim()) {
2038
+ return;
2039
+ }
2040
+ const result = await withAction('Credential renamed. Next: verify the MCP-visible record still reads clearly.', async function() {
2041
+ return fetchJson('/v1/core/credentials/' + encodeURIComponent(credentialId) + '/context', {
2042
+ method: 'PATCH',
2043
+ headers: {
2044
+ 'content-type': 'application/json'
2045
+ },
2046
+ body: JSON.stringify({ displayName: nextName.trim() })
2047
+ });
2048
+ });
2049
+ state.currentCredentialContext = result.credential;
2050
+ state.selectedCredentialId = credentialId;
2051
+ renderCredentialContextManager();
2052
+ return;
2053
+ }
2054
+
2055
+ if (action === 'retag') {
2056
+ const nextTags = window.prompt('Comma-separated tags', button.dataset.credentialContextTags || '');
2057
+ if (nextTags === null) {
2058
+ return;
2059
+ }
2060
+ const result = await withAction('Credential tags updated. Next: confirm the tags help the agent choose the right record.', async function() {
2061
+ return fetchJson('/v1/core/credentials/' + encodeURIComponent(credentialId) + '/context', {
2062
+ method: 'PATCH',
2063
+ headers: {
2064
+ 'content-type': 'application/json'
2065
+ },
2066
+ body: JSON.stringify({ tags: splitList(nextTags) })
2067
+ });
2068
+ });
2069
+ state.currentCredentialContext = result.credential;
2070
+ state.selectedCredentialId = credentialId;
2071
+ renderCredentialContextManager();
2072
+ return;
2073
+ }
2074
+
2075
+ if (action === 'status') {
2076
+ const result = await withAction(button.dataset.credentialContextStatus === 'disabled'
2077
+ ? 'Credential archived. Next: restore it when the agent should use it again.'
2078
+ : 'Credential restored. Next: rerun Test Credential if you want to confirm the live path.'
2079
+ , async function() {
2080
+ return fetchJson('/v1/core/credentials/' + encodeURIComponent(credentialId) + '/context', {
2081
+ method: 'PATCH',
2082
+ headers: {
2083
+ 'content-type': 'application/json'
2084
+ },
2085
+ body: JSON.stringify({ status: button.dataset.credentialContextStatus })
2086
+ });
2087
+ });
2088
+ state.currentCredentialContext = result.credential;
2089
+ state.selectedCredentialId = credentialId;
2090
+ renderCredentialContextManager();
2091
+ return;
2092
+ }
2093
+
2094
+ if (action === 'delete') {
2095
+ const confirmed = window.confirm('Delete "' + (button.dataset.credentialContextName || credentialId) + '" permanently? This removes the token metadata and its local secret material.');
2096
+ if (!confirmed) {
2097
+ return;
2098
+ }
2099
+ await withAction('Token deleted.', async function() {
2100
+ return fetchJson('/v1/core/credentials/' + encodeURIComponent(credentialId), {
2101
+ method: 'DELETE'
2102
+ });
2103
+ });
2104
+ if (state.selectedCredentialId === credentialId) {
2105
+ state.selectedCredentialId = '';
2106
+ state.currentCredentialContext = null;
2107
+ }
2108
+ if (state.lastCreatedCredentialId === credentialId) {
2109
+ state.lastCreatedCredentialId = '';
2110
+ }
2111
+ if (state.lastCredentialTestContext?.credentialId === credentialId) {
2112
+ state.lastCredentialTest = null;
2113
+ state.lastCredentialTestContext = null;
2114
+ }
2115
+ renderCredentialContextManager();
2116
+ return;
2117
+ }
2118
+ } catch (error) {
2119
+ setBusy(false);
2120
+ setNotice('error', error instanceof Error ? error.message : String(error));
2121
+ }
2122
+ }
2123
+
2124
+ async function handleCredentialContextSave(event) {
2125
+ event.preventDefault();
2126
+ if (!state.selectedCredentialId) {
2127
+ setNotice('error', 'Select a credential before updating its context.');
2128
+ return;
2129
+ }
2130
+ const payload = serializeCredentialContextForm();
2131
+ const assessment = credentialContextAssessment(payload);
2132
+ if (assessment.errors.length) {
2133
+ renderContextPreview(
2134
+ 'credential-context-preview',
2135
+ 'credential-context-preview-warnings',
2136
+ payload,
2137
+ state.selectedCredentialId,
2138
+ );
2139
+ setNotice('error', assessment.errors[0]);
2140
+ return;
2141
+ }
2142
+
2143
+ const result = await withAction('Credential context updated. Next: rerun Test Credential if you changed domains or operations.', async function() {
2144
+ return fetchJson('/v1/core/credentials/' + encodeURIComponent(state.selectedCredentialId) + '/context', {
2145
+ method: 'PATCH',
2146
+ headers: {
2147
+ 'content-type': 'application/json'
2148
+ },
2149
+ body: JSON.stringify(payload)
2150
+ });
2151
+ });
2152
+ state.currentCredentialContext = result.credential;
2153
+ renderCredentialContextManager();
2154
+ }
2155
+
2156
+ async function handleMcpConnectionCheck(event) {
2157
+ event.preventDefault();
2158
+ const clientId = byId('connect-client-id').value.trim();
2159
+ const clientSecret = byId('connect-client-secret').value;
2160
+ if (!clientId || !clientSecret) {
2161
+ setNotice('error', 'Client ID and client secret are required to mint a remote MCP token.');
2162
+ return;
2163
+ }
2164
+
2165
+ setBusy(true);
2166
+ clearNotice();
2167
+
2168
+ try {
2169
+ const tokenResponse = await fetch(state.baseUrl.replace(/\/$/, '') + '/oauth/token', {
2170
+ method: 'POST',
2171
+ headers: {
2172
+ 'content-type': 'application/x-www-form-urlencoded'
2173
+ },
2174
+ body: new URLSearchParams({
2175
+ grant_type: 'client_credentials',
2176
+ client_id: clientId,
2177
+ client_secret: clientSecret,
2178
+ scope: 'catalog:read broker:use mcp:use',
2179
+ resource: state.baseUrl.replace(/\/$/, '') + '/mcp'
2180
+ })
2181
+ });
2182
+ const tokenPayload = await tokenResponse.json().catch(function() {
2183
+ return { error: 'Unable to parse token response.' };
2184
+ });
2185
+ if (!tokenResponse.ok) {
2186
+ throw new Error(tokenPayload.error || 'Failed to mint MCP token.');
2187
+ }
2188
+
2189
+ state.mcpToken = tokenPayload.access_token;
2190
+
2191
+ const checkResult = await fetchJson('/v1/core/mcp/check', {
2192
+ method: 'POST',
2193
+ headers: {
2194
+ 'content-type': 'application/json'
2195
+ },
2196
+ body: JSON.stringify({ token: state.mcpToken })
2197
+ });
2198
+
2199
+ state.lastMcpConnection = {
2200
+ transport: 'http',
2201
+ tokenIssued: true,
2202
+ tokenScopes: tokenPayload.scope || 'catalog:read broker:use mcp:use',
2203
+ tokenType: tokenPayload.token_type || 'Bearer',
2204
+ verification: checkResult
2205
+ };
2206
+ setNotice('info', 'HTTP MCP token minted and verified. Next: export the token, restart the client, and try the first prompt below.');
2207
+ renderConnect();
2208
+ setBusy(false);
2209
+ } catch (error) {
2210
+ setBusy(false);
2211
+ setNotice('error', error instanceof Error ? error.message : String(error));
2212
+ }
2213
+ }
2214
+
2215
+ async function handleCreateTenant(event) {
2216
+ event.preventDefault();
2217
+ await withAction('Tenant created.', async function() {
2218
+ return fetchJson('/v1/tenants', {
2219
+ method: 'POST',
2220
+ headers: {
2221
+ 'content-type': 'application/json'
2222
+ },
2223
+ body: JSON.stringify({
2224
+ tenantId: byId('new-tenant-id').value.trim(),
2225
+ displayName: byId('new-tenant-name').value.trim(),
2226
+ description: byId('new-tenant-description').value.trim() || undefined,
2227
+ status: byId('new-tenant-status').value
2228
+ })
2229
+ });
2230
+ });
2231
+ byId('tenant-form').reset();
2232
+ }
2233
+
2234
+ async function handleCreateClient(event) {
2235
+ event.preventDefault();
2236
+ const payload = serializeAuthClientForm();
2237
+ const result = await withAction('OAuth client created.', async function() {
2238
+ return fetchJson('/v1/auth/clients', {
2239
+ method: 'POST',
2240
+ headers: {
2241
+ 'content-type': 'application/json'
2242
+ },
2243
+ body: JSON.stringify(payload)
2244
+ });
2245
+ });
2246
+ state.lastClientSecret = result;
2247
+ renderAuthClients();
2248
+ byId('auth-client-form').reset();
2249
+ }
2250
+
2251
+ async function handleBackupExport() {
2252
+ const result = await withAction('Backup exported.', async function() {
2253
+ return fetchJson('/v1/system/backups/export', { method: 'POST' });
2254
+ });
2255
+ state.lastBackup = result;
2256
+ byId('backup-json').value = prettyJson(result.backup);
2257
+ renderBackups();
2258
+ }
2259
+
2260
+ async function handleBackupInspect() {
2261
+ try {
2262
+ const backup = JSON.parse(byId('backup-json').value || '{}');
2263
+ const result = await withAction('Backup inspected.', async function() {
2264
+ return fetchJson('/v1/system/backups/inspect', {
2265
+ method: 'POST',
2266
+ headers: {
2267
+ 'content-type': 'application/json'
2268
+ },
2269
+ body: JSON.stringify({ backup: backup })
2270
+ });
2271
+ });
2272
+ state.lastBackup = result;
2273
+ renderBackups();
2274
+ } catch (error) {
2275
+ if (!(error instanceof Error) || !/Unexpected token|JSON/.test(error.message)) {
2276
+ return;
2277
+ }
2278
+ setNotice('error', 'Backup JSON is invalid.');
2279
+ }
2280
+ }
2281
+
2282
+ async function handleBackupRestore() {
2283
+ if (!window.confirm('Restore the pasted backup payload into the current tenant scope or global instance?')) {
2284
+ return;
2285
+ }
2286
+
2287
+ try {
2288
+ const backup = JSON.parse(byId('backup-json').value || '{}');
2289
+ const result = await withAction('Backup restore completed.', async function() {
2290
+ return fetchJson('/v1/system/backups/restore', {
2291
+ method: 'POST',
2292
+ headers: {
2293
+ 'content-type': 'application/json'
2294
+ },
2295
+ body: JSON.stringify({ confirm: true, backup: backup })
2296
+ });
2297
+ });
2298
+ state.lastBackup = result;
2299
+ renderBackups();
2300
+ } catch (error) {
2301
+ if (!(error instanceof Error) || !/Unexpected token|JSON/.test(error.message)) {
2302
+ return;
2303
+ }
2304
+ setNotice('error', 'Backup JSON is invalid.');
2305
+ }
2306
+ }
2307
+
2308
+ async function downloadBackup() {
2309
+ if (!state.lastBackup || !state.lastBackup.backup) {
2310
+ setNotice('warning', 'Export a backup before downloading.');
2311
+ return;
2312
+ }
2313
+ const blob = new Blob([prettyJson(state.lastBackup.backup)], { type: 'application/json' });
2314
+ const link = document.createElement('a');
2315
+ link.href = URL.createObjectURL(blob);
2316
+ link.download = 'keylore-backup.json';
2317
+ link.click();
2318
+ URL.revokeObjectURL(link.href);
2319
+ }
2320
+
2321
+ async function handleTenantAction(event) {
2322
+ if (!(event.target instanceof Element)) {
2323
+ return;
2324
+ }
2325
+ const button = event.target.closest('[data-tenant-action]');
2326
+ if (!button) {
2327
+ return;
2328
+ }
2329
+ await withAction('Tenant updated.', async function() {
2330
+ return fetchJson('/v1/tenants/' + encodeURIComponent(button.dataset.tenantId), {
2331
+ method: 'PATCH',
2332
+ headers: {
2333
+ 'content-type': 'application/json'
2334
+ },
2335
+ body: JSON.stringify({ status: button.dataset.nextStatus })
2336
+ });
2337
+ });
2338
+ }
2339
+
2340
+ async function handleClientAction(event) {
2341
+ if (!(event.target instanceof Element)) {
2342
+ return;
2343
+ }
2344
+ const button = event.target.closest('[data-client-action]');
2345
+ if (!button) {
2346
+ return;
2347
+ }
2348
+ const action = button.dataset.clientAction;
2349
+ const clientId = encodeURIComponent(button.dataset.clientId);
2350
+ if (action === 'rotate') {
2351
+ const newSecret = window.prompt('Optional new secret. Leave empty to generate one automatically.', '');
2352
+ const result = await withAction('Client secret rotated.', async function() {
2353
+ return fetchJson('/v1/auth/clients/' + clientId + '/rotate-secret', {
2354
+ method: 'POST',
2355
+ headers: {
2356
+ 'content-type': 'application/json'
2357
+ },
2358
+ body: JSON.stringify(newSecret ? { clientSecret: newSecret } : {})
2359
+ });
2360
+ });
2361
+ state.lastClientSecret = result;
2362
+ renderAuthClients();
2363
+ return;
2364
+ }
2365
+ await withAction('Client status updated.', async function() {
2366
+ return fetchJson('/v1/auth/clients/' + clientId + '/' + action, {
2367
+ method: 'POST'
2368
+ });
2369
+ });
2370
+ }
2371
+
2372
+ async function handleApprovalAction(event) {
2373
+ if (!(event.target instanceof Element)) {
2374
+ return;
2375
+ }
2376
+ const button = event.target.closest('[data-approval-action]');
2377
+ if (!button) {
2378
+ return;
2379
+ }
2380
+ const note = window.prompt('Optional review note', '') || undefined;
2381
+ await withAction('Approval review submitted.', async function() {
2382
+ return fetchJson('/v1/approvals/' + encodeURIComponent(button.dataset.approvalId) + '/' + button.dataset.approvalAction, {
2383
+ method: 'POST',
2384
+ headers: {
2385
+ 'content-type': 'application/json'
2386
+ },
2387
+ body: JSON.stringify(note ? { note: note } : {})
2388
+ });
2389
+ });
2390
+ }
2391
+
2392
+ async function handleBreakGlassAction(event) {
2393
+ if (!(event.target instanceof Element)) {
2394
+ return;
2395
+ }
2396
+ const button = event.target.closest('[data-breakglass-action]');
2397
+ if (!button) {
2398
+ return;
2399
+ }
2400
+ const note = window.prompt('Optional review note', '') || undefined;
2401
+ await withAction('Break-glass review submitted.', async function() {
2402
+ return fetchJson('/v1/break-glass/' + encodeURIComponent(button.dataset.breakglassId) + '/' + button.dataset.breakglassAction, {
2403
+ method: 'POST',
2404
+ headers: {
2405
+ 'content-type': 'application/json'
2406
+ },
2407
+ body: JSON.stringify(note ? { note: note } : {})
2408
+ });
2409
+ });
2410
+ }
2411
+
2412
+ function bindNavigation() {
2413
+ document.querySelectorAll('.nav-button').forEach(function(button) {
2414
+ button.addEventListener('click', function() {
2415
+ document.querySelectorAll('.nav-button').forEach(function(node) {
2416
+ node.classList.remove('is-active');
2417
+ });
2418
+ button.classList.add('is-active');
2419
+ openSection(button.dataset.section);
2420
+ });
2421
+ });
2422
+ }
2423
+
2424
+ function openSection(sectionId) {
2425
+ const target = byId(sectionId);
2426
+ if (target) {
2427
+ target.scrollIntoView({ behavior: 'smooth', block: 'start' });
2428
+ }
2429
+ }
2430
+
2431
+ async function initialize() {
2432
+ loadPersistedSession();
2433
+ bindNavigation();
2434
+ byId('base-url').value = state.baseUrl;
2435
+ byId('resource').value = state.resource;
2436
+ byId('scope-input').value = [
2437
+ 'catalog:read',
2438
+ 'catalog:write',
2439
+ 'admin:read',
2440
+ 'admin:write',
2441
+ 'auth:read',
2442
+ 'auth:write',
2443
+ 'audit:read',
2444
+ 'approval:read',
2445
+ 'approval:review',
2446
+ 'system:read',
2447
+ 'system:write',
2448
+ 'backup:read',
2449
+ 'backup:write',
2450
+ 'breakglass:read',
2451
+ 'breakglass:review',
2452
+ 'breakglass:request',
2453
+ 'broker:use',
2454
+ 'mcp:use'
2455
+ ].join(' ');
2456
+
2457
+ byId('login-form').addEventListener('submit', handleLogin);
2458
+ const localQuickstartButton = byId('local-login-submit');
2459
+ if (localQuickstartButton) {
2460
+ localQuickstartButton.addEventListener('click', handleLocalQuickstartLogin);
2461
+ }
2462
+ byId('credential-form').addEventListener('submit', handleCreateCredential);
2463
+ byId('credential-test-form').addEventListener('submit', handleCredentialTest);
2464
+ byId('credential-context-form').addEventListener('submit', handleCredentialContextSave);
2465
+ byId('connect-form').addEventListener('submit', handleMcpConnectionCheck);
2466
+ byId('credential-template').addEventListener('change', applyCredentialTemplate);
2467
+ byId('credential-name').addEventListener('input', function() {
2468
+ syncCredentialIdFromName(false);
2469
+ });
2470
+ byId('credential-id').addEventListener('input', function() {
2471
+ state.credentialIdManuallyEdited = true;
2472
+ renderCredentialPreview();
2473
+ });
2474
+ byId('credential-storage').addEventListener('change', syncCredentialSourceFields);
2475
+ byId('credential-form').addEventListener('input', renderCredentialPreview);
2476
+ byId('credential-form').addEventListener('change', renderCredentialPreview);
2477
+ byId('credential-test-id').addEventListener('change', function() {
2478
+ syncCredentialTestDefaults(true);
2479
+ });
2480
+ byId('tenant-form').addEventListener('submit', handleCreateTenant);
2481
+ byId('auth-client-form').addEventListener('submit', handleCreateClient);
2482
+ byId('refresh-dashboard').addEventListener('click', refreshDashboard);
2483
+ byId('logout').addEventListener('click', function() {
2484
+ clearSession();
2485
+ setNotice('info', 'Session cleared.');
2486
+ });
2487
+ byId('tenant-list').addEventListener('click', handleTenantAction);
2488
+ byId('auth-client-list').addEventListener('click', handleClientAction);
2489
+ byId('credential-list').addEventListener('click', handleCredentialContextAction);
2490
+ byId('approval-list').addEventListener('click', handleApprovalAction);
2491
+ byId('breakglass-list').addEventListener('click', handleBreakGlassAction);
2492
+ byId('backup-export').addEventListener('click', handleBackupExport);
2493
+ byId('backup-inspect').addEventListener('click', handleBackupInspect);
2494
+ byId('backup-restore').addEventListener('click', handleBackupRestore);
2495
+ byId('backup-download').addEventListener('click', downloadBackup);
2496
+ byId('credential-context-form').addEventListener('input', function() {
2497
+ if (!state.selectedCredentialId) {
2498
+ return;
2499
+ }
2500
+ renderContextPreview(
2501
+ 'credential-context-preview',
2502
+ 'credential-context-preview-warnings',
2503
+ serializeCredentialContextForm(),
2504
+ state.selectedCredentialId,
2505
+ );
2506
+ });
2507
+ byId('advanced-toggle').addEventListener('click', function() {
2508
+ state.advancedVisible = !state.advancedVisible;
2509
+ persistSession();
2510
+ renderAdvancedMode();
2511
+ if (state.advancedVisible) {
2512
+ openSection('overview-section');
2513
+ }
2514
+ });
2515
+ document.body.addEventListener('click', function(event) {
2516
+ if (!(event.target instanceof Element)) {
2517
+ return;
2518
+ }
2519
+ const button = event.target.closest('[data-nav-target]');
2520
+ if (!button) {
2521
+ return;
2522
+ }
2523
+ openSection(button.dataset.navTarget);
2524
+ });
2525
+ byId('run-maintenance').addEventListener('click', function() {
2526
+ withAction('Maintenance run completed.', function() {
2527
+ return fetchJson('/v1/system/maintenance/run', { method: 'POST' });
2528
+ });
2529
+ });
2530
+ byId('flush-traces').addEventListener('click', function() {
2531
+ withAction('Trace exporter flushed.', function() {
2532
+ return fetchJson('/v1/system/trace-exporter/flush', { method: 'POST' });
2533
+ });
2534
+ });
2535
+
2536
+ if (state.token) {
2537
+ byId('pasted-token').value = state.token;
2538
+ renderAll();
2539
+ setNotice('info', 'Restored the previous operator session.');
2540
+ await refreshDashboard();
2541
+ } else {
2542
+ renderAll();
2543
+ populateLoginDefaults(false);
2544
+ applyCredentialTemplate();
2545
+ syncCredentialSourceFields();
2546
+ renderCredentialPreview();
2547
+ if (state.localQuickstartEnabled) {
2548
+ setNotice('info', 'Starting local quickstart session...');
2549
+ await handleLocalQuickstartLogin();
2550
+ }
2551
+ }
2552
+ }
2553
+
2554
+ window.addEventListener('DOMContentLoaded', initialize);
2555
+ `;
2556
+ export function renderAdminPage(app) {
2557
+ const stdioEntryPath = path.resolve(process.cwd(), "dist/index.js");
2558
+ const config = {
2559
+ version: app.config.version,
2560
+ baseUrl: app.config.publicBaseUrl,
2561
+ localQuickstartEnabled: app.config.localQuickstartEnabled,
2562
+ localAdminBootstrap: app.config.localAdminBootstrap,
2563
+ stdioEntryPath,
2564
+ stdioAvailable: fs.existsSync(stdioEntryPath),
2565
+ };
2566
+ return `<!doctype html>
2567
+ <html lang="en">
2568
+ <head>
2569
+ <meta charset="utf-8" />
2570
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
2571
+ <title>KeyLore Admin</title>
2572
+ <style>${adminStyles}</style>
2573
+ </head>
2574
+ <body>
2575
+ <div class="page-shell">
2576
+ <aside class="sidebar">
2577
+ <p class="eyebrow">KeyLore Control Plane</p>
2578
+ <h1 class="brand">KeyLore<br />Admin</h1>
2579
+ <p class="brand-subtitle">Start with one short path: save a token, tell the AI when to use it, test it, and connect your tool. Everything technical stays out of the way unless you ask for it.</p>
2580
+ <p class="sidebar-section-label">Start Here</p>
2581
+ <nav class="nav-group">
2582
+ <button class="nav-button is-active" data-section="credentials-section" type="button">Save Token</button>
2583
+ <button class="nav-button" data-section="connect-section" type="button">Connect AI Tool</button>
2584
+ </nav>
2585
+ <p class="sidebar-section-label">More Options</p>
2586
+ <div class="nav-group" style="margin-top: 0;">
2587
+ <button class="button-secondary" id="advanced-toggle" type="button">Show advanced controls</button>
2588
+ </div>
2589
+ <nav id="advanced-nav" class="nav-group advanced-nav" hidden>
2590
+ <button class="nav-button" data-section="overview-section" type="button">Overview</button>
2591
+ <button class="nav-button" data-section="tenants-section" type="button">Tenants</button>
2592
+ <button class="nav-button" data-section="clients-section" type="button">OAuth Clients</button>
2593
+ <button class="nav-button" data-section="approvals-section" type="button">Approvals</button>
2594
+ <button class="nav-button" data-section="breakglass-section" type="button">Break Glass</button>
2595
+ <button class="nav-button" data-section="backups-section" type="button">Backups</button>
2596
+ <button class="nav-button" data-section="audit-section" type="button">Audit</button>
2597
+ <button class="nav-button" data-section="system-section" type="button">System</button>
2598
+ </nav>
2599
+ <p class="helper-copy">Use local quickstart for the shortest path. Advanced mode keeps the full operator console available later without crowding first-run setup.</p>
2600
+ </aside>
2601
+ <main class="content">
2602
+ <section class="hero">
2603
+ <div>
2604
+ <span class="eyebrow">Core Mode</span>
2605
+ <h1>Save a token. Teach the AI when to use it. Keep the secret hidden.</h1>
2606
+ <p class="hero-copy">KeyLore is now centered on one beginner-friendly workflow: save a token, describe it in plain language, test it safely, and connect Codex or Gemini without putting the raw secret into model-visible context.</p>
2607
+ </div>
2608
+ <div class="hero-meta">
2609
+ <div class="meta-card">
2610
+ <span class="meta-label">Release</span>
2611
+ <span class="meta-value mono">${config.version}</span>
2612
+ </div>
2613
+ <div class="meta-card">
2614
+ <span class="meta-label">Base URL</span>
2615
+ <span class="meta-value mono">${config.baseUrl}</span>
2616
+ </div>
2617
+ </div>
2618
+ </section>
2619
+
2620
+ <div id="notice" class="notice"></div>
2621
+
2622
+ <section id="login-panel" class="panel" style="margin-top: 24px;">
2623
+ <div class="section-heading">
2624
+ <div>
2625
+ <h2>Start here</h2>
2626
+ <p>For most local users, one click is enough. Manual sign-in is still available when you need it.</p>
2627
+ </div>
2628
+ </div>
2629
+ ${app.config.localQuickstartEnabled
2630
+ ? `<div class="panel-footnote" style="margin-bottom: 16px;">Local quickstart is enabled on this loopback development instance. KeyLore will try to open a local session automatically. If that fails, use the fallback button or the manual sign-in form below.</div>
2631
+ <div class="form-actions" style="margin-bottom: 16px;">
2632
+ <button class="button-secondary" id="local-login-submit" type="button" data-busy-label="Opening local session..." data-idle-label="Start working locally">Start working locally</button>
2633
+ </div>
2634
+ <details class="disclosure">
2635
+ <summary>Manual sign-in options</summary>`
2636
+ : ""}
2637
+ <form id="login-form" class="form-grid">
2638
+ <div class="field">
2639
+ <label for="base-url">Base URL</label>
2640
+ <input id="base-url" type="url" required />
2641
+ </div>
2642
+ <div class="field">
2643
+ <label for="resource">Protected Resource</label>
2644
+ <input id="resource" type="url" required />
2645
+ </div>
2646
+ <div class="field">
2647
+ <label for="client-id">Client ID</label>
2648
+ <input id="client-id" type="text" placeholder="keylore-admin-local" />
2649
+ </div>
2650
+ <div class="field">
2651
+ <label for="client-secret">Client Secret</label>
2652
+ <input id="client-secret" type="password" placeholder="operator secret" />
2653
+ </div>
2654
+ <div class="field-wide">
2655
+ <label for="scope-input">Scopes</label>
2656
+ <textarea id="scope-input"></textarea>
2657
+ </div>
2658
+ <div class="field-wide">
2659
+ <label for="pasted-token">Optional Existing Bearer Token</label>
2660
+ <textarea id="pasted-token" placeholder="Paste a bearer token here to skip token minting."></textarea>
2661
+ </div>
2662
+ <div class="form-actions field-wide">
2663
+ <button class="button" id="login-submit" type="submit" data-busy-label="Opening session..." data-idle-label="Open operator session">Open operator session</button>
2664
+ </div>
2665
+ </form>
2666
+ ${app.config.localQuickstartEnabled ? '</details>' : ''}
2667
+ </section>
2668
+
2669
+ <div id="dashboard" class="dashboard" hidden>
2670
+ <section id="session-section" class="panel">
2671
+ <div class="section-heading">
2672
+ <div>
2673
+ <h2>You&apos;re connected</h2>
2674
+ <p>The only things you need next are save token, test token, and connect your AI tool.</p>
2675
+ </div>
2676
+ <div class="toolbar">
2677
+ <button class="button-secondary" id="refresh-dashboard" type="button" data-busy-label="Refreshing..." data-idle-label="Refresh everything">Refresh everything</button>
2678
+ <button class="button-danger" id="logout" type="button">Clear session</button>
2679
+ </div>
2680
+ </div>
2681
+ <div class="session-line">
2682
+ <span id="session-status" class="state-warning">Not connected</span>
2683
+ <span class="pill"><strong>Current path</strong> Core onboarding</span>
2684
+ </div>
2685
+ <details class="disclosure">
2686
+ <summary>Session details</summary>
2687
+ <div class="list-meta">
2688
+ <span class="pill"><strong>Client</strong> <span id="session-client-id">anonymous token</span></span>
2689
+ <span class="pill"><strong>Tenant</strong> <span id="session-tenant">global operator</span></span>
2690
+ <span class="pill"><strong>Scopes</strong> <span id="session-scopes">not loaded</span></span>
2691
+ </div>
2692
+ </details>
2693
+ <div id="core-journey" style="margin-top: 18px;"></div>
2694
+ <div id="advanced-summary" class="advanced-summary"></div>
2695
+ </section>
2696
+
2697
+ <section id="credentials-section" class="panel">
2698
+ <div class="section-heading">
2699
+ <div>
2700
+ <h2>Save a token</h2>
2701
+ <p>Start with the basics. Paste the token, say when the AI should use it, and let KeyLore keep the raw secret out of the catalogue.</p>
2702
+ </div>
2703
+ </div>
2704
+ <div class="panel-grid">
2705
+ <div class="span-7 panel">
2706
+ <form id="credential-form" class="form-grid">
2707
+ <div class="field-wide"><label for="credential-template">What is this token for?</label><select id="credential-template"><option value="github-readonly">GitHub read-only</option><option value="github-write">GitHub write-capable</option><option value="npm-readonly">npm read-only</option><option value="internal-service">Internal service token</option><option value="generic-bearer">Generic bearer API</option></select></div>
2708
+ <div class="field"><label for="credential-name">Name shown in KeyLore</label><input id="credential-name" type="text" required /></div>
2709
+ <div class="field"><label for="credential-id">Token key</label><input id="credential-id" type="text" required placeholder="github-read-only-token-local" /></div>
2710
+ <div class="field-wide panel-footnote" style="margin-top:-4px;">This is the unique key for the token. It appears in the saved-token list and is what you change if KeyLore says a token key already exists.</div>
2711
+ <div class="field"><label for="credential-domains">Where can it be used?</label><textarea id="credential-domains" placeholder="api.github.com"></textarea></div>
2712
+ <div id="credential-secret-field" class="field-wide"><label for="credential-secret">Paste token</label><textarea id="credential-secret" placeholder="Paste the raw token here. KeyLore stores it outside the searchable metadata catalogue."></textarea></div>
2713
+ <div class="field-wide"><label for="credential-notes">Tell the AI when to use this token</label><textarea id="credential-notes" placeholder="Example: Use this for GitHub repository metadata, issues, and pull requests. Do not use it for write actions."></textarea></div>
2714
+ <div class="field-wide">
2715
+ <label for="credential-guidance">Writing help</label>
2716
+ <div id="credential-guidance"></div>
2717
+ </div>
2718
+ <div class="field-wide">
2719
+ <label for="credential-mcp-preview">What the AI will see</label>
2720
+ <div id="credential-preview-warnings" style="margin-bottom: 12px;"></div>
2721
+ <div id="credential-mcp-preview"></div>
2722
+ </div>
2723
+ <details class="disclosure field-wide">
2724
+ <summary>Advanced token settings</summary>
2725
+ <div class="form-grid">
2726
+ <div class="field"><label for="credential-storage">Where to store the token</label><select id="credential-storage"><option value="local">Local encrypted store</option><option value="env">Environment reference</option></select></div>
2727
+ <div class="field"><label for="credential-service">Service name</label><input id="credential-service" type="text" required /></div>
2728
+ <div class="field"><label for="credential-sensitivity">Risk level</label><select id="credential-sensitivity"><option value="moderate">moderate</option><option value="high">high</option><option value="critical">critical</option></select></div>
2729
+ <div class="field"><label for="credential-operations">Allow writes?</label><select id="credential-operations"><option value="http.get">No, read only</option><option value="http.get,http.post">Yes, allow controlled writes</option></select></div>
2730
+ <div class="field"><label for="credential-tags">Tags</label><input id="credential-tags" type="text" placeholder="github,readonly" /></div>
2731
+ <div id="credential-env-ref-field" class="field-wide" hidden><label for="credential-env-ref">Environment variable name</label><input id="credential-env-ref" type="text" placeholder="KEYLORE_SECRET_GITHUB_READONLY" /></div>
2732
+ </div>
2733
+ </details>
2734
+ <div class="form-actions field-wide"><button class="button" type="submit" data-busy-label="Saving token..." data-idle-label="Save token">Save token</button></div>
2735
+ </form>
2736
+ </div>
2737
+ <div class="span-5 code-stack">
2738
+ <div class="panel">
2739
+ <div class="section-heading">
2740
+ <div>
2741
+ <h2 style="font-size:1.4rem;">Saved tokens</h2>
2742
+ <p>Use these after you save something. The main next step is usually Test credential.</p>
2743
+ </div>
2744
+ </div>
2745
+ <div id="credential-list"></div>
2746
+ </div>
2747
+ <div class="panel">
2748
+ <div class="section-heading">
2749
+ <div>
2750
+ <h2 style="font-size:1.4rem;">Test credential</h2>
2751
+ <p>Run a real safe check. KeyLore will make an <code>http.get</code> call with the selected token and URL, without exposing the raw secret.</p>
2752
+ </div>
2753
+ </div>
2754
+ <form id="credential-test-form" class="form-grid">
2755
+ <div class="field"><label for="credential-test-id">Token to check</label><select id="credential-test-id"></select></div>
2756
+ <div class="field-wide"><label for="credential-test-url">URL to call with this token</label><input id="credential-test-url" type="url" placeholder="https://api.github.com/rate_limit" required /></div>
2757
+ <div class="panel-footnote field-wide" style="margin-top:-4px;">Success means the token, the target domain, and KeyLore policy all allowed the request.</div>
2758
+ <div class="form-actions field-wide"><button class="button-secondary" type="submit" data-busy-label="Testing credential..." data-idle-label="Check this token">Check this token</button></div>
2759
+ </form>
2760
+ <div id="credential-test-result" style="margin-top: 18px;"></div>
2761
+ </div>
2762
+ <details class="panel disclosure">
2763
+ <summary>Inspect or edit AI-facing context</summary>
2764
+ <div class="panel-footnote">This is metadata only. Secret storage and raw token values stay separate and are not shown here.</div>
2765
+ <div class="panel-grid" style="margin-top: 16px;">
2766
+ <div class="span-6 panel">
2767
+ <div class="section-heading"><div><h2 style="font-size:1.2rem;">Current AI-visible record</h2></div></div>
2768
+ <div id="credential-context-current"></div>
2769
+ </div>
2770
+ <div class="span-6 panel">
2771
+ <div class="section-heading"><div><h2 style="font-size:1.2rem;">Context editor</h2></div></div>
2772
+ <form id="credential-context-form" class="form-grid" hidden>
2773
+ <div class="field"><label for="credential-context-id">Credential ID</label><input id="credential-context-id" type="text" readonly /></div>
2774
+ <div class="field"><label for="credential-context-display-name">Display Name</label><input id="credential-context-display-name" type="text" required /></div>
2775
+ <div class="field"><label for="credential-context-service">Service</label><input id="credential-context-service" type="text" required /></div>
2776
+ <div class="field"><label for="credential-context-sensitivity">Risk level</label><select id="credential-context-sensitivity"><option value="moderate">moderate</option><option value="high">high</option><option value="critical">critical</option></select></div>
2777
+ <div class="field"><label for="credential-context-status">Lifecycle</label><select id="credential-context-status"><option value="active">active</option><option value="disabled">disabled</option></select></div>
2778
+ <div class="field"><label for="credential-context-operations">Allow writes?</label><select id="credential-context-operations"><option value="http.get">No, read only</option><option value="http.get,http.post">Yes, allow controlled writes</option></select></div>
2779
+ <div class="field-wide"><label for="credential-context-domains">Where can it be used?</label><textarea id="credential-context-domains"></textarea></div>
2780
+ <div class="field-wide"><label for="credential-context-notes">Tell the AI when to use this token</label><textarea id="credential-context-notes"></textarea></div>
2781
+ <div class="field-wide"><label for="credential-context-tags">Tags</label><input id="credential-context-tags" type="text" /></div>
2782
+ <div class="field-wide">
2783
+ <label for="credential-context-preview">Updated AI-visible preview</label>
2784
+ <div id="credential-context-preview-warnings" style="margin-bottom: 12px;"></div>
2785
+ <div id="credential-context-preview"></div>
2786
+ </div>
2787
+ <div class="form-actions field-wide"><button class="button-secondary" type="submit" data-busy-label="Saving context..." data-idle-label="Save context changes">Save context changes</button></div>
2788
+ </form>
2789
+ </div>
2790
+ </div>
2791
+ </details>
2792
+ </div>
2793
+ </div>
2794
+ </section>
2795
+
2796
+ <section id="connect-section" class="panel">
2797
+ <div class="section-heading">
2798
+ <div>
2799
+ <h2>Connect your AI tool</h2>
2800
+ <p>For most local users, copy the local snippet below, restart the AI tool, and try the suggested first prompt.</p>
2801
+ </div>
2802
+ </div>
2803
+ <div class="panel-grid">
2804
+ <div class="span-6 code-stack">
2805
+ <div class="panel">
2806
+ <div class="section-heading"><div><h2 style="font-size:1.4rem;">Codex local setup</h2><p>Recommended for local use.</p></div></div>
2807
+ <textarea id="codex-stdio-snippet" style="width:100%; min-height: 130px;"></textarea>
2808
+ <div class="panel-footnote">Local stdio uses the KeyLore process on this machine and avoids extra token setup.</div>
2809
+ <div class="section-heading" style="margin-top: 16px;"><div><h2 style="font-size:1.2rem;">First prompt to try in Codex</h2></div></div>
2810
+ <textarea id="codex-first-prompt" style="width:100%; min-height: 130px;"></textarea>
2811
+ </div>
2812
+ <div class="panel">
2813
+ <div class="section-heading"><div><h2 style="font-size:1.4rem;">Gemini local setup</h2><p>Recommended for local use.</p></div></div>
2814
+ <textarea id="gemini-stdio-snippet" style="width:100%; min-height: 170px;"></textarea>
2815
+ <div class="section-heading" style="margin-top: 16px;"><div><h2 style="font-size:1.2rem;">First prompt to try in Gemini</h2></div></div>
2816
+ <textarea id="gemini-first-prompt" style="width:100%; min-height: 130px;"></textarea>
2817
+ </div>
2818
+ </div>
2819
+ <div class="span-6 panel">
2820
+ <div class="section-heading"><div><h2 style="font-size:1.4rem;">Local connection check</h2></div></div>
2821
+ <div id="connect-stdio-status"></div>
2822
+ <div class="panel-footnote">If the local entry point is present, copy one of the local setup snippets above and restart your AI tool.</div>
2823
+ </div>
2824
+ <div class="span-12">
2825
+ <details class="panel disclosure">
2826
+ <summary>Remote or advanced connection options</summary>
2827
+ <div class="panel-grid" style="margin-top: 16px;">
2828
+ <div class="span-6 code-stack">
2829
+ <div class="panel">
2830
+ <div class="section-heading"><div><h2 style="font-size:1.4rem;">Codex HTTP</h2></div></div>
2831
+ <textarea id="codex-http-snippet" style="width:100%; min-height: 110px;"></textarea>
2832
+ </div>
2833
+ <div class="panel">
2834
+ <div class="section-heading"><div><h2 style="font-size:1.4rem;">Gemini HTTP</h2></div></div>
2835
+ <textarea id="gemini-http-snippet" style="width:100%; min-height: 190px;"></textarea>
2836
+ </div>
2837
+ <div class="panel">
2838
+ <div class="section-heading"><div><h2 style="font-size:1.4rem;">Generic HTTP</h2></div></div>
2839
+ <textarea id="generic-http-snippet" style="width:100%; min-height: 90px;"></textarea>
2840
+ </div>
2841
+ </div>
2842
+ <div class="span-6 panel">
2843
+ <div class="section-heading">
2844
+ <div>
2845
+ <h2 style="font-size:1.4rem;">HTTP MCP token and check</h2>
2846
+ <p>Use this only when you want a remote bearer-token connection to <code>/mcp</code>.</p>
2847
+ </div>
2848
+ </div>
2849
+ <form id="connect-form" class="form-grid">
2850
+ <div class="field"><label for="connect-client-id">Client ID</label><input id="connect-client-id" type="text" placeholder="keylore-admin-local" /></div>
2851
+ <div class="field"><label for="connect-client-secret">Client Secret</label><input id="connect-client-secret" type="password" placeholder="operator secret" /></div>
2852
+ <div class="field-wide"><label for="mcp-token-export">Export command</label><textarea id="mcp-token-export" style="width:100%; min-height: 90px;"></textarea></div>
2853
+ <div class="form-actions field-wide"><button class="button-secondary" type="submit" data-busy-label="Checking MCP..." data-idle-label="Mint token and verify HTTP MCP">Mint token and verify HTTP MCP</button></div>
2854
+ </form>
2855
+ <div id="connect-result" style="margin-top: 18px;"></div>
2856
+ </div>
2857
+ </div>
2858
+ </details>
2859
+ </div>
2860
+ </div>
2861
+ </section>
2862
+
2863
+ <div id="advanced-shell" class="advanced-shell" hidden>
2864
+ <section id="overview-section" class="panel">
2865
+ <div class="section-heading">
2866
+ <div>
2867
+ <h2>Overview</h2>
2868
+ <p>Current session status, health, and last operator response.</p>
2869
+ </div>
2870
+ </div>
2871
+ <div id="overview-metrics" class="metric-grid" style="margin-top: 18px;"></div>
2872
+ <div class="panel-grid" style="margin-top: 18px;">
2873
+ <div class="panel span-6"><div class="section-heading"><div><h2 style="font-size:1.4rem;">Ready</h2></div></div><div id="overview-ready"></div></div>
2874
+ <div class="panel span-6"><div class="section-heading"><div><h2 style="font-size:1.4rem;">Last Response</h2></div></div><div id="overview-last-response"></div></div>
2875
+ </div>
2876
+ </section>
2877
+
2878
+ <section id="tenants-section" class="panel">
2879
+ <div class="section-heading">
2880
+ <div>
2881
+ <h2>Tenants</h2>
2882
+ <p>Create tenants and toggle their status inside the existing admin contract.</p>
2883
+ </div>
2884
+ </div>
2885
+ <div class="panel-grid">
2886
+ <div class="span-5 panel">
2887
+ <form id="tenant-form" class="form-grid">
2888
+ <div class="field"><label for="new-tenant-id">Tenant ID</label><input id="new-tenant-id" type="text" required /></div>
2889
+ <div class="field"><label for="new-tenant-name">Display Name</label><input id="new-tenant-name" type="text" required /></div>
2890
+ <div class="field-wide"><label for="new-tenant-description">Description</label><textarea id="new-tenant-description"></textarea></div>
2891
+ <div class="field"><label for="new-tenant-status">Status</label><select id="new-tenant-status"><option value="active">active</option><option value="disabled">disabled</option></select></div>
2892
+ <div class="form-actions field-wide"><button class="button" type="submit" data-busy-label="Creating tenant..." data-idle-label="Create tenant">Create tenant</button></div>
2893
+ </form>
2894
+ </div>
2895
+ <div class="span-7 panel"><div id="tenant-list"></div></div>
2896
+ </div>
2897
+ </section>
2898
+
2899
+ <section id="clients-section" class="panel">
2900
+ <div class="section-heading">
2901
+ <div>
2902
+ <h2>OAuth Clients</h2>
2903
+ <p>Create, toggle, and rotate operator or tenant clients without exposing secrets twice.</p>
2904
+ </div>
2905
+ </div>
2906
+ <div class="panel-grid">
2907
+ <div class="span-5 panel">
2908
+ <form id="auth-client-form" class="form-grid">
2909
+ <div class="field"><label for="new-client-id">Client ID</label><input id="new-client-id" type="text" required /></div>
2910
+ <div class="field"><label for="new-client-name">Display Name</label><input id="new-client-name" type="text" required /></div>
2911
+ <div class="field"><label for="new-client-tenant">Tenant ID</label><input id="new-client-tenant" type="text" value="default" /></div>
2912
+ <div class="field"><label for="new-client-auth-method">Auth Method</label><select id="new-client-auth-method"><option value="client_secret_basic">client_secret_basic</option><option value="client_secret_post">client_secret_post</option><option value="none">none</option></select></div>
2913
+ <div class="field-wide"><label for="new-client-roles">Roles</label><textarea id="new-client-roles">consumer</textarea></div>
2914
+ <div class="field-wide"><label for="new-client-scopes">Allowed Scopes</label><textarea id="new-client-scopes">catalog:read</textarea></div>
2915
+ <div class="field-wide"><label for="new-client-grants">Grant Types</label><textarea id="new-client-grants">client_credentials</textarea></div>
2916
+ <div class="field-wide"><label for="new-client-redirects">Redirect URIs</label><textarea id="new-client-redirects" placeholder="Needed for authorization_code clients."></textarea></div>
2917
+ <div class="field-wide"><label for="new-client-secret">Optional Fixed Secret</label><input id="new-client-secret" type="password" placeholder="Leave empty to generate one automatically." /></div>
2918
+ <div class="form-actions field-wide"><button class="button" type="submit" data-busy-label="Creating client..." data-idle-label="Create OAuth client">Create OAuth client</button></div>
2919
+ </form>
2920
+ </div>
2921
+ <div class="span-7 code-stack">
2922
+ <div class="panel"><div class="section-heading"><div><h2 style="font-size:1.4rem;">Client Inventory</h2></div></div><div id="auth-client-list"></div></div>
2923
+ <div class="panel"><div class="section-heading"><div><h2 style="font-size:1.4rem;">One-Time Secret Output</h2></div></div><div id="issued-secret"></div></div>
2924
+ </div>
2925
+ </div>
2926
+ </section>
2927
+
2928
+ <section id="approvals-section" class="panel">
2929
+ <div class="section-heading">
2930
+ <div>
2931
+ <h2>Approvals</h2>
2932
+ <p>Work the review queue without dropping back to the CLI.</p>
2933
+ </div>
2934
+ </div>
2935
+ <div id="approval-list" class="list-stack"></div>
2936
+ </section>
2937
+
2938
+ <section id="breakglass-section" class="panel">
2939
+ <div class="section-heading">
2940
+ <div>
2941
+ <h2>Break Glass</h2>
2942
+ <p>Monitor and review emergency-access requests with the same quorum rules enforced by the API.</p>
2943
+ </div>
2944
+ </div>
2945
+ <div id="breakglass-list" class="list-stack"></div>
2946
+ </section>
2947
+
2948
+ <section id="backups-section" class="panel">
2949
+ <div class="section-heading">
2950
+ <div>
2951
+ <h2>Backups</h2>
2952
+ <p>Export, inspect, and restore logical backups inside the current tenant or global operator scope.</p>
2953
+ </div>
2954
+ </div>
2955
+ <div class="panel-grid">
2956
+ <div class="span-5 panel">
2957
+ <div class="panel-actions">
2958
+ <button class="button" id="backup-export" type="button" data-busy-label="Exporting..." data-idle-label="Export backup">Export backup</button>
2959
+ <button class="button-secondary" id="backup-download" type="button">Download last export</button>
2960
+ </div>
2961
+ <div class="panel-actions">
2962
+ <button class="button-secondary" id="backup-inspect" type="button" data-busy-label="Inspecting..." data-idle-label="Inspect pasted backup">Inspect pasted backup</button>
2963
+ <button class="button-danger" id="backup-restore" type="button" data-busy-label="Restoring..." data-idle-label="Restore pasted backup">Restore pasted backup</button>
2964
+ </div>
2965
+ <p class="panel-footnote">Tenant-scoped operators still get tenant-scoped export and restore behavior. Foreign-tenant restore payloads are rejected server-side.</p>
2966
+ </div>
2967
+ <div class="span-7 panel"><div class="section-heading"><div><h2 style="font-size:1.4rem;">Backup Summary</h2></div></div><div id="backup-summary"></div></div>
2968
+ <div class="span-12 panel"><div class="section-heading"><div><h2 style="font-size:1.4rem;">Backup JSON</h2></div></div><textarea id="backup-json" style="width:100%; min-height: 260px;"></textarea></div>
2969
+ </div>
2970
+ </section>
2971
+
2972
+ <section id="audit-section" class="panel">
2973
+ <div class="section-heading">
2974
+ <div>
2975
+ <h2>Audit</h2>
2976
+ <p>Recent broker, auth, and operator events in reverse chronological order.</p>
2977
+ </div>
2978
+ </div>
2979
+ <div id="audit-list"></div>
2980
+ </section>
2981
+
2982
+ <section id="system-section" class="panel">
2983
+ <div class="section-heading">
2984
+ <div>
2985
+ <h2>System</h2>
2986
+ <p>Maintenance state, adapters, trace exporter status, traces, and rotation runs.</p>
2987
+ </div>
2988
+ <div class="toolbar">
2989
+ <button class="button-secondary" id="run-maintenance" type="button" data-busy-label="Running maintenance..." data-idle-label="Run maintenance">Run maintenance</button>
2990
+ <button class="button-secondary" id="flush-traces" type="button" data-busy-label="Flushing..." data-idle-label="Flush trace exporter">Flush trace exporter</button>
2991
+ </div>
2992
+ </div>
2993
+ <div class="panel-grid">
2994
+ <div class="span-6 panel"><div class="section-heading"><div><h2 style="font-size:1.4rem;">Maintenance</h2></div></div><div id="maintenance-status"></div></div>
2995
+ <div class="span-6 panel"><div class="section-heading"><div><h2 style="font-size:1.4rem;">Trace Exporter</h2></div></div><div id="trace-exporter-status"></div></div>
2996
+ <div class="span-6 panel"><div class="section-heading"><div><h2 style="font-size:1.4rem;">Adapters</h2></div></div><div id="adapter-health"></div></div>
2997
+ <div class="span-6 panel"><div class="section-heading"><div><h2 style="font-size:1.4rem;">Recent Traces</h2></div></div><div id="recent-traces"></div></div>
2998
+ <div class="span-12 panel"><div class="section-heading"><div><h2 style="font-size:1.4rem;">Rotation Runs</h2></div></div><div id="rotation-list"></div></div>
2999
+ </div>
3000
+ </section>
3001
+ </div>
3002
+ </div>
3003
+ </main>
3004
+ </div>
3005
+
3006
+ <script>window.__KEYLORE_ADMIN_CONFIG__ = ${JSON.stringify(config)};</script>
3007
+ <script type="module">${adminApp}</script>
3008
+ </body>
3009
+ </html>`;
3010
+ }