@sandprivacy/sandgate 0.1.3 → 0.1.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.js +9 -2
- package/dist/relay/pwa-page.js +246 -96
- package/dist/relay/server.js +14 -1
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -315,21 +315,28 @@ async function cmdPair(relayUrl) {
|
|
|
315
315
|
qrcode.generate(pairLink, { small: true });
|
|
316
316
|
console.log("\nWaiting for the phone to subscribe (2 min)…");
|
|
317
317
|
const deadline = Date.now() + 120_000;
|
|
318
|
+
let seenAnnounced = false;
|
|
318
319
|
while (Date.now() < deadline) {
|
|
319
320
|
try {
|
|
320
321
|
const res = await fetch(`${base}/api/pair-status?pairId=${encodeURIComponent(pairing.pairId)}`);
|
|
321
322
|
const status = (await res.json());
|
|
322
323
|
if (status.subscribed) {
|
|
323
|
-
console.log("Paired
|
|
324
|
+
console.log("Paired, push notifications on. The PWA now takes over approvals (Telegram becomes the fallback). Try: sandgate test-approval");
|
|
324
325
|
return;
|
|
325
326
|
}
|
|
327
|
+
if (status.seen && !seenAnnounced) {
|
|
328
|
+
seenAnnounced = true;
|
|
329
|
+
console.log('Phone connected. For notifications with the app closed, tap "Enable notifications" in the app (on iPhone: install to home screen first)…');
|
|
330
|
+
}
|
|
326
331
|
}
|
|
327
332
|
catch {
|
|
328
333
|
// relay not reachable yet; keep trying
|
|
329
334
|
}
|
|
330
335
|
await new Promise((r) => setTimeout(r, 3000));
|
|
331
336
|
}
|
|
332
|
-
console.log(
|
|
337
|
+
console.log(seenAnnounced
|
|
338
|
+
? "Paired (no push yet — the app works while open; enable notifications in it when you can)."
|
|
339
|
+
: "No phone yet — the pairing is saved anyway. Open the link on the phone, then check with: sandgate test-approval");
|
|
333
340
|
}
|
|
334
341
|
async function cmdTestApproval() {
|
|
335
342
|
const prompter = new Prompter();
|
package/dist/relay/pwa-page.js
CHANGED
|
@@ -38,8 +38,14 @@ export function pwaManifest(opts) {
|
|
|
38
38
|
export const PWA_SW = `
|
|
39
39
|
self.addEventListener("install", function () { self.skipWaiting(); });
|
|
40
40
|
self.addEventListener("activate", function (e) { e.waitUntil(self.clients.claim()); });
|
|
41
|
-
// A fetch handler is required for Chrome's install prompt
|
|
42
|
-
|
|
41
|
+
// A fetch handler is required for Chrome's install prompt. Handle ONLY
|
|
42
|
+
// same-origin GETs: on iOS 16.4+, respondWith(fetch(request)) on POSTs
|
|
43
|
+
// (bodies) throws Internal error and silently kills subscribe/decision
|
|
44
|
+
// calls — anything we return from lets the browser handle natively.
|
|
45
|
+
self.addEventListener("fetch", function (e) {
|
|
46
|
+
if (e.request.method !== "GET") return;
|
|
47
|
+
e.respondWith(fetch(e.request));
|
|
48
|
+
});
|
|
43
49
|
self.addEventListener("push", function (e) {
|
|
44
50
|
e.waitUntil((async function () {
|
|
45
51
|
await self.registration.showNotification("sandgate", {
|
|
@@ -193,7 +199,8 @@ export const PWA_HTML = `<!doctype html>
|
|
|
193
199
|
.setup input {
|
|
194
200
|
width: 100%; padding: 12px 14px; margin: 14px 0 10px;
|
|
195
201
|
background: var(--panel); border: 1px solid var(--line); border-radius: 10px;
|
|
196
|
-
|
|
202
|
+
/* 16px minimum: below that, iOS Safari auto-zooms into focused inputs. */
|
|
203
|
+
color: var(--ink); font: 16px ui-monospace, monospace;
|
|
197
204
|
}
|
|
198
205
|
.setup input:focus { outline: 2px solid var(--accent); outline-offset: -1px; }
|
|
199
206
|
|
|
@@ -257,9 +264,42 @@ export const PWA_HTML = `<!doctype html>
|
|
|
257
264
|
var mm = String(text).match(/p=([A-Za-z0-9_-]{8,64})&s=([A-Za-z0-9_-]{8,})/);
|
|
258
265
|
return mm ? { pairId: mm[1], secret: mm[2] } : null;
|
|
259
266
|
}
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
267
|
+
// Several vaults can pair with this device (your laptop, your server…):
|
|
268
|
+
// pairings are a list, requests from all of them show together.
|
|
269
|
+
var PAIRS_KEY = "sandgate_pairs";
|
|
270
|
+
function loadPairs() {
|
|
271
|
+
try {
|
|
272
|
+
var list = JSON.parse(localStorage.getItem(PAIRS_KEY));
|
|
273
|
+
if (Array.isArray(list) && list.length) return list;
|
|
274
|
+
} catch (e) {}
|
|
275
|
+
// Migrate the single-pairing storage of earlier versions.
|
|
276
|
+
try {
|
|
277
|
+
var old = JSON.parse(localStorage.getItem(PAIR_KEY));
|
|
278
|
+
if (old && old.pairId) {
|
|
279
|
+
var migrated = [{ name: "Vault 1", pairId: old.pairId, secret: old.secret }];
|
|
280
|
+
localStorage.setItem(PAIRS_KEY, JSON.stringify(migrated));
|
|
281
|
+
localStorage.removeItem(PAIR_KEY);
|
|
282
|
+
return migrated;
|
|
283
|
+
}
|
|
284
|
+
} catch (e) {}
|
|
285
|
+
return [];
|
|
286
|
+
}
|
|
287
|
+
var pairs = loadPairs();
|
|
288
|
+
function savePairs() {
|
|
289
|
+
try { localStorage.setItem(PAIRS_KEY, JSON.stringify(pairs)); } catch (e) {}
|
|
290
|
+
}
|
|
291
|
+
function addPairing(parsed) {
|
|
292
|
+
for (var i = 0; i < pairs.length; i++) {
|
|
293
|
+
if (pairs[i].pairId === parsed.pairId) return false;
|
|
294
|
+
}
|
|
295
|
+
pairs.push({ name: "Vault " + (pairs.length + 1), pairId: parsed.pairId, secret: parsed.secret });
|
|
296
|
+
savePairs();
|
|
297
|
+
return true;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
var candidate = parsePairing(location.hash);
|
|
301
|
+
if (candidate) {
|
|
302
|
+
addPairing(candidate);
|
|
263
303
|
// iOS Safari (not installed): KEEP the pairing link in the address bar.
|
|
264
304
|
// With no start_url in the Apple manifest, Add to Home Screen captures
|
|
265
305
|
// this exact URL — fragment included — so the installed app opens
|
|
@@ -267,11 +307,9 @@ export const PWA_HTML = `<!doctype html>
|
|
|
267
307
|
if (!(isIOS && !standalone)) {
|
|
268
308
|
history.replaceState(null, "", location.pathname);
|
|
269
309
|
}
|
|
270
|
-
} else {
|
|
271
|
-
try { pair = JSON.parse(localStorage.getItem(PAIR_KEY)); } catch (e) {}
|
|
272
310
|
}
|
|
273
311
|
|
|
274
|
-
if (!
|
|
312
|
+
if (!pairs.length) {
|
|
275
313
|
setStatus("not paired", "err");
|
|
276
314
|
var setup = document.createElement("div");
|
|
277
315
|
setup.className = "setup";
|
|
@@ -284,15 +322,13 @@ export const PWA_HTML = `<!doctype html>
|
|
|
284
322
|
document.getElementById("pasteLink").addEventListener("input", function (e) {
|
|
285
323
|
var parsed = parsePairing(e.target.value);
|
|
286
324
|
if (parsed) {
|
|
287
|
-
|
|
325
|
+
addPairing(parsed);
|
|
288
326
|
location.replace(location.pathname);
|
|
289
327
|
}
|
|
290
328
|
});
|
|
291
329
|
return;
|
|
292
330
|
}
|
|
293
331
|
|
|
294
|
-
var pairLink = location.origin + "/#p=" + pair.pairId + "&s=" + pair.secret;
|
|
295
|
-
|
|
296
332
|
// --- install guidance (mobile browser, not yet installed) ---------------
|
|
297
333
|
var deferredInstall = null;
|
|
298
334
|
window.addEventListener("beforeinstallprompt", function (e) {
|
|
@@ -326,20 +362,26 @@ export const PWA_HTML = `<!doctype html>
|
|
|
326
362
|
}
|
|
327
363
|
renderBanner();
|
|
328
364
|
|
|
329
|
-
// --- crypto (mirror of pwacrypto.ts)
|
|
330
|
-
var
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
365
|
+
// --- crypto (mirror of pwacrypto.ts), one derived key per vault ----------
|
|
366
|
+
var keyCache = {};
|
|
367
|
+
function keyFor(p) {
|
|
368
|
+
if (!keyCache[p.pairId]) {
|
|
369
|
+
keyCache[p.pairId] = (async function () {
|
|
370
|
+
var raw = await crypto.subtle.importKey("raw", b64uToBytes(p.secret), "HKDF", false, ["deriveKey"]);
|
|
371
|
+
return crypto.subtle.deriveKey(
|
|
372
|
+
{ name: "HKDF", hash: "SHA-256", salt: enc.encode("sandgate-pwa-v1"), info: enc.encode("approval-channel") },
|
|
373
|
+
raw,
|
|
374
|
+
{ name: "AES-GCM", length: 256 },
|
|
375
|
+
false,
|
|
376
|
+
["encrypt", "decrypt"]
|
|
377
|
+
);
|
|
378
|
+
})();
|
|
379
|
+
}
|
|
380
|
+
return keyCache[p.pairId];
|
|
381
|
+
}
|
|
340
382
|
|
|
341
|
-
async function openSealed(sealed, aad) {
|
|
342
|
-
var key = await
|
|
383
|
+
async function openSealed(p, sealed, aad) {
|
|
384
|
+
var key = await keyFor(p);
|
|
343
385
|
var pt = await crypto.subtle.decrypt(
|
|
344
386
|
{ name: "AES-GCM", iv: b64uToBytes(sealed.iv), additionalData: enc.encode(aad) },
|
|
345
387
|
key,
|
|
@@ -347,8 +389,8 @@ export const PWA_HTML = `<!doctype html>
|
|
|
347
389
|
);
|
|
348
390
|
return JSON.parse(dec.decode(pt));
|
|
349
391
|
}
|
|
350
|
-
async function sealPayload(payload, aad) {
|
|
351
|
-
var key = await
|
|
392
|
+
async function sealPayload(p, payload, aad) {
|
|
393
|
+
var key = await keyFor(p);
|
|
352
394
|
var iv = crypto.getRandomValues(new Uint8Array(12));
|
|
353
395
|
var ct = await crypto.subtle.encrypt(
|
|
354
396
|
{ name: "AES-GCM", iv: iv, additionalData: enc.encode(aad) },
|
|
@@ -358,54 +400,101 @@ export const PWA_HTML = `<!doctype html>
|
|
|
358
400
|
return { iv: bytesToB64u(iv), ct: bytesToB64u(ct) };
|
|
359
401
|
}
|
|
360
402
|
|
|
361
|
-
// --- push subscription
|
|
403
|
+
// --- presence + push subscription ---------------------------------------
|
|
404
|
+
// Announce this page to the relay immediately (push or not), so the
|
|
405
|
+
// "sandgate pair" command can report "phone connected" without waiting
|
|
406
|
+
// on notification permission.
|
|
407
|
+
pairs.forEach(function (p) {
|
|
408
|
+
fetch("/api/hello", {
|
|
409
|
+
method: "POST",
|
|
410
|
+
headers: { "Content-Type": "application/json" },
|
|
411
|
+
body: JSON.stringify({ pairId: p.pairId }),
|
|
412
|
+
}).catch(function () {});
|
|
413
|
+
});
|
|
414
|
+
|
|
362
415
|
var pushOn = false;
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
if ("serviceWorker" in navigator) {
|
|
366
|
-
var reg = await navigator.serviceWorker.register("/sw.js");
|
|
416
|
+
var swRegPromise = "serviceWorker" in navigator
|
|
417
|
+
? navigator.serviceWorker.register("/sw.js").then(function (reg) {
|
|
367
418
|
navigator.serviceWorker.addEventListener("message", function (e) {
|
|
368
419
|
if (e.data === "refresh") fetchPending();
|
|
369
420
|
});
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
421
|
+
return reg;
|
|
422
|
+
})
|
|
423
|
+
: Promise.resolve(null);
|
|
424
|
+
|
|
425
|
+
async function enablePush() {
|
|
426
|
+
var reg = await swRegPromise;
|
|
427
|
+
if (!reg || !("PushManager" in window)) return false;
|
|
428
|
+
// iOS only shows the permission prompt inside a user gesture, and only
|
|
429
|
+
// in the installed app — never in Safari tabs.
|
|
430
|
+
var perm = await Notification.requestPermission();
|
|
431
|
+
if (perm !== "granted") return false;
|
|
432
|
+
var vapid = await (await fetch("/api/vapid")).json();
|
|
433
|
+
var sub = await reg.pushManager.subscribe({
|
|
434
|
+
userVisibleOnly: true,
|
|
435
|
+
applicationServerKey: b64uToBytes(vapid.publicKey),
|
|
436
|
+
});
|
|
437
|
+
// One device subscription, registered for every paired vault.
|
|
438
|
+
await Promise.all(pairs.map(function (p) {
|
|
439
|
+
return fetch("/api/subscribe", {
|
|
440
|
+
method: "POST",
|
|
441
|
+
headers: { "Content-Type": "application/json" },
|
|
442
|
+
body: JSON.stringify({ pairId: p.pairId, subscription: sub }),
|
|
443
|
+
});
|
|
444
|
+
}));
|
|
445
|
+
pushOn = true;
|
|
446
|
+
setStatus("push on");
|
|
447
|
+
var btn = document.getElementById("pushBtn");
|
|
448
|
+
if (btn) btn.parentElement.remove();
|
|
449
|
+
return true;
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
function showPushError(err) {
|
|
453
|
+
setStatus("push error", "err");
|
|
454
|
+
alert(
|
|
455
|
+
"Notifications could not be enabled: " +
|
|
456
|
+
(err && err.message ? err.name + " — " + err.message : err) +
|
|
457
|
+
"\\n\\nOn iPhone this requires iOS 16.4+, the app installed on the home screen, and Lockdown Mode off."
|
|
458
|
+
);
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
function offerPush() {
|
|
462
|
+
if (!("Notification" in window) || !("PushManager" in window)) return;
|
|
463
|
+
if (isIOS && !standalone) return; // impossible in Safari tabs; banner handles install
|
|
464
|
+
if (Notification.permission === "granted") {
|
|
465
|
+
enablePush().catch(showPushError);
|
|
466
|
+
return;
|
|
467
|
+
}
|
|
468
|
+
if (Notification.permission === "denied") return;
|
|
469
|
+
var b = document.createElement("div");
|
|
470
|
+
b.className = "banner";
|
|
471
|
+
b.innerHTML =
|
|
472
|
+
'<h2>One tap left: notifications</h2>' +
|
|
473
|
+
'<button class="act" id="pushBtn">Enable notifications</button>';
|
|
474
|
+
bannerEl.appendChild(b);
|
|
475
|
+
document.getElementById("pushBtn").addEventListener("click", function () {
|
|
476
|
+
enablePush().catch(showPushError);
|
|
477
|
+
});
|
|
478
|
+
}
|
|
479
|
+
offerPush();
|
|
392
480
|
|
|
393
481
|
// --- live updates: SSE with a slow safety poll ---------------------------
|
|
394
482
|
var sseOn = false;
|
|
395
483
|
function connectEvents() {
|
|
396
484
|
if (!("EventSource" in window)) return;
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
485
|
+
pairs.forEach(function (p) {
|
|
486
|
+
var es = new EventSource("/api/events?pairId=" + encodeURIComponent(p.pairId));
|
|
487
|
+
es.addEventListener("request", fetchPending);
|
|
488
|
+
es.addEventListener("decision", fetchPending);
|
|
489
|
+
es.onopen = function () {
|
|
490
|
+
sseOn = true;
|
|
491
|
+
if (!pushOn) setStatus("live");
|
|
492
|
+
};
|
|
493
|
+
es.onerror = function () {
|
|
494
|
+
// EventSource reconnects on its own (retry: 3000).
|
|
495
|
+
if (!pushOn) setStatus("polling", "warn");
|
|
496
|
+
};
|
|
497
|
+
});
|
|
409
498
|
}
|
|
410
499
|
connectEvents();
|
|
411
500
|
setInterval(function () { if (!document.hidden && !sseOn) fetchPending(); }, 8000);
|
|
@@ -470,32 +559,35 @@ export const PWA_HTML = `<!doctype html>
|
|
|
470
559
|
}
|
|
471
560
|
|
|
472
561
|
async function fetchPending() {
|
|
473
|
-
var raw;
|
|
474
|
-
try {
|
|
475
|
-
raw = await (await fetch("/api/pending?pairId=" + encodeURIComponent(pair.pairId))).json();
|
|
476
|
-
} catch (e) { return; }
|
|
477
562
|
var seen = {};
|
|
478
|
-
|
|
479
|
-
var
|
|
480
|
-
seen[id] = true;
|
|
481
|
-
if (cards[id]) continue;
|
|
563
|
+
await Promise.all(pairs.map(async function (p) {
|
|
564
|
+
var raw;
|
|
482
565
|
try {
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
566
|
+
raw = await (await fetch("/api/pending?pairId=" + encodeURIComponent(p.pairId))).json();
|
|
567
|
+
} catch (e) { return; }
|
|
568
|
+
for (var i = 0; i < raw.length; i++) {
|
|
569
|
+
var key = p.pairId + ":" + raw[i].requestId;
|
|
570
|
+
seen[key] = true;
|
|
571
|
+
if (cards[key]) continue;
|
|
572
|
+
try {
|
|
573
|
+
var req = await openSealed(p, raw[i].payload, "req:" + raw[i].requestId);
|
|
574
|
+
addCard(key, p, raw[i].requestId, req);
|
|
575
|
+
} catch (e) { /* not ours / tampered */ }
|
|
576
|
+
}
|
|
577
|
+
}));
|
|
487
578
|
for (var cid in cards) {
|
|
488
579
|
if (!seen[cid]) { cards[cid].el.remove(); delete cards[cid]; }
|
|
489
580
|
}
|
|
490
581
|
ensureEmpty(Object.keys(cards).length === 0);
|
|
491
582
|
}
|
|
492
583
|
|
|
493
|
-
function addCard(id, req) {
|
|
584
|
+
function addCard(id, p, requestId, req) {
|
|
494
585
|
var card = document.createElement("div");
|
|
495
586
|
card.className = "card";
|
|
496
587
|
|
|
497
588
|
var who = document.createElement("div"); who.className = "who";
|
|
498
|
-
who.textContent = "agent · approval request";
|
|
589
|
+
who.textContent = (pairs.length > 1 ? p.name + " · " : "") + "agent · approval request";
|
|
590
|
+
card.appendChild(who);
|
|
499
591
|
var h = document.createElement("h2"); h.textContent = req.title; card.appendChild(h);
|
|
500
592
|
if (req.body) { var p = document.createElement("p"); p.textContent = req.body; card.appendChild(p); }
|
|
501
593
|
|
|
@@ -513,7 +605,7 @@ export const PWA_HTML = `<!doctype html>
|
|
|
513
605
|
|
|
514
606
|
ensureEmpty(false);
|
|
515
607
|
listEl.appendChild(card);
|
|
516
|
-
cards[id] = { el: card, leftEl: left, fillEl: fill, barEl: bar, rowEl: row, req: req, done: false };
|
|
608
|
+
cards[id] = { el: card, leftEl: left, fillEl: fill, barEl: bar, rowEl: row, req: req, pair: p, requestId: requestId, done: false };
|
|
517
609
|
tickOne(cards[id]);
|
|
518
610
|
}
|
|
519
611
|
|
|
@@ -527,7 +619,7 @@ export const PWA_HTML = `<!doctype html>
|
|
|
527
619
|
c.rowEl.remove();
|
|
528
620
|
c.leftEl.textContent = "expired — denied";
|
|
529
621
|
c.fillEl.style.width = "0%";
|
|
530
|
-
recordHist(c
|
|
622
|
+
recordHist(histLabel(c), "expired");
|
|
531
623
|
}
|
|
532
624
|
return;
|
|
533
625
|
}
|
|
@@ -548,25 +640,83 @@ export const PWA_HTML = `<!doctype html>
|
|
|
548
640
|
var c = cards[id];
|
|
549
641
|
if (!c || c.done) return;
|
|
550
642
|
b.disabled = true;
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
643
|
+
try {
|
|
644
|
+
var payload = await sealPayload(
|
|
645
|
+
c.pair,
|
|
646
|
+
{ requestId: c.requestId, approved: cls === "ok", ts: Date.now() },
|
|
647
|
+
"dec:" + c.requestId
|
|
648
|
+
);
|
|
649
|
+
var res = await fetch("/api/decision", {
|
|
650
|
+
method: "POST",
|
|
651
|
+
headers: { "Content-Type": "application/json" },
|
|
652
|
+
body: JSON.stringify({ pairId: c.pair.pairId, requestId: c.requestId, payload: payload }),
|
|
653
|
+
});
|
|
654
|
+
if (!res.ok) throw new Error("relay answered HTTP " + res.status);
|
|
655
|
+
if (cards[id]) {
|
|
656
|
+
recordHist(histLabel(c), cls === "ok" ? "approved" : "denied");
|
|
657
|
+
cards[id].el.remove();
|
|
658
|
+
delete cards[id];
|
|
659
|
+
}
|
|
660
|
+
ensureEmpty(Object.keys(cards).length === 0);
|
|
661
|
+
} catch (err) {
|
|
662
|
+
b.disabled = false;
|
|
663
|
+
alert("Could not send your decision: " + (err && err.message ? err.message : err));
|
|
564
664
|
}
|
|
565
|
-
ensureEmpty(Object.keys(cards).length === 0);
|
|
566
665
|
};
|
|
567
666
|
return b;
|
|
568
667
|
}
|
|
569
668
|
|
|
669
|
+
function histLabel(c) {
|
|
670
|
+
return (pairs.length > 1 ? c.pair.name + ": " : "") + c.req.title;
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
// --- vault manager -------------------------------------------------------
|
|
674
|
+
var vaultsEl = document.createElement("div");
|
|
675
|
+
histEl.parentElement.appendChild(vaultsEl);
|
|
676
|
+
function renderVaults() {
|
|
677
|
+
vaultsEl.textContent = "";
|
|
678
|
+
var section = document.createElement("div");
|
|
679
|
+
section.className = "hist";
|
|
680
|
+
var h = document.createElement("h3");
|
|
681
|
+
h.textContent = "Vaults";
|
|
682
|
+
section.appendChild(h);
|
|
683
|
+
pairs.forEach(function (p, idx) {
|
|
684
|
+
var row = document.createElement("div"); row.className = "hrow";
|
|
685
|
+
var t = document.createElement("span"); t.className = "t"; t.textContent = p.name;
|
|
686
|
+
var x = document.createElement("span"); x.className = "d denied"; x.textContent = "remove";
|
|
687
|
+
x.style.cursor = "pointer";
|
|
688
|
+
x.onclick = function () {
|
|
689
|
+
if (!confirm("Remove " + p.name + " from this device?")) return;
|
|
690
|
+
pairs.splice(idx, 1);
|
|
691
|
+
savePairs();
|
|
692
|
+
location.reload();
|
|
693
|
+
};
|
|
694
|
+
row.appendChild(t); row.appendChild(x);
|
|
695
|
+
section.appendChild(row);
|
|
696
|
+
});
|
|
697
|
+
var addRow = document.createElement("div"); addRow.className = "hrow";
|
|
698
|
+
var add = document.createElement("span"); add.className = "d approved"; add.textContent = "+ add a vault";
|
|
699
|
+
add.style.cursor = "pointer";
|
|
700
|
+
add.onclick = function () {
|
|
701
|
+
if (document.getElementById("addPaste")) return;
|
|
702
|
+
var input = document.createElement("input");
|
|
703
|
+
input.id = "addPaste";
|
|
704
|
+
input.placeholder = "Paste a pairing link (sandgate pair)";
|
|
705
|
+
input.autocomplete = "off";
|
|
706
|
+
input.style.cssText = "width:100%;padding:10px 12px;margin-top:8px;background:var(--panel);border:1px solid var(--line);border-radius:8px;color:var(--ink);font:16px ui-monospace,monospace;box-sizing:border-box;";
|
|
707
|
+
input.addEventListener("input", function (e) {
|
|
708
|
+
var parsed = parsePairing(e.target.value);
|
|
709
|
+
if (parsed && addPairing(parsed)) location.reload();
|
|
710
|
+
});
|
|
711
|
+
section.appendChild(input);
|
|
712
|
+
input.focus();
|
|
713
|
+
};
|
|
714
|
+
addRow.appendChild(add);
|
|
715
|
+
section.appendChild(addRow);
|
|
716
|
+
vaultsEl.appendChild(section);
|
|
717
|
+
}
|
|
718
|
+
renderVaults();
|
|
719
|
+
|
|
570
720
|
fetchPending();
|
|
571
721
|
})();
|
|
572
722
|
</script>
|
package/dist/relay/server.js
CHANGED
|
@@ -135,11 +135,24 @@ export async function startRelay(opts) {
|
|
|
135
135
|
persist();
|
|
136
136
|
return json(res, 200, { ok: true });
|
|
137
137
|
}
|
|
138
|
+
// The page announces itself on load, push or not — this is what lets
|
|
139
|
+
// `sandgate pair` say "phone connected" even before notifications.
|
|
140
|
+
if (req.method === "POST" && url.pathname === "/api/hello") {
|
|
141
|
+
const body = await readBody(req);
|
|
142
|
+
if (!validId(body.pairId))
|
|
143
|
+
return json(res, 400, { error: "bad pairId" });
|
|
144
|
+
getPairing(body.pairId).lastSeen = Date.now();
|
|
145
|
+
return json(res, 200, { ok: true });
|
|
146
|
+
}
|
|
138
147
|
if (req.method === "GET" && url.pathname === "/api/pair-status") {
|
|
139
148
|
const pairId = url.searchParams.get("pairId") ?? "";
|
|
140
149
|
if (!validId(pairId))
|
|
141
150
|
return json(res, 400, { error: "bad pairId" });
|
|
142
|
-
|
|
151
|
+
const pairing = getPairing(pairId);
|
|
152
|
+
return json(res, 200, {
|
|
153
|
+
subscribed: !!pairing.subscription,
|
|
154
|
+
seen: !!pairing.lastSeen || !!pairing.subscription,
|
|
155
|
+
});
|
|
143
156
|
}
|
|
144
157
|
if (req.method === "POST" && url.pathname === "/api/request") {
|
|
145
158
|
const body = await readBody(req);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sandprivacy/sandgate",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.6",
|
|
4
4
|
"description": "The human gateway for AI agents — approvals, 2FA codes and email verification, self-hosted. Your agent asks; you decide; secrets never touch the LLM.",
|
|
5
5
|
"license": "AGPL-3.0-only",
|
|
6
6
|
"type": "module",
|