@sandprivacy/sandgate 0.1.2 → 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();
@@ -13,18 +13,28 @@ import { GLYPH_SVG_RECTS } from "./icons.js";
13
13
  * (copy → install → paste); Android installs in place via
14
14
  * beforeinstallprompt and shares storage with the browser.
15
15
  */
16
- export const PWA_MANIFEST = JSON.stringify({
17
- name: "sandgate",
18
- short_name: "sandgate",
19
- start_url: "/",
20
- display: "standalone",
21
- background_color: "#141210",
22
- theme_color: "#141210",
23
- icons: [
24
- { src: "/icon-192.png", sizes: "192x192", type: "image/png" },
25
- { src: "/icon-512.png", sizes: "512x512", type: "image/png" },
26
- ],
27
- });
16
+ /**
17
+ * Two manifests on purpose. Chrome requires start_url for installability
18
+ * and shares storage with the installed app, so Android gets "/". iOS home
19
+ * -screen apps have isolated storage, but WITHOUT a manifest start_url iOS
20
+ * captures the CURRENT page URL — fragment included. The page keeps the
21
+ * pairing link in the address bar on iOS Safari, so Add to Home Screen
22
+ * produces an app that opens already paired. No copy-paste.
23
+ */
24
+ export function pwaManifest(opts) {
25
+ return JSON.stringify({
26
+ name: "sandgate",
27
+ short_name: "sandgate",
28
+ ...(opts.includeStartUrl ? { start_url: "/" } : {}),
29
+ display: "standalone",
30
+ background_color: "#141210",
31
+ theme_color: "#141210",
32
+ icons: [
33
+ { src: "/icon-192.png", sizes: "192x192", type: "image/png" },
34
+ { src: "/icon-512.png", sizes: "512x512", type: "image/png" },
35
+ ],
36
+ });
37
+ }
28
38
  export const PWA_SW = `
29
39
  self.addEventListener("install", function () { self.skipWaiting(); });
30
40
  self.addEventListener("activate", function (e) { e.waitUntil(self.clients.claim()); });
@@ -186,6 +196,16 @@ export const PWA_HTML = `<!doctype html>
186
196
  color: var(--ink); font: 14px ui-monospace, monospace;
187
197
  }
188
198
  .setup input:focus { outline: 2px solid var(--accent); outline-offset: -1px; }
