@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.
- package/dist/relay/pwa-page.js +90 -24
- package/dist/relay/server.js +7 -3
- package/package.json +1 -1
package/dist/relay/pwa-page.js
CHANGED
|
@@ -13,18 +13,28 @@ import { GLYPH_SVG_RECTS } from "./icons.js";
|
|
|
13
13
|
* (copy → install → paste); Android installs in place via
|
|
14
14
|
* beforeinstallprompt and shares storage with the browser.
|
|
15
15
|
*/
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
16
|
+
/**
|
|
17
|
+
* Two manifests on purpose. Chrome requires start_url for installability
|
|
18
|
+
* and shares storage with the installed app, so Android gets "/". iOS home
|
|
19
|
+
* -screen apps have isolated storage, but WITHOUT a manifest start_url iOS
|
|
20
|
+
* captures the CURRENT page URL — fragment included. The page keeps the
|
|
21
|
+
* pairing link in the address bar on iOS Safari, so Add to Home Screen
|
|
22
|
+
* produces an app that opens already paired. No copy-paste.
|
|
23
|
+
*/
|
|
24
|
+
export function pwaManifest(opts) {
|
|
25
|
+
return JSON.stringify({
|
|
26
|
+
name: "sandgate",
|
|
27
|
+
short_name: "sandgate",
|
|
28
|
+
...(opts.includeStartUrl ? { start_url: "/" } : {}),
|
|
29
|
+
display: "standalone",
|
|
30
|
+
background_color: "#141210",
|
|
31
|
+
theme_color: "#141210",
|
|
32
|
+
icons: [
|
|
33
|
+
{ src: "/icon-192.png", sizes: "192x192", type: "image/png" },
|
|
34
|
+
{ src: "/icon-512.png", sizes: "512x512", type: "image/png" },
|
|
35
|
+
],
|
|
36
|
+
});
|
|
37
|
+
}
|
|
28
38
|
export const PWA_SW = `
|
|
29
39
|
self.addEventListener("install", function () { self.skipWaiting(); });
|
|
30
40
|
self.addEventListener("activate", function (e) { e.waitUntil(self.clients.claim()); });
|
|
@@ -186,6 +196,16 @@ export const PWA_HTML = `<!doctype html>
|
|
|
186
196
|
color: var(--ink); font: 14px ui-monospace, monospace;
|
|
187
197
|
}
|
|
188
198
|
.setup input:focus { outline: 2px solid var(--accent); outline-offset: -1px; }
|
|
199
|
+
|
|
200
|
+
.hist { margin-top: 30px; }
|
|
201
|
+
.hist h3 { font-size: 11px; letter-spacing: .08em; text-transform: uppercase; color: var(--soft); margin: 0 0 8px; }
|
|
202
|
+
.hrow { display: flex; gap: 10px; align-items: baseline; padding: 8px 0; border-bottom: 1px solid var(--line); font-size: 13.5px; }
|
|
203
|
+
.hrow .time { color: var(--soft); font-variant-numeric: tabular-nums; font-size: 12px; min-width: 74px; }
|
|
204
|
+
.hrow .t { flex: 1; color: #cfc6b2; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
205
|
+
.hrow .d { font-weight: 650; font-size: 10.5px; letter-spacing: .05em; text-transform: uppercase; }
|
|
206
|
+
.d.approved { color: #7fbf9a; }
|
|
207
|
+
.d.denied { color: #d98a76; }
|
|
208
|
+
.d.expired { color: var(--soft); }
|
|
189
209
|
</style>
|
|
190
210
|
</head>
|
|
191
211
|
<body>
|
|
@@ -197,7 +217,7 @@ export const PWA_HTML = `<!doctype html>
|
|
|
197
217
|
</div>
|
|
198
218
|
<div class="pill warn" id="status">starting</div>
|
|
199
219
|
</header>
|
|
200
|
-
<main><div id="banner"></div><div id="list"></div></main>
|
|
220
|
+
<main><div id="banner"></div><div id="list"></div><div id="hist"></div></main>
|
|
201
221
|
<script>
|
|
202
222
|
(function () {
|
|
203
223
|
var PAIR_KEY = "sandgate_pair";
|
|
@@ -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
|
-
|
|
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
|
|
286
|
-
'<li>
|
|
287
|
-
'<li>Open sandgate from your home screen
|
|
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]) {
|
|
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;
|
package/dist/relay/server.js
CHANGED
|
@@ -2,7 +2,7 @@ import { createServer } from "node:http";
|
|
|
2
2
|
import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs";
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
import webpush from "web-push";
|
|
5
|
-
import { PWA_HTML, PWA_SW,
|
|
5
|
+
import { PWA_HTML, PWA_SW, pwaManifest } from "./pwa-page.js";
|
|
6
6
|
import { ICON_SVG, iconPng } from "./icons.js";
|
|
7
7
|
const MAX_BODY = 64 * 1024;
|
|
8
8
|
const REQUEST_TTL_MS = 30 * 60 * 1000;
|
|
@@ -91,8 +91,12 @@ export async function startRelay(opts) {
|
|
|
91
91
|
return res.end(PWA_SW);
|
|
92
92
|
}
|
|
93
93
|
if (req.method === "GET" && url.pathname === "/manifest.webmanifest") {
|
|
94
|
-
|
|
95
|
-
|
|
94
|
+
const isApple = /iPhone|iPad|iPod/.test(req.headers["user-agent"] ?? "");
|
|
95
|
+
res.writeHead(200, {
|
|
96
|
+
"Content-Type": "application/manifest+json",
|
|
97
|
+
Vary: "User-Agent",
|
|
98
|
+
});
|
|
99
|
+
return res.end(pwaManifest({ includeStartUrl: !isApple }));
|
|
96
100
|
}
|
|
97
101
|
if (req.method === "GET" && url.pathname === "/icon.svg") {
|
|
98
102
|
res.writeHead(200, { "Content-Type": "image/svg+xml", "Cache-Control": "max-age=86400" });
|
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",
|