@weaveprotocol/cli 0.2.0 → 0.2.1
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 +17 -0
- package/dist/weave.mjs +742 -8
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -215,3 +215,20 @@ space anywhere leaves it everywhere.
|
|
|
215
215
|
|
|
216
216
|
- **WebRTC on the node.** Browsers reach it over WebSocket. `node-datachannel`
|
|
217
217
|
plugs into the same transport seam if measurement says it is needed.
|
|
218
|
+
- **Publishing.** `weave-protocol-cli` on npm (or a built JS package), and
|
|
219
|
+
trying the Bun binary with `node-datachannel`.
|
|
220
|
+
- **Hosting, before it's offered to anyone.**
|
|
221
|
+
- A load test with 1,000 spaces, with metrics. Check the pricing against those numbers, and decide on TURN from them.
|
|
222
|
+
- A real Stripe test-mode run end to end, and a decision on Stripe Tax or a merchant of record.
|
|
223
|
+
- A breach plan.
|
|
224
|
+
- **The pay page.**
|
|
225
|
+
- A real Base Sepolia wallet payment.
|
|
226
|
+
- A Lightning route (BTCPay), and gasless USDC (EIP-3009).
|
|
227
|
+
- An optional email for reminders: SMTP, with double opt-in.
|
|
228
|
+
- **TURN.** Run coturn with `use-auth-secret` and quotas, and set
|
|
229
|
+
`TURN_SECRET` and `TURN_URLS` on the relay and the node. Then test calls
|
|
230
|
+
across real networks, behind a strict NAT, in Safari, and on a phone
|
|
231
|
+
switching apps.
|
|
232
|
+
|
|
233
|
+
What the protocol still has planned is in [the spec](../docs/spec/README.md),
|
|
234
|
+
under **Planned** in each part.
|
package/dist/weave.mjs
CHANGED
|
@@ -2499,7 +2499,7 @@ function ecdsa(Point, hash2, ecdsaOpts = {}) {
|
|
|
2499
2499
|
const sig = drbg(seed, k2sig);
|
|
2500
2500
|
return sig.toBytes(opts2.format);
|
|
2501
2501
|
}
|
|
2502
|
-
function
|
|
2502
|
+
function verify2(signature, message, publicKey, opts2 = {}) {
|
|
2503
2503
|
const { lowS, prehash, format: format2 } = validateSigOpts(opts2, defaultSigOpts);
|
|
2504
2504
|
publicKey = abytes2(publicKey, void 0, "publicKey");
|
|
2505
2505
|
message = validateMsgAndHash(message, prehash);
|
|
@@ -2547,7 +2547,7 @@ function ecdsa(Point, hash2, ecdsaOpts = {}) {
|
|
|
2547
2547
|
lengths,
|
|
2548
2548
|
Point,
|
|
2549
2549
|
sign,
|
|
2550
|
-
verify,
|
|
2550
|
+
verify: verify2,
|
|
2551
2551
|
recoverPublicKey,
|
|
2552
2552
|
Signature,
|
|
2553
2553
|
hash: hash_
|
|
@@ -3610,6 +3610,8 @@ function withWrap(vault, wrap) {
|
|
|
3610
3610
|
var CONTACT_KEY_INFO = "weave/p256-contact-key/v1";
|
|
3611
3611
|
var SEAL_INFO = "weave/contact-seal/v1";
|
|
3612
3612
|
var MEMBER_KEY_INFO = "weave/p256-member-key/v1";
|
|
3613
|
+
var DOOR_KEY_INFO = "weave/p256-door-key/v1";
|
|
3614
|
+
var DOOR_SIGN_KEY_INFO = "weave/p256-door-sign-key/v1";
|
|
3613
3615
|
var P256_SEED_BYTES2 = 48;
|
|
3614
3616
|
var POINT_BYTES = 65;
|
|
3615
3617
|
var IV_BYTES2 = 12;
|
|
@@ -3628,6 +3630,39 @@ async function deriveContactKeyBytes(seed) {
|
|
|
3628
3630
|
async function deriveMemberKeyBytes(accountKey, spaceId) {
|
|
3629
3631
|
return p256.utils.randomSecretKey(await hkdf2(accountKey, `${MEMBER_KEY_INFO}|${spaceId}`, P256_SEED_BYTES2));
|
|
3630
3632
|
}
|
|
3633
|
+
async function deriveDoorKeyBytes(contactKey, doorId) {
|
|
3634
|
+
return p256.utils.randomSecretKey(await hkdf2(contactKey, `${DOOR_KEY_INFO}|${doorId}`, P256_SEED_BYTES2));
|
|
3635
|
+
}
|
|
3636
|
+
async function deriveDoorSignKeyBytes(contactKey, doorId) {
|
|
3637
|
+
return p256.utils.randomSecretKey(await hkdf2(contactKey, `${DOOR_SIGN_KEY_INFO}|${doorId}`, P256_SEED_BYTES2));
|
|
3638
|
+
}
|
|
3639
|
+
async function signWithScalar(secret, data) {
|
|
3640
|
+
const point = p256.getPublicKey(secret, false);
|
|
3641
|
+
const key3 = await globalThis.crypto.subtle.importKey(
|
|
3642
|
+
"jwk",
|
|
3643
|
+
{
|
|
3644
|
+
kty: "EC",
|
|
3645
|
+
crv: "P-256",
|
|
3646
|
+
x: base64UrlEncode(point.subarray(1, 33)),
|
|
3647
|
+
y: base64UrlEncode(point.subarray(33, 65)),
|
|
3648
|
+
d: base64UrlEncode(secret),
|
|
3649
|
+
ext: false
|
|
3650
|
+
},
|
|
3651
|
+
{ name: "ECDSA", namedCurve: "P-256" },
|
|
3652
|
+
false,
|
|
3653
|
+
["sign"]
|
|
3654
|
+
);
|
|
3655
|
+
return base64UrlEncode(new Uint8Array(await globalThis.crypto.subtle.sign({ name: "ECDSA", hash: "SHA-256" }, key3, data)));
|
|
3656
|
+
}
|
|
3657
|
+
async function verifyWithPoint(publicKey, data, signature) {
|
|
3658
|
+
try {
|
|
3659
|
+
const point = p256.Point.fromBytes(base64UrlDecode(publicKey)).toBytes(false);
|
|
3660
|
+
const key3 = await globalThis.crypto.subtle.importKey("raw", point, { name: "ECDSA", namedCurve: "P-256" }, false, ["verify"]);
|
|
3661
|
+
return await globalThis.crypto.subtle.verify({ name: "ECDSA", hash: "SHA-256" }, key3, base64UrlDecode(signature), data);
|
|
3662
|
+
} catch {
|
|
3663
|
+
return false;
|
|
3664
|
+
}
|
|
3665
|
+
}
|
|
3631
3666
|
function contactPublicKey(secret) {
|
|
3632
3667
|
return base64UrlEncode(p256.getPublicKey(secret, true));
|
|
3633
3668
|
}
|
|
@@ -10392,7 +10427,8 @@ var contact = typed()({
|
|
|
10392
10427
|
name: { type: "string", maxLength: 200, description: "What you call them" },
|
|
10393
10428
|
space: { type: "string", maxLength: 256, description: "The id of your space for two" },
|
|
10394
10429
|
note: { type: "string", maxLength: 2e3 },
|
|
10395
|
-
blocked: { type: "boolean", description: "Their contact requests are hidden, in every space" }
|
|
10430
|
+
blocked: { type: "boolean", description: "Their contact requests are hidden, in every space" },
|
|
10431
|
+
door: { type: "string", maxLength: 64, description: "The door they knocked on, while your answer is still to be written in your space for two" }
|
|
10396
10432
|
},
|
|
10397
10433
|
required: ["did", "name"]
|
|
10398
10434
|
},
|
|
@@ -10412,6 +10448,273 @@ var contactRequest = typed()({
|
|
|
10412
10448
|
},
|
|
10413
10449
|
rules: { edit: "creator", delete: "creator" }
|
|
10414
10450
|
});
|
|
10451
|
+
var door = typed()({
|
|
10452
|
+
name: "std.door",
|
|
10453
|
+
title: "Door",
|
|
10454
|
+
description: "A way for people you share no space with to ask to become your contact.",
|
|
10455
|
+
schema: {
|
|
10456
|
+
type: "object",
|
|
10457
|
+
properties: {
|
|
10458
|
+
id: { type: "string", minLength: 16, maxLength: 64, description: "Random; the door key is derived from it" },
|
|
10459
|
+
label: { type: "string", maxLength: 64, description: "What you call this door \u2014 only you see it" },
|
|
10460
|
+
name: { type: "string", maxLength: 64, description: "The name its code gives, shown to whoever knocks" },
|
|
10461
|
+
relays: { type: "array", items: { type: "string", maxLength: 200 }, minItems: 1, maxItems: 3, description: "Whose mailboxes hold its knocks" }
|
|
10462
|
+
},
|
|
10463
|
+
required: ["id", "relays"]
|
|
10464
|
+
},
|
|
10465
|
+
rules: { onePer: ["id"] }
|
|
10466
|
+
});
|
|
10467
|
+
var knock = typed()({
|
|
10468
|
+
name: "std.knock",
|
|
10469
|
+
title: "Knock",
|
|
10470
|
+
description: "A knock left on a door, waiting for an answer.",
|
|
10471
|
+
schema: {
|
|
10472
|
+
type: "object",
|
|
10473
|
+
properties: {
|
|
10474
|
+
space: { type: "string", maxLength: 256, description: "The space for two it invites them to" },
|
|
10475
|
+
name: { type: "string", maxLength: 64, description: "The name their door code gave" },
|
|
10476
|
+
door: { type: "string", maxLength: 64, description: "The door key knocked on" },
|
|
10477
|
+
sign: { type: "string", maxLength: 64, description: "The door's signing key, which their answer must be signed with" },
|
|
10478
|
+
invite: { type: "string", maxLength: 8e3, description: "The invite sent, to close once they answer" }
|
|
10479
|
+
},
|
|
10480
|
+
required: ["space", "name", "door", "sign", "invite"]
|
|
10481
|
+
},
|
|
10482
|
+
rules: { onePer: ["space"] }
|
|
10483
|
+
});
|
|
10484
|
+
var knockAnswer = typed()({
|
|
10485
|
+
name: "std.knock-answer",
|
|
10486
|
+
title: "Knock answer",
|
|
10487
|
+
description: "Proof that the account which joined is the one behind the door knocked on.",
|
|
10488
|
+
schema: {
|
|
10489
|
+
type: "object",
|
|
10490
|
+
properties: {
|
|
10491
|
+
sig: { type: "string", maxLength: 200, description: 'The door signing key over "weave/knock-answer/v1|<space>|<account>"' }
|
|
10492
|
+
},
|
|
10493
|
+
required: ["sig"]
|
|
10494
|
+
},
|
|
10495
|
+
rules: { edit: "creator", delete: "creator" }
|
|
10496
|
+
});
|
|
10497
|
+
|
|
10498
|
+
// ../src/doors/doors.ts
|
|
10499
|
+
var MAX_DOOR_RELAYS = 3;
|
|
10500
|
+
var KNOCK_TTL_SECONDS = 14 * 24 * 3600;
|
|
10501
|
+
var KNOCK_DROP_WINDOW_SECONDS = 600;
|
|
10502
|
+
var MAX_NAME = 64;
|
|
10503
|
+
var MAX_NOTE = 2e3;
|
|
10504
|
+
var MAX_INVITE = 6e3;
|
|
10505
|
+
var MAX_PROOF = 4096;
|
|
10506
|
+
function clip(text, max) {
|
|
10507
|
+
const chars = Array.from(text);
|
|
10508
|
+
return chars.length <= max ? text : chars.slice(0, max).join("");
|
|
10509
|
+
}
|
|
10510
|
+
function encodeDoorCode(code) {
|
|
10511
|
+
const problem = checkDoorCode({ v: 1, ...code });
|
|
10512
|
+
if (problem) throw new Error(problem);
|
|
10513
|
+
return base64UrlEncode(
|
|
10514
|
+
utf8Encode(canonicalize({ v: 1, key: code.key, sign: code.sign, relays: [...code.relays], ...code.name ? { name: code.name } : {} }))
|
|
10515
|
+
);
|
|
10516
|
+
}
|
|
10517
|
+
function parseDoorCode(text) {
|
|
10518
|
+
const trimmed = text.trim();
|
|
10519
|
+
const found = /(?:^|[#?&])door=([A-Za-z0-9_-]+)/.exec(trimmed);
|
|
10520
|
+
const raw = found ? found[1] : trimmed;
|
|
10521
|
+
let parsed;
|
|
10522
|
+
try {
|
|
10523
|
+
parsed = JSON.parse(utf8Decode(base64UrlDecode(raw)));
|
|
10524
|
+
} catch {
|
|
10525
|
+
throw new Error("That is not a door code \u2014 it may be cut short.");
|
|
10526
|
+
}
|
|
10527
|
+
const problem = checkDoorCode(parsed);
|
|
10528
|
+
if (problem) throw new Error(`That door code doesn't work: ${problem}`);
|
|
10529
|
+
const code = parsed;
|
|
10530
|
+
return Object.freeze({ v: 1, key: code.key, sign: code.sign, relays: Object.freeze([...code.relays]), ...code.name ? { name: code.name } : {} });
|
|
10531
|
+
}
|
|
10532
|
+
function checkDoorCode(value) {
|
|
10533
|
+
const code = value;
|
|
10534
|
+
if (!code || typeof code !== "object" || code.v !== 1) return "it is not a version 1 door";
|
|
10535
|
+
if (!isContactPublicKey(code.key)) return "its key is not a P-256 public key";
|
|
10536
|
+
if (!isContactPublicKey(code.sign) || code.sign === code.key) return "its signing key is not a P-256 public key of its own";
|
|
10537
|
+
if (!Array.isArray(code.relays) || code.relays.length === 0 || code.relays.length > MAX_DOOR_RELAYS) {
|
|
10538
|
+
return `it names 1\u2013${MAX_DOOR_RELAYS} relays`;
|
|
10539
|
+
}
|
|
10540
|
+
const relays = checkRelays(code.relays);
|
|
10541
|
+
if (relays) return relays;
|
|
10542
|
+
if (code.name !== void 0 && (typeof code.name !== "string" || Array.from(code.name).length > MAX_NAME)) return `its name is text of at most ${MAX_NAME} characters`;
|
|
10543
|
+
return null;
|
|
10544
|
+
}
|
|
10545
|
+
async function doorTopic(sign) {
|
|
10546
|
+
return base64UrlEncode(await sha2562(utf8Encode(`weave/door-topic/v1|${sign}`)));
|
|
10547
|
+
}
|
|
10548
|
+
var purgeMessage = (topic, nonce, ids) => utf8Encode(`weave/door-purge/v1|${topic}|${nonce}|${ids ? [...ids].sort().join(",") : "*"}`);
|
|
10549
|
+
function signPurge(signKey, topic, nonce, ids) {
|
|
10550
|
+
return signWithScalar(signKey, purgeMessage(topic, nonce, ids));
|
|
10551
|
+
}
|
|
10552
|
+
var answerMessage = (pairSpace, did) => utf8Encode(`weave/knock-answer/v1|${pairSpace}|${did}`);
|
|
10553
|
+
function signAnswer(signKey, pairSpace, did) {
|
|
10554
|
+
return signWithScalar(signKey, answerMessage(pairSpace, did));
|
|
10555
|
+
}
|
|
10556
|
+
function checkAnswer(sign, pairSpace, did, signature) {
|
|
10557
|
+
return verifyWithPoint(sign, answerMessage(pairSpace, did), signature);
|
|
10558
|
+
}
|
|
10559
|
+
async function knockId(blob) {
|
|
10560
|
+
return base64UrlEncode(await sha2562(utf8Encode(blob)));
|
|
10561
|
+
}
|
|
10562
|
+
var knockContext = (door2) => `weave/knock/v1|${door2}`;
|
|
10563
|
+
async function sealKnock(door2, knock2, session, provider) {
|
|
10564
|
+
const note = knock2.note ? clip(knock2.note.trim(), MAX_NOTE) : void 0;
|
|
10565
|
+
const body = {
|
|
10566
|
+
v: 1,
|
|
10567
|
+
door: door2,
|
|
10568
|
+
from: knock2.from,
|
|
10569
|
+
name: clip(knock2.name.trim(), MAX_NAME) || "Someone",
|
|
10570
|
+
invite: knock2.invite,
|
|
10571
|
+
...note ? { note } : {},
|
|
10572
|
+
at: Math.floor(Date.now() / 1e3),
|
|
10573
|
+
session: session.did,
|
|
10574
|
+
proof: session.proof
|
|
10575
|
+
};
|
|
10576
|
+
const sig = base64UrlEncode(await provider.sign(session.key, utf8Encode(canonicalize(body))));
|
|
10577
|
+
return sealFor(door2, { body, sig }, knockContext(door2));
|
|
10578
|
+
}
|
|
10579
|
+
async function openKnock(doorKey, blob, receivedAt, provider) {
|
|
10580
|
+
const door2 = contactPublicKey(doorKey);
|
|
10581
|
+
const opened = await openSealed((await contactKeyPair(doorKey)).privateKey, blob, knockContext(door2));
|
|
10582
|
+
const body = opened?.body;
|
|
10583
|
+
if (!body || typeof opened.sig !== "string") return null;
|
|
10584
|
+
if (body.v !== 1 || body.door !== door2) return null;
|
|
10585
|
+
if (typeof body.from !== "string" || typeof body.session !== "string" || typeof body.name !== "string") return null;
|
|
10586
|
+
if (Array.from(body.name).length > MAX_NAME) return null;
|
|
10587
|
+
if (typeof body.invite !== "string" || body.invite.length > MAX_INVITE) return null;
|
|
10588
|
+
if (typeof body.proof !== "string" || body.proof.length > MAX_PROOF) return null;
|
|
10589
|
+
if (body.note !== void 0 && (typeof body.note !== "string" || Array.from(body.note).length > MAX_NOTE)) return null;
|
|
10590
|
+
if (!Number.isSafeInteger(body.at) || !Number.isFinite(receivedAt)) return null;
|
|
10591
|
+
const now = Math.floor(Date.now() / 1e3);
|
|
10592
|
+
if (body.at > now + UCAN_CLOCK_SKEW_SECONDS || body.at < now - KNOCK_TTL_SECONDS) return null;
|
|
10593
|
+
const dropped = Math.floor(receivedAt / 1e3);
|
|
10594
|
+
if (body.at > dropped + UCAN_CLOCK_SKEW_SECONDS || body.at < dropped - KNOCK_DROP_WINDOW_SECONDS) return null;
|
|
10595
|
+
let signed = false;
|
|
10596
|
+
try {
|
|
10597
|
+
const key3 = await provider.importPublicKey(didToPublicKey(body.session).publicKeyBytes);
|
|
10598
|
+
signed = await provider.verify(key3, base64UrlDecode(opened.sig), utf8Encode(canonicalize(body)));
|
|
10599
|
+
} catch {
|
|
10600
|
+
return null;
|
|
10601
|
+
}
|
|
10602
|
+
if (!signed) return null;
|
|
10603
|
+
if (isAgentNote(body.proof)) return null;
|
|
10604
|
+
const chain = await resolveDelegationRoot(body.proof, () => null, provider, { at: body.at }).catch(() => null);
|
|
10605
|
+
if (!chain?.valid || chain.audience !== body.session || chain.rootDid !== body.from) return null;
|
|
10606
|
+
if (!chain.capabilities.some((capability) => capability.with === "*" && (capability.can === "expression/*" || capability.can === "*"))) return null;
|
|
10607
|
+
let invited;
|
|
10608
|
+
try {
|
|
10609
|
+
invited = parseSpaceInvite(body.invite);
|
|
10610
|
+
} catch {
|
|
10611
|
+
return null;
|
|
10612
|
+
}
|
|
10613
|
+
if (invited.space.creator !== body.from || invited.space.visibility !== "private" || !invited.key) return null;
|
|
10614
|
+
if (await checkSpace(invited.space) !== null) return null;
|
|
10615
|
+
return Object.freeze({
|
|
10616
|
+
from: body.from,
|
|
10617
|
+
name: clip(body.name, MAX_NAME) || "Someone",
|
|
10618
|
+
...body.note ? { note: body.note } : {},
|
|
10619
|
+
invite: body.invite,
|
|
10620
|
+
pairSpace: invited.space.id,
|
|
10621
|
+
at: body.at * 1e3
|
|
10622
|
+
});
|
|
10623
|
+
}
|
|
10624
|
+
|
|
10625
|
+
// ../src/network/mailbox.ts
|
|
10626
|
+
var MAX_PAGES = 8;
|
|
10627
|
+
function createMailboxClient(options = {}) {
|
|
10628
|
+
const timeoutMs = options.timeoutMs ?? 8e3;
|
|
10629
|
+
function ask(relay, message, pick) {
|
|
10630
|
+
return talk(relay, (send2) => send2(message), async (answer) => pick(answer));
|
|
10631
|
+
}
|
|
10632
|
+
function talk(relay, start, hear) {
|
|
10633
|
+
const Socket = options.WebSocket ?? globalThis.WebSocket;
|
|
10634
|
+
if (!Socket) return Promise.reject(new Error("No WebSocket here to reach a relay with"));
|
|
10635
|
+
return new Promise((resolve, reject) => {
|
|
10636
|
+
let settled = false;
|
|
10637
|
+
const ws = new Socket(relay);
|
|
10638
|
+
const finish = (error, value) => {
|
|
10639
|
+
if (settled) return;
|
|
10640
|
+
settled = true;
|
|
10641
|
+
clearTimeout(timer);
|
|
10642
|
+
try {
|
|
10643
|
+
ws.close();
|
|
10644
|
+
} catch {
|
|
10645
|
+
}
|
|
10646
|
+
if (error) reject(error);
|
|
10647
|
+
else resolve(value);
|
|
10648
|
+
};
|
|
10649
|
+
const timer = setTimeout(() => finish(new Error(`${relay} did not answer`)), timeoutMs);
|
|
10650
|
+
const say = (message) => ws.send(JSON.stringify(message));
|
|
10651
|
+
ws.onopen = () => start(say);
|
|
10652
|
+
ws.onerror = () => finish(new Error(`Could not reach ${relay}`));
|
|
10653
|
+
ws.onclose = () => finish(new Error(`${relay} closed the connection`));
|
|
10654
|
+
ws.onmessage = (event) => {
|
|
10655
|
+
let answer;
|
|
10656
|
+
try {
|
|
10657
|
+
answer = JSON.parse(String(event.data));
|
|
10658
|
+
} catch {
|
|
10659
|
+
return;
|
|
10660
|
+
}
|
|
10661
|
+
if (!answer || typeof answer !== "object") return;
|
|
10662
|
+
hear(answer, say).then(
|
|
10663
|
+
(value) => {
|
|
10664
|
+
if (value !== void 0) finish(null, value);
|
|
10665
|
+
},
|
|
10666
|
+
(error) => finish(error instanceof Error ? error : new Error(String(error)))
|
|
10667
|
+
);
|
|
10668
|
+
};
|
|
10669
|
+
});
|
|
10670
|
+
}
|
|
10671
|
+
const client = {
|
|
10672
|
+
drop(relay, topic, blob, ttlSeconds) {
|
|
10673
|
+
return ask(relay, { type: "drop", topic, blob, ...ttlSeconds ? { ttl: ttlSeconds } : {} }, (answer) => {
|
|
10674
|
+
if (answer.topic !== topic) return void 0;
|
|
10675
|
+
if (answer.type === "dropped" && typeof answer.id === "string") return answer.id;
|
|
10676
|
+
if (answer.type === "refused") throw new Error(`${relay} refused the knock: ${String(answer.reason ?? "no reason given")}`);
|
|
10677
|
+
return void 0;
|
|
10678
|
+
});
|
|
10679
|
+
},
|
|
10680
|
+
async fetch(relay, topic, after = 0) {
|
|
10681
|
+
const found = [];
|
|
10682
|
+
let from = after;
|
|
10683
|
+
for (let page = 0; page < MAX_PAGES; page++) {
|
|
10684
|
+
const { items, more } = await ask(
|
|
10685
|
+
relay,
|
|
10686
|
+
{ type: "fetch", topic, after: from },
|
|
10687
|
+
(answer) => answer.type === "mail" && answer.topic === topic && Array.isArray(answer.items) ? { items: answer.items.filter(isMailItem), more: answer.more === true } : void 0
|
|
10688
|
+
);
|
|
10689
|
+
found.push(...items);
|
|
10690
|
+
if (!more || items.length === 0) break;
|
|
10691
|
+
from = Math.max(...items.map((item) => item.seq));
|
|
10692
|
+
}
|
|
10693
|
+
return found;
|
|
10694
|
+
},
|
|
10695
|
+
purge(relay, topic, sign, ids, signChallenge) {
|
|
10696
|
+
return talk(
|
|
10697
|
+
relay,
|
|
10698
|
+
(send2) => send2({ type: "challenge" }),
|
|
10699
|
+
async (answer, send2) => {
|
|
10700
|
+
if (answer.type === "challenge" && typeof answer.nonce === "string") {
|
|
10701
|
+
send2({ type: "purge", topic, sign, ...ids ? { ids: [...ids] } : {}, sig: await signChallenge(answer.nonce) });
|
|
10702
|
+
return void 0;
|
|
10703
|
+
}
|
|
10704
|
+
if (answer.topic !== topic) return void 0;
|
|
10705
|
+
if (answer.type === "purged" && typeof answer.count === "number") return answer.count;
|
|
10706
|
+
if (answer.type === "refused") throw new Error(`${relay} refused to clear the door: ${String(answer.reason ?? "no reason given")}`);
|
|
10707
|
+
return void 0;
|
|
10708
|
+
}
|
|
10709
|
+
);
|
|
10710
|
+
}
|
|
10711
|
+
};
|
|
10712
|
+
return Object.freeze(client);
|
|
10713
|
+
}
|
|
10714
|
+
function isMailItem(value) {
|
|
10715
|
+
const item = value;
|
|
10716
|
+
return !!item && Number.isSafeInteger(item.seq) && typeof item.id === "string" && typeof item.at === "number" && typeof item.blob === "string";
|
|
10717
|
+
}
|
|
10415
10718
|
|
|
10416
10719
|
// ../src/space/notify.ts
|
|
10417
10720
|
var NOTIFY_COLLECTION = "sys.notify";
|
|
@@ -11425,11 +11728,13 @@ async function createNode(config) {
|
|
|
11425
11728
|
if (!contact2.did.startsWith("did:")) throw new Error("A contact needs their account DID");
|
|
11426
11729
|
const name = contact2.name.trim().slice(0, 200);
|
|
11427
11730
|
if (!name) throw new Error("A contact needs a name");
|
|
11731
|
+
const pending = (await contactRecords()).find((record) => record.body.did === contact2.did)?.body?.door;
|
|
11428
11732
|
return writeContact({
|
|
11429
11733
|
did: contact2.did,
|
|
11430
11734
|
name,
|
|
11431
11735
|
...contact2.space ? { space: contact2.space } : {},
|
|
11432
|
-
...contact2.note ? { note: contact2.note.slice(0, 2e3) } : {}
|
|
11736
|
+
...contact2.note ? { note: contact2.note.slice(0, 2e3) } : {},
|
|
11737
|
+
...pending ? { door: pending } : {}
|
|
11433
11738
|
});
|
|
11434
11739
|
},
|
|
11435
11740
|
async remove(did) {
|
|
@@ -11505,6 +11810,253 @@ async function createNode(config) {
|
|
|
11505
11810
|
return [...seen].filter((account2) => account2 !== config.signer.did && account2 !== did).sort();
|
|
11506
11811
|
}
|
|
11507
11812
|
});
|
|
11813
|
+
const mailbox = config.mailbox ?? createMailboxClient();
|
|
11814
|
+
const knockCache = /* @__PURE__ */ new Map();
|
|
11815
|
+
const DAY_MS = 24 * 3600 * 1e3;
|
|
11816
|
+
function requireContactKey(what) {
|
|
11817
|
+
if (agentSession) throw new Error(`An agent can't use doors. ${what} is the person's to do.`);
|
|
11818
|
+
if (!config.contactKey) throw new Error(`${what} needs your contact key. Connect to your account home again, and allow contacts.`);
|
|
11819
|
+
return config.contactKey;
|
|
11820
|
+
}
|
|
11821
|
+
async function doorKeys(id) {
|
|
11822
|
+
const contactKey = requireContactKey("A door");
|
|
11823
|
+
const key3 = await deriveDoorKeyBytes(contactKey, id);
|
|
11824
|
+
const sign = await deriveDoorSignKeyBytes(contactKey, id);
|
|
11825
|
+
const signPublic = contactPublicKey(sign);
|
|
11826
|
+
return { key: key3, sign, publicKey: contactPublicKey(key3), signPublic, topic: await doorTopic(signPublic) };
|
|
11827
|
+
}
|
|
11828
|
+
async function doorRecords() {
|
|
11829
|
+
if (!contactsSpaceId) return [];
|
|
11830
|
+
return (await (await contactsRuntime()).list({ collection: door.name })).filter(
|
|
11831
|
+
(record) => record.verified && record.root === config.signer.did && typeof record.body?.id === "string" && Array.isArray(record.body.relays)
|
|
11832
|
+
);
|
|
11833
|
+
}
|
|
11834
|
+
async function doorView(record) {
|
|
11835
|
+
const body = record.body;
|
|
11836
|
+
const keys = await doorKeys(body.id);
|
|
11837
|
+
return Object.freeze({
|
|
11838
|
+
id: body.id,
|
|
11839
|
+
...body.label ? { label: body.label } : {},
|
|
11840
|
+
...body.name ? { name: body.name } : {},
|
|
11841
|
+
key: keys.publicKey,
|
|
11842
|
+
sign: keys.signPublic,
|
|
11843
|
+
relays: Object.freeze([...body.relays]),
|
|
11844
|
+
code: encodeDoorCode({ key: keys.publicKey, sign: keys.signPublic, relays: body.relays, ...body.name ? { name: body.name } : {} }),
|
|
11845
|
+
createdAt: record.createdAt
|
|
11846
|
+
});
|
|
11847
|
+
}
|
|
11848
|
+
async function purgeDoor(door2, ids) {
|
|
11849
|
+
const keys = await doorKeys(door2.id);
|
|
11850
|
+
const done = await Promise.allSettled(
|
|
11851
|
+
door2.relays.map((relay) => mailbox.purge(relay, keys.topic, keys.signPublic, ids, (nonce) => signPurge(keys.sign, keys.topic, nonce, ids)))
|
|
11852
|
+
);
|
|
11853
|
+
return done.filter((result) => result.status === "fulfilled").length;
|
|
11854
|
+
}
|
|
11855
|
+
async function sentRecords() {
|
|
11856
|
+
if (!contactsSpaceId) return [];
|
|
11857
|
+
return (await (await contactsRuntime()).list({ collection: knock.name })).filter(
|
|
11858
|
+
(record) => record.verified && record.root === config.signer.did && typeof record.body?.space === "string" && typeof record.body.sign === "string" && typeof record.body.invite === "string"
|
|
11859
|
+
);
|
|
11860
|
+
}
|
|
11861
|
+
async function settleSent() {
|
|
11862
|
+
for (const record of await sentRecords()) {
|
|
11863
|
+
const { space: space2, name, sign, invite } = record.body;
|
|
11864
|
+
if (!await registry.get(space2)) {
|
|
11865
|
+
await (await contactsRuntime()).remove(record.key);
|
|
11866
|
+
continue;
|
|
11867
|
+
}
|
|
11868
|
+
const open2 = await runtime(space2);
|
|
11869
|
+
const { members } = await open2.access();
|
|
11870
|
+
let answered = null;
|
|
11871
|
+
for (const answer of await open2.list({ collection: knockAnswer.name })) {
|
|
11872
|
+
const did = answer.root;
|
|
11873
|
+
if (!answer.verified || !did || did === config.signer.did || answer.createdBy !== did || typeof answer.body?.sig !== "string") continue;
|
|
11874
|
+
if (!members.some((member) => member.did === did)) continue;
|
|
11875
|
+
if (await checkAnswer(sign, space2, did, answer.body.sig)) {
|
|
11876
|
+
answered = did;
|
|
11877
|
+
break;
|
|
11878
|
+
}
|
|
11879
|
+
}
|
|
11880
|
+
if (answered) {
|
|
11881
|
+
await spaces.closeInvite(space2, invite).catch(() => {
|
|
11882
|
+
});
|
|
11883
|
+
const given = (await open2.profiles()).find((profile) => profile.did === answered)?.name;
|
|
11884
|
+
await writeContact({ did: answered, name: given ?? name, space: space2 });
|
|
11885
|
+
await (await contactsRuntime()).remove(record.key);
|
|
11886
|
+
} else if (Date.now() - Date.parse(record.createdAt) > KNOCK_TTL_SECONDS * 1e3 + DAY_MS) {
|
|
11887
|
+
await spaces.leave(space2).catch(() => {
|
|
11888
|
+
});
|
|
11889
|
+
await (await contactsRuntime()).remove(record.key);
|
|
11890
|
+
}
|
|
11891
|
+
}
|
|
11892
|
+
}
|
|
11893
|
+
async function answerAccepted() {
|
|
11894
|
+
for (const record of await contactRecords()) {
|
|
11895
|
+
const { did, space: space2, door: door2 } = record.body;
|
|
11896
|
+
if (typeof door2 !== "string" || typeof space2 !== "string") continue;
|
|
11897
|
+
if (!await registry.get(space2)) continue;
|
|
11898
|
+
const open2 = await runtime(space2);
|
|
11899
|
+
if ((await open2.access()).role === null) continue;
|
|
11900
|
+
const { sign } = await doorKeys(door2);
|
|
11901
|
+
try {
|
|
11902
|
+
await open2.put(knockAnswer.name, { sig: await signAnswer(sign, space2, config.signer.did) });
|
|
11903
|
+
} catch {
|
|
11904
|
+
continue;
|
|
11905
|
+
}
|
|
11906
|
+
const { door: _answered, ...rest } = record.body;
|
|
11907
|
+
await writeContact({ ...rest, did });
|
|
11908
|
+
}
|
|
11909
|
+
}
|
|
11910
|
+
async function readDoors() {
|
|
11911
|
+
const now = Date.now();
|
|
11912
|
+
for (const [id, cached] of knockCache) if (cached.until <= now) knockCache.delete(id);
|
|
11913
|
+
const found = [];
|
|
11914
|
+
for (const record of await doorRecords()) {
|
|
11915
|
+
const { id: doorId, relays } = record.body;
|
|
11916
|
+
const keys = await doorKeys(doorId);
|
|
11917
|
+
const held = /* @__PURE__ */ new Map();
|
|
11918
|
+
for (const result of await Promise.allSettled(relays.map((relay) => mailbox.fetch(relay, keys.topic)))) {
|
|
11919
|
+
if (result.status !== "fulfilled") continue;
|
|
11920
|
+
for (const item of result.value) {
|
|
11921
|
+
const id = await knockId(item.blob);
|
|
11922
|
+
const seen = held.get(id);
|
|
11923
|
+
if (!seen || item.at < seen.at) held.set(id, { blob: item.blob, at: item.at });
|
|
11924
|
+
}
|
|
11925
|
+
}
|
|
11926
|
+
for (const [id, item] of held) {
|
|
11927
|
+
let cached = knockCache.get(id);
|
|
11928
|
+
if (!cached) {
|
|
11929
|
+
cached = { opened: await openKnock(keys.key, item.blob, item.at, provider), door: doorId, until: item.at + KNOCK_TTL_SECONDS * 1e3 };
|
|
11930
|
+
knockCache.set(id, cached);
|
|
11931
|
+
}
|
|
11932
|
+
if (cached.opened) found.push({ id, door: doorId, opened: cached.opened });
|
|
11933
|
+
}
|
|
11934
|
+
}
|
|
11935
|
+
return found;
|
|
11936
|
+
}
|
|
11937
|
+
const doors = Object.freeze({
|
|
11938
|
+
async list() {
|
|
11939
|
+
if (!config.contactKey || agentSession) return [];
|
|
11940
|
+
return Promise.all((await doorRecords()).map(doorView));
|
|
11941
|
+
},
|
|
11942
|
+
async open(options = {}) {
|
|
11943
|
+
requireContactKey("Opening a door");
|
|
11944
|
+
if (!contactsSpaceId) await contactsRuntime();
|
|
11945
|
+
const relays = [...options.relays ?? (config.network?.relays ?? []).filter((url) => checkRelays([url]) === null)].slice(0, MAX_DOOR_RELAYS);
|
|
11946
|
+
if (relays.length === 0) throw new Error("A door needs a relay to hold its knocks, and this node has none.");
|
|
11947
|
+
const name = clip((options.name ?? await ownName() ?? "").trim(), 64);
|
|
11948
|
+
const label = options.label ? clip(options.label.trim(), 64) : "";
|
|
11949
|
+
const id = base64UrlEncode(globalThis.crypto.getRandomValues(new Uint8Array(16)));
|
|
11950
|
+
const keys = await doorKeys(id);
|
|
11951
|
+
encodeDoorCode({ key: keys.publicKey, sign: keys.signPublic, relays, ...name ? { name } : {} });
|
|
11952
|
+
const open2 = await contactsRuntime();
|
|
11953
|
+
await ensureDefined(open2, door);
|
|
11954
|
+
return doorView(await open2.put(door.name, { id, relays, ...name ? { name } : {}, ...label ? { label } : {} }));
|
|
11955
|
+
},
|
|
11956
|
+
async close(id) {
|
|
11957
|
+
const found = (await doorRecords()).find((record) => record.body.id === id);
|
|
11958
|
+
if (found) await (await contactsRuntime()).remove(found.key);
|
|
11959
|
+
for (const [knock2, cached] of knockCache) if (cached.door === id) knockCache.delete(knock2);
|
|
11960
|
+
},
|
|
11961
|
+
async clear(id) {
|
|
11962
|
+
requireContactKey("Clearing a door");
|
|
11963
|
+
const found = (await doorRecords()).find((record) => record.body.id === id);
|
|
11964
|
+
if (!found) throw new Error("There is no such door");
|
|
11965
|
+
if (await purgeDoor(found.body, null) === 0) throw new Error("None of the door's relays could be reached to clear it.");
|
|
11966
|
+
for (const [knock2, cached] of knockCache) if (cached.door === id) knockCache.delete(knock2);
|
|
11967
|
+
},
|
|
11968
|
+
async knock(code, options = {}) {
|
|
11969
|
+
requireContactKey("Knocking on a door");
|
|
11970
|
+
requireEverywhere("Knocking on a door");
|
|
11971
|
+
const door2 = parseDoorCode(code);
|
|
11972
|
+
for (const mine2 of await doors.list()) if (mine2.key === door2.key) throw new Error("That is one of your own doors");
|
|
11973
|
+
if (!contactsSpaceId) await contactsRuntime();
|
|
11974
|
+
const mine = await ownName() ?? "Someone";
|
|
11975
|
+
const theirs = door2.name ?? "Contact";
|
|
11976
|
+
const pair = await spaces.create({ name: `${clip(mine, 64)} & ${theirs}`, visibility: "private", ...team });
|
|
11977
|
+
let invite;
|
|
11978
|
+
try {
|
|
11979
|
+
await ensureDefined(await runtime(pair.id), knockAnswer);
|
|
11980
|
+
invite = await spaces.invite(pair.id, { role: "editor" });
|
|
11981
|
+
const blob = await sealKnock(
|
|
11982
|
+
door2.key,
|
|
11983
|
+
{ from: config.signer.did, name: mine, invite, ...options.note ? { note: options.note } : {} },
|
|
11984
|
+
{ did: session.did, key: session.key, proof: session.proof() },
|
|
11985
|
+
provider
|
|
11986
|
+
);
|
|
11987
|
+
const topic = await doorTopic(door2.sign);
|
|
11988
|
+
const left = await Promise.allSettled(door2.relays.map((relay) => mailbox.drop(relay, topic, blob, KNOCK_TTL_SECONDS)));
|
|
11989
|
+
if (!left.some((result) => result.status === "fulfilled")) {
|
|
11990
|
+
const reasons = left.map((result) => result.status === "rejected" ? String(result.reason?.message ?? result.reason) : "").filter(Boolean);
|
|
11991
|
+
throw new Error(`None of their door's relays took the knock. ${reasons.join("; ")}`);
|
|
11992
|
+
}
|
|
11993
|
+
} catch (error) {
|
|
11994
|
+
await spaces.leave(pair.id).catch(() => {
|
|
11995
|
+
});
|
|
11996
|
+
throw error;
|
|
11997
|
+
}
|
|
11998
|
+
const open2 = await contactsRuntime();
|
|
11999
|
+
await ensureDefined(open2, knock);
|
|
12000
|
+
await open2.put(knock.name, { space: pair.id, name: theirs, door: door2.key, sign: door2.sign, invite });
|
|
12001
|
+
return { space: pair.id };
|
|
12002
|
+
},
|
|
12003
|
+
async knocks() {
|
|
12004
|
+
requireContactKey("Reading knocks");
|
|
12005
|
+
await settleSent().catch(() => {
|
|
12006
|
+
});
|
|
12007
|
+
await answerAccepted().catch(() => {
|
|
12008
|
+
});
|
|
12009
|
+
const blocked = new Set((await contactRecords()).filter((record) => record.body.blocked === true).map((record) => record.body.did));
|
|
12010
|
+
const found = [];
|
|
12011
|
+
for (const { id, door: door2, opened } of await readDoors()) {
|
|
12012
|
+
if (opened.from === config.signer.did || blocked.has(opened.from)) continue;
|
|
12013
|
+
if (await registry.get(opened.pairSpace)) continue;
|
|
12014
|
+
found.push(
|
|
12015
|
+
Object.freeze({
|
|
12016
|
+
id,
|
|
12017
|
+
door: door2,
|
|
12018
|
+
from: opened.from,
|
|
12019
|
+
name: opened.name,
|
|
12020
|
+
...opened.note ? { note: opened.note } : {},
|
|
12021
|
+
pairSpace: opened.pairSpace,
|
|
12022
|
+
at: new Date(opened.at).toISOString()
|
|
12023
|
+
})
|
|
12024
|
+
);
|
|
12025
|
+
}
|
|
12026
|
+
return found.sort((a, b) => b.at.localeCompare(a.at));
|
|
12027
|
+
},
|
|
12028
|
+
async sent() {
|
|
12029
|
+
await settleSent().catch(() => {
|
|
12030
|
+
});
|
|
12031
|
+
return (await sentRecords()).map((record) => Object.freeze({ space: record.body.space, name: record.body.name, at: record.createdAt }));
|
|
12032
|
+
},
|
|
12033
|
+
async accept(id) {
|
|
12034
|
+
requireContactKey("Opening the door to someone");
|
|
12035
|
+
requireEverywhere("Opening the door to someone");
|
|
12036
|
+
if (!knockCache.get(id)?.opened) await readDoors();
|
|
12037
|
+
const cached = knockCache.get(id);
|
|
12038
|
+
const knocked = cached?.opened;
|
|
12039
|
+
if (!cached || !knocked) throw new Error("That knock is gone: it expired, or the door was closed or cleared.");
|
|
12040
|
+
await spaces.join(knocked.invite);
|
|
12041
|
+
const known = await contacts.get(knocked.from);
|
|
12042
|
+
const contact2 = await writeContact({ did: knocked.from, name: known?.name ?? knocked.name, space: knocked.pairSpace, door: cached.door });
|
|
12043
|
+
await answerAccepted().catch(() => {
|
|
12044
|
+
});
|
|
12045
|
+
const door2 = (await doorRecords()).find((record) => record.body.id === cached.door);
|
|
12046
|
+
if (door2) await purgeDoor(door2.body, [id]).catch(() => 0);
|
|
12047
|
+
knockCache.delete(id);
|
|
12048
|
+
return contact2;
|
|
12049
|
+
},
|
|
12050
|
+
async dismiss(id) {
|
|
12051
|
+
requireContactKey("Dismissing a knock");
|
|
12052
|
+
if (!knockCache.has(id)) await readDoors();
|
|
12053
|
+
const cached = knockCache.get(id);
|
|
12054
|
+
if (!cached) return;
|
|
12055
|
+
const door2 = (await doorRecords()).find((record) => record.body.id === cached.door);
|
|
12056
|
+
if (door2) await purgeDoor(door2.body, [id]);
|
|
12057
|
+
knockCache.delete(id);
|
|
12058
|
+
}
|
|
12059
|
+
});
|
|
11508
12060
|
const collections = Object.freeze({
|
|
11509
12061
|
async list(spaceId) {
|
|
11510
12062
|
return (await runtime(spaceId)).collections();
|
|
@@ -11721,6 +12273,18 @@ async function createNode(config) {
|
|
|
11721
12273
|
accept: person("accept contact requests"),
|
|
11722
12274
|
others: person("look inside a contact's space")
|
|
11723
12275
|
}),
|
|
12276
|
+
// Doors are the person's: an agent has none and knocks on none.
|
|
12277
|
+
doors: Object.freeze({
|
|
12278
|
+
list: async () => [],
|
|
12279
|
+
open: person("open a door"),
|
|
12280
|
+
close: person("close a door"),
|
|
12281
|
+
knock: person("knock on anyone's door"),
|
|
12282
|
+
clear: person("clear a door"),
|
|
12283
|
+
knocks: person("read knocks"),
|
|
12284
|
+
sent: person("look at knocks"),
|
|
12285
|
+
accept: person("open the door to anyone"),
|
|
12286
|
+
dismiss: person("dismiss a knock")
|
|
12287
|
+
}),
|
|
11724
12288
|
delegation: () => note,
|
|
11725
12289
|
iceServers: node.iceServers,
|
|
11726
12290
|
delegate: person("pass its access on"),
|
|
@@ -11744,6 +12308,7 @@ async function createNode(config) {
|
|
|
11744
12308
|
hosting,
|
|
11745
12309
|
notifications,
|
|
11746
12310
|
contacts,
|
|
12311
|
+
doors,
|
|
11747
12312
|
asAgent,
|
|
11748
12313
|
delegation: () => current,
|
|
11749
12314
|
iceServers: async () => mesh ? mesh.iceServers() : config.network?.iceServers ?? DEFAULT_ICE_SERVERS,
|
|
@@ -13390,7 +13955,7 @@ import { createServer } from "node:http";
|
|
|
13390
13955
|
import { WebSocketServer } from "ws";
|
|
13391
13956
|
|
|
13392
13957
|
// ../server/relay.mjs
|
|
13393
|
-
import { createHmac, randomBytes as randomBytes4 } from "node:crypto";
|
|
13958
|
+
import { createHash, createHmac, createPublicKey, randomBytes as randomBytes4, verify } from "node:crypto";
|
|
13394
13959
|
var MAX_MESSAGE_BYTES = 64 * 1024;
|
|
13395
13960
|
var MAX_CONNECTIONS = 5e3;
|
|
13396
13961
|
var MAX_CONNECTIONS_PER_IP = 32;
|
|
@@ -13404,6 +13969,32 @@ var RATE_BURST = 100;
|
|
|
13404
13969
|
var HEARTBEAT_MS = 15e3;
|
|
13405
13970
|
var MAX_BUFFERED_BYTES = 1024 * 1024;
|
|
13406
13971
|
var MAX_TURN_ADDRESSES = 2e4;
|
|
13972
|
+
var MAX_BLOB_LENGTH = 12e3;
|
|
13973
|
+
var TOPIC_PATTERN = /^[A-Za-z0-9_-]{43}$/;
|
|
13974
|
+
var MAX_MAIL_PER_TOPIC = 64;
|
|
13975
|
+
var RESERVED_PER_TOPIC = 2;
|
|
13976
|
+
var DROP_WINDOW_MS = 36e5;
|
|
13977
|
+
var MAILBOX_LIMITS = Object.freeze({
|
|
13978
|
+
/** Topics held at once */
|
|
13979
|
+
maxTopics: 5e4,
|
|
13980
|
+
/** Everything held at once, in blob characters */
|
|
13981
|
+
maxChars: 64 * 1024 * 1024,
|
|
13982
|
+
/**
|
|
13983
|
+
* Past `maxChars`, a reserve only topics holding fewer than two knocks may
|
|
13984
|
+
* use: a mailbox someone filled still takes a knock or two on every door.
|
|
13985
|
+
*/
|
|
13986
|
+
reserveChars: 8 * 1024 * 1024,
|
|
13987
|
+
/** How long a knock is kept. A client may ask for less, never more. */
|
|
13988
|
+
ttlSeconds: 14 * 24 * 3600,
|
|
13989
|
+
/** Drops one address may make into one topic per hour: filling a door takes many addresses */
|
|
13990
|
+
dropsPerNetworkPerTopic: 4,
|
|
13991
|
+
/** Drops one address may make in all, per hour: filling the mailbox takes very many */
|
|
13992
|
+
dropsPerNetwork: 30
|
|
13993
|
+
});
|
|
13994
|
+
var PURGE_SPKI_PREFIX = Buffer.from("3039301306072a8648ce3d020106082a8648ce3d030107032200", "hex");
|
|
13995
|
+
var MAX_WATCHES_PER_SOCKET = 32;
|
|
13996
|
+
var MAX_PAGE_ITEMS = 16;
|
|
13997
|
+
var MAX_PAGE_CHARS = 48 * 1024;
|
|
13407
13998
|
var ROUTED = /* @__PURE__ */ new Set(["offer", "answer", "candidate"]);
|
|
13408
13999
|
var DID_PATTERN = /^did:key:z[1-9A-HJ-NP-Za-km-z]+$/;
|
|
13409
14000
|
var CLOSE_DID_TAKEN = 4009;
|
|
@@ -13415,19 +14006,25 @@ function turnFromEnv(env) {
|
|
|
13415
14006
|
}
|
|
13416
14007
|
function createRelay(options = {}) {
|
|
13417
14008
|
const { turn } = options;
|
|
14009
|
+
const limits = { ...MAILBOX_LIMITS, ...options.mailbox };
|
|
13418
14010
|
const log = options.log ?? (() => {
|
|
13419
14011
|
});
|
|
13420
14012
|
const rooms = /* @__PURE__ */ new Map();
|
|
13421
14013
|
const clients = /* @__PURE__ */ new Set();
|
|
13422
14014
|
const perIp = /* @__PURE__ */ new Map();
|
|
13423
14015
|
const turnByNetwork = /* @__PURE__ */ new Map();
|
|
14016
|
+
const mailbox = /* @__PURE__ */ new Map();
|
|
14017
|
+
const watchers = /* @__PURE__ */ new Map();
|
|
14018
|
+
const dropsBy = /* @__PURE__ */ new Map();
|
|
14019
|
+
let mailChars = 0;
|
|
14020
|
+
let mailSeq = 0;
|
|
13424
14021
|
function canEnter(room) {
|
|
13425
14022
|
if (typeof room !== "string" || room.length === 0 || room.length > MAX_ROOM_LENGTH) return false;
|
|
13426
14023
|
const peers = rooms.get(room);
|
|
13427
14024
|
return peers ? peers.size < MAX_PEERS_PER_ROOM : rooms.size < MAX_ROOMS;
|
|
13428
14025
|
}
|
|
13429
14026
|
function accept(ws, legacyRoom, ip) {
|
|
13430
|
-
const client = { ws, legacyRoom, ip, did: null, rooms: /* @__PURE__ */ new Set(), alive: true, tokens: RATE_BURST, refilled: Date.now() };
|
|
14027
|
+
const client = { ws, legacyRoom, ip, did: null, rooms: /* @__PURE__ */ new Set(), watching: /* @__PURE__ */ new Set(), nonce: null, alive: true, tokens: RATE_BURST, refilled: Date.now() };
|
|
13431
14028
|
clients.add(client);
|
|
13432
14029
|
perIp.set(ip, (perIp.get(ip) ?? 0) + 1);
|
|
13433
14030
|
ws.on("pong", () => client.alive = true);
|
|
@@ -13447,6 +14044,7 @@ function createRelay(options = {}) {
|
|
|
13447
14044
|
if (left > 0) perIp.set(ip, left);
|
|
13448
14045
|
else perIp.delete(ip);
|
|
13449
14046
|
for (const room of [...client.rooms]) leave(client, room);
|
|
14047
|
+
for (const topic of [...client.watching]) unwatch(client, topic);
|
|
13450
14048
|
};
|
|
13451
14049
|
ws.on("close", drop);
|
|
13452
14050
|
ws.on("error", () => ws.terminate());
|
|
@@ -13477,6 +14075,10 @@ function createRelay(options = {}) {
|
|
|
13477
14075
|
log(`peer joined room ${short(room)} (${rooms.get(room).size} present)`);
|
|
13478
14076
|
return;
|
|
13479
14077
|
}
|
|
14078
|
+
if (message.type === "drop" || message.type === "fetch" || message.type === "unwatch" || message.type === "challenge" || message.type === "purge") {
|
|
14079
|
+
handleMail(client, message);
|
|
14080
|
+
return;
|
|
14081
|
+
}
|
|
13480
14082
|
if (message.type === "ice") {
|
|
13481
14083
|
if (client.did) offerTurn(client);
|
|
13482
14084
|
return;
|
|
@@ -13494,6 +14096,137 @@ function createRelay(options = {}) {
|
|
|
13494
14096
|
}
|
|
13495
14097
|
}
|
|
13496
14098
|
}
|
|
14099
|
+
function handleMail(client, message) {
|
|
14100
|
+
if (message.type === "challenge") {
|
|
14101
|
+
client.nonce = randomBytes4(18).toString("base64url");
|
|
14102
|
+
send2(client, JSON.stringify({ type: "challenge", nonce: client.nonce }));
|
|
14103
|
+
return;
|
|
14104
|
+
}
|
|
14105
|
+
const { topic } = message;
|
|
14106
|
+
if (typeof topic !== "string" || !TOPIC_PATTERN.test(topic)) return;
|
|
14107
|
+
if (message.type === "unwatch") {
|
|
14108
|
+
unwatch(client, topic);
|
|
14109
|
+
return;
|
|
14110
|
+
}
|
|
14111
|
+
if (message.type === "purge") {
|
|
14112
|
+
purge(client, message);
|
|
14113
|
+
return;
|
|
14114
|
+
}
|
|
14115
|
+
if (message.type === "fetch") {
|
|
14116
|
+
const after = Number.isSafeInteger(message.after) ? message.after : 0;
|
|
14117
|
+
if (message.watch === true && !client.watching.has(topic) && client.watching.size < MAX_WATCHES_PER_SOCKET) {
|
|
14118
|
+
client.watching.add(topic);
|
|
14119
|
+
if (!watchers.has(topic)) watchers.set(topic, /* @__PURE__ */ new Set());
|
|
14120
|
+
watchers.get(topic).add(client);
|
|
14121
|
+
}
|
|
14122
|
+
const now2 = Date.now();
|
|
14123
|
+
const items = [];
|
|
14124
|
+
let chars = 0;
|
|
14125
|
+
let more = false;
|
|
14126
|
+
for (const item2 of mailbox.get(topic) ?? []) {
|
|
14127
|
+
if (item2.seq <= after || item2.expires <= now2) continue;
|
|
14128
|
+
if (items.length >= MAX_PAGE_ITEMS || chars + item2.blob.length > MAX_PAGE_CHARS) {
|
|
14129
|
+
more = true;
|
|
14130
|
+
break;
|
|
14131
|
+
}
|
|
14132
|
+
items.push(mailItem(item2));
|
|
14133
|
+
chars += item2.blob.length;
|
|
14134
|
+
}
|
|
14135
|
+
send2(client, JSON.stringify({ type: "mail", topic, items, more }));
|
|
14136
|
+
return;
|
|
14137
|
+
}
|
|
14138
|
+
const refuse = (reason) => send2(client, JSON.stringify({ type: "refused", topic, reason }));
|
|
14139
|
+
const { blob } = message;
|
|
14140
|
+
if (typeof blob !== "string" || blob.length === 0 || blob.length > MAX_BLOB_LENGTH || !/^[A-Za-z0-9_-]+$/.test(blob)) {
|
|
14141
|
+
refuse("A knock is base64url, at most 12000 characters");
|
|
14142
|
+
return;
|
|
14143
|
+
}
|
|
14144
|
+
const id = createHash("sha256").update(blob).digest("base64url");
|
|
14145
|
+
const held = mailbox.get(topic) ?? [];
|
|
14146
|
+
if (held.some((item2) => item2.id === id)) {
|
|
14147
|
+
send2(client, JSON.stringify({ type: "dropped", topic, id }));
|
|
14148
|
+
return;
|
|
14149
|
+
}
|
|
14150
|
+
const now = Date.now();
|
|
14151
|
+
const network = networkOf(client.ip);
|
|
14152
|
+
const here = countOf(`${network}|${topic}`, now);
|
|
14153
|
+
const everywhere = countOf(network, now);
|
|
14154
|
+
if (here >= limits.dropsPerNetworkPerTopic) return refuse("Too many knocks on this door from here; try later");
|
|
14155
|
+
if (everywhere >= limits.dropsPerNetwork) return refuse("Too many knocks from here; try later");
|
|
14156
|
+
if (held.length >= MAX_MAIL_PER_TOPIC) return refuse("This door is full");
|
|
14157
|
+
if (!mailbox.has(topic) && mailbox.size >= limits.maxTopics) return refuse("The mailbox is full");
|
|
14158
|
+
const room = held.length < RESERVED_PER_TOPIC ? limits.maxChars + limits.reserveChars : limits.maxChars;
|
|
14159
|
+
if (mailChars + blob.length > room) return refuse("The mailbox is full");
|
|
14160
|
+
const ttl = Number.isFinite(message.ttl) && message.ttl > 0 ? Math.min(message.ttl, limits.ttlSeconds) : limits.ttlSeconds;
|
|
14161
|
+
const item = { seq: ++mailSeq, id, at: now, expires: now + ttl * 1e3, blob };
|
|
14162
|
+
held.push(item);
|
|
14163
|
+
mailbox.set(topic, held);
|
|
14164
|
+
mailChars += blob.length;
|
|
14165
|
+
counted(`${network}|${topic}`, now);
|
|
14166
|
+
counted(network, now);
|
|
14167
|
+
send2(client, JSON.stringify({ type: "dropped", topic, id }));
|
|
14168
|
+
const news = JSON.stringify({ type: "mail", topic, items: [mailItem(item)], more: false });
|
|
14169
|
+
for (const watcher of watchers.get(topic) ?? []) {
|
|
14170
|
+
if (watcher !== client) send2(watcher, news);
|
|
14171
|
+
}
|
|
14172
|
+
}
|
|
14173
|
+
const mailItem = ({ seq, id, at, blob }) => ({ seq, id, at, blob });
|
|
14174
|
+
function countOf(key3, now) {
|
|
14175
|
+
const recent = dropsBy.get(key3);
|
|
14176
|
+
return recent && now - recent.since < DROP_WINDOW_MS ? recent.count : 0;
|
|
14177
|
+
}
|
|
14178
|
+
function counted(key3, now) {
|
|
14179
|
+
const recent = dropsBy.get(key3);
|
|
14180
|
+
if (recent && now - recent.since < DROP_WINDOW_MS) recent.count += 1;
|
|
14181
|
+
else dropsBy.set(key3, { count: 1, since: now });
|
|
14182
|
+
}
|
|
14183
|
+
function purge(client, message) {
|
|
14184
|
+
const { topic, sign, sig } = message;
|
|
14185
|
+
const refuse = (reason) => send2(client, JSON.stringify({ type: "refused", topic, reason }));
|
|
14186
|
+
const nonce = client.nonce;
|
|
14187
|
+
client.nonce = null;
|
|
14188
|
+
const ids = message.ids === void 0 ? null : message.ids;
|
|
14189
|
+
if (!nonce) return refuse("Ask for a challenge first");
|
|
14190
|
+
if (typeof sign !== "string" || !/^[A-Za-z0-9_-]{44}$/.test(sign) || typeof sig !== "string" || sig.length > 200) return refuse("Not a purge");
|
|
14191
|
+
if (ids !== null && (!Array.isArray(ids) || ids.length > MAX_MAIL_PER_TOPIC || !ids.every((id) => typeof id === "string" && id.length <= 64))) {
|
|
14192
|
+
return refuse("Not a purge");
|
|
14193
|
+
}
|
|
14194
|
+
if (createHash("sha256").update(`weave/door-topic/v1|${sign}`).digest("base64url") !== topic) return refuse("That key is not this door\u2019s");
|
|
14195
|
+
let good = false;
|
|
14196
|
+
try {
|
|
14197
|
+
const key3 = createPublicKey({ key: Buffer.concat([PURGE_SPKI_PREFIX, Buffer.from(sign, "base64url")]), format: "der", type: "spki" });
|
|
14198
|
+
const signed = `weave/door-purge/v1|${topic}|${nonce}|${ids ? [...ids].sort().join(",") : "*"}`;
|
|
14199
|
+
good = verify("sha256", Buffer.from(signed), { key: key3, dsaEncoding: "ieee-p1363" }, Buffer.from(sig, "base64url"));
|
|
14200
|
+
} catch {
|
|
14201
|
+
good = false;
|
|
14202
|
+
}
|
|
14203
|
+
if (!good) return refuse("The signature does not check out");
|
|
14204
|
+
const held = mailbox.get(topic) ?? [];
|
|
14205
|
+
const gone = ids === null ? held : held.filter((item) => ids.includes(item.id));
|
|
14206
|
+
for (const item of gone) mailChars -= item.blob.length;
|
|
14207
|
+
const kept = ids === null ? [] : held.filter((item) => !ids.includes(item.id));
|
|
14208
|
+
if (kept.length === 0) mailbox.delete(topic);
|
|
14209
|
+
else mailbox.set(topic, kept);
|
|
14210
|
+
send2(client, JSON.stringify({ type: "purged", topic, count: gone.length }));
|
|
14211
|
+
}
|
|
14212
|
+
function unwatch(client, topic) {
|
|
14213
|
+
client.watching.delete(topic);
|
|
14214
|
+
const watching = watchers.get(topic);
|
|
14215
|
+
watching?.delete(client);
|
|
14216
|
+
if (watching?.size === 0) watchers.delete(topic);
|
|
14217
|
+
}
|
|
14218
|
+
function sweepMail() {
|
|
14219
|
+
const now = Date.now();
|
|
14220
|
+
for (const [topic, held] of mailbox) {
|
|
14221
|
+
const kept = held.filter((item) => item.expires > now);
|
|
14222
|
+
for (const item of held) if (item.expires <= now) mailChars -= item.blob.length;
|
|
14223
|
+
if (kept.length === 0) mailbox.delete(topic);
|
|
14224
|
+
else if (kept.length !== held.length) mailbox.set(topic, kept);
|
|
14225
|
+
}
|
|
14226
|
+
for (const [key3, recent] of dropsBy) {
|
|
14227
|
+
if (now - recent.since >= DROP_WINDOW_MS) dropsBy.delete(key3);
|
|
14228
|
+
}
|
|
14229
|
+
}
|
|
13497
14230
|
function offerTurn(client) {
|
|
13498
14231
|
if (!turn) return;
|
|
13499
14232
|
const network = networkOf(client.ip);
|
|
@@ -13551,6 +14284,7 @@ function createRelay(options = {}) {
|
|
|
13551
14284
|
for (const [network, held] of turnByNetwork) {
|
|
13552
14285
|
if (held.expires <= now) turnByNetwork.delete(network);
|
|
13553
14286
|
}
|
|
14287
|
+
sweepMail();
|
|
13554
14288
|
for (const client of clients) {
|
|
13555
14289
|
if (!client.alive) {
|
|
13556
14290
|
client.ws.terminate();
|
|
@@ -14831,7 +15565,7 @@ var ASK = "agent-link:ask";
|
|
|
14831
15565
|
var HEARD = "agent-link:heard";
|
|
14832
15566
|
var ANSWER = "agent-link:answer";
|
|
14833
15567
|
var DONE = "agent-link:done";
|
|
14834
|
-
var
|
|
15568
|
+
var MAX_NAME2 = 80;
|
|
14835
15569
|
function readAgentCode(input) {
|
|
14836
15570
|
const match = /wv_([A-Za-z0-9_-]{22})(?![A-Za-z0-9_-])/.exec(input);
|
|
14837
15571
|
const secret = match ? base64UrlDecode(match[1]) : null;
|
|
@@ -14898,7 +15632,7 @@ async function acceptAgentLink(params) {
|
|
|
14898
15632
|
done();
|
|
14899
15633
|
};
|
|
14900
15634
|
net.on("peer-connected", (peer) => {
|
|
14901
|
-
void seal2({ did: params.did, name: params.name.slice(0,
|
|
15635
|
+
void seal2({ did: params.did, name: params.name.slice(0, MAX_NAME2) }, key3).then((payload) => {
|
|
14902
15636
|
net.send(peer.did, { type: ASK, from: params.did, payload });
|
|
14903
15637
|
});
|
|
14904
15638
|
});
|