@sandprivacy/sandgate 0.1.3 → 0.1.5

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 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! The PWA now takes over approvals (Telegram becomes the fallback). Try: sandgate test-approval");
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("No subscription yet — the pairing is saved anyway. Open the link on the phone, then check with: sandgate test-approval");
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();
@@ -257,9 +257,42 @@ export const PWA_HTML = `<!doctype html>
257
257
  var mm = String(text).match(/p=([A-Za-z0-9_-]{8,64})&s=([A-Za-z0-9_-]{8,})/);
258
258
  return mm ? { pairId: mm[1], secret: mm[2] } : null;
259
259
  }
260
- var pair = parsePairing(location.hash);
261
- if (pair) {
262
- try { localStorage.setItem(PAIR_KEY, JSON.stringify(pair)); } catch (e) {}
260
+ // Several vaults can pair with this device (your laptop, your server…):
261
+ // pairings are a list, requests from all of them show together.
262
+ var PAIRS_KEY = "sandgate_pairs";
263
+ function loadPairs() {
264
+ try {
265
+ var list = JSON.parse(localStorage.getItem(PAIRS_KEY));
266
+ if (Array.isArray(list) && list.length) return list;
267
+ } catch (e) {}
268
+ // Migrate the single-pairing storage of earlier versions.
269
+ try {
270
+ var old = JSON.parse(localStorage.getItem(PAIR_KEY));
271
+ if (old && old.pairId) {
272
+ var migrated = [{ name: "Vault 1", pairId: old.pairId, secret: old.secret }];
273
+ localStorage.setItem(PAIRS_KEY, JSON.stringify(migrated));
274
+ localStorage.removeItem(PAIR_KEY);
275
+ return migrated;
276
+ }
277
+ } catch (e) {}
278
+ return [];
279
+ }
280
+ var pairs = loadPairs();
281
+ function savePairs() {
282
+ try { localStorage.setItem(PAIRS_KEY, JSON.stringify(pairs)); } catch (e) {}
283
+ }
284
+ function addPairing(parsed) {
285
+ for (var i = 0; i < pairs.length; i++) {
286
+ if (pairs[i].pairId === parsed.pairId) return false;
287
+ }
288
+ pairs.push({ name: "Vault " + (pairs.length + 1), pairId: parsed.pairId, secret: parsed.secret });
289
+ savePairs();
290
+ return true;
291
+ }
292
+
293
+ var candidate = parsePairing(location.hash);
294
+ if (candidate) {
295
+ addPairing(candidate);
263
296
  // iOS Safari (not installed): KEEP the pairing link in the address bar.
264
297
  // With no start_url in the Apple manifest, Add to Home Screen captures
265
298
  // this exact URL — fragment included — so the installed app opens
@@ -267,11 +300,9 @@ export const PWA_HTML = `<!doctype html>
267
300
  if (!(isIOS && !standalone)) {
268
301
  history.replaceState(null, "", location.pathname);
269
302
  }
270
- } else {
271
- try { pair = JSON.parse(localStorage.getItem(PAIR_KEY)); } catch (e) {}
272
303
  }
273
304
 
