@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 +9 -2
- package/dist/relay/pwa-page.js +211 -83
- 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
|
@@ -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
|
-
|
|
261
|
-
|
|
262
|
-
|
|
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 (!
|
|
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
|
-
|
|
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
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
364
|
-
|
|
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
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
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
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
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
|
-
|
|
479
|
-
var
|
|
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
|
-
|
|
484
|
-
|
|
485
|
-
|
|
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";
|
|
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
|
|
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
|
-
|
|
553
|
-
|
|
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:
|
|
635
|
+
body: JSON.stringify({ pairId: c.pair.pairId, requestId: c.requestId, payload: payload }),
|
|
559
636
|
});
|
|
560
637
|
if (cards[id]) {
|
|
561
|
-
recordHist(
|
|
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>
|
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.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",
|