@sandprivacy/sandgate 0.1.3 → 0.1.6

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/index.js CHANGED
@@ -315,21 +315,28 @@ async function cmdPair(relayUrl) {
315
315
  qrcode.generate(pairLink, { small: true });
316
316
  console.log("\nWaiting for the phone to subscribe (2 min)…");
317
317
  const deadline = Date.now() + 120_000;
318
+ let seenAnnounced = false;
318
319
  while (Date.now() < deadline) {
319
320
  try {
320
321
  const res = await fetch(`${base}/api/pair-status?pairId=${encodeURIComponent(pairing.pairId)}`);
321
322
  const status = (await res.json());
322
323
  if (status.subscribed) {
323
- console.log("Paired! The PWA now takes over approvals (Telegram becomes the fallback). Try: sandgate test-approval");
324
+ console.log("Paired, push notifications on. The PWA now takes over approvals (Telegram becomes the fallback). Try: sandgate test-approval");
324
325
  return;
325
326
  }
327
+ if (status.seen && !seenAnnounced) {
328
+ seenAnnounced = true;
329
+ console.log('Phone connected. For notifications with the app closed, tap "Enable notifications" in the app (on iPhone: install to home screen first)…');
330
+ }
326
331
  }
327
332
  catch {
328
333
  // relay not reachable yet; keep trying
329
334
  }
330
335
  await new Promise((r) => setTimeout(r, 3000));
331
336
  }
332
- console.log("No subscription yet — the pairing is saved anyway. Open the link on the phone, then check with: sandgate test-approval");
337
+ console.log(seenAnnounced
338
+ ? "Paired (no push yet — the app works while open; enable notifications in it when you can)."
339
+ : "No phone yet — the pairing is saved anyway. Open the link on the phone, then check with: sandgate test-approval");
333
340
  }