199
+
200
+ .hist { margin-top: 30px; }
201
+ .hist h3 { font-size: 11px; letter-spacing: .08em; text-transform: uppercase; color: var(--soft); margin: 0 0 8px; }
202
+ .hrow { display: flex; gap: 10px; align-items: baseline; padding: 8px 0; border-bottom: 1px solid var(--line); font-size: 13.5px; }
203
+ .hrow .time { color: var(--soft); font-variant-numeric: tabular-nums; font-size: 12px; min-width: 74px; }
204
+ .hrow .t { flex: 1; color: #cfc6b2; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
205
+ .hrow .d { font-weight: 650; font-size: 10.5px; letter-spacing: .05em; text-transform: uppercase; }
206
+ .d.approved { color: #7fbf9a; }
207
+ .d.denied { color: #d98a76; }
208
+ .d.expired { color: var(--soft); }
189
209
  </style>
190
210
  </head>
191
211
  <body>
@@ -197,7 +217,7 @@ export const PWA_HTML = `<!doctype html>
197
217
  </div>
198
218
  <div class="pill warn" id="status">starting</div>
199
219
  </header>
200
- <main><div id="banner"></div><div id="list"></div></main>
220
+ <main><div id="banner"></div><div id="list"></div><div id="hist"></div></main>
201
221
  <script>
202
222
  (function () {
203
223
  var PAIR_KEY = "sandgate_pair";
@@ -237,15 +257,52 @@ export const PWA_HTML = `<!doctype html>
237
257
  var mm = String(text).match(/p=([A-Za-z0-9_-]{8,64})&s=([A-Za-z0-9_-]{8,})/);
238
258
  return mm ? { pairId: mm[1], secret: mm[2] } : null;
239
259
  }
240
- var pair = parsePairing(location.hash);
241
- if (pair) {
242
- try { localStorage.setItem(PAIR_KEY, JSON.stringify(pair)); } catch (e) {}
243
- history.replaceState(null, "", location.pathname);
244
- } else {
245
- try { pair = JSON.parse(localStorage.getItem(PAIR_KEY)); } 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;
246
291
  }
247
292
 
248
- if (!pair) {
293
+ var candidate = parsePairing(location.hash);
294
+ if (candidate) {
295
+ addPairing(candidate);
296
+ // iOS Safari (not installed): KEEP the pairing link in the address bar.
297
+ // With no start_url in the Apple manifest, Add to Home Screen captures
298
+ // this exact URL — fragment included — so the installed app opens
299
+ // already paired despite iOS's isolated storage.
300
+ if (!(isIOS && !standalone)) {
301
+ history.replaceState(null, "", location.pathname);
302
+ }
303
+ }
304
+
305
+ if (!pairs.length) {
249
306
  setStatus("not paired", "err");
250
307
  var setup = document.createElement("div");
251
308
  setup.className = "setup";
@@ -258,15 +315,13 @@ export const PWA_HTML = `<!doctype html>
258
315
  document.getElementById("pasteLink").addEventListener("input", function (e) {
259
316
  var parsed = parsePairing(e.target.value);
260
317
  if (parsed) {
261
- try { localStorage.setItem(PAIR_KEY, JSON.stringify(parsed)); } catch (err) {}
318
+ addPairing(parsed);
262
319
  location.replace(location.pathname);
263
320
  }
264
321
  });
265
322
  return;
266
323
  }
267
324
 
268
- var pairLink = location.origin + "/#p=" + pair.pairId + "&s=" + pair.secret;
269
-
270
325
  // --- install guidance (mobile browser, not yet installed) ---------------
271
326
  var deferredInstall = null;
272
327
  window.addEventListener("beforeinstallprompt", function (e) {
@@ -282,16 +337,10 @@ export const PWA_HTML = `<!doctype html>
282
337
  if (isIOS) {
283
338
  b.innerHTML =
284
339
  '<h2>Install sandgate to get push notifications</h2>' +
285
- '<ol><li>Tap the button below to copy your pairing link</li>' +
286
- '<li>Tap Share, then "Add to Home Screen"</li>' +
287
- '<li>Open sandgate from your home screen and paste the link</li></ol>' +
288
- '<button class="act" id="copyLink">Copy pairing link</button>';
340
+ '<ol><li>Tap Share (the square with an arrow)</li>' +
341
+ '<li>Choose "Add to Home Screen"</li>' +
342
+ '<li>Open sandgate from your home screen your pairing carries over</li></ol>';
289
343
  bannerEl.textContent = ""; bannerEl.appendChild(b);
290
- document.getElementById("copyLink").addEventListener("click", function () {
291
- navigator.clipboard.writeText(pairLink).then(function () {
292
- document.getElementById("copyLink").textContent = "Copied — now: Share → Add to Home Screen";
293
- });
294
- });
295
344
  } else {
296
345
  b.innerHTML =
297
346
  '<h2>Install sandgate to get push notifications</h2>' +
@@ -306,20 +355,26 @@ export const PWA_HTML = `<!doctype html>
306
355
  }
307
356
  renderBanner();
308
357
 
309
- // --- crypto (mirror of pwacrypto.ts) ------------------------------------
310
- var keyPromise = (async function () {
311
- var raw = await crypto.subtle.importKey("raw", b64uToBytes(pair.secret), "HKDF", false, ["deriveKey"]);
312
- return crypto.subtle.deriveKey(
313
- { name: "HKDF", hash: "SHA-256", salt: enc.encode("sandgate-pwa-v1"), info: enc.encode("approval-channel") },
314
- raw,
315
- { name: "AES-GCM", length: 256 },
316
- false,
317
- ["encrypt", "decrypt"]
318
- );
319
- })();
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
+ }
320
375
 
321
- async function openSealed(sealed, aad) {
322
- var key = await keyPromise;
376
+ async function openSealed(p, sealed, aad) {
377
+ var key = await keyFor(p);
323
378
  var pt = await crypto.subtle.decrypt(
324
379
  { name: "AES-GCM", iv: b64uToBytes(sealed.iv), additionalData: enc.encode(aad) },
325
380
  key,
@@ -327,8 +382,8 @@ export const PWA_HTML = `<!doctype html>
327
382
  );
328
383
  return JSON.parse(dec.decode(pt));
329
384
  }
330
- async function sealPayload(payload, aad) {
331
- var key = await keyPromise;
385
+ async function sealPayload(p, payload, aad) {
386
+ var key = await keyFor(p);
332
387
  var iv = crypto.getRandomValues(new Uint8Array(12));
333
388
  var ct = await crypto.subtle.encrypt(
334
389
  { name: "AES-GCM", iv: iv, additionalData: enc.encode(aad) },
@@ -338,60 +393,139 @@ export const PWA_HTML = `<!doctype html>
338
393
  return { iv: bytesToB64u(iv), ct: bytesToB64u(ct) };
339
394
  }
340
395
 
341
- // --- 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
+
342
408
  var pushOn = false;
343
- (async function () {
344
- try {
345
- if ("serviceWorker" in navigator) {
346
- var reg = await navigator.serviceWorker.register("/sw.js");
409
+ var swRegPromise = "serviceWorker" in navigator
410
+ ? navigator.serviceWorker.register("/sw.js").then(function (reg) {
347
411
  navigator.serviceWorker.addEventListener("message", function (e) {
348
412
  if (e.data === "refresh") fetchPending();
349
413
  });
350
- if ("PushManager" in window && (standalone || !isIOS)) {
351
- var perm = await Notification.requestPermission();
352
- if (perm === "granted") {
353
- var vapid = await (await fetch("/api/vapid")).json();
354
- var sub = await reg.pushManager.subscribe({
355
- userVisibleOnly: true,
356
- applicationServerKey: b64uToBytes(vapid.publicKey),
357
- });
358
- await fetch("/api/subscribe", {
359
- method: "POST",
360
- headers: { "Content-Type": "application/json" },
361
- body: JSON.stringify({ pairId: pair.pairId, subscription: sub }),
362
- });
363
- pushOn = true;
364
- setStatus("push on");
365
- return;
366
- }
367
- }
368
- }
369
- } catch (e) { /* fall through to live/polling status */ }
370
- if (!pushOn) setStatus(sseOn ? "live" : "polling", "warn");
371
- })();
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();
372
464
 
373
465
  // --- live updates: SSE with a slow safety poll ---------------------------
374
466
  var sseOn = false;
375
467
  function connectEvents() {
376
468
  if (!("EventSource" in window)) return;
377
- var es = new EventSource("/api/events?pairId=" + encodeURIComponent(pair.pairId));
378
- es.addEventListener("request", fetchPending);
379
- es.addEventListener("decision", fetchPending);
380
- es.onopen = function () {
381
- sseOn = true;
382
- if (!pushOn) setStatus("live");
383
- };
384
- es.onerror = function () {
385
- sseOn = false;
386
- if (!pushOn) setStatus("polling", "warn");
387
- // EventSource reconnects on its own (retry: 3000).
388
- };
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
+ });
389
482
  }
390
483
  connectEvents();
391
484
  setInterval(function () { if (!document.hidden && !sseOn) fetchPending(); }, 8000);
392
485
  setInterval(function () { if (!document.hidden) fetchPending(); }, 45000); // safety net
393
486
  document.addEventListener("visibilitychange", function () { if (!document.hidden) fetchPending(); });
394
487
 
488
+ // --- local decision history (on-device only; synced history is a paid
489
+ // --- gateway feature, not the PWA's business) ----------------------------
490
+ var HIST_KEY = "sandgate_history";
491
+ var histEl = document.getElementById("hist");
492
+ function loadHist() {
493
+ try { return JSON.parse(localStorage.getItem(HIST_KEY)) || []; } catch (e) { return []; }
494
+ }
495
+ function recordHist(title, decision) {
496
+ var entries = loadHist();
497
+ entries.unshift({ t: title, d: decision, ts: Date.now() });
498
+ entries = entries.slice(0, 30);
499
+ try { localStorage.setItem(HIST_KEY, JSON.stringify(entries)); } catch (e) {}
500
+ renderHist();
501
+ }
502
+ function histTime(ts) {
503
+ var d = new Date(ts), now = new Date();
504
+ var hm = ("0" + d.getHours()).slice(-2) + ":" + ("0" + d.getMinutes()).slice(-2);
505
+ if (d.toDateString() === now.toDateString()) return hm;
506
+ return ("0" + d.getDate()).slice(-2) + "/" + ("0" + (d.getMonth() + 1)).slice(-2) + " " + hm;
507
+ }
508
+ function renderHist() {
509
+ var entries = loadHist();
510
+ histEl.textContent = "";
511
+ if (!entries.length) return;
512
+ var section = document.createElement("div");
513
+ section.className = "hist";
514
+ var h = document.createElement("h3");
515
+ h.textContent = "Recent — this device";
516
+ section.appendChild(h);
517
+ entries.forEach(function (entry) {
518
+ var row = document.createElement("div"); row.className = "hrow";
519
+ var time = document.createElement("span"); time.className = "time"; time.textContent = histTime(entry.ts);
520
+ var t = document.createElement("span"); t.className = "t"; t.textContent = entry.t;
521
+ var d = document.createElement("span"); d.className = "d " + entry.d; d.textContent = entry.d;
522
+ row.appendChild(time); row.appendChild(t); row.appendChild(d);
523
+ section.appendChild(row);
524
+ });
525
+ histEl.appendChild(section);
526
+ }
527
+ renderHist();
528
+
395
529
  // --- approval cards: stable DOM, in-place countdowns ---------------------
396
530
  var cards = {}; // requestId -> {el, leftEl, fillEl, barEl, rowEl, req, done}
397
531
  var emptyEl = null;
@@ -409,32 +543,35 @@ export const PWA_HTML = `<!doctype html>
409
543
  }
410
544
 
411
545
  async function fetchPending() {
412
- var raw;
413
- try {
414
- raw = await (await fetch("/api/pending?pairId=" + encodeURIComponent(pair.pairId))).json();
415
- } catch (e) { return; }
416
546
  var seen = {};
417
- for (var i = 0; i < raw.length; i++) {
418
- var id = raw[i].requestId;
419
- seen[id] = true;
420
- if (cards[id]) continue;
547
+ await Promise.all(pairs.map(async function (p) {
548
+ var raw;
421
549
  try {
422
- var req = await openSealed(raw[i].payload, "req:" + id);
423
- addCard(id, req);
424
- } catch (e) { /* not ours / tampered */ }
425
- }
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
+ }));
426
562
  for (var cid in cards) {
427
563
  if (!seen[cid]) { cards[cid].el.remove(); delete cards[cid]; }
428
564
  }
429
565
  ensureEmpty(Object.keys(cards).length === 0);
430
566
  }
431
567
 
432
- function addCard(id, req) {
568
+ function addCard(id, p, requestId, req) {
433
569
  var card = document.createElement("div");
434
570
  card.className = "card";
435
571
 
436
572
  var who = document.createElement("div"); who.className = "who";
437
- who.textContent = "agent · approval request"; card.appendChild(who);
573
+ who.textContent = (pairs.length > 1 ? p.name + " · " : "") + "agent · approval request";
574
+ card.appendChild(who);
438
575
  var h = document.createElement("h2"); h.textContent = req.title; card.appendChild(h);
439
576
  if (req.body) { var p = document.createElement("p"); p.textContent = req.body; card.appendChild(p); }
440
577
 
@@ -452,7 +589,7 @@ export const PWA_HTML = `<!doctype html>
452
589
 
453
590
  ensureEmpty(false);
454
591
  listEl.appendChild(card);
455
- 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 };
456
593
  tickOne(cards[id]);
457
594
  }
458
595
 
@@ -466,6 +603,7 @@ export const PWA_HTML = `<!doctype html>
466
603
  c.rowEl.remove();
467
604
  c.leftEl.textContent = "expired — denied";
468
605
  c.fillEl.style.width = "0%";
606
+ recordHist(histLabel(c), "expired");
469
607
  }
470
608
  return;
471
609
  }
@@ -487,20 +625,76 @@ export const PWA_HTML = `<!doctype html>
487
625
  if (!c || c.done) return;
488
626
  b.disabled = true;
489
627
  var payload = await sealPayload(
490
- { requestId: id, approved: cls === "ok", ts: Date.now() },
491
- "dec:" + id
628
+ c.pair,
629
+ { requestId: c.requestId, approved: cls === "ok", ts: Date.now() },
630
+ "dec:" + c.requestId
492
631
  );
493
632
  await fetch("/api/decision", {
494
633
  method: "POST",
495
634
  headers: { "Content-Type": "application/json" },
496
- body: JSON.stringify({ pairId: pair.pairId, requestId: id, payload: payload }),
635
+ body: JSON.stringify({ pairId: c.pair.pairId, requestId: c.requestId, payload: payload }),
497
636
  });
498
- if (cards[id]) { cards[id].el.remove(); delete cards[id]; }
637
+ if (cards[id]) {
638
+ recordHist(histLabel(c), cls === "ok" ? "approved" : "denied");
639
+ cards[id].el.remove();
640
+ delete cards[id];
641
+ }
499
642
  ensureEmpty(Object.keys(cards).length === 0);
500
643
  };
501
644
  return b;
502
645
  }
503
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
+
504
698
  fetchPending();
505
699
  })();
506
700
  </script>
@@ -2,7 +2,7 @@ import { createServer } from "node:http";
2
2
  import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs";
3
3
  import { join } from "node:path";
4
4
  import webpush from "web-push";
5
- import { PWA_HTML, PWA_SW, PWA_MANIFEST } from "./pwa-page.js";
5
+ import { PWA_HTML, PWA_SW, pwaManifest } from "./pwa-page.js";
6
6
  import { ICON_SVG, iconPng } from "./icons.js";
7
7
  const MAX_BODY = 64 * 1024;
8
8
  const REQUEST_TTL_MS = 30 * 60 * 1000;
@@ -91,8 +91,12 @@ export async function startRelay(opts) {
91
91
  return res.end(PWA_SW);
92
92
  }
93
93
  if (req.method === "GET" && url.pathname === "/manifest.webmanifest") {
94
- res.writeHead(200, { "Content-Type": "application/manifest+json" });
95
- return res.end(PWA_MANIFEST);
94
+ const isApple = /iPhone|iPad|iPod/.test(req.headers["user-agent"] ?? "");
95
+ res.writeHead(200, {
96
+ "Content-Type": "application/manifest+json",
97
+ Vary: "User-Agent",
98
+ });
99
+ return res.end(pwaManifest({ includeStartUrl: !isApple }));
96
100
  }
97
101
  if (req.method === "GET" && url.pathname === "/icon.svg") {
98
102
  res.writeHead(200, { "Content-Type": "image/svg+xml", "Cache-Control": "max-age=86400" });
@@ -131,11 +135,24 @@ export async function startRelay(opts) {
131
135
  persist();
132
136
  return json(res, 200, { ok: true });
133
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
+ }
134
147
  if (req.method === "GET" && url.pathname === "/api/pair-status") {
135
148
  const pairId = url.searchParams.get("pairId") ?? "";
136
149
  if (!validId(pairId))
137
150
  return json(res, 400, { error: "bad pairId" });
138
- 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
+ });
139
156
  }
140
157
  if (req.method === "POST" && url.pathname === "/api/request") {
141
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.2",
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",