@synoi/gateway-lite 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,502 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>SynOI local operator dashboard</title>
7
+ <style>
8
+ :root { color-scheme: light dark; }
9
+ * { box-sizing: border-box; }
10
+ body {
11
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
12
+ margin: 0; padding: 1.5rem; max-width: 860px; margin-inline: auto;
13
+ line-height: 1.45;
14
+ }
15
+ h1 { font-size: 1.25rem; margin-bottom: 0.25rem; }
16
+ .subtitle { color: #666; font-size: 0.85rem; margin-bottom: 1.5rem; }
17
+ .card {
18
+ border: 1px solid #ccc; border-radius: 8px; padding: 1rem 1.25rem;
19
+ margin-bottom: 1rem; background: rgba(127,127,127,0.04);
20
+ }
21
+ .card.danger { border-color: #c0392b; background: rgba(192,57,43,0.06); }
22
+ .card.warn { border-color: #d68910; background: rgba(214,136,16,0.06); }
23
+ .row { display: flex; gap: 0.5rem; align-items: center; flex-wrap: wrap; }
24
+ .pill {
25
+ display: inline-block; font-size: 0.72rem; padding: 0.1rem 0.5rem;
26
+ border-radius: 999px; background: #eee; color: #333; font-family: monospace;
27
+ }
28
+ .pill.risk { background: #c0392b; color: #fff; font-weight: 600; }
29
+ button {
30
+ font: inherit; padding: 0.45rem 0.9rem; border-radius: 6px; border: 1px solid #999;
31
+ background: #fff; cursor: pointer;
32
+ }
33
+ button:hover { background: #f2f2f2; }
34
+ button:disabled { opacity: 0.5; cursor: not-allowed; }
35
+ button.primary { background: #2e7d32; color: #fff; border-color: #2e7d32; }
36
+ button.primary:hover { background: #256428; }
37
+ button.deny { background: #c0392b; color: #fff; border-color: #c0392b; }
38
+ button.deny:hover { background: #a5311f; }
39
+ input[type=text] {
40
+ font: inherit; padding: 0.4rem 0.6rem; border-radius: 6px; border: 1px solid #999;
41
+ width: 100%; max-width: 320px;
42
+ }
43
+ pre {
44
+ background: #111; color: #e8e8e8; padding: 0.75rem; border-radius: 6px;
45
+ overflow-x: auto; font-size: 0.8rem; white-space: pre-wrap; word-break: break-word;
46
+ }
47
+ .muted { color: #777; font-size: 0.85rem; }
48
+ .oid { font-family: monospace; font-size: 0.78rem; word-break: break-all; }
49
+ label.confirm { display: flex; gap: 0.5rem; align-items: flex-start; margin: 0.75rem 0; }
50
+ .status-ok { color: #2e7d32; }
51
+ .status-deny { color: #c0392b; }
52
+ .status-pending { color: #d68910; }
53
+ nav { display: flex; gap: 0.75rem; margin-bottom: 1.25rem; }
54
+ nav button { background: none; border: none; border-bottom: 2px solid transparent; border-radius: 0; padding: 0.25rem 0; }
55
+ nav button.active { border-bottom-color: #333; font-weight: 600; }
56
+ #toast {
57
+ position: fixed; bottom: 1rem; right: 1rem; max-width: 320px; padding: 0.75rem 1rem;
58
+ border-radius: 8px; background: #222; color: #fff; font-size: 0.85rem; display: none;
59
+ }
60
+ @media (prefers-color-scheme: dark) {
61
+ body { background: #15171a; color: #e6e6e6; }
62
+ .card { border-color: #333; }
63
+ button { background: #23262b; color: #e6e6e6; border-color: #444; }
64
+ button:hover { background: #2c3036; }
65
+ input[type=text] { background: #1c1e22; color: #e6e6e6; border-color: #444; }
66
+ .pill { background: #2a2d33; color: #ddd; }
67
+ }
68
+ </style>
69
+ </head>
70
+ <body>
71
+
72
+ <h1>SynOI local operator dashboard</h1>
73
+ <div class="subtitle">
74
+ Governed-action approval, self-hosted. This surface is loopback-only and talks to this
75
+ daemon's <code>/local/*</code> API on the same origin. No external network calls.
76
+ </div>
77
+
78
+ <div id="app"><p class="muted">Loading...</p></div>
79
+ <div id="toast"></div>
80
+
81
+ <script>
82
+ (function () {
83
+ 'use strict';
84
+
85
+ // ── Constants matching the daemon's contract (ADR_014 / ADR_015 / ADR_016) ──
86
+
87
+ var LOCAL_HDR = { 'X-SynOI-Local': '1' };
88
+ var STORAGE_KEY = 'synoi_local_operator_v1';
89
+ var KNOWN_ACTION_KINDS = ['command', 'render', 'background-refresh'];
90
+
91
+ // ── Tiny RFC 8785-JCS-equivalent canonicalizer for the FLAT, string-only
92
+ // payloads this dashboard signs (enroll / fetch-challenge / decide).
93
+ // Sorting keys + JSON.stringify-ing each string value is byte-identical
94
+ // to the server's @synoi/sraid canonicalize() for this restricted shape
95
+ // (no numbers, no nesting, no arrays in any payload signed here). Do NOT
96
+ // reuse this for anything beyond these three payloads without checking
97
+ // it against the full JCS spec. -->
98
+ function canonicalizeFlat(obj) {
99
+ var keys = Object.keys(obj).filter(function (k) { return obj[k] !== undefined; }).sort();
100
+ var parts = keys.map(function (k) { return JSON.stringify(k) + ':' + JSON.stringify(obj[k]); });
101
+ return '{' + parts.join(',') + '}';
102
+ }
103
+
104
+ function bytesToHex(bytes) {
105
+ var out = '';
106
+ for (var i = 0; i < bytes.length; i++) out += bytes[i].toString(16).padStart(2, '0');
107
+ return out;
108
+ }
109
+ function hexToBytes(hex) {
110
+ var out = new Uint8Array(hex.length / 2);
111
+ for (var i = 0; i < out.length; i++) out[i] = parseInt(hex.substr(i * 2, 2), 16);
112
+ return out;
113
+ }
114
+ function base64ToBytes(b64) {
115
+ var bin = atob(b64);
116
+ var out = new Uint8Array(bin.length);
117
+ for (var i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
118
+ return out;
119
+ }
120
+ function bytesToBase64(bytes) {
121
+ var bin = '';
122
+ for (var i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]);
123
+ return btoa(bin);
124
+ }
125
+ function escapeHtml(s) {
126
+ return String(s).replace(/[&<>"']/g, function (c) {
127
+ return ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c];
128
+ });
129
+ }
130
+
131
+ function toast(msg, isError) {
132
+ var el = document.getElementById('toast');
133
+ el.textContent = msg;
134
+ el.style.background = isError ? '#c0392b' : '#222';
135
+ el.style.display = 'block';
136
+ clearTimeout(toast._t);
137
+ toast._t = setTimeout(function () { el.style.display = 'none'; }, 4500);
138
+ }
139
+
140
+ // ── Operator identity: generated ONCE in this browser, persisted in
141
+ // localStorage. The private key never leaves this page (no network
142
+ // call ever carries it). This is the SAME signed-assent ceremony
143
+ // /local/decide requires from any caller; the dashboard has no
144
+ // shortcut around it. -->
145
+
146
+ var identity = null; // { privateKey: CryptoKey, publicKeyHex, operatorOid }
147
+
148
+ function loadPersistedIdentity() {
149
+ var raw = localStorage.getItem(STORAGE_KEY);
150
+ if (!raw) return null;
151
+ try { return JSON.parse(raw); } catch (e) { return null; }
152
+ }
153
+
154
+ function ensureIdentity() {
155
+ var persisted = loadPersistedIdentity();
156
+ if (persisted && persisted.pkcs8Base64 && persisted.publicKeyHex && persisted.operatorOid) {
157
+ return crypto.subtle.importKey(
158
+ 'pkcs8', base64ToBytes(persisted.pkcs8Base64), { name: 'Ed25519' }, false, ['sign']
159
+ ).then(function (privateKey) {
160
+ identity = { privateKey: privateKey, publicKeyHex: persisted.publicKeyHex, operatorOid: persisted.operatorOid };
161
+ return identity;
162
+ });
163
+ }
164
+ return crypto.subtle.generateKey({ name: 'Ed25519' }, true, ['sign', 'verify']).then(function (pair) {
165
+ return Promise.all([
166
+ crypto.subtle.exportKey('raw', pair.publicKey),
167
+ crypto.subtle.exportKey('pkcs8', pair.privateKey),
168
+ ]).then(function (exported) {
169
+ var rawPub = new Uint8Array(exported[0]);
170
+ var pkcs8 = new Uint8Array(exported[1]);
171
+ return crypto.subtle.digest('SHA-256', rawPub).then(function (digest) {
172
+ var publicKeyHex = bytesToHex(rawPub);
173
+ var operatorOid = 'oid-' + bytesToHex(new Uint8Array(digest));
174
+ localStorage.setItem(STORAGE_KEY, JSON.stringify({
175
+ pkcs8Base64: bytesToBase64(pkcs8),
176
+ publicKeyHex: publicKeyHex,
177
+ operatorOid: operatorOid,
178
+ }));
179
+ identity = { privateKey: pair.privateKey, publicKeyHex: publicKeyHex, operatorOid: operatorOid };
180
+ return identity;
181
+ });
182
+ });
183
+ });
184
+ }
185
+
186
+ function signCanonical(canonical) {
187
+ var bytes = new TextEncoder().encode(canonical);
188
+ return crypto.subtle.sign('Ed25519', identity.privateKey, bytes).then(function (sig) {
189
+ return bytesToHex(new Uint8Array(sig));
190
+ });
191
+ }
192
+
193
+ function tenantId() { return 'tenant:local-' + identity.operatorOid; }
194
+
195
+ // ── API helpers ──────────────────────────────────────────────────────────
196
+
197
+ function api(method, path, body, extraHeaders) {
198
+ var headers = Object.assign({}, LOCAL_HDR, extraHeaders || {});
199
+ var opts = { method: method, headers: headers };
200
+ if (body !== undefined) {
201
+ headers['Content-Type'] = 'application/json';
202
+ opts.body = JSON.stringify(body);
203
+ }
204
+ return fetch(path, opts).then(function (res) {
205
+ return res.json().catch(function () { return {}; }).then(function (json) {
206
+ return { status: res.status, body: json };
207
+ });
208
+ });
209
+ }
210
+
211
+ // ── Enrollment ceremony ──────────────────────────────────────────────────
212
+
213
+ function enroll(deviceLabel) {
214
+ var canonical = canonicalizeFlat({
215
+ action: 'enroll', device_label: deviceLabel, operator_oid: identity.operatorOid, tenant_id: tenantId(),
216
+ });
217
+ return signCanonical(canonical).then(function (sig) {
218
+ return api('POST', '/local/enroll', {
219
+ operator_oid: identity.operatorOid,
220
+ operator_pubkey_hex: identity.publicKeyHex,
221
+ device_label: deviceLabel,
222
+ confirm: true,
223
+ enrollment_signature: sig,
224
+ });
225
+ });
226
+ }
227
+
228
+ // ── The FULL signed-assent decide ceremony (ADR_014 Section 3.2, ADR_015).
229
+ // This is the ONLY state-changing control this dashboard emits besides
230
+ // the one-time /local/enroll bootstrap above: GET the challenge, sign
231
+ // it, sign the decide payload, POST /local/decide. No shortcut. -->
232
+
233
+ function decide(pendingOid, choice, reason) {
234
+ var challengeCanonical = canonicalizeFlat({
235
+ action: 'fetch-challenge', operator_oid: identity.operatorOid, pending_oid: pendingOid, tenant_id: tenantId(),
236
+ });
237
+ return signCanonical(challengeCanonical).then(function (challengeSig) {
238
+ return api('GET', '/local/decide-challenge/' + encodeURIComponent(pendingOid), undefined, {
239
+ 'X-Operator-OID': identity.operatorOid,
240
+ 'X-Operator-Pubkey': identity.publicKeyHex,
241
+ 'X-Operator-Challenge-Sig': challengeSig,
242
+ }).then(function (challengeRes) {
243
+ if (challengeRes.status !== 200) {
244
+ return Promise.reject(new Error('decide-challenge failed: ' + challengeRes.status + ' ' + JSON.stringify(challengeRes.body)));
245
+ }
246
+ var nonce = challengeRes.body.challenge_nonce;
247
+ var decideCanonical = canonicalizeFlat({
248
+ choice: choice, operator_oid: identity.operatorOid, pending_oid: pendingOid,
249
+ challenge_nonce: nonce, tenant_id: tenantId(),
250
+ });
251
+ return signCanonical(decideCanonical).then(function (decideSig) {
252
+ return api('POST', '/local/decide', {
253
+ pending_oid: pendingOid, choice: choice, reason: reason,
254
+ operator_oid: identity.operatorOid, operator_pubkey_hex: identity.publicKeyHex,
255
+ operator_signature: decideSig, challenge_nonce: nonce,
256
+ });
257
+ });
258
+ });
259
+ });
260
+ }
261
+
262
+ // ── Rendering ────────────────────────────────────────────────────────────
263
+
264
+ var app = document.getElementById('app');
265
+ var activeTab = 'pending';
266
+ var pendingCache = [];
267
+ var expandedOid = null;
268
+ var pollTimer = null;
269
+
270
+ function render() {
271
+ if (!identity) { app.innerHTML = '<p class="muted">Preparing local operator identity...</p>'; return; }
272
+
273
+ var nav = '<nav>' +
274
+ '<button data-tab="pending" class="' + (activeTab === 'pending' ? 'active' : '') + '">Pending</button>' +
275
+ '<button data-tab="receipts" class="' + (activeTab === 'receipts' ? 'active' : '') + '">Receipts</button>' +
276
+ '</nav>';
277
+
278
+ var idCard = '<div class="card"><div class="row">' +
279
+ '<span class="muted">Operator:</span> <span class="oid">' + escapeHtml(identity.operatorOid) + '</span>' +
280
+ '</div></div>';
281
+
282
+ if (activeTab === 'pending') {
283
+ app.innerHTML = nav + idCard + renderPending();
284
+ } else {
285
+ app.innerHTML = nav + idCard + '<p class="muted">Loading receipts...</p>';
286
+ loadReceipts();
287
+ }
288
+
289
+ Array.prototype.forEach.call(document.querySelectorAll('nav button'), function (btn) {
290
+ btn.onclick = function () { activeTab = btn.getAttribute('data-tab'); render(); };
291
+ });
292
+ wirePendingHandlers();
293
+ }
294
+
295
+ function renderPending() {
296
+ if (pendingCache.length === 0) {
297
+ return '<div class="card"><p class="muted">No pending actions. Waiting for gate() calls...</p></div>';
298
+ }
299
+ return pendingCache.map(function (action) {
300
+ var isExpanded = expandedOid === action.pending_oid;
301
+ var known = KNOWN_ACTION_KINDS.indexOf(action.action_kind) !== -1;
302
+ var riskPill = known ? '' : '<span class="pill risk">UNKNOWN ACTION KIND -- HIGHEST RISK</span>';
303
+ var head = '<div class="card' + (known ? '' : ' danger') + '" data-oid="' + escapeHtml(action.pending_oid) + '">' +
304
+ '<div class="row">' +
305
+ '<strong>' + escapeHtml(action.action_kind) + '</strong> ' + riskPill +
306
+ '<span class="pill">' + escapeHtml(action.pending_oid.slice(0, 18)) + '...</span>' +
307
+ '<span class="muted">' + escapeHtml(action.ingested_at || '') + '</span>' +
308
+ '</div>';
309
+ if (!isExpanded) {
310
+ return head + '<div class="row" style="margin-top:0.5rem"><button class="review-btn" data-oid="' + escapeHtml(action.pending_oid) + '">Review</button></div></div>';
311
+ }
312
+ var detail = action._detail;
313
+ if (!detail) {
314
+ return head + '<p class="muted">Loading detail...</p></div>';
315
+ }
316
+ var confirmId = 'confirm-' + action.pending_oid;
317
+ var needsConfirm = !known;
318
+ return head +
319
+ '<div style="margin-top:0.75rem">' +
320
+ '<div class="muted">bundle_oid: <span class="oid">' + escapeHtml(detail.bundle_oid) + '</span></div>' +
321
+ '<div class="muted">principal_oid: <span class="oid">' + escapeHtml(detail.principal_oid) + '</span></div>' +
322
+ (detail.originating_receipt_oid ? '<div class="muted">originating_receipt_oid: <span class="oid">' + escapeHtml(detail.originating_receipt_oid) + '</span></div>' : '') +
323
+ '<div class="muted" style="margin-top:0.5rem">Arguments (verbatim, unmodified):</div>' +
324
+ '<pre>' + escapeHtml(JSON.stringify(detail.args, null, 2)) + '</pre>' +
325
+ (needsConfirm ?
326
+ '<label class="confirm"><input type="checkbox" id="' + confirmId + '"> ' +
327
+ 'I have reviewed the raw arguments above for this unrecognized action kind and understand the risk.</label>'
328
+ : '') +
329
+ '<div class="row">' +
330
+ '<button class="primary approve-btn" data-oid="' + escapeHtml(action.pending_oid) + '"' + (needsConfirm ? ' data-needs-confirm="1" data-confirm-id="' + confirmId + '"' : '') + '>Approve</button>' +
331
+ '<button class="deny deny-btn" data-oid="' + escapeHtml(action.pending_oid) + '">Deny</button>' +
332
+ '<button class="collapse-btn" data-oid="' + escapeHtml(action.pending_oid) + '">Close</button>' +
333
+ '</div>' +
334
+ '</div></div>';
335
+ }).join('');
336
+ }
337
+
338
+ function wirePendingHandlers() {
339
+ Array.prototype.forEach.call(document.querySelectorAll('.review-btn'), function (btn) {
340
+ btn.onclick = function () {
341
+ var oid = btn.getAttribute('data-oid');
342
+ expandedOid = oid;
343
+ api('GET', '/local/pending/' + encodeURIComponent(oid)).then(function (res) {
344
+ if (res.status !== 200) { toast('Could not load detail: ' + res.status, true); return; }
345
+ var action = pendingCache.filter(function (a) { return a.pending_oid === oid; })[0];
346
+ if (action) action._detail = res.body;
347
+ render();
348
+ });
349
+ render();
350
+ };
351
+ });
352
+ Array.prototype.forEach.call(document.querySelectorAll('.collapse-btn'), function (btn) {
353
+ btn.onclick = function () { expandedOid = null; render(); };
354
+ });
355
+ Array.prototype.forEach.call(document.querySelectorAll('.approve-btn'), function (btn) {
356
+ btn.onclick = function () {
357
+ if (btn.getAttribute('data-needs-confirm') === '1') {
358
+ var cb = document.getElementById(btn.getAttribute('data-confirm-id'));
359
+ if (!cb || !cb.checked) { toast('Confirm you have reviewed the raw arguments first.', true); return; }
360
+ }
361
+ runDecide(btn.getAttribute('data-oid'), 'allow');
362
+ };
363
+ });
364
+ Array.prototype.forEach.call(document.querySelectorAll('.deny-btn'), function (btn) {
365
+ btn.onclick = function () { runDecide(btn.getAttribute('data-oid'), 'deny'); };
366
+ });
367
+ }
368
+
369
+ function runDecide(pendingOid, choice) {
370
+ toast(choice === 'allow' ? 'Signing approval...' : 'Signing denial...');
371
+ decide(pendingOid, choice).then(function (res) {
372
+ if (res.status !== 200) {
373
+ toast('Decision failed: ' + res.status + ' ' + (res.body && res.body.error || ''), true);
374
+ return;
375
+ }
376
+ toast('Decision recorded. Receipt: ' + res.body.receipt_oid.slice(0, 24) + '...');
377
+ expandedOid = null;
378
+ refreshPending();
379
+ }).catch(function (err) {
380
+ toast('Decision failed: ' + err.message, true);
381
+ });
382
+ }
383
+
384
+ function refreshPending() {
385
+ return api('GET', '/local/pending').then(function (res) {
386
+ if (res.status !== 200) return;
387
+ var next = res.body.pending || [];
388
+ // Preserve any already-loaded detail across a refresh.
389
+ next.forEach(function (a) {
390
+ var prior = pendingCache.filter(function (p) { return p.pending_oid === a.pending_oid; })[0];
391
+ if (prior && prior._detail) a._detail = prior._detail;
392
+ });
393
+ pendingCache = next;
394
+ if (activeTab === 'pending') render();
395
+ });
396
+ }
397
+
398
+ function loadReceipts() {
399
+ api('GET', '/local/receipts').then(function (res) {
400
+ if (activeTab !== 'receipts') return;
401
+ if (res.status !== 200) { app.innerHTML += '<div class="card danger">Failed to load receipts.</div>'; return; }
402
+ var receipts = (res.body.receipts || []).slice().reverse();
403
+ var html = receipts.length === 0
404
+ ? '<div class="card"><p class="muted">No receipts yet.</p></div>'
405
+ : receipts.map(renderReceiptRow).join('');
406
+ var wrap = document.createElement('div');
407
+ wrap.innerHTML = html;
408
+ app.appendChild(wrap);
409
+ Array.prototype.forEach.call(document.querySelectorAll('.receipt-row'), function (row) {
410
+ row.onclick = function () {
411
+ var oid = row.getAttribute('data-oid');
412
+ api('GET', '/local/receipts/' + encodeURIComponent(oid)).then(function (r) {
413
+ var pre = row.querySelector('pre');
414
+ if (pre) { pre.style.display = pre.style.display === 'none' ? 'block' : 'none'; return; }
415
+ var el = document.createElement('pre');
416
+ el.textContent = JSON.stringify(r.body, null, 2);
417
+ row.appendChild(el);
418
+ });
419
+ };
420
+ });
421
+ });
422
+ }
423
+
424
+ function statusClass(r) {
425
+ var subj = (r.body && r.body.subject_oid) || '';
426
+ var status = r.body && r.body.status;
427
+ if (status === 'ok') return 'status-ok';
428
+ if (status === 'denied') return 'status-deny';
429
+ return 'status-pending';
430
+ }
431
+
432
+ function renderReceiptRow(r) {
433
+ var scheme = r.receipt_scheme || (r.attestation ? 'synoi.receipt/v2' : 'unknown');
434
+ var verifyNote = scheme === 'synoi.receipt/gap-selfsign'
435
+ ? '<div class="muted">Verifies against your own key. A neutral third-party resolver is not yet live.</div>'
436
+ : '';
437
+ return '<div class="card receipt-row" data-oid="' + escapeHtml(r.oid) + '" style="cursor:pointer">' +
438
+ '<div class="row">' +
439
+ '<span class="' + statusClass(r) + '">' + escapeHtml((r.body && r.body.status) || '') + '</span>' +
440
+ '<span class="pill">' + escapeHtml(scheme) + '</span>' +
441
+ '<span class="oid">' + escapeHtml(r.oid) + '</span>' +
442
+ '</div>' +
443
+ verifyNote +
444
+ '</div>';
445
+ }
446
+
447
+ // ── Boot ─────────────────────────────────────────────────────────────────
448
+
449
+ function boot() {
450
+ if (!window.crypto || !window.crypto.subtle || typeof window.crypto.subtle.sign !== 'function') {
451
+ app.innerHTML = '<div class="card danger">This browser does not support the Web Crypto Ed25519 API ' +
452
+ 'needed to sign operator decisions locally. Use a current Chrome, Firefox, or Safari.</div>';
453
+ return;
454
+ }
455
+ ensureIdentity().then(function () {
456
+ return api('GET', '/local/health');
457
+ }).then(function (res) {
458
+ if (res.status !== 200) { app.innerHTML = '<div class="card danger">Daemon health check failed.</div>'; return; }
459
+ if (!res.body.enrolled) { renderEnroll(); return; }
460
+ startPolling();
461
+ render();
462
+ }).catch(function (err) {
463
+ app.innerHTML = '<div class="card danger">Startup failed: ' + escapeHtml(err.message) + '</div>';
464
+ });
465
+ }
466
+
467
+ function renderEnroll() {
468
+ app.innerHTML =
469
+ '<div class="card warn">' +
470
+ '<h2 style="margin-top:0">Enroll this device</h2>' +
471
+ '<p class="muted">No operator is enrolled yet. Enrolling binds this browser\'s locally generated ' +
472
+ 'key (never transmitted) to an operator identity that can approve or deny governed actions on this instance.</p>' +
473
+ '<div class="row">' +
474
+ '<input type="text" id="device-label" placeholder="Device label, e.g. my-laptop" value="local-browser">' +
475
+ '<button class="primary" id="enroll-btn">Enroll</button>' +
476
+ '</div>' +
477
+ '</div>';
478
+ document.getElementById('enroll-btn').onclick = function () {
479
+ var label = document.getElementById('device-label').value || 'local-browser';
480
+ toast('Enrolling...');
481
+ enroll(label).then(function (res) {
482
+ if (res.status !== 201) { toast('Enroll failed: ' + res.status + ' ' + JSON.stringify(res.body), true); return; }
483
+ toast('Enrolled.');
484
+ startPolling();
485
+ render();
486
+ }).catch(function (err) { toast('Enroll failed: ' + err.message, true); });
487
+ };
488
+ }
489
+
490
+ function startPolling() {
491
+ refreshPending();
492
+ if (pollTimer) clearInterval(pollTimer);
493
+ pollTimer = setInterval(function () {
494
+ if (activeTab === 'pending' && expandedOid === null) refreshPending();
495
+ }, 3000);
496
+ }
497
+
498
+ boot();
499
+ })();
500
+ </script>
501
+ </body>
502
+ </html>