@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.
- package/dist/relay/pwa-page.js +3 -1
- package/dist/relay/server.js +9 -2
- package/dist/test/pwa-browser.test.js +142 -0
- package/package.json +3 -1
package/dist/relay/pwa-page.js
CHANGED
|
@@ -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
|
-
|
|
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";
|
package/dist/relay/server.js
CHANGED
|
@@ -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
|
-
|
|
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(() => {
|
|
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.
|
|
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
|
}
|