@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 +9 -2
- package/dist/relay/pwa-page.js +299 -105
- package/dist/relay/server.js +21 -4
- 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
|
@@ -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
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
286
|
-
'<li>
|
|
287
|
-
'<li>Open sandgate from your home screen
|
|
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
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
344
|
-
|
|
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
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
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
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
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
|
-
|
|
418
|
-
var
|
|
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
|
-
|
|
423
|
-
|
|
424
|
-
|
|
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";
|
|
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
|
-
|
|
491
|
-
|
|
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:
|
|
635
|
+
body: JSON.stringify({ pairId: c.pair.pairId, requestId: c.requestId, payload: payload }),
|
|
497
636
|
});
|
|
498
|
-
if (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>
|
package/dist/relay/server.js
CHANGED
|
@@ -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,
|
|
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
|
-
|
|
95
|
-
|
|
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
|
-
|
|
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.
|
|
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",
|