@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.
@@ -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
- * No emoji anywhere: the identity is the geometric gate mark from icons.ts.
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: 60px 24px; color: #cfc6b2; }
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; margin-top: 10px; padding: 8px 14px; border-radius: 8px;
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
- var pair = null;
202
- var m = location.hash.match(/p=([A-Za-z0-9_-]+)&s=([A-Za-z0-9_-]+)/);
203
- if (m) {
204
- pair = { pairId: m[1], secret: m[2] };
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
- listEl.innerHTML = '<div class="setup"><div class="mark">' + GLYPH + '</div>Not paired yet.<br>On your computer, run<br><code>sandgate pair</code><br><br>then open the link it prints on this device.</div>';
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
- setStatus("polling keep open", "warn");
275
- } catch (e) {
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
- // --- approval list -------------------------------------------------------
281
- var items = []; // [{requestId, req:{title,body,timeoutSec,ts}, decided}]
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 res, raw;
412
+ var raw;
285
413
  try {
286
- res = await fetch("/api/pending?pairId=" + encodeURIComponent(pair.pairId));
287
- raw = await res.json();
414
+ raw = await (await fetch("/api/pending?pairId=" + encodeURIComponent(pair.pairId))).json();
288
415
  } catch (e) { return; }
289
- var next = [];
416
+ var seen = {};
290
417
  for (var i = 0; i < raw.length; i++) {
291
- var existing = items.find(function (x) { return x.requestId === raw[i].requestId; });
292
- if (existing) { next.push(existing); continue; }
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:" + raw[i].requestId);
295
- next.push({ requestId: raw[i].requestId, req: req, decided: false });
422
+ var req = await openSealed(raw[i].payload, "req:" + id);
423
+ addCard(id, req);
296
424
  } catch (e) { /* not ours / tampered */ }
297
425
  }
298
- items = next;
299
- render();
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 render() {
303
- listEl.textContent = "";
304
- var active = items.filter(function (x) { return !x.decided; });
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" : "");
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
- var who = document.createElement("div"); who.className = "who";
320
- who.textContent = "agent · approval request"; card.appendChild(who);
321
- var h = document.createElement("h2"); h.textContent = req.title; card.appendChild(h);
322
- if (req.body) { var p = document.createElement("p"); p.textContent = req.body; card.appendChild(p); }
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
- var timer = document.createElement("div"); timer.className = "timer";
325
- var left = document.createElement("div"); left.className = "left";
326
- var secs = Math.ceil(remaining / 1000);
327
- left.textContent = remaining > 0 ? secs + "s — then denied" : "expired — denied";
328
- var bar = document.createElement("div"); bar.className = "bar" + (remaining > 0 && remaining < total * .25 ? " low" : "");
329
- var fill = document.createElement("i");
330
- fill.style.width = Math.max(0, Math.min(100, (remaining / total) * 100)) + "%";
331
- bar.appendChild(fill); timer.appendChild(left); timer.appendChild(bar);
332
- card.appendChild(timer);
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
- if (remaining > 0) {
335
- var row = document.createElement("div"); row.className = "row";
336
- row.appendChild(makeBtn("Approve", "ok", CHECK, item));
337
- row.appendChild(makeBtn("Deny", "no", CROSS, item));
338
- card.appendChild(row);
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
- listEl.appendChild(card);
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, item) {
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: item.requestId, approved: cls === "ok", ts: Date.now() },
353
- "dec:" + item.requestId
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: item.requestId, payload: payload }),
496
+ body: JSON.stringify({ pairId: pair.pairId, requestId: id, payload: payload }),
359
497
  });
360
- item.decided = true;
361
- render();
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>
@@ -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 = { subscription: state.subscriptions[pairId], requests: new Map() };
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") {
@@ -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.0",
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",