@sandprivacy/sandgate 0.1.6 → 0.1.8

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.
@@ -589,7 +589,9 @@ export const PWA_HTML = `<!doctype html>
589
589
  who.textContent = (pairs.length > 1 ? p.name + " · " : "") + "agent · approval request";
590
590
  card.appendChild(who);
591
591
  var h = document.createElement("h2"); h.textContent = req.title; card.appendChild(h);
592
- if (req.body) { var p = document.createElement("p"); p.textContent = req.body; card.appendChild(p); }
592
+ // NOTE: never name this variable p var is function-scoped and would
593
+ // shadow the pairing parameter for the rest of addCard (real bug once).
594
+ if (req.body) { var bodyP = document.createElement("p"); bodyP.textContent = req.body; card.appendChild(bodyP); }
593
595
 
594
596
  var timer = document.createElement("div"); timer.className = "timer";
595
597
  var left = document.createElement("div"); left.className = "left";
@@ -19,7 +19,10 @@ export async function startRelay(opts) {
19
19
  writeFileSync(statePath, JSON.stringify(state), { mode: 0o600 });
20
20
  }
21
21
  const persist = () => writeFileSync(statePath, JSON.stringify(state), { mode: 0o600 });
22
- webpush.setVapidDetails("mailto:relay@sandgate.local", state.vapid.publicKey, state.vapid.privateKey);
22
+ // Apple's push service rejects invalid VAPID subjects (a .local mailto
23
+ // qualifies); use a real https URL and log delivery failures instead of
24
+ // swallowing them.
25
+ webpush.setVapidDetails("https://sandgate.dev", state.vapid.publicKey, state.vapid.privateKey);
23
26
  const pairings = new Map();
24
27
  const getPairing = (pairId) => {
25
28
  let p = pairings.get(pairId);
@@ -169,7 +172,11 @@ export async function startRelay(opts) {
169
172
  if (pairing.subscription) {
170
173
  webpush
171
174
  .sendNotification(pairing.subscription, JSON.stringify({ type: "approval" }))
172
- .catch(() => { }); // phone offline / stale sub — PWA polls anyway
175
+ .catch((err) => {
176
+ // Phone offline / stale sub is normal (PWA polls anyway), but
177
+ // ops must be able to SEE a push service rejecting us.
178
+ console.error(`[push] delivery failed (HTTP ${err?.statusCode ?? "?"}): ${String(err?.body ?? err).slice(0, 200)}`);
179
+ });
173
180
  }
174
181
  notifyListeners(pairing, "request");
175
182
  return json(res, 200, { ok: true });
@@ -0,0 +1,142 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { mkdtempSync } from "node:fs";
4
+ import { tmpdir } from "node:os";
5
+ import { join } from "node:path";
6
+ import { webcrypto } from "node:crypto";
7
+ import { JSDOM } from "jsdom";
8
+ import { newPairing } from "../pwacrypto.js";
9
+ import { startRelay } from "../relay/server.js";
10
+ import { PWA_HTML } from "../relay/pwa-page.js";
11
+ import { PwaApprover } from "../pwa-approver.js";
12
+ /**
13
+ * Browser-level tests: the REAL page script, executed in a DOM, against a
14
+ * REAL relay — pairing via URL fragment, card rendering, a click on
15
+ * Approve/Deny, and the legacy-storage migration. This is the layer that
16
+ * caught nothing while it didn't exist; it exists now.
17
+ */
18
+ async function loadPage(relayUrl, opts) {
19
+ const alerts = [];
20
+ const dom = new JSDOM(PWA_HTML, {
21
+ url: relayUrl + "/" + (opts.hash ?? ""),
22
+ runScripts: "outside-only",
23
+ pretendToBeVisual: true,
24
+ });
25
+ const w = dom.window;
26
+ // The pieces jsdom doesn't ship, wired to the real relay / real crypto.
27
+ w.fetch = (input, init) => fetch(new URL(input, relayUrl), init);
28
+ Object.defineProperty(w, "crypto", { value: webcrypto });
29
+ w.alert = (message) => alerts.push(String(message));
30
+ w.confirm = () => true;
31
+ if (opts.localStorage) {
32
+ for (const [key, value] of Object.entries(opts.localStorage)) {
33
+ w.localStorage.setItem(key, value);
34
+ }
35
+ }
36
+ // Execute the page's inline script exactly as a browser would. The
37
+ // decision alert is instrumented to carry the full stack: a one-line
38
+ // message told us nothing the day this layer was missing.
39
+ let script = dom.window.document.querySelector("script").textContent;
40
+ script = script.replace('alert("Could not send your decision: " + (err && err.message ? err.message : err));', 'alert("Could not send your decision: " + (err && err.stack ? err.stack : err));');
41
+ dom.window.eval(script);
42
+ // window.close() tears down the page's setIntervals so the test process
43
+ // can exit; without it the suite hangs forever.
44
+ return { window: w, alerts, close: () => dom.window.close() };
45
+ }
46
+ // Without EventSource in jsdom the page falls back to its 8s poll, so give
47
+ // waits comfortable room past that boundary.
48
+ function waitFor(fn, ms = 15000) {
49
+ return new Promise((resolve, reject) => {
50
+ const started = Date.now();
51
+ const timer = setInterval(() => {
52
+ const value = fn();
53
+ if (value) {
54
+ clearInterval(timer);
55
+ resolve(value);
56
+ }
57
+ else if (Date.now() - started > ms) {
58
+ clearInterval(timer);
59
+ reject(new Error("waitFor timed out"));
60
+ }
61
+ }, 50);
62
+ });
63
+ }
64
+ for (const scenario of ["fragment", "legacy-storage"]) {
65
+ test(`page pairs (${scenario}), renders the card, and Approve round-trips`, async () => {
66
+ const relay = await startRelay({
67
+ port: 0,
68
+ stateDir: mkdtempSync(join(tmpdir(), "sandgate-relay-")),
69
+ });
70
+ const relayUrl = `http://localhost:${relay.port}`;
71
+ try {
72
+ const pairing = newPairing();
73
+ const { window, alerts, close } = await loadPage(relayUrl, {
74
+ hash: scenario === "fragment" ? `#p=${pairing.pairId}&s=${pairing.secret}` : undefined,
75
+ localStorage: scenario === "legacy-storage"
76
+ ? { sandgate_pair: JSON.stringify({ pairId: pairing.pairId, secret: pairing.secret }) }
77
+ : undefined,
78
+ });
79
+ try {
80
+ const approver = new PwaApprover({
81
+ relayUrl,
82
+ pairId: pairing.pairId,
83
+ secret: pairing.secret,
84
+ });
85
+ const decision = approver.request({
86
+ title: "Browser-level test",
87
+ body: "Click approve",
88
+ timeoutSec: 15,
89
+ });
90
+ const okButton = await waitFor(() => window.document.querySelector(".card button.ok"));
91
+ assert.match(window.document.querySelector(".card h2").textContent, /Browser-level test/);
92
+ okButton.click();
93
+ const result = await decision;
94
+ assert.deepEqual(alerts, [], `page alerted: ${alerts.join(" | ")}`);
95
+ assert.deepEqual(result, { approved: true, decision: "approved" });
96
+ await waitFor(() => window.document.querySelector(".hrow .d.approved"));
97
+ }
98
+ finally {
99
+ close();
100
+ }
101
+ }
102
+ finally {
103
+ relay.close();
104
+ }
105
+ });
106
+ }
107
+ test("Deny works and corrupt stored pairings do not break the page", async () => {
108
+ const relay = await startRelay({
109
+ port: 0,
110
+ stateDir: mkdtempSync(join(tmpdir(), "sandgate-relay-")),
111
+ });
112
+ const relayUrl = `http://localhost:${relay.port}`;
113
+ try {
114
+ const pairing = newPairing();
115
+ const { window, alerts, close } = await loadPage(relayUrl, {
116
+ hash: `#p=${pairing.pairId}&s=${pairing.secret}`,
117
+ // A broken entry left over from older versions must be ignored, not fatal.
118
+ localStorage: {
119
+ sandgate_pairs: JSON.stringify([{ name: "Broken", pairId: "brokenbroken" }]),
120
+ },
121
+ });
122
+ try {
123
+ const approver = new PwaApprover({
124
+ relayUrl,
125
+ pairId: pairing.pairId,
126
+ secret: pairing.secret,
127
+ });
128
+ const decision = approver.request({ title: "Deny me", timeoutSec: 15 });
129
+ const noButton = await waitFor(() => window.document.querySelector(".card button.no"));
130
+ noButton.click();
131
+ const result = await decision;
132
+ assert.deepEqual(result, { approved: false, decision: "denied" });
133
+ assert.deepEqual(alerts, [], `page alerted: ${alerts.join(" | ")}`);
134
+ }
135
+ finally {
136
+ close();
137
+ }
138
+ }
139
+ finally {
140
+ relay.close();
141
+ }
142
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sandprivacy/sandgate",
3
- "version": "0.1.6",
3
+ "version": "0.1.8",
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",
@@ -51,10 +51,12 @@
51
51
  "zod": "^4.5.4"
52
52
  },
53
53
  "devDependencies": {
54
+ "@types/jsdom": "^30.0.0",
54
55
  "@types/mailparser": "^3.4.6",
55
56
  "@types/node": "^26.4.1",
56
57
  "@types/qrcode-terminal": "^0.12.2",
57
58
  "@types/web-push": "^3.6.4",
59
+ "jsdom": "^29.1.1",
58
60
  "tsx": "^4.23.13",
59
61
  "typescript": "^7.0.2"
60
62
  }