@sandprivacy/sandgate 0.1.1 → 0.1.3
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/relay/pwa-page.js +288 -87
- package/dist/relay/server.js +41 -4
- package/dist/test/pwa.test.js +35 -0
- package/package.json +1 -1
package/dist/relay/pwa-page.js
CHANGED
|
@@ -5,23 +5,41 @@ import { GLYPH_SVG_RECTS } from "./icons.js";
|
|
|
5
5
|
* HKDF-SHA256(salt "sandgate-pwa-v1", info "approval-channel") -> AES-256-GCM,
|
|
6
6
|
* AAD "req:<id>" / "dec:<id>". The pairing secret arrives once in the URL
|
|
7
7
|
* fragment (never sent to the relay) and lives in localStorage.
|
|
8
|
-
*
|
|
8
|
+
*
|
|
9
|
+
* Live updates: SSE (/api/events) with a slow safety poll — no visible
|
|
10
|
+
* refresh; the DOM is stable and only countdown text/bars mutate in place.
|
|
11
|
+
* Onboarding is per-platform: iOS home-screen apps have storage separate
|
|
12
|
+
* from Safari, so the install flow carries the pairing link by hand
|
|
13
|
+
* (copy → install → paste); Android installs in place via
|
|
14
|
+
* beforeinstallprompt and shares storage with the browser.
|
|
9
15
|
*/
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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
|
+
}
|
|
22
38
|
export const PWA_SW = `
|
|
23
39
|
self.addEventListener("install", function () { self.skipWaiting(); });
|
|
24
40
|
self.addEventListener("activate", function (e) { e.waitUntil(self.clients.claim()); });
|
|
41
|
+
// A fetch handler is required for Chrome's install prompt; plain passthrough.
|
|
42
|
+
self.addEventListener("fetch", function (e) { e.respondWith(fetch(e.request)); });
|
|
25
43
|
self.addEventListener("push", function (e) {
|
|
26
44
|
e.waitUntil((async function () {
|
|
27
45
|
await self.registration.showNotification("sandgate", {
|
|
@@ -53,6 +71,7 @@ export const PWA_HTML = `<!doctype html>
|
|
|
53
71
|
<meta charset="utf-8">
|
|
54
72
|
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
|
55
73
|
<meta name="apple-mobile-web-app-capable" content="yes">
|
|
74
|
+
<meta name="apple-mobile-web-app-title" content="sandgate">
|
|
56
75
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
|
57
76
|
<meta name="theme-color" content="#141210">
|
|
58
77
|
<link rel="manifest" href="/manifest.webmanifest">
|
|
@@ -113,6 +132,18 @@ export const PWA_HTML = `<!doctype html>
|
|
|
113
132
|
|
|
114
133
|
main { max-width: 520px; margin: 0 auto; padding: 18px 16px calc(env(safe-area-inset-bottom, 0px) + 40px); }
|
|
115
134
|
|
|
135
|
+
.banner {
|
|
136
|
+
background: var(--panel-raised); border: 1px solid #4a3d1f; border-radius: 14px;
|
|
137
|
+
padding: 16px; margin-bottom: 16px;
|
|
138
|
+
}
|
|
139
|
+
.banner h2 { font-size: 15.5px; margin: 0 0 8px; color: var(--accent); }
|
|
140
|
+
.banner ol { margin: 0 0 12px; padding-left: 20px; font-size: 14px; color: #cfc6b2; }
|
|
141
|
+
.banner ol li { margin-bottom: 4px; }
|
|
142
|
+
.banner .act {
|
|
143
|
+
width: 100%; padding: 12px; border: 0; border-radius: 10px; cursor: pointer;
|
|
144
|
+
background: var(--accent); color: #1a1508; font: 650 15px/1 inherit; font-family: inherit;
|
|
145
|
+
}
|
|
146
|
+
|
|
116
147
|
.card {
|
|
117
148
|
background: var(--panel);
|
|
118
149
|
border: 1px solid var(--line);
|
|
@@ -132,15 +163,15 @@ export const PWA_HTML = `<!doctype html>
|
|
|
132
163
|
.bar i { display: block; height: 100%; background: var(--accent); border-radius: 2px; transition: width 1s linear; }
|
|
133
164
|
.bar.low i { background: var(--no); }
|
|
134
165
|
.row { display: flex; gap: 10px; }
|
|
135
|
-
button {
|
|
166
|
+
.card button {
|
|
136
167
|
flex: 1; display: flex; align-items: center; justify-content: center; gap: 8px;
|
|
137
168
|
padding: 13px; font-size: 15.5px; font-weight: 650;
|
|
138
169
|
border: 0; border-radius: 10px; cursor: pointer; color: #fff;
|
|
139
170
|
font-family: inherit; letter-spacing: .01em;
|
|
140
171
|
transition: transform .06s ease;
|
|
141
172
|
}
|
|
142
|
-
button:active { transform: scale(.97); }
|
|
143
|
-
button:disabled { opacity: .5; }
|
|
173
|
+
.card button:active { transform: scale(.97); }
|
|
174
|
+
.card button:disabled { opacity: .5; }
|
|
144
175
|
.ok { background: var(--ok); } .ok:active { background: var(--ok-press); }
|
|
145
176
|
.no { background: var(--no); } .no:active { background: var(--no-press); }
|
|
146
177
|
.expired { opacity: .45; }
|
|
@@ -151,13 +182,30 @@ export const PWA_HTML = `<!doctype html>
|
|
|
151
182
|
.empty .big { font-size: 16px; color: #cfc6b2; margin-bottom: 4px; }
|
|
152
183
|
.empty .hint { font-size: 13px; }
|
|
153
184
|
|
|
154
|
-
.setup { text-align: center; padding:
|
|
185
|
+
.setup { text-align: center; padding: 48px 8px; color: #cfc6b2; }
|
|
155
186
|
.setup .mark { color: var(--accent); opacity: .5; margin-bottom: 16px; }
|
|
187
|
+
.setup p { font-size: 14.5px; margin: 0 0 14px; }
|
|
156
188
|
.setup code {
|
|
157
|
-
display: inline-block;
|
|
189
|
+
display: inline-block; padding: 8px 14px; border-radius: 8px;
|
|
158
190
|
background: var(--panel-raised); border: 1px solid var(--line);
|
|
159
191
|
font: 14px ui-monospace, "Cascadia Mono", monospace; color: var(--accent);
|
|
160
192
|
}
|
|
193
|
+
.setup input {
|
|
194
|
+
width: 100%; padding: 12px 14px; margin: 14px 0 10px;
|
|
195
|
+
background: var(--panel); border: 1px solid var(--line); border-radius: 10px;
|
|
196
|
+
color: var(--ink); font: 14px ui-monospace, monospace;
|
|
197
|
+
}
|
|
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); }
|
|
161
209
|
</style>
|
|
162
210
|
</head>
|
|
163
211
|
<body>
|
|
@@ -169,7 +217,7 @@ export const PWA_HTML = `<!doctype html>
|
|
|
169
217
|
</div>
|
|
170
218
|
<div class="pill warn" id="status">starting</div>
|
|
171
219
|
</header>
|
|
172
|
-
<main><div id="list"></div></main>
|
|
220
|
+
<main><div id="banner"></div><div id="list"></div><div id="hist"></div></main>
|
|
173
221
|
<script>
|
|
174
222
|
(function () {
|
|
175
223
|
var PAIR_KEY = "sandgate_pair";
|
|
@@ -192,27 +240,92 @@ export const PWA_HTML = `<!doctype html>
|
|
|
192
240
|
|
|
193
241
|
var statusEl = document.getElementById("status");
|
|
194
242
|
var listEl = document.getElementById("list");
|
|
243
|
+
var bannerEl = document.getElementById("banner");
|
|
195
244
|
function setStatus(text, cls) {
|
|
196
245
|
statusEl.textContent = text;
|
|
197
246
|
statusEl.className = "pill" + (cls ? " " + cls : "");
|
|
198
247
|
}
|
|
199
248
|
|
|
249
|
+
var standalone =
|
|
250
|
+
(window.matchMedia && window.matchMedia("(display-mode: standalone)").matches) ||
|
|
251
|
+
window.navigator.standalone === true;
|
|
252
|
+
var isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent);
|
|
253
|
+
var isAndroid = /Android/.test(navigator.userAgent);
|
|
254
|
+
|
|
200
255
|
// --- pairing -------------------------------------------------------------
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
256
|
+
function parsePairing(text) {
|
|
257
|
+
var mm = String(text).match(/p=([A-Za-z0-9_-]{8,64})&s=([A-Za-z0-9_-]{8,})/);
|
|
258
|
+
return mm ? { pairId: mm[1], secret: mm[2] } : null;
|
|
259
|
+
}
|
|
260
|
+
var pair = parsePairing(location.hash);
|
|
261
|
+
if (pair) {
|
|
205
262
|
try { localStorage.setItem(PAIR_KEY, JSON.stringify(pair)); } catch (e) {}
|
|
206
|
-
|
|
263
|
+
// iOS Safari (not installed): KEEP the pairing link in the address bar.
|
|
264
|
+
// With no start_url in the Apple manifest, Add to Home Screen captures
|
|
265
|
+
// this exact URL — fragment included — so the installed app opens
|
|
266
|
+
// already paired despite iOS's isolated storage.
|
|
267
|
+
if (!(isIOS && !standalone)) {
|
|
268
|
+
history.replaceState(null, "", location.pathname);
|
|
269
|
+
}
|
|
207
270
|
} else {
|
|
208
271
|
try { pair = JSON.parse(localStorage.getItem(PAIR_KEY)); } catch (e) {}
|
|
209
272
|
}
|
|
273
|
+
|
|
210
274
|
if (!pair) {
|
|
211
275
|
setStatus("not paired", "err");
|
|
212
|
-
|
|
276
|
+
var setup = document.createElement("div");
|
|
277
|
+
setup.className = "setup";
|
|
278
|
+
setup.innerHTML =
|
|
279
|
+
'<div class="mark">' + GLYPH + '</div>' +
|
|
280
|
+
'<p>Not paired yet. On your computer, run</p><code>sandgate pair</code>' +
|
|
281
|
+
'<p style="margin-top:14px">then open the link it prints on this device —<br>or paste it here:</p>' +
|
|
282
|
+
'<input id="pasteLink" placeholder="https://relay…/#p=…&s=…" autocomplete="off">';
|
|
283
|
+
listEl.appendChild(setup);
|
|
284
|
+
document.getElementById("pasteLink").addEventListener("input", function (e) {
|
|
285
|
+
var parsed = parsePairing(e.target.value);
|
|
286
|
+
if (parsed) {
|
|
287
|
+
try { localStorage.setItem(PAIR_KEY, JSON.stringify(parsed)); } catch (err) {}
|
|
288
|
+
location.replace(location.pathname);
|
|
289
|
+
}
|
|
290
|
+
});
|
|
213
291
|
return;
|
|
214
292
|
}
|
|
215
293
|
|
|
294
|
+
var pairLink = location.origin + "/#p=" + pair.pairId + "&s=" + pair.secret;
|
|
295
|
+
|
|
296
|
+
// --- install guidance (mobile browser, not yet installed) ---------------
|
|
297
|
+
var deferredInstall = null;
|
|
298
|
+
window.addEventListener("beforeinstallprompt", function (e) {
|
|
299
|
+
e.preventDefault();
|
|
300
|
+
deferredInstall = e;
|
|
301
|
+
renderBanner();
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
function renderBanner() {
|
|
305
|
+
if (standalone || (!isIOS && !isAndroid)) { bannerEl.textContent = ""; return; }
|
|
306
|
+
var b = document.createElement("div");
|
|
307
|
+
b.className = "banner";
|
|
308
|
+
if (isIOS) {
|
|
309
|
+
b.innerHTML =
|
|
310
|
+
'<h2>Install sandgate to get push notifications</h2>' +
|
|
311
|
+
'<ol><li>Tap Share (the square with an arrow)</li>' +
|
|
312
|
+
'<li>Choose "Add to Home Screen"</li>' +
|
|
313
|
+
'<li>Open sandgate from your home screen — your pairing carries over</li></ol>';
|
|
314
|
+
bannerEl.textContent = ""; bannerEl.appendChild(b);
|
|
315
|
+
} else {
|
|
316
|
+
b.innerHTML =
|
|
317
|
+
'<h2>Install sandgate to get push notifications</h2>' +
|
|
318
|
+
'<ol><li>Install the app (your pairing carries over automatically)</li>' +
|
|
319
|
+
'<li>Open it from your home screen and allow notifications</li></ol>' +
|
|
320
|
+
'<button class="act" id="installBtn">' + (deferredInstall ? "Install the app" : "Open browser menu → Install app") + '</button>';
|
|
321
|
+
bannerEl.textContent = ""; bannerEl.appendChild(b);
|
|
322
|
+
document.getElementById("installBtn").addEventListener("click", function () {
|
|
323
|
+
if (deferredInstall) deferredInstall.prompt();
|
|
324
|
+
});
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
renderBanner();
|
|
328
|
+
|
|
216
329
|
// --- crypto (mirror of pwacrypto.ts) ------------------------------------
|
|
217
330
|
var keyPromise = (async function () {
|
|
218
331
|
var raw = await crypto.subtle.importKey("raw", b64uToBytes(pair.secret), "HKDF", false, ["deriveKey"]);
|
|
@@ -246,6 +359,7 @@ export const PWA_HTML = `<!doctype html>
|
|
|
246
359
|
}
|
|
247
360
|
|
|
248
361
|
// --- push subscription ---------------------------------------------------
|
|
362
|
+
var pushOn = false;
|
|
249
363
|
(async function () {
|
|
250
364
|
try {
|
|
251
365
|
if ("serviceWorker" in navigator) {
|
|
@@ -253,7 +367,7 @@ export const PWA_HTML = `<!doctype html>
|
|
|
253
367
|
navigator.serviceWorker.addEventListener("message", function (e) {
|
|
254
368
|
if (e.data === "refresh") fetchPending();
|
|
255
369
|
});
|
|
256
|
-
if ("PushManager" in window) {
|
|
370
|
+
if ("PushManager" in window && (standalone || !isIOS)) {
|
|
257
371
|
var perm = await Notification.requestPermission();
|
|
258
372
|
if (perm === "granted") {
|
|
259
373
|
var vapid = await (await fetch("/api/vapid")).json();
|
|
@@ -266,107 +380,194 @@ export const PWA_HTML = `<!doctype html>
|
|
|
266
380
|
headers: { "Content-Type": "application/json" },
|
|
267
381
|
body: JSON.stringify({ pairId: pair.pairId, subscription: sub }),
|
|
268
382
|
});
|
|
383
|
+
pushOn = true;
|
|
269
384
|
setStatus("push on");
|
|
270
385
|
return;
|
|
271
386
|
}
|
|
272
387
|
}
|
|
273
388
|
}
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
setStatus("polling — keep open", "warn");
|
|
277
|
-
}
|
|
389
|
+
} catch (e) { /* fall through to live/polling status */ }
|
|
390
|
+
if (!pushOn) setStatus(sseOn ? "live" : "polling", "warn");
|
|
278
391
|
})();
|
|
279
392
|
|
|
280
|
-
// ---
|
|
281
|
-
var
|
|
393
|
+
// --- live updates: SSE with a slow safety poll ---------------------------
|
|
394
|
+
var sseOn = false;
|
|
395
|
+
function connectEvents() {
|
|
396
|
+
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
|
+
};
|
|
409
|
+
}
|
|
410
|
+
connectEvents();
|
|
411
|
+
setInterval(function () { if (!document.hidden && !sseOn) fetchPending(); }, 8000);
|
|
412
|
+
setInterval(function () { if (!document.hidden) fetchPending(); }, 45000); // safety net
|
|
413
|
+
document.addEventListener("visibilitychange", function () { if (!document.hidden) fetchPending(); });
|
|
414
|
+
|
|
415
|
+
// --- local decision history (on-device only; synced history is a paid
|
|
416
|
+
// --- gateway feature, not the PWA's business) ----------------------------
|
|
417
|
+
var HIST_KEY = "sandgate_history";
|
|
418
|
+
var histEl = document.getElementById("hist");
|
|
419
|
+
function loadHist() {
|
|
420
|
+
try { return JSON.parse(localStorage.getItem(HIST_KEY)) || []; } catch (e) { return []; }
|
|
421
|
+
}
|
|
422
|
+
function recordHist(title, decision) {
|
|
423
|
+
var entries = loadHist();
|
|
424
|
+
entries.unshift({ t: title, d: decision, ts: Date.now() });
|
|
425
|
+
entries = entries.slice(0, 30);
|
|
426
|
+
try { localStorage.setItem(HIST_KEY, JSON.stringify(entries)); } catch (e) {}
|
|
427
|
+
renderHist();
|
|
428
|
+
}
|
|
429
|
+
function histTime(ts) {
|
|
430
|
+
var d = new Date(ts), now = new Date();
|
|
431
|
+
var hm = ("0" + d.getHours()).slice(-2) + ":" + ("0" + d.getMinutes()).slice(-2);
|
|
432
|
+
if (d.toDateString() === now.toDateString()) return hm;
|
|
433
|
+
return ("0" + d.getDate()).slice(-2) + "/" + ("0" + (d.getMonth() + 1)).slice(-2) + " " + hm;
|
|
434
|
+
}
|
|
435
|
+
function renderHist() {
|
|
436
|
+
var entries = loadHist();
|
|
437
|
+
histEl.textContent = "";
|
|
438
|
+
if (!entries.length) return;
|
|
439
|
+
var section = document.createElement("div");
|
|
440
|
+
section.className = "hist";
|
|
441
|
+
var h = document.createElement("h3");
|
|
442
|
+
h.textContent = "Recent — this device";
|
|
443
|
+
section.appendChild(h);
|
|
444
|
+
entries.forEach(function (entry) {
|
|
445
|
+
var row = document.createElement("div"); row.className = "hrow";
|
|
446
|
+
var time = document.createElement("span"); time.className = "time"; time.textContent = histTime(entry.ts);
|
|
447
|
+
var t = document.createElement("span"); t.className = "t"; t.textContent = entry.t;
|
|
448
|
+
var d = document.createElement("span"); d.className = "d " + entry.d; d.textContent = entry.d;
|
|
449
|
+
row.appendChild(time); row.appendChild(t); row.appendChild(d);
|
|
450
|
+
section.appendChild(row);
|
|
451
|
+
});
|
|
452
|
+
histEl.appendChild(section);
|
|
453
|
+
}
|
|
454
|
+
renderHist();
|
|
455
|
+
|
|
456
|
+
// --- approval cards: stable DOM, in-place countdowns ---------------------
|
|
457
|
+
var cards = {}; // requestId -> {el, leftEl, fillEl, barEl, rowEl, req, done}
|
|
458
|
+
var emptyEl = null;
|
|
459
|
+
|
|
460
|
+
function ensureEmpty(show) {
|
|
461
|
+
if (show && !emptyEl) {
|
|
462
|
+
emptyEl = document.createElement("div");
|
|
463
|
+
emptyEl.className = "empty";
|
|
464
|
+
emptyEl.innerHTML = '<div class="mark">' + GLYPH + '</div><div class="big">All quiet.</div><div class="hint">When an agent needs you, it shows up here.</div>';
|
|
465
|
+
listEl.appendChild(emptyEl);
|
|
466
|
+
} else if (!show && emptyEl) {
|
|
467
|
+
emptyEl.remove();
|
|
468
|
+
emptyEl = null;
|
|
469
|
+
}
|
|
470
|
+
}
|
|
282
471
|
|
|
283
472
|
async function fetchPending() {
|
|
284
|
-
var
|
|
473
|
+
var raw;
|
|
285
474
|
try {
|
|
286
|
-
|
|
287
|
-
raw = await res.json();
|
|
475
|
+
raw = await (await fetch("/api/pending?pairId=" + encodeURIComponent(pair.pairId))).json();
|
|
288
476
|
} catch (e) { return; }
|
|
289
|
-
var
|
|
477
|
+
var seen = {};
|
|
290
478
|
for (var i = 0; i < raw.length; i++) {
|
|
291
|
-
var
|
|
292
|
-
|
|
479
|
+
var id = raw[i].requestId;
|
|
480
|
+
seen[id] = true;
|
|
481
|
+
if (cards[id]) continue;
|
|
293
482
|
try {
|
|
294
|
-
var req = await openSealed(raw[i].payload, "req:" +
|
|
295
|
-
|
|
483
|
+
var req = await openSealed(raw[i].payload, "req:" + id);
|
|
484
|
+
addCard(id, req);
|
|
296
485
|
} catch (e) { /* not ours / tampered */ }
|
|
297
486
|
}
|
|
298
|
-
|
|
299
|
-
|
|
487
|
+
for (var cid in cards) {
|
|
488
|
+
if (!seen[cid]) { cards[cid].el.remove(); delete cards[cid]; }
|
|
489
|
+
}
|
|
490
|
+
ensureEmpty(Object.keys(cards).length === 0);
|
|
300
491
|
}
|
|
301
492
|
|
|
302
|
-
function
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
if (!active.length) {
|
|
306
|
-
var empty = document.createElement("div");
|
|
307
|
-
empty.className = "empty";
|
|
308
|
-
empty.innerHTML = '<div class="mark">' + GLYPH + '</div><div class="big">All quiet.</div><div class="hint">When an agent needs you, it shows up here.</div>';
|
|
309
|
-
listEl.appendChild(empty);
|
|
310
|
-
return;
|
|
311
|
-
}
|
|
312
|
-
active.forEach(function (item) {
|
|
313
|
-
var req = item.req;
|
|
314
|
-
var total = req.timeoutSec * 1000;
|
|
315
|
-
var remaining = Math.max(0, req.ts + total - Date.now());
|
|
316
|
-
var card = document.createElement("div");
|
|
317
|
-
card.className = "card" + (remaining <= 0 ? " expired" : "");
|
|
493
|
+
function addCard(id, req) {
|
|
494
|
+
var card = document.createElement("div");
|
|
495
|
+
card.className = "card";
|
|
318
496
|
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
497
|
+
var who = document.createElement("div"); who.className = "who";
|
|
498
|
+
who.textContent = "agent · approval request"; card.appendChild(who);
|
|
499
|
+
var h = document.createElement("h2"); h.textContent = req.title; card.appendChild(h);
|
|
500
|
+
if (req.body) { var p = document.createElement("p"); p.textContent = req.body; card.appendChild(p); }
|
|
323
501
|
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
502
|
+
var timer = document.createElement("div"); timer.className = "timer";
|
|
503
|
+
var left = document.createElement("div"); left.className = "left";
|
|
504
|
+
var bar = document.createElement("div"); bar.className = "bar";
|
|
505
|
+
var fill = document.createElement("i");
|
|
506
|
+
bar.appendChild(fill); timer.appendChild(left); timer.appendChild(bar);
|
|
507
|
+
card.appendChild(timer);
|
|
508
|
+
|
|
509
|
+
var row = document.createElement("div"); row.className = "row";
|
|
510
|
+
row.appendChild(makeBtn("Approve", "ok", CHECK, id));
|
|
511
|
+
row.appendChild(makeBtn("Deny", "no", CROSS, id));
|
|
512
|
+
card.appendChild(row);
|
|
513
|
+
|
|
514
|
+
ensureEmpty(false);
|
|
515
|
+
listEl.appendChild(card);
|
|
516
|
+
cards[id] = { el: card, leftEl: left, fillEl: fill, barEl: bar, rowEl: row, req: req, done: false };
|
|
517
|
+
tickOne(cards[id]);
|
|
518
|
+
}
|
|
333
519
|
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
520
|
+
function tickOne(c) {
|
|
521
|
+
var total = c.req.timeoutSec * 1000;
|
|
522
|
+
var remaining = Math.max(0, c.req.ts + total - Date.now());
|
|
523
|
+
if (remaining <= 0) {
|
|
524
|
+
if (!c.done) {
|
|
525
|
+
c.done = true;
|
|
526
|
+
c.el.classList.add("expired");
|
|
527
|
+
c.rowEl.remove();
|
|
528
|
+
c.leftEl.textContent = "expired — denied";
|
|
529
|
+
c.fillEl.style.width = "0%";
|
|
530
|
+
recordHist(c.req.title, "expired");
|
|
339
531
|
}
|
|
340
|
-
|
|
341
|
-
}
|
|
532
|
+
return;
|
|
533
|
+
}
|
|
534
|
+
c.leftEl.textContent = Math.ceil(remaining / 1000) + "s — then denied";
|
|
535
|
+
c.fillEl.style.width = Math.min(100, (remaining / total) * 100) + "%";
|
|
536
|
+
c.barEl.className = "bar" + (remaining < total * 0.25 ? " low" : "");
|
|
342
537
|
}
|
|
538
|
+
setInterval(function () {
|
|
539
|
+
for (var id in cards) tickOne(cards[id]);
|
|
540
|
+
}, 1000);
|
|
343
541
|
|
|
344
|
-
function makeBtn(label, cls, icon,
|
|
542
|
+
function makeBtn(label, cls, icon, id) {
|
|
345
543
|
var b = document.createElement("button");
|
|
346
544
|
b.className = cls;
|
|
347
545
|
b.innerHTML = icon + "<span></span>";
|
|
348
546
|
b.querySelector("span").textContent = label;
|
|
349
547
|
b.onclick = async function () {
|
|
548
|
+
var c = cards[id];
|
|
549
|
+
if (!c || c.done) return;
|
|
350
550
|
b.disabled = true;
|
|
351
551
|
var payload = await sealPayload(
|
|
352
|
-
{ requestId:
|
|
353
|
-
"dec:" +
|
|
552
|
+
{ requestId: id, approved: cls === "ok", ts: Date.now() },
|
|
553
|
+
"dec:" + id
|
|
354
554
|
);
|
|
355
555
|
await fetch("/api/decision", {
|
|
356
556
|
method: "POST",
|
|
357
557
|
headers: { "Content-Type": "application/json" },
|
|
358
|
-
body: JSON.stringify({ pairId: pair.pairId, requestId:
|
|
558
|
+
body: JSON.stringify({ pairId: pair.pairId, requestId: id, payload: payload }),
|
|
359
559
|
});
|
|
360
|
-
|
|
361
|
-
|
|
560
|
+
if (cards[id]) {
|
|
561
|
+
recordHist(cards[id].req.title, cls === "ok" ? "approved" : "denied");
|
|
562
|
+
cards[id].el.remove();
|
|
563
|
+
delete cards[id];
|
|
564
|
+
}
|
|
565
|
+
ensureEmpty(Object.keys(cards).length === 0);
|
|
362
566
|
};
|
|
363
567
|
return b;
|
|
364
568
|
}
|
|
365
569
|
|
|
366
570
|
fetchPending();
|
|
367
|
-
setInterval(function () { if (!document.hidden) fetchPending(); }, 4000);
|
|
368
|
-
setInterval(function () { if (!document.hidden) render(); }, 1000);
|
|
369
|
-
document.addEventListener("visibilitychange", function () { if (!document.hidden) fetchPending(); });
|
|
370
571
|
})();
|
|
371
572
|
</script>
|
|
372
573
|
</body>
|
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;
|
|
@@ -24,11 +24,20 @@ export async function startRelay(opts) {
|
|
|
24
24
|
const getPairing = (pairId) => {
|
|
25
25
|
let p = pairings.get(pairId);
|
|
26
26
|
if (!p) {
|
|
27
|
-
p = {
|
|
27
|
+
p = {
|
|
28
|
+
subscription: state.subscriptions[pairId],
|
|
29
|
+
requests: new Map(),
|
|
30
|
+
listeners: new Set(),
|
|
31
|
+
};
|
|
28
32
|
pairings.set(pairId, p);
|
|
29
33
|
}
|
|
30
34
|
return p;
|
|
31
35
|
};
|
|
36
|
+
const notifyListeners = (pairing, event) => {
|
|
37
|
+
for (const listener of pairing.listeners) {
|
|
38
|
+
listener.write(`event: ${event}\ndata: {}\n\n`);
|
|
39
|
+
}
|
|
40
|
+
};
|
|
32
41
|
const gc = setInterval(() => {
|
|
33
42
|
const cutoff = Date.now() - REQUEST_TTL_MS;
|
|
34
43
|
for (const p of pairings.values()) {
|
|
@@ -82,8 +91,12 @@ export async function startRelay(opts) {
|
|
|
82
91
|
return res.end(PWA_SW);
|
|
83
92
|
}
|
|
84
93
|
if (req.method === "GET" && url.pathname === "/manifest.webmanifest") {
|
|
85
|
-
|
|
86
|
-
|
|
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 }));
|
|
87
100
|
}
|
|
88
101
|
if (req.method === "GET" && url.pathname === "/icon.svg") {
|
|
89
102
|
res.writeHead(200, { "Content-Type": "image/svg+xml", "Cache-Control": "max-age=86400" });
|
|
@@ -145,8 +158,31 @@ export async function startRelay(opts) {
|
|
|
145
158
|
.sendNotification(pairing.subscription, JSON.stringify({ type: "approval" }))
|
|
146
159
|
.catch(() => { }); // phone offline / stale sub — PWA polls anyway
|
|
147
160
|
}
|
|
161
|
+
notifyListeners(pairing, "request");
|
|
148
162
|
return json(res, 200, { ok: true });
|
|
149
163
|
}
|
|
164
|
+
// SSE stream for open PWA pages: instant updates instead of polling.
|
|
165
|
+
if (req.method === "GET" && url.pathname === "/api/events") {
|
|
166
|
+
const pairId = url.searchParams.get("pairId") ?? "";
|
|
167
|
+
if (!validId(pairId))
|
|
168
|
+
return json(res, 400, { error: "bad pairId" });
|
|
169
|
+
const pairing = getPairing(pairId);
|
|
170
|
+
res.writeHead(200, {
|
|
171
|
+
"Content-Type": "text/event-stream",
|
|
172
|
+
"Cache-Control": "no-cache",
|
|
173
|
+
Connection: "keep-alive",
|
|
174
|
+
});
|
|
175
|
+
res.write("retry: 3000\n\n");
|
|
176
|
+
pairing.listeners.add(res);
|
|
177
|
+
// Heartbeat keeps proxies (and our own 90s read timeout) from
|
|
178
|
+
// cutting an idle stream; EventSource ignores comment lines.
|
|
179
|
+
const heartbeat = setInterval(() => res.write(": hb\n\n"), 25_000);
|
|
180
|
+
req.on("close", () => {
|
|
181
|
+
clearInterval(heartbeat);
|
|
182
|
+
pairing.listeners.delete(res);
|
|
183
|
+
});
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
150
186
|
if (req.method === "GET" && url.pathname === "/api/pending") {
|
|
151
187
|
const pairId = url.searchParams.get("pairId") ?? "";
|
|
152
188
|
if (!validId(pairId))
|
|
@@ -169,6 +205,7 @@ export async function startRelay(opts) {
|
|
|
169
205
|
entry.decision = body.payload;
|
|
170
206
|
for (const waiter of entry.waiters.splice(0))
|
|
171
207
|
waiter(body.payload);
|
|
208
|
+
notifyListeners(getPairing(body.pairId), "decision");
|
|
172
209
|
return json(res, 200, { ok: true });
|
|
173
210
|
}
|
|
174
211
|
if (req.method === "GET" && url.pathname === "/api/decision") {
|
package/dist/test/pwa.test.js
CHANGED
|
@@ -69,6 +69,41 @@ test("full approval round-trip through the relay (approve, deny, timeout)", asyn
|
|
|
69
69
|
relay.close();
|
|
70
70
|
}
|
|
71
71
|
});
|
|
72
|
+
test("SSE stream announces new requests to listening pages", async () => {
|
|
73
|
+
const relay = await startRelay({
|
|
74
|
+
port: 0,
|
|
75
|
+
stateDir: mkdtempSync(join(tmpdir(), "sandgate-relay-")),
|
|
76
|
+
});
|
|
77
|
+
const relayUrl = `http://localhost:${relay.port}`;
|
|
78
|
+
try {
|
|
79
|
+
const pairing = newPairing();
|
|
80
|
+
const res = await fetch(`${relayUrl}/api/events?pairId=${pairing.pairId}`);
|
|
81
|
+
assert.equal(res.headers.get("content-type"), "text/event-stream");
|
|
82
|
+
const reader = res.body.getReader();
|
|
83
|
+
const approver = new PwaApprover({
|
|
84
|
+
relayUrl,
|
|
85
|
+
pairId: pairing.pairId,
|
|
86
|
+
secret: pairing.secret,
|
|
87
|
+
});
|
|
88
|
+
const pendingRequest = approver
|
|
89
|
+
.request({ title: "SSE ping", timeoutSec: 1 })
|
|
90
|
+
.catch(() => { });
|
|
91
|
+
let streamed = "";
|
|
92
|
+
const deadline = Date.now() + 5000;
|
|
93
|
+
while (!streamed.includes("event: request") && Date.now() < deadline) {
|
|
94
|
+
const { value, done } = await reader.read();
|
|
95
|
+
if (done)
|
|
96
|
+
break;
|
|
97
|
+
streamed += new TextDecoder().decode(value);
|
|
98
|
+
}
|
|
99
|
+
assert.ok(streamed.includes("event: request"), `stream was: ${streamed}`);
|
|
100
|
+
reader.cancel();
|
|
101
|
+
await pendingRequest;
|
|
102
|
+
}
|
|
103
|
+
finally {
|
|
104
|
+
relay.close();
|
|
105
|
+
}
|
|
106
|
+
});
|
|
72
107
|
test("a relay cannot forge an approval (bad blob is rejected, request times out)", async () => {
|
|
73
108
|
const relay = await startRelay({
|
|
74
109
|
port: 0,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sandprivacy/sandgate",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.3",
|
|
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",
|