@sandprivacy/sandgate 0.1.0

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/paths.js ADDED
@@ -0,0 +1,11 @@
1
+ import { homedir } from "node:os";
2
+ import { join } from "node:path";
3
+ import { mkdirSync } from "node:fs";
4
+ export function sandgateDir() {
5
+ const dir = process.env.SANDGATE_HOME || join(homedir(), ".sandgate");
6
+ mkdirSync(dir, { recursive: true });
7
+ return dir;
8
+ }
9
+ export const vaultPath = () => join(sandgateDir(), "vault.enc");
10
+ export const configPath = () => join(sandgateDir(), "config.json");
11
+ export const auditPath = () => join(sandgateDir(), "audit.jsonl");
@@ -0,0 +1,43 @@
1
+ import { randomBytes } from "node:crypto";
2
+ import { deriveKey, seal, open, aadForRequest, aadForDecision } from "./pwacrypto.js";
3
+ export class PwaApprover {
4
+ config;
5
+ key;
6
+ constructor(config) {
7
+ this.config = config;
8
+ this.key = deriveKey(config.secret);
9
+ }
10
+ url(path) {
11
+ return this.config.relayUrl.replace(/\/$/, "") + path;
12
+ }
13
+ async request(req) {
14
+ const requestId = randomBytes(16).toString("base64url");
15
+ const sealed = seal(this.key, { title: req.title, body: req.body, timeoutSec: req.timeoutSec, ts: Date.now() }, aadForRequest(requestId));
16
+ const post = await fetch(this.url("/api/request"), {
17
+ method: "POST",
18
+ headers: { "Content-Type": "application/json" },
19
+ body: JSON.stringify({ pairId: this.config.pairId, requestId, payload: sealed }),
20
+ });
21
+ if (!post.ok)
22
+ throw new Error(`Relay refused the request (HTTP ${post.status}).`);
23
+ const deadline = Date.now() + req.timeoutSec * 1000;
24
+ while (Date.now() < deadline) {
25
+ const pollSec = Math.min(25, Math.max(1, Math.ceil((deadline - Date.now()) / 1000)));
26
+ const res = await fetch(this.url(`/api/decision?pairId=${encodeURIComponent(this.config.pairId)}` +
27
+ `&requestId=${encodeURIComponent(requestId)}&timeoutSec=${pollSec}`));
28
+ if (res.status === 204)
29
+ continue;
30
+ if (!res.ok)
31
+ throw new Error(`Relay error while waiting (HTTP ${res.status}).`);
32
+ const { payload } = (await res.json());
33
+ const decision = open(this.key, payload, aadForDecision(requestId));
34
+ if (decision.requestId !== requestId)
35
+ continue; // belt and suspenders; AAD already binds it
36
+ return {
37
+ approved: decision.approved,
38
+ decision: decision.approved ? "approved" : "denied",
39
+ };
40
+ }
41
+ return { approved: false, decision: "timeout" };
42
+ }
43
+ }
@@ -0,0 +1,49 @@
1
+ import { hkdfSync, randomBytes, createCipheriv, createDecipheriv } from "node:crypto";
2
+ /**
3
+ * End-to-end crypto between the gateway and the paired phone. The 32-byte
4
+ * pairing secret travels once, inside a URL *fragment* (never sent to any
5
+ * server); both sides derive one AES-256-GCM key via HKDF-SHA256. Every
6
+ * message is sealed with the request id and direction in the AAD, so the
7
+ * relay — which stores and forwards blobs — can neither read nor forge
8
+ * nor cross-replay anything. The browser side mirrors this exactly with
9
+ * WebCrypto (HKDF + AES-GCM produce identical bytes).
10
+ */
11
+ const HKDF_SALT = Buffer.from("sandgate-pwa-v1", "utf8");
12
+ const HKDF_INFO = Buffer.from("approval-channel", "utf8");
13
+ export function newPairing() {
14
+ return {
15
+ pairId: randomBytes(16).toString("base64url"),
16
+ secret: randomBytes(32).toString("base64url"),
17
+ };
18
+ }
19
+ export function deriveKey(secretB64url) {
20
+ const secret = Buffer.from(secretB64url, "base64url");
21
+ return Buffer.from(hkdfSync("sha256", secret, HKDF_SALT, HKDF_INFO, 32));
22
+ }
23
+ export function seal(key, payload, aad) {
24
+ const iv = randomBytes(12);
25
+ const cipher = createCipheriv("aes-256-gcm", key, iv);
26
+ cipher.setAAD(Buffer.from(aad, "utf8"));
27
+ const plaintext = Buffer.from(JSON.stringify(payload), "utf8");
28
+ const ct = Buffer.concat([cipher.update(plaintext), cipher.final(), cipher.getAuthTag()]);
29
+ return { iv: iv.toString("base64url"), ct: ct.toString("base64url") };
30
+ }
31
+ export function open(key, sealed, aad) {
32
+ const raw = Buffer.from(sealed.ct, "base64url");
33
+ if (raw.length < 17)
34
+ throw new Error("Sealed message too short.");
35
+ const tag = raw.subarray(raw.length - 16);
36
+ const ct = raw.subarray(0, raw.length - 16);
37
+ const decipher = createDecipheriv("aes-256-gcm", key, Buffer.from(sealed.iv, "base64url"));
38
+ decipher.setAAD(Buffer.from(aad, "utf8"));
39
+ decipher.setAuthTag(tag);
40
+ try {
41
+ const plaintext = Buffer.concat([decipher.update(ct), decipher.final()]);
42
+ return JSON.parse(plaintext.toString("utf8"));
43
+ }
44
+ catch {
45
+ throw new Error("Sealed message failed authentication (wrong key or tampered).");
46
+ }
47
+ }
48
+ export const aadForRequest = (requestId) => `req:${requestId}`;
49
+ export const aadForDecision = (requestId) => `dec:${requestId}`;
@@ -0,0 +1,92 @@
1
+ import { deflateSync } from "node:zlib";
2
+ /**
3
+ * The sandgate mark: a geometric gate (two posts, two lintels) in amber on
4
+ * the warm dark ground. Drawn from axis-aligned rectangles so the PNG app
5
+ * icons can be generated in pure Node — no rasterizer dependency — and stay
6
+ * pixel-crisp at every size. The SVG twin is used inline in the PWA.
7
+ */
8
+ const BG = [20, 18, 16]; // #141210
9
+ const AMBER = [217, 164, 65]; // #D9A441
10
+ // Gate geometry as fractions of the canvas: [x0, y0, x1, y1]
11
+ const GATE_RECTS = [
12
+ [0.18, 0.26, 0.82, 0.34], // top lintel
13
+ [0.26, 0.42, 0.74, 0.48], // second bar
14
+ [0.3, 0.34, 0.38, 0.78], // left post
15
+ [0.62, 0.34, 0.7, 0.78], // right post
16
+ ];
17
+ export const ICON_SVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">` +
18
+ `<rect width="100" height="100" rx="22" fill="#141210"/>` +
19
+ GATE_RECTS.map(([x0, y0, x1, y1]) => `<rect x="${x0 * 100}" y="${y0 * 100}" width="${(x1 - x0) * 100}" height="${(y1 - y0) * 100}" rx="2.5" fill="#D9A441"/>`).join("") +
20
+ `</svg>`;
21
+ /** The bare glyph (transparent ground) for inline UI use. */
22
+ export const GLYPH_SVG_RECTS = GATE_RECTS.map(([x0, y0, x1, y1]) => `<rect x="${x0 * 24}" y="${y0 * 24}" width="${(x1 - x0) * 24}" height="${(y1 - y0) * 24}" rx="0.6"/>`).join("");
23
+ const CRC_TABLE = (() => {
24
+ const table = new Uint32Array(256);
25
+ for (let n = 0; n < 256; n++) {
26
+ let c = n;
27
+ for (let k = 0; k < 8; k++)
28
+ c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
29
+ table[n] = c >>> 0;
30
+ }
31
+ return table;
32
+ })();
33
+ function crc32(buf) {
34
+ let c = 0xffffffff;
35
+ for (const byte of buf)
36
+ c = CRC_TABLE[(c ^ byte) & 0xff] ^ (c >>> 8);
37
+ return (c ^ 0xffffffff) >>> 0;
38
+ }
39
+ function pngChunk(type, data) {
40
+ const len = Buffer.alloc(4);
41
+ len.writeUInt32BE(data.length);
42
+ const body = Buffer.concat([Buffer.from(type, "ascii"), data]);
43
+ const crc = Buffer.alloc(4);
44
+ crc.writeUInt32BE(crc32(body));
45
+ return Buffer.concat([len, body, crc]);
46
+ }
47
+ const cache = new Map();
48
+ /** Render the app icon as a PNG at the given square size. */
49
+ export function iconPng(size) {
50
+ const cached = cache.get(size);
51
+ if (cached)
52
+ return cached;
53
+ const px = Buffer.alloc(size * size * 3);
54
+ for (let i = 0; i < size * size; i++) {
55
+ px[i * 3] = BG[0];
56
+ px[i * 3 + 1] = BG[1];
57
+ px[i * 3 + 2] = BG[2];
58
+ }
59
+ for (const [fx0, fy0, fx1, fy1] of GATE_RECTS) {
60
+ const x0 = Math.round(fx0 * size);
61
+ const x1 = Math.round(fx1 * size);
62
+ const y0 = Math.round(fy0 * size);
63
+ const y1 = Math.round(fy1 * size);
64
+ for (let y = y0; y < y1; y++) {
65
+ for (let x = x0; x < x1; x++) {
66
+ const i = (y * size + x) * 3;
67
+ px[i] = AMBER[0];
68
+ px[i + 1] = AMBER[1];
69
+ px[i + 2] = AMBER[2];
70
+ }
71
+ }
72
+ }
73
+ // Raw scanlines: filter byte 0 + RGB row.
74
+ const raw = Buffer.alloc(size * (size * 3 + 1));
75
+ for (let y = 0; y < size; y++) {
76
+ raw[y * (size * 3 + 1)] = 0;
77
+ px.copy(raw, y * (size * 3 + 1) + 1, y * size * 3, (y + 1) * size * 3);
78
+ }
79
+ const ihdr = Buffer.alloc(13);
80
+ ihdr.writeUInt32BE(size, 0);
81
+ ihdr.writeUInt32BE(size, 4);
82
+ ihdr[8] = 8; // bit depth
83
+ ihdr[9] = 2; // color type: truecolor RGB
84
+ const png = Buffer.concat([
85
+ Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
86
+ pngChunk("IHDR", ihdr),
87
+ pngChunk("IDAT", deflateSync(raw, { level: 9 })),
88
+ pngChunk("IEND", Buffer.alloc(0)),
89
+ ]);
90
+ cache.set(size, png);
91
+ return png;
92
+ }
@@ -0,0 +1,374 @@
1
+ import { GLYPH_SVG_RECTS } from "./icons.js";
2
+ /**
3
+ * The phone-side PWA, shipped as strings so the npm package stays
4
+ * self-contained. The inline JS mirrors src/pwacrypto.ts byte-for-byte:
5
+ * HKDF-SHA256(salt "sandgate-pwa-v1", info "approval-channel") -> AES-256-GCM,
6
+ * AAD "req:<id>" / "dec:<id>". The pairing secret arrives once in the URL
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.
9
+ */
10
+ export const PWA_MANIFEST = JSON.stringify({
11
+ name: "sandgate",
12
+ short_name: "sandgate",
13
+ start_url: "/",
14
+ display: "standalone",
15
+ background_color: "#141210",
16
+ theme_color: "#141210",
17
+ icons: [
18
+ { src: "/icon-192.png", sizes: "192x192", type: "image/png" },
19
+ { src: "/icon-512.png", sizes: "512x512", type: "image/png" },
20
+ ],
21
+ });
22
+ export const PWA_SW = `
23
+ self.addEventListener("install", function () { self.skipWaiting(); });
24
+ self.addEventListener("activate", function (e) { e.waitUntil(self.clients.claim()); });
25
+ self.addEventListener("push", function (e) {
26
+ e.waitUntil((async function () {
27
+ await self.registration.showNotification("sandgate", {
28
+ body: "Approval requested — tap to answer",
29
+ tag: "sandgate-approval",
30
+ renotify: true,
31
+ icon: "/icon-192.png",
32
+ badge: "/icon-192.png",
33
+ });
34
+ var clientList = await self.clients.matchAll({ type: "window" });
35
+ clientList.forEach(function (c) { c.postMessage("refresh"); });
36
+ })());
37
+ });
38
+ self.addEventListener("notificationclick", function (e) {
39
+ e.notification.close();
40
+ e.waitUntil((async function () {
41
+ var clientList = await self.clients.matchAll({ type: "window" });
42
+ if (clientList.length) return clientList[0].focus();
43
+ return self.clients.openWindow("/");
44
+ })());
45
+ });
46
+ `;
47
+ const GATE_GLYPH = (size, className) => `<svg class="${className}" width="${size}" height="${size}" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">${GLYPH_SVG_RECTS}</svg>`;
48
+ const CHECK_ICON = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4.5 12.5l5 5L19.5 6.5"/></svg>';
49
+ const CROSS_ICON = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round" aria-hidden="true"><path d="M6.5 6.5l11 11M17.5 6.5l-11 11"/></svg>';
50
+ export const PWA_HTML = `<!doctype html>
51
+ <html lang="en">
52
+ <head>
53
+ <meta charset="utf-8">
54
+ <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
55
+ <meta name="apple-mobile-web-app-capable" content="yes">
56
+ <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
57
+ <meta name="theme-color" content="#141210">
58
+ <link rel="manifest" href="/manifest.webmanifest">
59
+ <link rel="icon" href="/icon.svg" type="image/svg+xml">
60
+ <link rel="apple-touch-icon" href="/icon-180.png">
61
+ <title>sandgate</title>
62
+ <style>
63
+ :root {
64
+ color-scheme: dark;
65
+ --ground: #141210;
66
+ --panel: #1e1b16;
67
+ --panel-raised: #262219;
68
+ --line: #35301f;
69
+ --ink: #efe9db;
70
+ --soft: #a69c85;
71
+ --accent: #d9a441;
72
+ --ok: #3f9169;
73
+ --ok-press: #337a57;
74
+ --no: #c2563e;
75
+ --no-press: #a64732;
76
+ }
77
+ * { box-sizing: border-box; }
78
+ body {
79
+ margin: 0;
80
+ background: var(--ground);
81
+ color: var(--ink);
82
+ font: 16px/1.5 -apple-system, "SF Pro Text", "Segoe UI", Roboto, system-ui, sans-serif;
83
+ min-height: 100dvh;
84
+ }
85
+ header {
86
+ position: sticky; top: 0; z-index: 2;
87
+ display: flex; align-items: center; gap: 12px;
88
+ padding: calc(env(safe-area-inset-top, 0px) + 14px) 18px 14px;
89
+ background: color-mix(in srgb, var(--ground) 88%, transparent);
90
+ backdrop-filter: blur(10px);
91
+ border-bottom: 1px solid var(--line);
92
+ }
93
+ .logo {
94
+ width: 38px; height: 38px; border-radius: 10px;
95
+ background: linear-gradient(145deg, #241f14, #1a1610);
96
+ border: 1px solid #453a1e;
97
+ display: grid; place-items: center;
98
+ color: var(--accent);
99
+ box-shadow: inset 0 1px 0 rgba(217,164,65,.12);
100
+ }
101
+ .title { flex: 1; }
102
+ .title h1 { font-size: 17px; margin: 0; letter-spacing: .01em; }
103
+ .title .sub { font-size: 11.5px; color: var(--soft); letter-spacing: .06em; text-transform: uppercase; }
104
+ .pill {
105
+ display: flex; align-items: center; gap: 6px;
106
+ font-size: 11px; font-weight: 600; letter-spacing: .04em;
107
+ padding: 5px 10px; border-radius: 999px; white-space: nowrap;
108
+ background: #26311f; color: #9dc98a; border: 1px solid #3b4a30;
109
+ }
110
+ .pill::before { content: ""; width: 6px; height: 6px; border-radius: 50%; background: currentColor; }
111
+ .pill.warn { background: #332a17; color: var(--accent); border-color: #4a3d1f; }
112
+ .pill.err { background: #331d17; color: #d98a76; border-color: #4a2a1f; }
113
+
114
+ main { max-width: 520px; margin: 0 auto; padding: 18px 16px calc(env(safe-area-inset-bottom, 0px) + 40px); }
115
+
116
+ .card {
117
+ background: var(--panel);
118
+ border: 1px solid var(--line);
119
+ border-radius: 14px;
120
+ padding: 16px 16px 14px;
121
+ margin-bottom: 14px;
122
+ animation: rise .25s ease-out;
123
+ }
124
+ @keyframes rise { from { opacity: 0; transform: translateY(6px); } }
125
+ @media (prefers-reduced-motion: reduce) { .card { animation: none; } }
126
+ .card .who { font-size: 11px; color: var(--accent); letter-spacing: .08em; text-transform: uppercase; margin-bottom: 6px; }
127
+ .card h2 { font-size: 18px; line-height: 1.3; margin: 0 0 6px; }
128
+ .card p { margin: 0 0 12px; color: #cfc6b2; font-size: 14.5px; white-space: pre-wrap; overflow-wrap: anywhere; }
129
+ .timer { display: flex; align-items: center; gap: 10px; margin-bottom: 14px; }
130
+ .timer .left { font-size: 12px; color: var(--soft); min-width: 96px; font-variant-numeric: tabular-nums; }
131
+ .bar { flex: 1; height: 4px; border-radius: 2px; background: var(--line); overflow: hidden; }
132
+ .bar i { display: block; height: 100%; background: var(--accent); border-radius: 2px; transition: width 1s linear; }
133
+ .bar.low i { background: var(--no); }
134
+ .row { display: flex; gap: 10px; }
135
+ button {
136
+ flex: 1; display: flex; align-items: center; justify-content: center; gap: 8px;
137
+ padding: 13px; font-size: 15.5px; font-weight: 650;
138
+ border: 0; border-radius: 10px; cursor: pointer; color: #fff;
139
+ font-family: inherit; letter-spacing: .01em;
140
+ transition: transform .06s ease;
141
+ }
142
+ button:active { transform: scale(.97); }
143
+ button:disabled { opacity: .5; }
144
+ .ok { background: var(--ok); } .ok:active { background: var(--ok-press); }
145
+ .no { background: var(--no); } .no:active { background: var(--no-press); }
146
+ .expired { opacity: .45; }
147
+ .expired h2 { text-decoration: line-through; text-decoration-thickness: 1px; }
148
+
149
+ .empty { text-align: center; padding: 72px 20px; color: var(--soft); }
150
+ .empty .mark { color: var(--accent); opacity: .3; margin-bottom: 16px; }
151
+ .empty .big { font-size: 16px; color: #cfc6b2; margin-bottom: 4px; }
152
+ .empty .hint { font-size: 13px; }
153
+
154
+ .setup { text-align: center; padding: 60px 24px; color: #cfc6b2; }
155
+ .setup .mark { color: var(--accent); opacity: .5; margin-bottom: 16px; }
156
+ .setup code {
157
+ display: inline-block; margin-top: 10px; padding: 8px 14px; border-radius: 8px;
158
+ background: var(--panel-raised); border: 1px solid var(--line);
159
+ font: 14px ui-monospace, "Cascadia Mono", monospace; color: var(--accent);
160
+ }
161
+ </style>
162
+ </head>
163
+ <body>
164
+ <header>
165
+ <div class="logo">${GATE_GLYPH(22, "mark")}</div>
166
+ <div class="title">
167
+ <h1>sandgate</h1>
168
+ <div class="sub">your agents ask. you decide.</div>
169
+ </div>
170
+ <div class="pill warn" id="status">starting</div>
171
+ </header>
172
+ <main><div id="list"></div></main>
173
+ <script>
174
+ (function () {
175
+ var PAIR_KEY = "sandgate_pair";
176
+ var GLYPH = '${GATE_GLYPH(56, "mark").replace(/'/g, "\\'")}';
177
+ var CHECK = '${CHECK_ICON.replace(/'/g, "\\'")}';
178
+ var CROSS = '${CROSS_ICON.replace(/'/g, "\\'")}';
179
+
180
+ function b64uToBytes(s) {
181
+ s = s.replace(/-/g, "+").replace(/_/g, "/");
182
+ var bin = atob(s), out = new Uint8Array(bin.length);
183
+ for (var i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
184
+ return out;
185
+ }
186
+ function bytesToB64u(buf) {
187
+ var bytes = new Uint8Array(buf), bin = "";
188
+ for (var i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]);
189
+ return btoa(bin).replace(/\\+/g, "-").replace(/\\//g, "_").replace(/=+$/, "");
190
+ }
191
+ var enc = new TextEncoder(), dec = new TextDecoder();
192
+
193
+ var statusEl = document.getElementById("status");
194
+ var listEl = document.getElementById("list");
195
+ function setStatus(text, cls) {
196
+ statusEl.textContent = text;
197
+ statusEl.className = "pill" + (cls ? " " + cls : "");
198
+ }
199
+
200
+ // --- 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] };
205
+ try { localStorage.setItem(PAIR_KEY, JSON.stringify(pair)); } catch (e) {}
206
+ history.replaceState(null, "", location.pathname);
207
+ } else {
208
+ try { pair = JSON.parse(localStorage.getItem(PAIR_KEY)); } catch (e) {}
209
+ }
210
+ if (!pair) {
211
+ 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>';
213
+ return;
214
+ }
215
+
216
+ // --- crypto (mirror of pwacrypto.ts) ------------------------------------
217
+ var keyPromise = (async function () {
218
+ var raw = await crypto.subtle.importKey("raw", b64uToBytes(pair.secret), "HKDF", false, ["deriveKey"]);
219
+ return crypto.subtle.deriveKey(
220
+ { name: "HKDF", hash: "SHA-256", salt: enc.encode("sandgate-pwa-v1"), info: enc.encode("approval-channel") },
221
+ raw,
222
+ { name: "AES-GCM", length: 256 },
223
+ false,
224
+ ["encrypt", "decrypt"]
225
+ );
226
+ })();
227
+
228
+ async function openSealed(sealed, aad) {
229
+ var key = await keyPromise;
230
+ var pt = await crypto.subtle.decrypt(
231
+ { name: "AES-GCM", iv: b64uToBytes(sealed.iv), additionalData: enc.encode(aad) },
232
+ key,
233
+ b64uToBytes(sealed.ct)
234
+ );
235
+ return JSON.parse(dec.decode(pt));
236
+ }
237
+ async function sealPayload(payload, aad) {
238
+ var key = await keyPromise;
239
+ var iv = crypto.getRandomValues(new Uint8Array(12));
240
+ var ct = await crypto.subtle.encrypt(
241
+ { name: "AES-GCM", iv: iv, additionalData: enc.encode(aad) },
242
+ key,
243
+ enc.encode(JSON.stringify(payload))
244
+ );
245
+ return { iv: bytesToB64u(iv), ct: bytesToB64u(ct) };
246
+ }
247
+
248
+ // --- push subscription ---------------------------------------------------
249
+ (async function () {
250
+ try {
251
+ if ("serviceWorker" in navigator) {
252
+ var reg = await navigator.serviceWorker.register("/sw.js");
253
+ navigator.serviceWorker.addEventListener("message", function (e) {
254
+ if (e.data === "refresh") fetchPending();
255
+ });
256
+ if ("PushManager" in window) {
257
+ var perm = await Notification.requestPermission();
258
+ if (perm === "granted") {
259
+ var vapid = await (await fetch("/api/vapid")).json();
260
+ var sub = await reg.pushManager.subscribe({
261
+ userVisibleOnly: true,
262
+ applicationServerKey: b64uToBytes(vapid.publicKey),
263
+ });
264
+ await fetch("/api/subscribe", {
265
+ method: "POST",
266
+ headers: { "Content-Type": "application/json" },
267
+ body: JSON.stringify({ pairId: pair.pairId, subscription: sub }),
268
+ });
269
+ setStatus("push on");
270
+ return;
271
+ }
272
+ }
273
+ }
274
+ setStatus("polling — keep open", "warn");
275
+ } catch (e) {
276
+ setStatus("polling — keep open", "warn");
277
+ }
278
+ })();
279
+
280
+ // --- approval list -------------------------------------------------------
281
+ var items = []; // [{requestId, req:{title,body,timeoutSec,ts}, decided}]
282
+
283
+ async function fetchPending() {
284
+ var res, raw;
285
+ try {
286
+ res = await fetch("/api/pending?pairId=" + encodeURIComponent(pair.pairId));
287
+ raw = await res.json();
288
+ } catch (e) { return; }
289
+ var next = [];
290
+ 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; }
293
+ try {
294
+ var req = await openSealed(raw[i].payload, "req:" + raw[i].requestId);
295
+ next.push({ requestId: raw[i].requestId, req: req, decided: false });
296
+ } catch (e) { /* not ours / tampered */ }
297
+ }
298
+ items = next;
299
+ render();
300
+ }
301
+
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" : "");
318
+
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); }
323
+
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);
333
+
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);
339
+ }
340
+ listEl.appendChild(card);
341
+ });
342
+ }
343
+
344
+ function makeBtn(label, cls, icon, item) {
345
+ var b = document.createElement("button");
346
+ b.className = cls;
347
+ b.innerHTML = icon + "<span></span>";
348
+ b.querySelector("span").textContent = label;
349
+ b.onclick = async function () {
350
+ b.disabled = true;
351
+ var payload = await sealPayload(
352
+ { requestId: item.requestId, approved: cls === "ok", ts: Date.now() },
353
+ "dec:" + item.requestId
354
+ );
355
+ await fetch("/api/decision", {
356
+ method: "POST",
357
+ headers: { "Content-Type": "application/json" },
358
+ body: JSON.stringify({ pairId: pair.pairId, requestId: item.requestId, payload: payload }),
359
+ });
360
+ item.decided = true;
361
+ render();
362
+ };
363
+ return b;
364
+ }
365
+
366
+ 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
+ })();
371
+ </script>
372
+ </body>
373
+ </html>
374
+ `;