@sandprivacy/sandgate 0.1.2 → 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.
@@ -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
- export const PWA_MANIFEST = JSON.stringify({
17
- name: "sandgate",
18
- short_name: "sandgate",
19
- start_url: "/",
20
- display: "standalone",
21
- background_color: "#141210",
22
- theme_color: "#141210",
23
- icons: [
24
- { src: "/icon-192.png", sizes: "192x192", type: "image/png" },
25
- { src: "/icon-512.png", sizes: "512x512", type: "image/png" },
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";
@@ -240,7 +260,13 @@ export const PWA_HTML = `<!doctype html>
240
260
  var pair = parsePairing(location.hash);
241
261
  if (pair) {
242
262
  try { localStorage.setItem(PAIR_KEY, JSON.stringify(pair)); } catch (e) {}
243
- history.replaceState(null, "", location.pathname);
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
+ }
244
270
  } else {
245
271
  try { pair = JSON.parse(localStorage.getItem(PAIR_KEY)); } catch (e) {}
246
272
  }
@@ -282,16 +308,10 @@ export const PWA_HTML = `<!doctype html>
282
308
  if (isIOS) {
283
309
  b.innerHTML =
284
310
  '<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>';
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>';
289
314
  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
315
  } else {
296
316
  b.innerHTML =
297
317
  '<h2>Install sandgate to get push notifications</h2>' +
@@ -392,6 +412,47 @@ export const PWA_HTML = `<!doctype html>
392
412
  setInterval(function () { if (!document.hidden) fetchPending(); }, 45000); // safety net
393
413
  document.addEventListener("visibilitychange", function () { if (!document.hidden) fetchPending(); });
394
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
+
395
456
  // --- approval cards: stable DOM, in-place countdowns ---------------------
396
457
  var cards = {}; // requestId -> {el, leftEl, fillEl, barEl, rowEl, req, done}
397
458
  var emptyEl = null;
@@ -466,6 +527,7 @@ export const PWA_HTML = `<!doctype html>
466
527
  c.rowEl.remove();
467
528
  c.leftEl.textContent = "expired — denied";
468
529
  c.fillEl.style.width = "0%";
530
+ recordHist(c.req.title, "expired");
469
531
  }
470
532
  return;
471
533
  }
@@ -495,7 +557,11 @@ export const PWA_HTML = `<!doctype html>
495
557
  headers: { "Content-Type": "application/json" },
496
558
  body: JSON.stringify({ pairId: pair.pairId, requestId: id, payload: payload }),
497
559
  });
498
- if (cards[id]) { cards[id].el.remove(); delete cards[id]; }
560
+ if (cards[id]) {
561
+ recordHist(cards[id].req.title, cls === "ok" ? "approved" : "denied");
562
+ cards[id].el.remove();
563
+ delete cards[id];
564
+ }
499
565
  ensureEmpty(Object.keys(cards).length === 0);
500
566
  };
501
567
  return b;
@@ -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, PWA_MANIFEST } from "./pwa-page.js";
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
- res.writeHead(200, { "Content-Type": "application/manifest+json" });
95
- return res.end(PWA_MANIFEST);
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" });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sandprivacy/sandgate",
3
- "version": "0.1.2",
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",