274
- if (!pair) {
305
+ if (!pairs.length) {
275
306
  setStatus("not paired", "err");
276
307
  var setup = document.createElement("div");
277
308
  setup.className = "setup";
@@ -284,15 +315,13 @@ export const PWA_HTML = `<!doctype html>
284
315
  document.getElementById("pasteLink").addEventListener("input", function (e) {
285
316
  var parsed = parsePairing(e.target.value);
286
317
  if (parsed) {
287
- try { localStorage.setItem(PAIR_KEY, JSON.stringify(parsed)); } catch (err) {}
318
+ addPairing(parsed);
288
319
  location.replace(location.pathname);
289
320
  }
290
321
  });
291
322
  return;
292
323
  }
293
324
 
294
- var pairLink = location.origin + "/#p=" + pair.pairId + "&s=" + pair.secret;
295
-
296
325
  // --- install guidance (mobile browser, not yet installed) ---------------
297
326
  var deferredInstall = null;
298
327
  window.addEventListener("beforeinstallprompt", function (e) {
@@ -326,20 +355,26 @@ export const PWA_HTML = `<!doctype html>
326
355
  }
327
356
  renderBanner();
328
357
 
329
- // --- crypto (mirror of pwacrypto.ts) ------------------------------------
330
- var keyPromise = (async function () {
331
- var raw = await crypto.subtle.importKey("raw", b64uToBytes(pair.secret), "HKDF", false, ["deriveKey"]);
332
- return crypto.subtle.deriveKey(
333
- { name: "HKDF", hash: "SHA-256", salt: enc.encode("sandgate-pwa-v1"), info: enc.encode("approval-channel") },
334
- raw,
335
- { name: "AES-GCM", length: 256 },
336
- false,
337
- ["encrypt", "decrypt"]
338
- );
339
- })();
358
+ // --- crypto (mirror of pwacrypto.ts), one derived key per vault ----------
359
+ var keyCache = {};
360
+ function keyFor(p) {
361
+ if (!keyCache[p.pairId]) {
362
+ keyCache[p.pairId] = (async function () {
363
+ var raw = await crypto.subtle.importKey("raw", b64uToBytes(p.secret), "HKDF", false, ["deriveKey"]);
364
+ return crypto.subtle.deriveKey(
365
+ { name: "HKDF", hash: "SHA-256", salt: enc.encode("sandgate-pwa-v1"), info: enc.encode("approval-channel") },
366
+ raw,
367
+ { name: "AES-GCM", length: 256 },
368
+ false,
369
+ ["encrypt", "decrypt"]
370
+ );
371
+ })();
372
+ }
373
+ return keyCache[p.pairId];
374
+ }
340
375
 
341
- async function openSealed(sealed, aad) {
342
- var key = await keyPromise;
376
+ async function openSealed(p, sealed, aad) {
377
+ var key = await keyFor(p);
343
378
  var pt = await crypto.subtle.decrypt(
344
379
  { name: "AES-GCM", iv: b64uToBytes(sealed.iv), additionalData: enc.encode(aad) },
345
380
  key,
@@ -347,8 +382,8 @@ export const PWA_HTML = `<!doctype html>
347
382
  );
348
383
  return JSON.parse(dec.decode(pt));
349
384
  }
350
- async function sealPayload(payload, aad) {
351
- var key = await keyPromise;
385
+ async function sealPayload(p, payload, aad) {
386
+ var key = await keyFor(p);
352
387
  var iv = crypto.getRandomValues(new Uint8Array(12));
353
388
  var ct = await crypto.subtle.encrypt(
354
389
  { name: "AES-GCM", iv: iv, additionalData: enc.encode(aad) },
@@ -358,54 +393,92 @@ export const PWA_HTML = `<!doctype html>
358
393
  return { iv: bytesToB64u(iv), ct: bytesToB64u(ct) };
359
394
  }
360
395
 
361
- // --- push subscription ---------------------------------------------------
396
+ // --- presence + push subscription ---------------------------------------
397
+ // Announce this page to the relay immediately (push or not), so the
398
+ // "sandgate pair" command can report "phone connected" without waiting
399
+ // on notification permission.
400
+ pairs.forEach(function (p) {
401
+ fetch("/api/hello", {
402
+ method: "POST",
403
+ headers: { "Content-Type": "application/json" },
404
+ body: JSON.stringify({ pairId: p.pairId }),
405
+ }).catch(function () {});
406
+ });
407
+
362
408
  var pushOn = false;
363
- (async function () {
364
- try {
365
- if ("serviceWorker" in navigator) {
366
- var reg = await navigator.serviceWorker.register("/sw.js");
409
+ var swRegPromise = "serviceWorker" in navigator
410
+ ? navigator.serviceWorker.register("/sw.js").then(function (reg) {
367
411
  navigator.serviceWorker.addEventListener("message", function (e) {
368
412
  if (e.data === "refresh") fetchPending();
369
413
  });
370
- if ("PushManager" in window && (standalone || !isIOS)) {
371
- var perm = await Notification.requestPermission();
372
- if (perm === "granted") {
373
- var vapid = await (await fetch("/api/vapid")).json();
374
- var sub = await reg.pushManager.subscribe({
375
- userVisibleOnly: true,
376
- applicationServerKey: b64uToBytes(vapid.publicKey),
377
- });
378
- await fetch("/api/subscribe", {
379
- method: "POST",
380
- headers: { "Content-Type": "application/json" },
381
- body: JSON.stringify({ pairId: pair.pairId, subscription: sub }),
382
- });
383
- pushOn = true;
384
- setStatus("push on");
385
- return;
386
- }
387
- }
388
- }
389
- } catch (e) { /* fall through to live/polling status */ }
390
- if (!pushOn) setStatus(sseOn ? "live" : "polling", "warn");
391
- })();
414
+ return reg;
415
+ })
416
+ : Promise.resolve(null);
417
+
418
+ async function enablePush() {
419
+ var reg = await swRegPromise;
420
+ if (!reg || !("PushManager" in window)) return false;
421
+ // iOS only shows the permission prompt inside a user gesture, and only
422
+ // in the installed app — never in Safari tabs.
423
+ var perm = await Notification.requestPermission();
424
+ if (perm !== "granted") return false;
425
+ var vapid = await (await fetch("/api/vapid")).json();
426
+ var sub = await reg.pushManager.subscribe({
427
+ userVisibleOnly: true,
428
+ applicationServerKey: b64uToBytes(vapid.publicKey),
429
+ });
430
+ // One device subscription, registered for every paired vault.
431
+ await Promise.all(pairs.map(function (p) {
432
+ return fetch("/api/subscribe", {
433
+ method: "POST",
434
+ headers: { "Content-Type": "application/json" },
435
+ body: JSON.stringify({ pairId: p.pairId, subscription: sub }),
436
+ });
437
+ }));
438
+ pushOn = true;
439
+ setStatus("push on");
440
+ var btn = document.getElementById("pushBtn");
441
+ if (btn) btn.parentElement.remove();
442
+ return true;
443
+ }
444
+
445
+ function offerPush() {
446
+ if (!("Notification" in window) || !("PushManager" in window)) return;
447
+ if (isIOS && !standalone) return; // impossible in Safari tabs; banner handles install
448
+ if (Notification.permission === "granted") {
449
+ enablePush().catch(function () {});
450
+ return;
451
+ }
452
+ if (Notification.permission === "denied") return;
453
+ var b = document.createElement("div");
454
+ b.className = "banner";
455
+ b.innerHTML =
456
+ '<h2>One tap left: notifications</h2>' +
457
+ '<button class="act" id="pushBtn">Enable notifications</button>';
458
+ bannerEl.appendChild(b);
459
+ document.getElementById("pushBtn").addEventListener("click", function () {
460
+ enablePush().catch(function () {});
461
+ });
462
+ }
463
+ offerPush();
392
464
 
393
465
  // --- live updates: SSE with a slow safety poll ---------------------------
394
466
  var sseOn = false;
395
467
  function connectEvents() {
396
468
  if (!("EventSource" in window)) return;
397
- var es = new EventSource("/api/events?pairId=" + encodeURIComponent(pair.pairId));
398
- es.addEventListener("request", fetchPending);
399
- es.addEventListener("decision", fetchPending);
400
- es.onopen = function () {
401
- sseOn = true;
402
- if (!pushOn) setStatus("live");
403
- };
404
- es.onerror = function () {
405
- sseOn = false;
406
- if (!pushOn) setStatus("polling", "warn");
407
- // EventSource reconnects on its own (retry: 3000).
408
- };
469
+ pairs.forEach(function (p) {
470
+ var es = new EventSource("/api/events?pairId=" + encodeURIComponent(p.pairId));
471
+ es.addEventListener("request", fetchPending);
472
+ es.addEventListener("decision", fetchPending);
473
+ es.onopen = function () {
474
+ sseOn = true;
475
+ if (!pushOn) setStatus("live");
476
+ };
477
+ es.onerror = function () {
478
+ // EventSource reconnects on its own (retry: 3000).
479
+ if (!pushOn) setStatus("polling", "warn");
480
+ };
481
+ });
409
482
  }
410
483
  connectEvents();
411
484
  setInterval(function () { if (!document.hidden && !sseOn) fetchPending(); }, 8000);
@@ -470,32 +543,35 @@ export const PWA_HTML = `<!doctype html>
470
543
  }
471
544
 
472
545
  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
546
  var seen = {};
478
- for (var i = 0; i < raw.length; i++) {
479
- var id = raw[i].requestId;
480
- seen[id] = true;
481
- if (cards[id]) continue;
547
+ await Promise.all(pairs.map(async function (p) {
548
+ var raw;
482
549
  try {
483
- var req = await openSealed(raw[i].payload, "req:" + id);
484
- addCard(id, req);
485
- } catch (e) { /* not ours / tampered */ }
486
- }
550
+ raw = await (await fetch("/api/pending?pairId=" + encodeURIComponent(p.pairId))).json();
551
+ } catch (e) { return; }
552
+ for (var i = 0; i < raw.length; i++) {
553
+ var key = p.pairId + ":" + raw[i].requestId;
554
+ seen[key] = true;
555
+ if (cards[key]) continue;
556
+ try {
557
+ var req = await openSealed(p, raw[i].payload, "req:" + raw[i].requestId);
558
+ addCard(key, p, raw[i].requestId, req);
559
+ } catch (e) { /* not ours / tampered */ }
560
+ }
561
+ }));
487
562
  for (var cid in cards) {
488
563
  if (!seen[cid]) { cards[cid].el.remove(); delete cards[cid]; }
489
564
  }
490
565
  ensureEmpty(Object.keys(cards).length === 0);
491
566
  }
492
567
 
493
- function addCard(id, req) {
568
+ function addCard(id, p, requestId, req) {
494
569
  var card = document.createElement("div");
495
570
  card.className = "card";
496
571
 
497
572
  var who = document.createElement("div"); who.className = "who";
498
- who.textContent = "agent · approval request"; card.appendChild(who);
573
+ who.textContent = (pairs.length > 1 ? p.name + " · " : "") + "agent · approval request";
574
+ card.appendChild(who);
499
575
  var h = document.createElement("h2"); h.textContent = req.title; card.appendChild(h);
500
576
  if (req.body) { var p = document.createElement("p"); p.textContent = req.body; card.appendChild(p); }
501
577
 
@@ -513,7 +589,7 @@ export const PWA_HTML = `<!doctype html>
513
589
 
514
590
  ensureEmpty(false);
515
591
  listEl.appendChild(card);
516
- cards[id] = { el: card, leftEl: left, fillEl: fill, barEl: bar, rowEl: row, req: req, done: false };
592
+ cards[id] = { el: card, leftEl: left, fillEl: fill, barEl: bar, rowEl: row, req: req, pair: p, requestId: requestId, done: false };
517
593
  tickOne(cards[id]);
518
594
  }
519
595
 
@@ -527,7 +603,7 @@ export const PWA_HTML = `<!doctype html>
527
603
  c.rowEl.remove();
528
604
  c.leftEl.textContent = "expired — denied";
529
605
  c.fillEl.style.width = "0%";
530
- recordHist(c.req.title, "expired");
606
+ recordHist(histLabel(c), "expired");
531
607
  }
532
608
  return;
533
609
  }
@@ -549,16 +625,17 @@ export const PWA_HTML = `<!doctype html>
549
625
  if (!c || c.done) return;
550
626
  b.disabled = true;
551
627
  var payload = await sealPayload(
552
- { requestId: id, approved: cls === "ok", ts: Date.now() },
553
- "dec:" + id
628
+ c.pair,
629
+ { requestId: c.requestId, approved: cls === "ok", ts: Date.now() },
630
+ "dec:" + c.requestId
554
631
  );
555
632
  await fetch("/api/decision", {
556
633
  method: "POST",
557
634
  headers: { "Content-Type": "application/json" },
558
- body: JSON.stringify({ pairId: pair.pairId, requestId: id, payload: payload }),
635
+ body: JSON.stringify({ pairId: c.pair.pairId, requestId: c.requestId, payload: payload }),
559
636
  });
560
637
  if (cards[id]) {
561
- recordHist(cards[id].req.title, cls === "ok" ? "approved" : "denied");
638
+ recordHist(histLabel(c), cls === "ok" ? "approved" : "denied");
562
639
  cards[id].el.remove();
563
640
  delete cards[id];
564
641
  }
@@ -567,6 +644,57 @@ export const PWA_HTML = `<!doctype html>
567
644
  return b;
568
645
  }
569
646
 
647
+ function histLabel(c) {
648
+ return (pairs.length > 1 ? c.pair.name + ": " : "") + c.req.title;
649
+ }
650
+
651
+ // --- vault manager -------------------------------------------------------
652
+ var vaultsEl = document.createElement("div");
653
+ histEl.parentElement.appendChild(vaultsEl);
654
+ function renderVaults() {
655
+ vaultsEl.textContent = "";
656
+ var section = document.createElement("div");
657
+ section.className = "hist";
658
+ var h = document.createElement("h3");
659
+ h.textContent = "Vaults";
660
+ section.appendChild(h);
661
+ pairs.forEach(function (p, idx) {
662
+ var row = document.createElement("div"); row.className = "hrow";
663
+ var t = document.createElement("span"); t.className = "t"; t.textContent = p.name;
664
+ var x = document.createElement("span"); x.className = "d denied"; x.textContent = "remove";
665
+ x.style.cursor = "pointer";
666
+ x.onclick = function () {
667
+ if (!confirm("Remove " + p.name + " from this device?")) return;
668
+ pairs.splice(idx, 1);
669
+ savePairs();
670
+ location.reload();
671
+ };
672
+ row.appendChild(t); row.appendChild(x);
673
+ section.appendChild(row);
674
+ });
675
+ var addRow = document.createElement("div"); addRow.className = "hrow";
676
+ var add = document.createElement("span"); add.className = "d approved"; add.textContent = "+ add a vault";
677
+ add.style.cursor = "pointer";
678
+ add.onclick = function () {
679
+ if (document.getElementById("addPaste")) return;
680
+ var input = document.createElement("input");
681
+ input.id = "addPaste";
682
+ input.placeholder = "Paste a pairing link (sandgate pair)";
683
+ input.autocomplete = "off";
684
+ 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:13px ui-monospace,monospace;";
685
+ input.addEventListener("input", function (e) {
686
+ var parsed = parsePairing(e.target.value);
687
+ if (parsed && addPairing(parsed)) location.reload();
688
+ });
689
+ section.appendChild(input);
690
+ input.focus();
691
+ };
692
+ addRow.appendChild(add);
693
+ section.appendChild(addRow);
694
+ vaultsEl.appendChild(section);
695
+ }
696
+ renderVaults();
697
+
570
698
  fetchPending();
571
699
  })();
572
700
  </script>
@@ -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
- return json(res, 200, { subscribed: !!getPairing(pairId).subscription });
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",
3
+ "version": "0.1.5",
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",