334
341
  async function cmdTestApproval() {
335
342
  const prompter = new Prompter();
@@ -38,8 +38,14 @@ export function pwaManifest(opts) {
38
38
  export const PWA_SW = `
39
39
  self.addEventListener("install", function () { self.skipWaiting(); });
40
40
  self.addEventListener("activate", function (e) { e.waitUntil(self.clients.claim()); });
41
- // A fetch handler is required for Chrome's install prompt; plain passthrough.
42
- self.addEventListener("fetch", function (e) { e.respondWith(fetch(e.request)); });
41
+ // A fetch handler is required for Chrome's install prompt. Handle ONLY
42
+ // same-origin GETs: on iOS 16.4+, respondWith(fetch(request)) on POSTs
43
+ // (bodies) throws Internal error and silently kills subscribe/decision
44
+ // calls — anything we return from lets the browser handle natively.
45
+ self.addEventListener("fetch", function (e) {
46
+ if (e.request.method !== "GET") return;
47
+ e.respondWith(fetch(e.request));
48
+ });
43
49
  self.addEventListener("push", function (e) {
44
50
  e.waitUntil((async function () {
45
51
  await self.registration.showNotification("sandgate", {
@@ -193,7 +199,8 @@ export const PWA_HTML = `<!doctype html>
193
199
  .setup input {
194
200
  width: 100%; padding: 12px 14px; margin: 14px 0 10px;
195
201
  background: var(--panel); border: 1px solid var(--line); border-radius: 10px;
196
- color: var(--ink); font: 14px ui-monospace, monospace;
202
+ /* 16px minimum: below that, iOS Safari auto-zooms into focused inputs. */
203
+ color: var(--ink); font: 16px ui-monospace, monospace;
197
204
  }
198
205
  .setup input:focus { outline: 2px solid var(--accent); outline-offset: -1px; }
199
206
 
@@ -257,9 +264,42 @@ export const PWA_HTML = `<!doctype html>
257
264
  var mm = String(text).match(/p=([A-Za-z0-9_-]{8,64})&s=([A-Za-z0-9_-]{8,})/);
258
265
  return mm ? { pairId: mm[1], secret: mm[2] } : null;
259
266
  }
260
- var pair = parsePairing(location.hash);
261
- if (pair) {
262
- try { localStorage.setItem(PAIR_KEY, JSON.stringify(pair)); } catch (e) {}
267
+ // Several vaults can pair with this device (your laptop, your server…):
268
+ // pairings are a list, requests from all of them show together.
269
+ var PAIRS_KEY = "sandgate_pairs";
270
+ function loadPairs() {
271
+ try {
272
+ var list = JSON.parse(localStorage.getItem(PAIRS_KEY));
273
+ if (Array.isArray(list) && list.length) return list;
274
+ } catch (e) {}
275
+ // Migrate the single-pairing storage of earlier versions.
276
+ try {
277
+ var old = JSON.parse(localStorage.getItem(PAIR_KEY));
278
+ if (old && old.pairId) {
279
+ var migrated = [{ name: "Vault 1", pairId: old.pairId, secret: old.secret }];
280
+ localStorage.setItem(PAIRS_KEY, JSON.stringify(migrated));
281
+ localStorage.removeItem(PAIR_KEY);
282
+ return migrated;
283
+ }
284
+ } catch (e) {}
285
+ return [];
286
+ }
287
+ var pairs = loadPairs();
288
+ function savePairs() {
289
+ try { localStorage.setItem(PAIRS_KEY, JSON.stringify(pairs)); } catch (e) {}
290
+ }
291
+ function addPairing(parsed) {
292
+ for (var i = 0; i < pairs.length; i++) {
293
+ if (pairs[i].pairId === parsed.pairId) return false;
294
+ }
295
+ pairs.push({ name: "Vault " + (pairs.length + 1), pairId: parsed.pairId, secret: parsed.secret });
296
+ savePairs();
297
+ return true;
298
+ }
299
+
300
+ var candidate = parsePairing(location.hash);
301
+ if (candidate) {
302
+ addPairing(candidate);
263
303
  // iOS Safari (not installed): KEEP the pairing link in the address bar.
264
304
  // With no start_url in the Apple manifest, Add to Home Screen captures
265
305
  // this exact URL — fragment included — so the installed app opens
@@ -267,11 +307,9 @@ export const PWA_HTML = `<!doctype html>
267
307
  if (!(isIOS && !standalone)) {
268
308
  history.replaceState(null, "", location.pathname);
269
309
  }
270
- } else {
271
- try { pair = JSON.parse(localStorage.getItem(PAIR_KEY)); } catch (e) {}
272
310
  }
273
311
 
274
- if (!pair) {
312
+ if (!pairs.length) {
275
313
  setStatus("not paired", "err");
276
314
  var setup = document.createElement("div");
277
315
  setup.className = "setup";
@@ -284,15 +322,13 @@ export const PWA_HTML = `<!doctype html>
284
322
  document.getElementById("pasteLink").addEventListener("input", function (e) {
285
323
  var parsed = parsePairing(e.target.value);
286
324
  if (parsed) {
287
- try { localStorage.setItem(PAIR_KEY, JSON.stringify(parsed)); } catch (err) {}
325
+ addPairing(parsed);
288
326
  location.replace(location.pathname);
289
327
  }
290
328
  });
291
329
  return;
292
330
  }
293
331
 
294
- var pairLink = location.origin + "/#p=" + pair.pairId + "&s=" + pair.secret;
295
-
296
332
  // --- install guidance (mobile browser, not yet installed) ---------------
297
333
  var deferredInstall = null;
298
334
  window.addEventListener("beforeinstallprompt", function (e) {
@@ -326,20 +362,26 @@ export const PWA_HTML = `<!doctype html>
326
362
  }
327
363
  renderBanner();
328
364
 
329
- // --- crypto (mirror of pwacrypto.ts) ------------------------------------
330
- var keyPromise = (async function () {
331
- var raw = await crypto.subtle.importKey("raw", b64uToBytes(pair.secret), "HKDF", false, ["deriveKey"]);
332
- return crypto.subtle.deriveKey(
333
- { name: "HKDF", hash: "SHA-256", salt: enc.encode("sandgate-pwa-v1"), info: enc.encode("approval-channel") },
334
- raw,
335
- { name: "AES-GCM", length: 256 },
336
- false,
337
- ["encrypt", "decrypt"]
338
- );
339
- })();
365
+ // --- crypto (mirror of pwacrypto.ts), one derived key per vault ----------
366
+ var keyCache = {};
367
+ function keyFor(p) {
368
+ if (!keyCache[p.pairId]) {
369
+ keyCache[p.pairId] = (async function () {
370
+ var raw = await crypto.subtle.importKey("raw", b64uToBytes(p.secret), "HKDF", false, ["deriveKey"]);
371
+ return crypto.subtle.deriveKey(
372
+ { name: "HKDF", hash: "SHA-256", salt: enc.encode("sandgate-pwa-v1"), info: enc.encode("approval-channel") },
373
+ raw,
374
+ { name: "AES-GCM", length: 256 },
375
+ false,
376
+ ["encrypt", "decrypt"]
377
+ );
378
+ })();
379
+ }
380
+ return keyCache[p.pairId];
381
+ }
340
382
 
341
- async function openSealed(sealed, aad) {
342
- var key = await keyPromise;
383
+ async function openSealed(p, sealed, aad) {
384
+ var key = await keyFor(p);
343
385
  var pt = await crypto.subtle.decrypt(
344
386
  { name: "AES-GCM", iv: b64uToBytes(sealed.iv), additionalData: enc.encode(aad) },
345
387
  key,
@@ -347,8 +389,8 @@ export const PWA_HTML = `<!doctype html>
347
389
  );
348
390
  return JSON.parse(dec.decode(pt));
349
391
  }
350
- async function sealPayload(payload, aad) {
351
- var key = await keyPromise;
392
+ async function sealPayload(p, payload, aad) {
393
+ var key = await keyFor(p);
352
394
  var iv = crypto.getRandomValues(new Uint8Array(12));
353
395
  var ct = await crypto.subtle.encrypt(
354
396
  { name: "AES-GCM", iv: iv, additionalData: enc.encode(aad) },
@@ -358,54 +400,101 @@ export const PWA_HTML = `<!doctype html>
358
400
  return { iv: bytesToB64u(iv), ct: bytesToB64u(ct) };
359
401
  }
360
402
 
361
- // --- push subscription ---------------------------------------------------
403
+ // --- presence + push subscription ---------------------------------------
404
+ // Announce this page to the relay immediately (push or not), so the
405
+ // "sandgate pair" command can report "phone connected" without waiting
406
+ // on notification permission.
407
+ pairs.forEach(function (p) {
408
+ fetch("/api/hello", {
409
+ method: "POST",
410
+ headers: { "Content-Type": "application/json" },
411
+ body: JSON.stringify({ pairId: p.pairId }),
412
+ }).catch(function () {});
413
+ });
414
+
362
415
  var pushOn = false;
363
- (async function () {
364
- try {
365
- if ("serviceWorker" in navigator) {
366
- var reg = await navigator.serviceWorker.register("/sw.js");
416
+ var swRegPromise = "serviceWorker" in navigator
417
+ ? navigator.serviceWorker.register("/sw.js").then(function (reg) {
367
418
  navigator.serviceWorker.addEventListener("message", function (e) {
368
419
  if (e.data === "refresh") fetchPending();
369
420
  });
370
- if ("PushManager" in window && (standalone || !isIOS)) {
371
- var perm = await Notification.requestPermission();
372
- if (perm === "granted") {
373
- var vapid = await (await fetch("/api/vapid")).json();
374
- var sub = await reg.pushManager.subscribe({
375
- userVisibleOnly: true,
376
- applicationServerKey: b64uToBytes(vapid.publicKey),
377
- });
378
- await fetch("/api/subscribe", {
379
- method: "POST",
380
- headers: { "Content-Type": "application/json" },
381
- body: JSON.stringify({ pairId: pair.pairId, subscription: sub }),
382
- });
383
- pushOn = true;
384
- setStatus("push on");
385
- return;
386
- }
387
- }
388
- }
389
- } catch (e) { /* fall through to live/polling status */ }
390
- if (!pushOn) setStatus(sseOn ? "live" : "polling", "warn");
391
- })();
421
+ return reg;
422
+ })
423
+ : Promise.resolve(null);
424
+
425
+ async function enablePush() {
426
+ var reg = await swRegPromise;
427
+ if (!reg || !("PushManager" in window)) return false;
428
+ // iOS only shows the permission prompt inside a user gesture, and only
429
+ // in the installed app — never in Safari tabs.
430
+ var perm = await Notification.requestPermission();
431
+ if (perm !== "granted") return false;
432
+ var vapid = await (await fetch("/api/vapid")).json();
433
+ var sub = await reg.pushManager.subscribe({
434
+ userVisibleOnly: true,
435
+ applicationServerKey: b64uToBytes(vapid.publicKey),
436
+ });
437
+ // One device subscription, registered for every paired vault.
438
+ await Promise.all(pairs.map(function (p) {
439
+ return fetch("/api/subscribe", {
440
+ method: "POST",
441
+ headers: { "Content-Type": "application/json" },
442
+ body: JSON.stringify({ pairId: p.pairId, subscription: sub }),
443
+ });
444
+ }));
445
+ pushOn = true;
446
+ setStatus("push on");
447
+ var btn = document.getElementById("pushBtn");
448
+ if (btn) btn.parentElement.remove();
449
+ return true;
450
+ }
451
+
452
+ function showPushError(err) {
453
+ setStatus("push error", "err");
454
+ alert(
455
+ "Notifications could not be enabled: " +
456
+ (err && err.message ? err.name + " — " + err.message : err) +
457
+ "\\n\\nOn iPhone this requires iOS 16.4+, the app installed on the home screen, and Lockdown Mode off."
458
+ );
459
+ }
460
+
461
+ function offerPush() {
462
+ if (!("Notification" in window) || !("PushManager" in window)) return;
463
+ if (isIOS && !standalone) return; // impossible in Safari tabs; banner handles install
464
+ if (Notification.permission === "granted") {
465
+ enablePush().catch(showPushError);
466
+ return;
467
+ }
468
+ if (Notification.permission === "denied") return;
469
+ var b = document.createElement("div");
470
+ b.className = "banner";
471
+ b.innerHTML =
472
+ '<h2>One tap left: notifications</h2>' +
473
+ '<button class="act" id="pushBtn">Enable notifications</button>';
474
+ bannerEl.appendChild(b);
475
+ document.getElementById("pushBtn").addEventListener("click", function () {
476
+ enablePush().catch(showPushError);
477
+ });
478
+ }
479
+ offerPush();
392
480
 
393
481
  // --- live updates: SSE with a slow safety poll ---------------------------
394
482
  var sseOn = false;
395
483
  function connectEvents() {
396
484
  if (!("EventSource" in window)) return;
397
- var es = new EventSource("/api/events?pairId=" + encodeURIComponent(pair.pairId));
398
- es.addEventListener("request", fetchPending);
399
- es.addEventListener("decision", fetchPending);
400
- es.onopen = function () {
401
- sseOn = true;
402
- if (!pushOn) setStatus("live");
403
- };
404
- es.onerror = function () {
405
- sseOn = false;
406
- if (!pushOn) setStatus("polling", "warn");
407
- // EventSource reconnects on its own (retry: 3000).
408
- };
485
+ pairs.forEach(function (p) {
486
+ var es = new EventSource("/api/events?pairId=" + encodeURIComponent(p.pairId));
487
+ es.addEventListener("request", fetchPending);
488
+ es.addEventListener("decision", fetchPending);
489
+ es.onopen = function () {
490
+ sseOn = true;
491
+ if (!pushOn) setStatus("live");
492
+ };
493
+ es.onerror = function () {
494
+ // EventSource reconnects on its own (retry: 3000).
495
+ if (!pushOn) setStatus("polling", "warn");
496
+ };
497
+ });
409
498
  }
410
499
  connectEvents();
411
500
  setInterval(function () { if (!document.hidden && !sseOn) fetchPending(); }, 8000);
@@ -470,32 +559,35 @@ export const PWA_HTML = `<!doctype html>
470
559
  }
471
560
 
472
561
  async function fetchPending() {
473
- var raw;
474
- try {
475
- raw = await (await fetch("/api/pending?pairId=" + encodeURIComponent(pair.pairId))).json();
476
- } catch (e) { return; }
477
562
  var seen = {};
478
- for (var i = 0; i < raw.length; i++) {
479
- var id = raw[i].requestId;
480
- seen[id] = true;
481
- if (cards[id]) continue;
563
+ await Promise.all(pairs.map(async function (p) {
564
+ var raw;
482
565
  try {
483
- var req = await openSealed(raw[i].payload, "req:" + id);
484
- addCard(id, req);
485
- } catch (e) { /* not ours / tampered */ }
486
- }
566
+ raw = await (await fetch("/api/pending?pairId=" + encodeURIComponent(p.pairId))).json();
567
+ } catch (e) { return; }
568
+ for (var i = 0; i < raw.length; i++) {
569
+ var key = p.pairId + ":" + raw[i].requestId;
570
+ seen[key] = true;
571
+ if (cards[key]) continue;
572
+ try {
573
+ var req = await openSealed(p, raw[i].payload, "req:" + raw[i].requestId);
574
+ addCard(key, p, raw[i].requestId, req);
575
+ } catch (e) { /* not ours / tampered */ }
576
+ }
577
+ }));
487
578
  for (var cid in cards) {
488
579
  if (!seen[cid]) { cards[cid].el.remove(); delete cards[cid]; }
489
580
  }
490
581
  ensureEmpty(Object.keys(cards).length === 0);
491
582
  }
492
583
 
493
- function addCard(id, req) {
584
+ function addCard(id, p, requestId, req) {
494
585
  var card = document.createElement("div");
495
586
  card.className = "card";
496
587
 
497
588
  var who = document.createElement("div"); who.className = "who";
498
- who.textContent = "agent · approval request"; card.appendChild(who);
589
+ who.textContent = (pairs.length > 1 ? p.name + " · " : "") + "agent · approval request";
590
+ card.appendChild(who);
499
591
  var h = document.createElement("h2"); h.textContent = req.title; card.appendChild(h);
500
592
  if (req.body) { var p = document.createElement("p"); p.textContent = req.body; card.appendChild(p); }
501
593
 
@@ -513,7 +605,7 @@ export const PWA_HTML = `<!doctype html>
513
605
 
514
606
  ensureEmpty(false);
515
607
  listEl.appendChild(card);
516
- cards[id] = { el: card, leftEl: left, fillEl: fill, barEl: bar, rowEl: row, req: req, done: false };
608
+ cards[id] = { el: card, leftEl: left, fillEl: fill, barEl: bar, rowEl: row, req: req, pair: p, requestId: requestId, done: false };
517
609
  tickOne(cards[id]);
518
610
  }
519
611
 
@@ -527,7 +619,7 @@ export const PWA_HTML = `<!doctype html>
527
619
  c.rowEl.remove();
528
620
  c.leftEl.textContent = "expired — denied";
529
621
  c.fillEl.style.width = "0%";
530
- recordHist(c.req.title, "expired");
622
+ recordHist(histLabel(c), "expired");
531
623
  }
532
624
  return;
533
625
  }
@@ -548,25 +640,83 @@ export const PWA_HTML = `<!doctype html>
548
640
  var c = cards[id];
549
641
  if (!c || c.done) return;
550
642
  b.disabled = true;
551
- var payload = await sealPayload(
552
- { requestId: id, approved: cls === "ok", ts: Date.now() },
553
- "dec:" + id
554
- );
555
- await fetch("/api/decision", {
556
- method: "POST",
557
- headers: { "Content-Type": "application/json" },
558
- body: JSON.stringify({ pairId: pair.pairId, requestId: id, payload: payload }),
559
- });
560
- if (cards[id]) {
561
- recordHist(cards[id].req.title, cls === "ok" ? "approved" : "denied");
562
- cards[id].el.remove();
563
- delete cards[id];
643
+ try {
644
+ var payload = await sealPayload(
645
+ c.pair,
646
+ { requestId: c.requestId, approved: cls === "ok", ts: Date.now() },
647
+ "dec:" + c.requestId
648
+ );
649
+ var res = await fetch("/api/decision", {
650
+ method: "POST",
651
+ headers: { "Content-Type": "application/json" },
652
+ body: JSON.stringify({ pairId: c.pair.pairId, requestId: c.requestId, payload: payload }),
653
+ });
654
+ if (!res.ok) throw new Error("relay answered HTTP " + res.status);
655
+ if (cards[id]) {
656
+ recordHist(histLabel(c), cls === "ok" ? "approved" : "denied");
657
+ cards[id].el.remove();
658
+ delete cards[id];
659
+ }
660
+ ensureEmpty(Object.keys(cards).length === 0);
661
+ } catch (err) {
662
+ b.disabled = false;
663
+ alert("Could not send your decision: " + (err && err.message ? err.message : err));
564
664
  }
565
- ensureEmpty(Object.keys(cards).length === 0);
566
665
  };
567
666
  return b;
568
667
  }
569
668
 
669
+ function histLabel(c) {
670
+ return (pairs.length > 1 ? c.pair.name + ": " : "") + c.req.title;
671
+ }
672
+
673
+ // --- vault manager -------------------------------------------------------
674
+ var vaultsEl = document.createElement("div");
675
+ histEl.parentElement.appendChild(vaultsEl);
676
+ function renderVaults() {
677
+ vaultsEl.textContent = "";
678
+ var section = document.createElement("div");
679
+ section.className = "hist";
680
+ var h = document.createElement("h3");
681
+ h.textContent = "Vaults";
682
+ section.appendChild(h);
683
+ pairs.forEach(function (p, idx) {
684
+ var row = document.createElement("div"); row.className = "hrow";
685
+ var t = document.createElement("span"); t.className = "t"; t.textContent = p.name;
686
+ var x = document.createElement("span"); x.className = "d denied"; x.textContent = "remove";
687
+ x.style.cursor = "pointer";
688
+ x.onclick = function () {
689
+ if (!confirm("Remove " + p.name + " from this device?")) return;
690
+ pairs.splice(idx, 1);
691
+ savePairs();
692
+ location.reload();
693
+ };
694
+ row.appendChild(t); row.appendChild(x);
695
+ section.appendChild(row);
696
+ });
697
+ var addRow = document.createElement("div"); addRow.className = "hrow";
698
+ var add = document.createElement("span"); add.className = "d approved"; add.textContent = "+ add a vault";
699
+ add.style.cursor = "pointer";
700
+ add.onclick = function () {
701
+ if (document.getElementById("addPaste")) return;
702
+ var input = document.createElement("input");
703
+ input.id = "addPaste";
704
+ input.placeholder = "Paste a pairing link (sandgate pair)";
705
+ input.autocomplete = "off";
706
+ input.style.cssText = "width:100%;padding:10px 12px;margin-top:8px;background:var(--panel);border:1px solid var(--line);border-radius:8px;color:var(--ink);font:16px ui-monospace,monospace;box-sizing:border-box;";
707
+ input.addEventListener("input", function (e) {
708
+ var parsed = parsePairing(e.target.value);
709
+ if (parsed && addPairing(parsed)) location.reload();
710
+ });
711
+ section.appendChild(input);
712
+ input.focus();
713
+ };
714
+ addRow.appendChild(add);
715
+ section.appendChild(addRow);
716
+ vaultsEl.appendChild(section);
717
+ }
718
+ renderVaults();
719
+
570
720
  fetchPending();
571
721
  })();
