@sandprivacy/sandgate 0.1.0 → 0.1.2
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 +209 -74
- package/dist/relay/server.js +48 -1
- package/dist/test/pwa.test.js +35 -0
- package/package.json +1 -1
package/dist/relay/pwa-page.js
CHANGED
|
@@ -5,7 +5,13 @@ 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
16
|
export const PWA_MANIFEST = JSON.stringify({
|
|
11
17
|
name: "sandgate",
|
|
@@ -22,6 +28,8 @@ export const PWA_MANIFEST = JSON.stringify({
|
|
|
22
28
|
export const PWA_SW = `
|
|
23
29
|
self.addEventListener("install", function () { self.skipWaiting(); });
|
|
24
30
|
self.addEventListener("activate", function (e) { e.waitUntil(self.clients.claim()); });
|
|
31
|
+
// A fetch handler is required for Chrome's install prompt; plain passthrough.
|
|
32
|
+
self.addEventListener("fetch", function (e) { e.respondWith(fetch(e.request)); });
|
|
25
33
|
self.addEventListener("push", function (e) {
|
|
26
34
|
e.waitUntil((async function () {
|
|
27
35
|
await self.registration.showNotification("sandgate", {
|
|
@@ -53,6 +61,7 @@ export const PWA_HTML = `<!doctype html>
|
|
|
53
61
|
<meta charset="utf-8">
|
|
54
62
|
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
|
55
63
|
<meta name="apple-mobile-web-app-capable" content="yes">
|
|
64
|
+
<meta name="apple-mobile-web-app-title" content="sandgate">
|
|
56
65
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
|
57
66
|
<meta name="theme-color" content="#141210">
|
|
58
67
|
<link rel="manifest" href="/manifest.webmanifest">
|
|
@@ -113,6 +122,18 @@ export const PWA_HTML = `<!doctype html>
|
|
|
113
122
|
|
|
114
123
|
main { max-width: 520px; margin: 0 auto; padding: 18px 16px calc(env(safe-area-inset-bottom, 0px) + 40px); }
|
|
115
124
|
|
|
125
|
+
.banner {
|
|
126
|
+
background: var(--panel-raised); border: 1px solid #4a3d1f; border-radius: 14px;
|
|
127
|
+
padding: 16px; margin-bottom: 16px;
|
|
128
|
+
}
|
|
129
|
+
.banner h2 { font-size: 15.5px; margin: 0 0 8px; color: var(--accent); }
|
|
130
|
+
.banner ol { margin: 0 0 12px; padding-left: 20px; font-size: 14px; color: #cfc6b2; }
|
|
131
|
+
.banner ol li { margin-bottom: 4px; }
|
|
132
|
+
.banner .act {
|
|
133
|
+
width: 100%; padding: 12px; border: 0; border-radius: 10px; cursor: pointer;
|
|
134
|
+
background: var(--accent); color: #1a1508; font: 650 15px/1 inherit; font-family: inherit;
|
|
135
|
+
}
|
|
136
|
+
|
|
116
137
|
.card {
|
|
117
138
|
background: var(--panel);
|
|
118
139
|
border: 1px solid var(--line);
|
|
@@ -132,15 +153,15 @@ export const PWA_HTML = `<!doctype html>
|
|
|
132
153
|
.bar i { display: block; height: 100%; background: var(--accent); border-radius: 2px; transition: width 1s linear; }
|
|
133
154
|
.bar.low i { background: var(--no); }
|
|
134
155
|
.row { display: flex; gap: 10px; }
|
|
135
|
-
button {
|
|
156
|
+
.card button {
|
|
136
157
|
flex: 1; display: flex; align-items: center; justify-content: center; gap: 8px;
|
|
137
158
|
padding: 13px; font-size: 15.5px; font-weight: 650;
|
|
138
159
|
border: 0; border-radius: 10px; cursor: pointer; color: #fff;
|
|
139
160
|
font-family: inherit; letter-spacing: .01em;
|
|
140
161
|
transition: transform .06s ease;
|
|
141
162
|
}
|
|
142
|
-
button:active { transform: scale(.97); }
|
|
143
|
-
button:disabled { opacity: .5; }
|
|
163
|
+
.card button:active { transform: scale(.97); }
|
|
164
|
+
.card button:disabled { opacity: .5; }
|
|
144
165
|
.ok { background: var(--ok); } .ok:active { background: var(--ok-press); }
|
|
145
166
|
.no { background: var(--no); } .no:active { background: var(--no-press); }
|
|
146
167
|
.expired { opacity: .45; }
|
|
@@ -151,13 +172,20 @@ export const PWA_HTML = `<!doctype html>
|
|
|
151
172
|
.empty .big { font-size: 16px; color: #cfc6b2; margin-bottom: 4px; }
|
|
152
173
|
.empty .hint { font-size: 13px; }
|
|
153
174
|
|
|
154
|
-
.setup { text-align: center; padding:
|
|
175
|
+
.setup { text-align: center; padding: 48px 8px; color: #cfc6b2; }
|
|
155
176
|
.setup .mark { color: var(--accent); opacity: .5; margin-bottom: 16px; }
|
|
177
|
+
.setup p { font-size: 14.5px; margin: 0 0 14px; }
|
|
156
178
|
.setup code {
|
|
157
|
-
display: inline-block;
|
|
179
|
+
display: inline-block; padding: 8px 14px; border-radius: 8px;
|
|
158
180
|
background: var(--panel-raised); border: 1px solid var(--line);
|
|
159
181
|
font: 14px ui-monospace, "Cascadia Mono", monospace; color: var(--accent);
|
|
160
182
|
}
|
|
183
|
+
.setup input {
|
|
184
|
+
width: 100%; padding: 12px 14px; margin: 14px 0 10px;
|
|
185
|
+
background: var(--panel); border: 1px solid var(--line); border-radius: 10px;
|
|
186
|
+
color: var(--ink); font: 14px ui-monospace, monospace;
|
|
187
|
+
}
|
|
188
|
+
.setup input:focus { outline: 2px solid var(--accent); outline-offset: -1px; }
|
|
161
189
|
</style>
|
|
162
190
|
</head>
|
|
163
191
|
<body>
|
|
@@ -169,7 +197,7 @@ export const PWA_HTML = `<!doctype html>
|
|
|
169
197
|
</div>
|
|
170
198
|
<div class="pill warn" id="status">starting</div>
|
|
171
199
|
</header>
|
|
172
|
-
<main><div id="list"></div></main>
|
|
200
|
+
<main><div id="banner"></div><div id="list"></div></main>
|
|
173
201
|
<script>
|
|
174
202
|
(function () {
|
|
175
203
|
var PAIR_KEY = "sandgate_pair";
|
|
@@ -192,27 +220,92 @@ export const PWA_HTML = `<!doctype html>
|
|
|
192
220
|
|
|
193
221
|
var statusEl = document.getElementById("status");
|
|
194
222
|
var listEl = document.getElementById("list");
|
|
223
|
+
var bannerEl = document.getElementById("banner");
|
|
195
224
|
function setStatus(text, cls) {
|
|
196
225
|
statusEl.textContent = text;
|
|
197
226
|
statusEl.className = "pill" + (cls ? " " + cls : "");
|
|
198
227
|
}
|
|
199
228
|
|
|
229
|
+
var standalone =
|
|
230
|
+
(window.matchMedia && window.matchMedia("(display-mode: standalone)").matches) ||
|
|
231
|
+
window.navigator.standalone === true;
|
|
232
|
+
var isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent);
|
|
233
|
+
var isAndroid = /Android/.test(navigator.userAgent);
|
|
234
|
+
|
|
200
235
|
// --- pairing -------------------------------------------------------------
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
236
|
+
function parsePairing(text) {
|
|
237
|
+
var mm = String(text).match(/p=([A-Za-z0-9_-]{8,64})&s=([A-Za-z0-9_-]{8,})/);
|
|
238
|
+
return mm ? { pairId: mm[1], secret: mm[2] } : null;
|
|
239
|
+
}
|
|
240
|
+
var pair = parsePairing(location.hash);
|
|
241
|
+
if (pair) {
|
|
205
242
|
try { localStorage.setItem(PAIR_KEY, JSON.stringify(pair)); } catch (e) {}
|
|
206
243
|
history.replaceState(null, "", location.pathname);
|
|
207
244
|
} else {
|
|
208
245
|
try { pair = JSON.parse(localStorage.getItem(PAIR_KEY)); } catch (e) {}
|
|
209
246
|
}
|
|
247
|
+
|
|
210
248
|
if (!pair) {
|
|
211
249
|
setStatus("not paired", "err");
|
|
212
|
-
|
|
250
|
+
var setup = document.createElement("div");
|
|
251
|
+
setup.className = "setup";
|
|
252
|
+
setup.innerHTML =
|
|
253
|
+
'<div class="mark">' + GLYPH + '</div>' +
|
|
254
|
+
'<p>Not paired yet. On your computer, run</p><code>sandgate pair</code>' +
|
|
255
|
+
'<p style="margin-top:14px">then open the link it prints on this device —<br>or paste it here:</p>' +
|
|
256
|
+
'<input id="pasteLink" placeholder="https://relay…/#p=…&s=…" autocomplete="off">';
|
|
257
|
+
listEl.appendChild(setup);
|
|
258
|
+
document.getElementById("pasteLink").addEventListener("input", function (e) {
|
|
259
|
+
var parsed = parsePairing(e.target.value);
|
|
260
|
+
if (parsed) {
|
|
261
|
+
try { localStorage.setItem(PAIR_KEY, JSON.stringify(parsed)); } catch (err) {}
|
|
262
|
+
location.replace(location.pathname);
|
|
263
|
+
}
|
|
264
|
+
});
|
|
213
265
|
return;
|
|
214
266
|
}
|
|
215
267
|
|
|
268
|
+
var pairLink = location.origin + "/#p=" + pair.pairId + "&s=" + pair.secret;
|
|
269
|
+
|
|
270
|
+
// --- install guidance (mobile browser, not yet installed) ---------------
|
|
271
|
+
var deferredInstall = null;
|
|
272
|
+
window.addEventListener("beforeinstallprompt", function (e) {
|
|
273
|
+
e.preventDefault();
|
|
274
|
+
deferredInstall = e;
|
|
275
|
+
renderBanner();
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
function renderBanner() {
|
|
279
|
+
if (standalone || (!isIOS && !isAndroid)) { bannerEl.textContent = ""; return; }
|
|
280
|
+
var b = document.createElement("div");
|
|
281
|
+
b.className = "banner";
|
|
282
|
+
if (isIOS) {
|
|
283
|
+
b.innerHTML =
|
|
284
|
+
'<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>';
|
|
289
|
+
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
|
+
} else {
|
|
296
|
+
b.innerHTML =
|
|
297
|
+
'<h2>Install sandgate to get push notifications</h2>' +
|
|
298
|
+
'<ol><li>Install the app (your pairing carries over automatically)</li>' +
|
|
299
|
+
'<li>Open it from your home screen and allow notifications</li></ol>' +
|
|
300
|
+
'<button class="act" id="installBtn">' + (deferredInstall ? "Install the app" : "Open browser menu → Install app") + '</button>';
|
|
301
|
+
bannerEl.textContent = ""; bannerEl.appendChild(b);
|
|
302
|
+
document.getElementById("installBtn").addEventListener("click", function () {
|
|
303
|
+
if (deferredInstall) deferredInstall.prompt();
|
|
304
|
+
});
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
renderBanner();
|
|
308
|
+
|
|
216
309
|
// --- crypto (mirror of pwacrypto.ts) ------------------------------------
|
|
217
310
|
var keyPromise = (async function () {
|
|
218
311
|
var raw = await crypto.subtle.importKey("raw", b64uToBytes(pair.secret), "HKDF", false, ["deriveKey"]);
|
|
@@ -246,6 +339,7 @@ export const PWA_HTML = `<!doctype html>
|
|
|
246
339
|
}
|
|
247
340
|
|
|
248
341
|
// --- push subscription ---------------------------------------------------
|
|
342
|
+
var pushOn = false;
|
|
249
343
|
(async function () {
|
|
250
344
|
try {
|
|
251
345
|
if ("serviceWorker" in navigator) {
|
|
@@ -253,7 +347,7 @@ export const PWA_HTML = `<!doctype html>
|
|
|
253
347
|
navigator.serviceWorker.addEventListener("message", function (e) {
|
|
254
348
|
if (e.data === "refresh") fetchPending();
|
|
255
349
|
});
|
|
256
|
-
if ("PushManager" in window) {
|
|
350
|
+
if ("PushManager" in window && (standalone || !isIOS)) {
|
|
257
351
|
var perm = await Notification.requestPermission();
|
|
258
352
|
if (perm === "granted") {
|
|
259
353
|
var vapid = await (await fetch("/api/vapid")).json();
|
|
@@ -266,107 +360,148 @@ export const PWA_HTML = `<!doctype html>
|
|
|
266
360
|
headers: { "Content-Type": "application/json" },
|
|
267
361
|
body: JSON.stringify({ pairId: pair.pairId, subscription: sub }),
|
|
268
362
|
});
|
|
363
|
+
pushOn = true;
|
|
269
364
|
setStatus("push on");
|
|
270
365
|
return;
|
|
271
366
|
}
|
|
272
367
|
}
|
|
273
368
|
}
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
setStatus("polling — keep open", "warn");
|
|
277
|
-
}
|
|
369
|
+
} catch (e) { /* fall through to live/polling status */ }
|
|
370
|
+
if (!pushOn) setStatus(sseOn ? "live" : "polling", "warn");
|
|
278
371
|
})();
|
|
279
372
|
|
|
280
|
-
// ---
|
|
281
|
-
var
|
|
373
|
+
// --- live updates: SSE with a slow safety poll ---------------------------
|
|
374
|
+
var sseOn = false;
|
|
375
|
+
function connectEvents() {
|
|
376
|
+
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
|
+
};
|
|
389
|
+
}
|
|
390
|
+
connectEvents();
|
|
391
|
+
setInterval(function () { if (!document.hidden && !sseOn) fetchPending(); }, 8000);
|
|
392
|
+
setInterval(function () { if (!document.hidden) fetchPending(); }, 45000); // safety net
|
|
393
|
+
document.addEventListener("visibilitychange", function () { if (!document.hidden) fetchPending(); });
|
|
394
|
+
|
|
395
|
+
// --- approval cards: stable DOM, in-place countdowns ---------------------
|
|
396
|
+
var cards = {}; // requestId -> {el, leftEl, fillEl, barEl, rowEl, req, done}
|
|
397
|
+
var emptyEl = null;
|
|
398
|
+
|
|
399
|
+
function ensureEmpty(show) {
|
|
400
|
+
if (show && !emptyEl) {
|
|
401
|
+
emptyEl = document.createElement("div");
|
|
402
|
+
emptyEl.className = "empty";
|
|
403
|
+
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>';
|
|
404
|
+
listEl.appendChild(emptyEl);
|
|
405
|
+
} else if (!show && emptyEl) {
|
|
406
|
+
emptyEl.remove();
|
|
407
|
+
emptyEl = null;
|
|
408
|
+
}
|
|
409
|
+
}
|
|
282
410
|
|
|
283
411
|
async function fetchPending() {
|
|
284
|
-
var
|
|
412
|
+
var raw;
|
|
285
413
|
try {
|
|
286
|
-
|
|
287
|
-
raw = await res.json();
|
|
414
|
+
raw = await (await fetch("/api/pending?pairId=" + encodeURIComponent(pair.pairId))).json();
|
|
288
415
|
} catch (e) { return; }
|
|
289
|
-
var
|
|
416
|
+
var seen = {};
|
|
290
417
|
for (var i = 0; i < raw.length; i++) {
|
|
291
|
-
var
|
|
292
|
-
|
|
418
|
+
var id = raw[i].requestId;
|
|
419
|
+
seen[id] = true;
|
|
420
|
+
if (cards[id]) continue;
|
|
293
421
|
try {
|
|
294
|
-
var req = await openSealed(raw[i].payload, "req:" +
|
|
295
|
-
|
|
422
|
+
var req = await openSealed(raw[i].payload, "req:" + id);
|
|
423
|
+
addCard(id, req);
|
|
296
424
|
} catch (e) { /* not ours / tampered */ }
|
|
297
425
|
}
|
|
298
|
-
|
|
299
|
-
|
|
426
|
+
for (var cid in cards) {
|
|
427
|
+
if (!seen[cid]) { cards[cid].el.remove(); delete cards[cid]; }
|
|
428
|
+
}
|
|
429
|
+
ensureEmpty(Object.keys(cards).length === 0);
|
|
300
430
|
}
|
|
301
431
|
|
|
302
|
-
function
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
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" : "");
|
|
432
|
+
function addCard(id, req) {
|
|
433
|
+
var card = document.createElement("div");
|
|
434
|
+
card.className = "card";
|
|
435
|
+
|
|
436
|
+
var who = document.createElement("div"); who.className = "who";
|
|
437
|
+
who.textContent = "agent · approval request"; card.appendChild(who);
|
|
438
|
+
var h = document.createElement("h2"); h.textContent = req.title; card.appendChild(h);
|
|
439
|
+
if (req.body) { var p = document.createElement("p"); p.textContent = req.body; card.appendChild(p); }
|
|
318
440
|
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
441
|
+
var timer = document.createElement("div"); timer.className = "timer";
|
|
442
|
+
var left = document.createElement("div"); left.className = "left";
|
|
443
|
+
var bar = document.createElement("div"); bar.className = "bar";
|
|
444
|
+
var fill = document.createElement("i");
|
|
445
|
+
bar.appendChild(fill); timer.appendChild(left); timer.appendChild(bar);
|
|
446
|
+
card.appendChild(timer);
|
|
323
447
|
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
448
|
+
var row = document.createElement("div"); row.className = "row";
|
|
449
|
+
row.appendChild(makeBtn("Approve", "ok", CHECK, id));
|
|
450
|
+
row.appendChild(makeBtn("Deny", "no", CROSS, id));
|
|
451
|
+
card.appendChild(row);
|
|
452
|
+
|
|
453
|
+
ensureEmpty(false);
|
|
454
|
+
listEl.appendChild(card);
|
|
455
|
+
cards[id] = { el: card, leftEl: left, fillEl: fill, barEl: bar, rowEl: row, req: req, done: false };
|
|
456
|
+
tickOne(cards[id]);
|
|
457
|
+
}
|
|
333
458
|
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
459
|
+
function tickOne(c) {
|
|
460
|
+
var total = c.req.timeoutSec * 1000;
|
|
461
|
+
var remaining = Math.max(0, c.req.ts + total - Date.now());
|
|
462
|
+
if (remaining <= 0) {
|
|
463
|
+
if (!c.done) {
|
|
464
|
+
c.done = true;
|
|
465
|
+
c.el.classList.add("expired");
|
|
466
|
+
c.rowEl.remove();
|
|
467
|
+
c.leftEl.textContent = "expired — denied";
|
|
468
|
+
c.fillEl.style.width = "0%";
|
|
339
469
|
}
|
|
340
|
-
|
|
341
|
-
}
|
|
470
|
+
return;
|
|
471
|
+
}
|
|
472
|
+
c.leftEl.textContent = Math.ceil(remaining / 1000) + "s — then denied";
|
|
473
|
+
c.fillEl.style.width = Math.min(100, (remaining / total) * 100) + "%";
|
|
474
|
+
c.barEl.className = "bar" + (remaining < total * 0.25 ? " low" : "");
|
|
342
475
|
}
|
|
476
|
+
setInterval(function () {
|
|
477
|
+
for (var id in cards) tickOne(cards[id]);
|
|
478
|
+
}, 1000);
|
|
343
479
|
|
|
344
|
-
function makeBtn(label, cls, icon,
|
|
480
|
+
function makeBtn(label, cls, icon, id) {
|
|
345
481
|
var b = document.createElement("button");
|
|
346
482
|
b.className = cls;
|
|
347
483
|
b.innerHTML = icon + "<span></span>";
|
|
348
484
|
b.querySelector("span").textContent = label;
|
|
349
485
|
b.onclick = async function () {
|
|
486
|
+
var c = cards[id];
|
|
487
|
+
if (!c || c.done) return;
|
|
350
488
|
b.disabled = true;
|
|
351
489
|
var payload = await sealPayload(
|
|
352
|
-
{ requestId:
|
|
353
|
-
"dec:" +
|
|
490
|
+
{ requestId: id, approved: cls === "ok", ts: Date.now() },
|
|
491
|
+
"dec:" + id
|
|
354
492
|
);
|
|
355
493
|
await fetch("/api/decision", {
|
|
356
494
|
method: "POST",
|
|
357
495
|
headers: { "Content-Type": "application/json" },
|
|
358
|
-
body: JSON.stringify({ pairId: pair.pairId, requestId:
|
|
496
|
+
body: JSON.stringify({ pairId: pair.pairId, requestId: id, payload: payload }),
|
|
359
497
|
});
|
|
360
|
-
|
|
361
|
-
|
|
498
|
+
if (cards[id]) { cards[id].el.remove(); delete cards[id]; }
|
|
499
|
+
ensureEmpty(Object.keys(cards).length === 0);
|
|
362
500
|
};
|
|
363
501
|
return b;
|
|
364
502
|
}
|
|
365
503
|
|
|
366
504
|
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
505
|
})();
|
|
371
506
|
</script>
|
|
372
507
|
</body>
|
package/dist/relay/server.js
CHANGED
|
@@ -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()) {
|
|
@@ -95,6 +104,20 @@ export async function startRelay(opts) {
|
|
|
95
104
|
return res.end(iconPng(parseInt(iconMatch[1], 10)));
|
|
96
105
|
}
|
|
97
106
|
// --- API ---
|
|
107
|
+
if (req.method === "GET" && url.pathname === "/api/health") {
|
|
108
|
+
let activeRequests = 0;
|
|
109
|
+
for (const p of pairings.values()) {
|
|
110
|
+
for (const e of p.requests.values())
|
|
111
|
+
if (e.decision === undefined)
|
|
112
|
+
activeRequests++;
|
|
113
|
+
}
|
|
114
|
+
return json(res, 200, {
|
|
115
|
+
ok: true,
|
|
116
|
+
uptime_sec: Math.round(process.uptime()),
|
|
117
|
+
pairings: Object.keys(state.subscriptions).length,
|
|
118
|
+
active_requests: activeRequests,
|
|
119
|
+
});
|
|
120
|
+
}
|
|
98
121
|
if (req.method === "GET" && url.pathname === "/api/vapid") {
|
|
99
122
|
return json(res, 200, { publicKey: state.vapid.publicKey });
|
|
100
123
|
}
|
|
@@ -131,8 +154,31 @@ export async function startRelay(opts) {
|
|
|
131
154
|
.sendNotification(pairing.subscription, JSON.stringify({ type: "approval" }))
|
|
132
155
|
.catch(() => { }); // phone offline / stale sub — PWA polls anyway
|
|
133
156
|
}
|
|
157
|
+
notifyListeners(pairing, "request");
|
|
134
158
|
return json(res, 200, { ok: true });
|
|
135
159
|
}
|
|
160
|
+
// SSE stream for open PWA pages: instant updates instead of polling.
|
|
161
|
+
if (req.method === "GET" && url.pathname === "/api/events") {
|
|
162
|
+
const pairId = url.searchParams.get("pairId") ?? "";
|
|
163
|
+
if (!validId(pairId))
|
|
164
|
+
return json(res, 400, { error: "bad pairId" });
|
|
165
|
+
const pairing = getPairing(pairId);
|
|
166
|
+
res.writeHead(200, {
|
|
167
|
+
"Content-Type": "text/event-stream",
|
|
168
|
+
"Cache-Control": "no-cache",
|
|
169
|
+
Connection: "keep-alive",
|
|
170
|
+
});
|
|
171
|
+
res.write("retry: 3000\n\n");
|
|
172
|
+
pairing.listeners.add(res);
|
|
173
|
+
// Heartbeat keeps proxies (and our own 90s read timeout) from
|
|
174
|
+
// cutting an idle stream; EventSource ignores comment lines.
|
|
175
|
+
const heartbeat = setInterval(() => res.write(": hb\n\n"), 25_000);
|
|
176
|
+
req.on("close", () => {
|
|
177
|
+
clearInterval(heartbeat);
|
|
178
|
+
pairing.listeners.delete(res);
|
|
179
|
+
});
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
136
182
|
if (req.method === "GET" && url.pathname === "/api/pending") {
|
|
137
183
|
const pairId = url.searchParams.get("pairId") ?? "";
|
|
138
184
|
if (!validId(pairId))
|
|
@@ -155,6 +201,7 @@ export async function startRelay(opts) {
|
|
|
155
201
|
entry.decision = body.payload;
|
|
156
202
|
for (const waiter of entry.waiters.splice(0))
|
|
157
203
|
waiter(body.payload);
|
|
204
|
+
notifyListeners(getPairing(body.pairId), "decision");
|
|
158
205
|
return json(res, 200, { ok: true });
|
|
159
206
|
}
|
|
160
207
|
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.2",
|
|
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",
|