@sandprivacy/sandgate 0.3.0 → 0.3.2
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/README.md +5 -0
- package/dist/config.js +7 -0
- package/dist/index.js +26 -7
- package/dist/pwa-approver.js +15 -0
- package/dist/relay/pwa-page.js +35 -5
- package/dist/server.js +4 -8
- package/dist/test/approver-wiring.test.js +122 -0
- package/dist/test/pwa-dedup.test.js +64 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -74,6 +74,11 @@ the challenge and the user-verification flag. Anything off — a replayed
|
|
|
74
74
|
assertion, another device, a missing biometric — is a denial, never an
|
|
75
75
|
approval. Off by default; `sandgate status` tells you where you stand.
|
|
76
76
|
|
|
77
|
+
Turning it back off (`sandgate biometric off`) asks for the vault
|
|
78
|
+
passphrase: the switch lives inside the encrypted vault, so editing a
|
|
79
|
+
config file — or running the command without the passphrase — cannot
|
|
80
|
+
weaken your setup.
|
|
81
|
+
|
|
77
82
|
## Policies
|
|
78
83
|
|
|
79
84
|
```bash
|
package/dist/config.js
CHANGED
|
@@ -26,3 +26,10 @@ export function saveConfig(config) {
|
|
|
26
26
|
export function totpPolicy(config, domain) {
|
|
27
27
|
return config.policies.totp[domain] ?? config.policies.totpDefault;
|
|
28
28
|
}
|
|
29
|
+
/**
|
|
30
|
+
* Is a verified biometric required? The vault wins; the plaintext config
|
|
31
|
+
* flag is only a fallback for vaults written before 0.3.2.
|
|
32
|
+
*/
|
|
33
|
+
export function biometricRequired(vault, config) {
|
|
34
|
+
return vault.requireBiometric ?? config.requireBiometric ?? false;
|
|
35
|
+
}
|
package/dist/index.js
CHANGED
|
@@ -8,7 +8,7 @@ import { testImapConnection } from "./inbox.js";
|
|
|
8
8
|
import { resolvePassphrase, protectPassphraseDpapi, dpapiDecryptCommand } from "./passphrase.js";
|
|
9
9
|
import { auditPath } from "./paths.js";
|
|
10
10
|
import { vaultExists, loadVault, saveVault, rekeyVault, } from "./vault.js";
|
|
11
|
-
import { loadConfig, saveConfig } from "./config.js";
|
|
11
|
+
import { loadConfig, saveConfig, biometricRequired } from "./config.js";
|
|
12
12
|
import { normalizeSecret, generateCode } from "./totp.js";
|
|
13
13
|
import { TelegramApprover, discoverChatId } from "./telegram.js";
|
|
14
14
|
import { serve } from "./server.js";
|
|
@@ -284,9 +284,24 @@ async function cmdBiometric(mode) {
|
|
|
284
284
|
console.error("Usage: sandgate biometric <on|off>");
|
|
285
285
|
process.exit(1);
|
|
286
286
|
}
|
|
287
|
+
// Turning a protection off must cost the passphrase, so the flag lives
|
|
288
|
+
// in the vault — not in a plaintext file anyone could edit.
|
|
289
|
+
const prompter = new Prompter();
|
|
290
|
+
const pass = await getPassphrase(prompter);
|
|
291
|
+
prompter.close();
|
|
292
|
+
const data = loadVault(pass);
|
|
293
|
+
if (mode === "on" && !data.biometric) {
|
|
294
|
+
console.error("Nothing enrolled yet. Run `sandgate enroll-biometric` first.");
|
|
295
|
+
process.exit(1);
|
|
296
|
+
}
|
|
297
|
+
data.requireBiometric = mode === "on";
|
|
298
|
+
saveVault(pass, data);
|
|
299
|
+
// Retire the legacy plaintext flag so the two can never disagree.
|
|
287
300
|
const config = loadConfig();
|
|
288
|
-
config.requireBiometric
|
|
289
|
-
|
|
301
|
+
if (config.requireBiometric) {
|
|
302
|
+
config.requireBiometric = false;
|
|
303
|
+
saveConfig(config);
|
|
304
|
+
}
|
|
290
305
|
console.log(mode === "on"
|
|
291
306
|
? "Biometric approvals required. Every approval must now be signed by the enrolled device."
|
|
292
307
|
: "Biometric requirement off. A tap is enough again.");
|
|
@@ -305,6 +320,7 @@ async function cmdStatus() {
|
|
|
305
320
|
const auditCount = existsSync(auditPath())
|
|
306
321
|
? readFileSync(auditPath(), "utf8").trim().split("\n").filter(Boolean).length
|
|
307
322
|
: 0;
|
|
323
|
+
const requiresBio = biometricRequired(data, config);
|
|
308
324
|
const approval = data.pwa
|
|
309
325
|
? `PWA via ${data.pwa.relayUrl} (Telegram ${data.telegram ? "fallback" : "not set"})`
|
|
310
326
|
: data.telegram
|
|
@@ -326,7 +342,7 @@ async function cmdStatus() {
|
|
|
326
342
|
.map(([d, p]) => `${d}=${p}`)
|
|
327
343
|
.join(", ")
|
|
328
344
|
: ""));
|
|
329
|
-
console.log(` biometric ${
|
|
345
|
+
console.log(` biometric ${requiresBio
|
|
330
346
|
? data.biometric
|
|
331
347
|
? "required (enrolled)"
|
|
332
348
|
: "REQUIRED BUT NOT ENROLLED — run `sandgate enroll-biometric`"
|
|
@@ -423,9 +439,12 @@ async function cmdTestApproval() {
|
|
|
423
439
|
const data = loadVault(pass);
|
|
424
440
|
let approver;
|
|
425
441
|
if (data.pwa) {
|
|
426
|
-
const {
|
|
427
|
-
|
|
428
|
-
|
|
442
|
+
const { pwaApproverFrom } = await import("./pwa-approver.js");
|
|
443
|
+
const config = loadConfig();
|
|
444
|
+
approver = pwaApproverFrom(data, config);
|
|
445
|
+
console.log(biometricRequired(data, config)
|
|
446
|
+
? "Sending test approval to the paired PWA — Face ID required (60s timeout)…"
|
|
447
|
+
: "Sending test approval to the paired PWA (60s timeout)…");
|
|
429
448
|
}
|
|
430
449
|
else if (data.telegram) {
|
|
431
450
|
approver = new TelegramApprover(data.telegram.botToken, data.telegram.chatId);
|
package/dist/pwa-approver.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { randomBytes } from "node:crypto";
|
|
2
|
+
import { biometricRequired } from "./config.js";
|
|
2
3
|
import { deriveKey, seal, open, aadForRequest, aadForDecision } from "./pwacrypto.js";
|
|
3
4
|
import { verifyAssertion, verifyEnrollment, } from "./webauthn.js";
|
|
4
5
|
export class PwaApprover {
|
|
@@ -101,3 +102,17 @@ export class PwaApprover {
|
|
|
101
102
|
});
|
|
102
103
|
}
|
|
103
104
|
}
|
|
105
|
+
/**
|
|
106
|
+
* Build the PWA approver from vault + config. Everything that talks to the
|
|
107
|
+
* phone goes through here: when `serve` and the CLI each assembled their
|
|
108
|
+
* own config, the CLI silently lost biometric enforcement.
|
|
109
|
+
*/
|
|
110
|
+
export function pwaApproverFrom(vault, config) {
|
|
111
|
+
if (!vault.pwa)
|
|
112
|
+
return null;
|
|
113
|
+
return new PwaApprover({
|
|
114
|
+
...vault.pwa,
|
|
115
|
+
biometric: vault.biometric,
|
|
116
|
+
requireBiometric: biometricRequired(vault, config),
|
|
117
|
+
});
|
|
118
|
+
}
|
package/dist/relay/pwa-page.js
CHANGED
|
@@ -631,7 +631,26 @@ export const PWA_HTML = `<!doctype html>
|
|
|
631
631
|
}
|
|
632
632
|
}
|
|
633
633
|
|
|
634
|
+
// Push, SSE and the safety poll all trigger refreshes, and they arrive
|
|
635
|
+
// together. Two of them used to race in the gap between "do I have this
|
|
636
|
+
// card?" and adding it — decrypting is async — so one request could
|
|
637
|
+
// render several times, and only the last copy stayed wired. One refresh
|
|
638
|
+
// runs at a time now; overlapping triggers coalesce into one re-run.
|
|
639
|
+
var refreshing = false;
|
|
640
|
+
var refreshQueued = false;
|
|
641
|
+
|
|
634
642
|
async function fetchPending() {
|
|
643
|
+
if (refreshing) { refreshQueued = true; return; }
|
|
644
|
+
refreshing = true;
|
|
645
|
+
try {
|
|
646
|
+
await refreshOnce();
|
|
647
|
+
} finally {
|
|
648
|
+
refreshing = false;
|
|
649
|
+
if (refreshQueued) { refreshQueued = false; fetchPending(); }
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
async function refreshOnce() {
|
|
635
654
|
var seen = {};
|
|
636
655
|
await Promise.all(pairs.map(async function (p) {
|
|
637
656
|
var raw;
|
|
@@ -642,16 +661,27 @@ export const PWA_HTML = `<!doctype html>
|
|
|
642
661
|
var key = p.pairId + ":" + raw[i].requestId;
|
|
643
662
|
seen[key] = true;
|
|
644
663
|
if (cards[key]) continue;
|
|
664
|
+
// Claim the key before awaiting: two vaults resolving at once must
|
|
665
|
+
// not both decide the card is missing.
|
|
666
|
+
cards[key] = null;
|
|
645
667
|
try {
|
|
646
668
|
var req = await openSealed(p, raw[i].payload, "req:" + raw[i].requestId);
|
|
647
669
|
addCard(key, p, raw[i].requestId, req);
|
|
648
|
-
} catch (e) {
|
|
670
|
+
} catch (e) {
|
|
671
|
+
delete cards[key]; // not ours / tampered — release the claim
|
|
672
|
+
}
|
|
649
673
|
}
|
|
650
674
|
}));
|
|
651
675
|
for (var cid in cards) {
|
|
652
|
-
if (!seen[cid]) { cards[cid].el.remove(); delete cards[cid]; }
|
|
676
|
+
if (!seen[cid] && cards[cid]) { cards[cid].el.remove(); delete cards[cid]; }
|
|
653
677
|
}
|
|
654
|
-
ensureEmpty(
|
|
678
|
+
ensureEmpty(activeCount() === 0);
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
function activeCount() {
|
|
682
|
+
var n = 0;
|
|
683
|
+
for (var k in cards) if (cards[k]) n++;
|
|
684
|
+
return n;
|
|
655
685
|
}
|
|
656
686
|
|
|
657
687
|
function addCard(id, p, requestId, req) {
|
|
@@ -765,7 +795,7 @@ export const PWA_HTML = `<!doctype html>
|
|
|
765
795
|
c.barEl.className = "bar" + (remaining < total * 0.25 ? " low" : "");
|
|
766
796
|
}
|
|
767
797
|
setInterval(function () {
|
|
768
|
-
for (var id in cards) tickOne(cards[id]);
|
|
798
|
+
for (var id in cards) if (cards[id]) tickOne(cards[id]);
|
|
769
799
|
}, 1000);
|
|
770
800
|
|
|
771
801
|
function makeActionBtn(label, cls, icon, onTap) {
|
|
@@ -794,7 +824,7 @@ export const PWA_HTML = `<!doctype html>
|
|
|
794
824
|
cards[id].el.remove();
|
|
795
825
|
delete cards[id];
|
|
796
826
|
}
|
|
797
|
-
ensureEmpty(
|
|
827
|
+
ensureEmpty(activeCount() === 0);
|
|
798
828
|
} catch (err) {
|
|
799
829
|
btn.disabled = false;
|
|
800
830
|
alert("Could not send your decision: " + (err && err.message ? err.message : err));
|
package/dist/server.js
CHANGED
|
@@ -2,10 +2,10 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
|
2
2
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
3
3
|
import { z } from "zod";
|
|
4
4
|
import { loadVault } from "./vault.js";
|
|
5
|
-
import { loadConfig, totpPolicy } from "./config.js";
|
|
5
|
+
import { loadConfig, totpPolicy, biometricRequired } from "./config.js";
|
|
6
6
|
import { generateCode } from "./totp.js";
|
|
7
7
|
import { TelegramApprover } from "./telegram.js";
|
|
8
|
-
import {
|
|
8
|
+
import { pwaApproverFrom } from "./pwa-approver.js";
|
|
9
9
|
import { backendFromVault } from "./inbox.js";
|
|
10
10
|
import { audit } from "./audit.js";
|
|
11
11
|
/**
|
|
@@ -23,17 +23,13 @@ function refusal(message) {
|
|
|
23
23
|
export async function serve(passphrase) {
|
|
24
24
|
const vault = loadVault(passphrase);
|
|
25
25
|
const config = loadConfig();
|
|
26
|
-
if (config
|
|
26
|
+
if (biometricRequired(vault, config) && !vault.biometric) {
|
|
27
27
|
// Fail closed rather than silently downgrade to a plain tap.
|
|
28
28
|
throw new Error("requireBiometric is on but no credential is enrolled. Run `sandgate enroll-biometric`, or turn it off with `sandgate biometric off`.");
|
|
29
29
|
}
|
|
30
30
|
// PWA (E2EE push) wins over Telegram when both are configured.
|
|
31
31
|
const approver = vault.pwa
|
|
32
|
-
?
|
|
33
|
-
...vault.pwa,
|
|
34
|
-
biometric: vault.biometric,
|
|
35
|
-
requireBiometric: config.requireBiometric,
|
|
36
|
-
})
|
|
32
|
+
? pwaApproverFrom(vault, config)
|
|
37
33
|
: vault.telegram
|
|
38
34
|
? new TelegramApprover(vault.telegram.botToken, vault.telegram.chatId)
|
|
39
35
|
: null;
|
|
@@ -0,0 +1,122 @@
|
|
|
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 { newPairing, deriveKey, seal, open, aadForRequest, aadForDecision } from "../pwacrypto.js";
|
|
7
|
+
import { startRelay } from "../relay/server.js";
|
|
8
|
+
import { pwaApproverFrom } from "../pwa-approver.js";
|
|
9
|
+
import { DEFAULT_CONFIG } from "../config.js";
|
|
10
|
+
/**
|
|
11
|
+
* Guards the wiring, not the crypto: enforcement must reach the phone
|
|
12
|
+
* from every entry point. `sandgate test-approval` once built its own
|
|
13
|
+
* approver config and quietly dropped requireBiometric, so an enforced
|
|
14
|
+
* vault still accepted a bare tap. One builder now serves serve() and the
|
|
15
|
+
* CLI, and this test fails if it ever stops enforcing.
|
|
16
|
+
*/
|
|
17
|
+
const CREDENTIAL = {
|
|
18
|
+
credentialId: "unused-for-this-test",
|
|
19
|
+
publicKeySpki: "unused-for-this-test",
|
|
20
|
+
rpId: "localhost",
|
|
21
|
+
origin: "http://localhost",
|
|
22
|
+
enrolledAt: new Date().toISOString(),
|
|
23
|
+
};
|
|
24
|
+
/** A phone that taps Approve without producing any assertion. */
|
|
25
|
+
async function tapWithoutAssertion(relayUrl, pairId, secret) {
|
|
26
|
+
const key = deriveKey(secret);
|
|
27
|
+
for (let i = 0; i < 100; i++) {
|
|
28
|
+
const res = await fetch(`${relayUrl}/api/pending?pairId=${pairId}`);
|
|
29
|
+
const items = (await res.json());
|
|
30
|
+
if (items.length) {
|
|
31
|
+
const { requestId, payload } = items[0];
|
|
32
|
+
const request = open(key, payload, aadForRequest(requestId));
|
|
33
|
+
await fetch(`${relayUrl}/api/decision`, {
|
|
34
|
+
method: "POST",
|
|
35
|
+
headers: { "Content-Type": "application/json" },
|
|
36
|
+
body: JSON.stringify({
|
|
37
|
+
pairId,
|
|
38
|
+
requestId,
|
|
39
|
+
payload: seal(key, { requestId, approved: true, ts: Date.now() }, aadForDecision(requestId)),
|
|
40
|
+
}),
|
|
41
|
+
});
|
|
42
|
+
return request;
|
|
43
|
+
}
|
|
44
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
45
|
+
}
|
|
46
|
+
throw new Error("phone never saw a request");
|
|
47
|
+
}
|
|
48
|
+
/** Settle a promise without ever leaving a rejection unhandled. */
|
|
49
|
+
function settled(p) {
|
|
50
|
+
return p.then((value) => ({ value }), (error) => ({ error }));
|
|
51
|
+
}
|
|
52
|
+
async function withRelay(fn) {
|
|
53
|
+
const relay = await startRelay({
|
|
54
|
+
port: 0,
|
|
55
|
+
stateDir: mkdtempSync(join(tmpdir(), "sandgate-relay-")),
|
|
56
|
+
});
|
|
57
|
+
try {
|
|
58
|
+
return await fn(`http://localhost:${relay.port}`);
|
|
59
|
+
}
|
|
60
|
+
finally {
|
|
61
|
+
relay.close();
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
test("an enforced vault rejects an approval that carries no assertion", async () => {
|
|
65
|
+
await withRelay(async (relayUrl) => {
|
|
66
|
+
const pairing = newPairing();
|
|
67
|
+
const vault = {
|
|
68
|
+
totp: {},
|
|
69
|
+
pwa: { relayUrl, pairId: pairing.pairId, secret: pairing.secret },
|
|
70
|
+
biometric: CREDENTIAL,
|
|
71
|
+
};
|
|
72
|
+
const approver = pwaApproverFrom(vault, { ...DEFAULT_CONFIG, requireBiometric: true });
|
|
73
|
+
const deciding = settled(approver.request({ title: "Pay 300 EUR", timeoutSec: 15 }));
|
|
74
|
+
const request = await tapWithoutAssertion(relayUrl, pairing.pairId, pairing.secret);
|
|
75
|
+
// The phone must be TOLD to ask for biometrics...
|
|
76
|
+
assert.equal(request.requireBiometric, true, "the request did not demand a biometric");
|
|
77
|
+
// ...and a bare tap must not pass for an approval.
|
|
78
|
+
const outcome = await deciding;
|
|
79
|
+
assert.equal(outcome.value, undefined, "a bare tap was accepted as an approval");
|
|
80
|
+
assert.match(String(outcome.error), /without the required biometric assertion/);
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
test("without enforcement, the same tap is a normal approval", async () => {
|
|
84
|
+
await withRelay(async (relayUrl) => {
|
|
85
|
+
const pairing = newPairing();
|
|
86
|
+
const vault = {
|
|
87
|
+
totp: {},
|
|
88
|
+
pwa: { relayUrl, pairId: pairing.pairId, secret: pairing.secret },
|
|
89
|
+
};
|
|
90
|
+
const approver = pwaApproverFrom(vault, { ...DEFAULT_CONFIG, requireBiometric: false });
|
|
91
|
+
const deciding = settled(approver.request({ title: "Harmless", timeoutSec: 15 }));
|
|
92
|
+
const request = await tapWithoutAssertion(relayUrl, pairing.pairId, pairing.secret);
|
|
93
|
+
assert.equal(request.requireBiometric, false);
|
|
94
|
+
assert.deepEqual((await deciding).value, { approved: true, decision: "approved" });
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
test("enforcement without an enrolled credential refuses instead of downgrading", async () => {
|
|
98
|
+
await withRelay(async (relayUrl) => {
|
|
99
|
+
const pairing = newPairing();
|
|
100
|
+
const vault = {
|
|
101
|
+
totp: {},
|
|
102
|
+
pwa: { relayUrl, pairId: pairing.pairId, secret: pairing.secret },
|
|
103
|
+
};
|
|
104
|
+
const approver = pwaApproverFrom(vault, { ...DEFAULT_CONFIG, requireBiometric: true });
|
|
105
|
+
const deciding = settled(approver.request({ title: "Pay 300 EUR", timeoutSec: 15 }));
|
|
106
|
+
await tapWithoutAssertion(relayUrl, pairing.pairId, pairing.secret);
|
|
107
|
+
const outcome = await deciding;
|
|
108
|
+
assert.equal(outcome.value, undefined, "an unenrolled vault approved anyway");
|
|
109
|
+
assert.match(String(outcome.error), /no credential is enrolled/);
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
test("the vault flag wins over the legacy plaintext config flag", async () => {
|
|
113
|
+
const { biometricRequired } = await import("../config.js");
|
|
114
|
+
const base = { totp: {} };
|
|
115
|
+
// Vaults written before 0.3.2 kept the switch in config.json.
|
|
116
|
+
assert.equal(biometricRequired(base, { ...DEFAULT_CONFIG, requireBiometric: true }), true);
|
|
117
|
+
// Once the vault carries it, the plaintext file cannot re-enable...
|
|
118
|
+
assert.equal(biometricRequired({ ...base, requireBiometric: false }, { ...DEFAULT_CONFIG, requireBiometric: true }), false);
|
|
119
|
+
// ...nor disable it: only the passphrase-protected vault decides.
|
|
120
|
+
assert.equal(biometricRequired({ ...base, requireBiometric: true }, { ...DEFAULT_CONFIG, requireBiometric: false }), true);
|
|
121
|
+
assert.equal(biometricRequired(base, DEFAULT_CONFIG), false);
|
|
122
|
+
});
|
|
@@ -0,0 +1,64 @@
|
|
|
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 { newPairing } from "../pwacrypto.js";
|
|
7
|
+
import { startRelay } from "../relay/server.js";
|
|
8
|
+
import { PwaApprover } from "../pwa-approver.js";
|
|
9
|
+
import { loadPage, waitFor } from "./helpers/page.js";
|
|
10
|
+
/**
|
|
11
|
+
* One request must never render twice. Concurrent refreshes (an SSE event,
|
|
12
|
+
* the safety poll and a visibility change can all land together) used to
|
|
13
|
+
* race in the async gap between "do I already have this card?" and adding
|
|
14
|
+
* it, leaving a duplicate whose buttons pointed at nothing.
|
|
15
|
+
*/
|
|
16
|
+
test("concurrent refreshes render a single card per request", async () => {
|
|
17
|
+
const relay = await startRelay({
|
|
18
|
+
port: 0,
|
|
19
|
+
stateDir: mkdtempSync(join(tmpdir(), "sandgate-relay-")),
|
|
20
|
+
});
|
|
21
|
+
const relayUrl = `http://localhost:${relay.port}`;
|
|
22
|
+
try {
|
|
23
|
+
const pairing = newPairing();
|
|
24
|
+
const { window, close } = await loadPage(relayUrl, {
|
|
25
|
+
hash: `#p=${pairing.pairId}&s=${pairing.secret}`,
|
|
26
|
+
});
|
|
27
|
+
try {
|
|
28
|
+
const approver = new PwaApprover({
|
|
29
|
+
relayUrl,
|
|
30
|
+
pairId: pairing.pairId,
|
|
31
|
+
secret: pairing.secret,
|
|
32
|
+
});
|
|
33
|
+
const deciding = approver.request({ title: "Only once", timeoutSec: 25 }).catch(() => { });
|
|
34
|
+
// Wait until the relay actually holds the request, otherwise the
|
|
35
|
+
// burst below races an empty queue and proves nothing.
|
|
36
|
+
for (let i = 0; i < 60; i++) {
|
|
37
|
+
const items = (await (await fetch(`${relayUrl}/api/pending?pairId=${pairing.pairId}`)).json());
|
|
38
|
+
if (items.length)
|
|
39
|
+
break;
|
|
40
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
41
|
+
}
|
|
42
|
+
// Every visibilitychange kicks a refresh; firing a burst reproduces
|
|
43
|
+
// the overlap that a real phone hits when push, SSE and poll agree.
|
|
44
|
+
for (let i = 0; i < 8; i++) {
|
|
45
|
+
window.document.dispatchEvent(new window.Event("visibilitychange"));
|
|
46
|
+
}
|
|
47
|
+
await waitFor(() => window.document.querySelector(".card"));
|
|
48
|
+
await new Promise((r) => setTimeout(r, 1500)); // let every racer land
|
|
49
|
+
const cards = window.document.querySelectorAll(".card");
|
|
50
|
+
assert.equal(cards.length, 1, `rendered ${cards.length} cards for one request`);
|
|
51
|
+
assert.equal(window.document.querySelectorAll(".card button.ok").length, 1, "a duplicate card left dead buttons behind");
|
|
52
|
+
// And the surviving card must still work.
|
|
53
|
+
window.document.querySelector(".card button.ok").click();
|
|
54
|
+
await waitFor(() => (window.document.querySelectorAll(".card").length === 0 ? true : null));
|
|
55
|
+
await deciding;
|
|
56
|
+
}
|
|
57
|
+
finally {
|
|
58
|
+
close();
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
finally {
|
|
62
|
+
relay.close();
|
|
63
|
+
}
|
|
64
|
+
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sandprivacy/sandgate",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.2",
|
|
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",
|