572
722
  </script>
@@ -135,11 +135,24 @@ export async function startRelay(opts) {
135
135
  persist();
136
136
  return json(res, 200, { ok: true });
137
137
  }
138
+ // The page announces itself on load, push or not — this is what lets
139
+ // `sandgate pair` say "phone connected" even before notifications.
140
+ if (req.method === "POST" && url.pathname === "/api/hello") {
141
+ const body = await readBody(req);
142
+ if (!validId(body.pairId))
143
+ return json(res, 400, { error: "bad pairId" });
144
+ getPairing(body.pairId).lastSeen = Date.now();
145
+ return json(res, 200, { ok: true });
146
+ }
138
147
  if (req.method === "GET" && url.pathname === "/api/pair-status") {
139
148
  const pairId = url.searchParams.get("pairId") ?? "";
140
149
  if (!validId(pairId))
141
150
  return json(res, 400, { error: "bad pairId" });
142
- return json(res, 200, { subscribed: !!getPairing(pairId).subscription });
151
+ const pairing = getPairing(pairId);
152
+ return json(res, 200, {
153
+ subscribed: !!pairing.subscription,
154
+ seen: !!pairing.lastSeen || !!pairing.subscription,
155
+ });
143
156
  }
144
157
  if (req.method === "POST" && url.pathname === "/api/request") {
145
158
  const body = await readBody(req);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sandprivacy/sandgate",
3
- "version": "0.1.3",
3
+ "version": "0.1.6",
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",