@weaveprotocol/core 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.
Files changed (44) hide show
  1. package/README.md +80 -8
  2. package/dist/doors/doors.d.ts +139 -0
  3. package/dist/doors/doors.d.ts.map +1 -0
  4. package/dist/doors/doors.js +210 -0
  5. package/dist/doors/doors.js.map +1 -0
  6. package/dist/doors/index.d.ts +9 -0
  7. package/dist/doors/index.d.ts.map +1 -0
  8. package/dist/doors/index.js +7 -0
  9. package/dist/doors/index.js.map +1 -0
  10. package/dist/identity/contact-key.d.ts +26 -0
  11. package/dist/identity/contact-key.d.ts.map +1 -1
  12. package/dist/identity/contact-key.js +52 -0
  13. package/dist/identity/contact-key.js.map +1 -1
  14. package/dist/index.d.ts +1 -1
  15. package/dist/index.d.ts.map +1 -1
  16. package/dist/index.js +1 -1
  17. package/dist/index.js.map +1 -1
  18. package/dist/network/index.d.ts +2 -0
  19. package/dist/network/index.d.ts.map +1 -1
  20. package/dist/network/index.js +1 -0
  21. package/dist/network/index.js.map +1 -1
  22. package/dist/network/mailbox.d.ts +49 -0
  23. package/dist/network/mailbox.d.ts.map +1 -0
  24. package/dist/network/mailbox.js +125 -0
  25. package/dist/network/mailbox.js.map +1 -0
  26. package/dist/node/node.d.ts.map +1 -1
  27. package/dist/node/node.js +320 -2
  28. package/dist/node/node.js.map +1 -1
  29. package/dist/node/types.d.ts +107 -1
  30. package/dist/node/types.d.ts.map +1 -1
  31. package/dist/schemas/contacts.d.ts +137 -0
  32. package/dist/schemas/contacts.d.ts.map +1 -1
  33. package/dist/schemas/contacts.js +63 -0
  34. package/dist/schemas/contacts.js.map +1 -1
  35. package/dist/schemas/index.d.ts +2 -2
  36. package/dist/schemas/index.d.ts.map +1 -1
  37. package/dist/schemas/index.js +1 -1
  38. package/dist/schemas/index.js.map +1 -1
  39. package/dist/session/auth.d.ts.map +1 -1
  40. package/dist/session/auth.js +2 -1
  41. package/dist/session/auth.js.map +1 -1
  42. package/dist/session/hosting.d.ts +1 -1
  43. package/dist/space/notify.d.ts +1 -1
  44. package/package.json +6 -2
package/dist/node/node.js CHANGED
@@ -30,8 +30,10 @@ import { DEFAULT_ICE_SERVERS } from '../network/rtc-transport.js';
30
30
  import { deriveInviteKey } from '../space/space-access.js';
31
31
  import { base64UrlDecode, base64UrlEncode } from '../utils/encoding.js';
32
32
  import { onePerKey } from '../records/rules.js';
33
- import { contactKeyPair, deriveMemberKeyBytes, openSealed, sealFor } from '../identity/contact-key.js';
34
- import { contact as contactSchema, contactRequest as contactRequestSchema } from '../schemas/contacts.js';
33
+ import { contactKeyPair, contactPublicKey, deriveDoorKeyBytes, deriveDoorSignKeyBytes, deriveMemberKeyBytes, openSealed, sealFor } from '../identity/contact-key.js';
34
+ import { contact as contactSchema, contactRequest as contactRequestSchema, door as doorSchema, knock as knockSchema, knockAnswer as knockAnswerSchema, } from '../schemas/contacts.js';
35
+ import { checkAnswer, clip, doorTopic, encodeDoorCode, KNOCK_TTL_SECONDS, knockId, MAX_DOOR_RELAYS, openKnock, parseDoorCode, sealKnock, signAnswer, signPurge, } from '../doors/doors.js';
36
+ import { createMailboxClient } from '../network/mailbox.js';
35
37
  import { team } from '../space/presets.js';
36
38
  import { CARRIER_COLLECTION, deriveAccountRegistry, deriveContactsSpace, MEMBERSHIP_COLLECTION, PROFILE_COLLECTION, PROFILE_KEY, } from '../space/account-registry.js';
37
39
  import { CARRY_CLOSED_KEY, makePass, PASS_COLLECTION, passKey } from '../space/pass.js';
@@ -1137,11 +1139,14 @@ export async function createNode(config) {
1137
1139
  const name = contact.name.trim().slice(0, 200);
1138
1140
  if (!name)
1139
1141
  throw new Error('A contact needs a name');
1142
+ // An answer to their knock still to be written stays to be written.
1143
+ const pending = (await contactRecords()).find((record) => record.body.did === contact.did)?.body?.door;
1140
1144
  return writeContact({
1141
1145
  did: contact.did,
1142
1146
  name,
1143
1147
  ...(contact.space ? { space: contact.space } : {}),
1144
1148
  ...(contact.note ? { note: contact.note.slice(0, 2000) } : {}),
1149
+ ...(pending ? { door: pending } : {}),
1145
1150
  });
1146
1151
  },
1147
1152
  async remove(did) {
@@ -1227,6 +1232,306 @@ export async function createNode(config) {
1227
1232
  return [...seen].filter((account) => account !== config.signer.did && account !== did).sort();
1228
1233
  },
1229
1234
  });
1235
+ // ─── Doors ─────────────────────────────────────────────────────────
1236
+ //
1237
+ // A door is a `std.door` in the contacts space; its keys are derived from
1238
+ // the contact key and its id, so every device with the contact key opens the
1239
+ // same doors. Knocks wait in the mailboxes of the relays a door names.
1240
+ //
1241
+ // A knock you left is a `std.knock` until the person behind the door
1242
+ // answers in the space for two — a `std.knock-answer` signed with the
1243
+ // door's signing key — when it becomes a contact and the invite is closed.
1244
+ // Accepting a knock writes that answer as soon as this account's membership
1245
+ // there has landed; until then the contact carries the door's id.
1246
+ // See `src/doors/doors.ts`.
1247
+ const mailbox = config.mailbox ?? createMailboxClient();
1248
+ /** Knocks seen at this node's doors, by id: opened (null when they didn't check out), and until when to keep them */
1249
+ const knockCache = new Map();
1250
+ const DAY_MS = 24 * 3600 * 1000;
1251
+ function requireContactKey(what) {
1252
+ // Agents never open doors or knock, even one a home gave the contact key.
1253
+ if (agentSession)
1254
+ throw new Error(`An agent can't use doors. ${what} is the person's to do.`);
1255
+ if (!config.contactKey)
1256
+ throw new Error(`${what} needs your contact key. Connect to your account home again, and allow contacts.`);
1257
+ return config.contactKey;
1258
+ }
1259
+ /** A door's keys, from the contact key and its id */
1260
+ async function doorKeys(id) {
1261
+ const contactKey = requireContactKey('A door');
1262
+ const key = await deriveDoorKeyBytes(contactKey, id);
1263
+ const sign = await deriveDoorSignKeyBytes(contactKey, id);
1264
+ const signPublic = contactPublicKey(sign);
1265
+ return { key, sign, publicKey: contactPublicKey(key), signPublic, topic: await doorTopic(signPublic) };
1266
+ }
1267
+ async function doorRecords() {
1268
+ if (!contactsSpaceId)
1269
+ return [];
1270
+ return (await (await contactsRuntime()).list({ collection: doorSchema.name })).filter((record) => record.verified && record.root === config.signer.did && typeof record.body?.id === 'string' && Array.isArray(record.body.relays));
1271
+ }
1272
+ async function doorView(record) {
1273
+ const body = record.body;
1274
+ const keys = await doorKeys(body.id);
1275
+ return Object.freeze({
1276
+ id: body.id,
1277
+ ...(body.label ? { label: body.label } : {}),
1278
+ ...(body.name ? { name: body.name } : {}),
1279
+ key: keys.publicKey,
1280
+ sign: keys.signPublic,
1281
+ relays: Object.freeze([...body.relays]),
1282
+ code: encodeDoorCode({ key: keys.publicKey, sign: keys.signPublic, relays: body.relays, ...(body.name ? { name: body.name } : {}) }),
1283
+ createdAt: record.createdAt,
1284
+ });
1285
+ }
1286
+ /** Clears knocks from every relay a door names, as its owner; how many relays did */
1287
+ async function purgeDoor(door, ids) {
1288
+ const keys = await doorKeys(door.id);
1289
+ const done = await Promise.allSettled(door.relays.map((relay) => mailbox.purge(relay, keys.topic, keys.signPublic, ids, (nonce) => signPurge(keys.sign, keys.topic, nonce, ids))));
1290
+ return done.filter((result) => result.status === 'fulfilled').length;
1291
+ }
1292
+ async function sentRecords() {
1293
+ if (!contactsSpaceId)
1294
+ return [];
1295
+ return (await (await contactsRuntime()).list({ collection: knockSchema.name })).filter((record) => record.verified &&
1296
+ record.root === config.signer.did &&
1297
+ typeof record.body?.space === 'string' &&
1298
+ typeof record.body.sign === 'string' &&
1299
+ typeof record.body.invite === 'string');
1300
+ }
1301
+ /**
1302
+ * Knocks of yours: answered ones become contacts, and the invite is closed;
1303
+ * ones nobody answered within the knock's life are let go, space and all.
1304
+ */
1305
+ async function settleSent() {
1306
+ for (const record of await sentRecords()) {
1307
+ const { space, name, sign, invite } = record.body;
1308
+ if (!(await registry.get(space))) {
1309
+ await (await contactsRuntime()).remove(record.key);
1310
+ continue;
1311
+ }
1312
+ const open = await runtime(space);
1313
+ const { members } = await open.access();
1314
+ let answered = null;
1315
+ for (const answer of await open.list({ collection: knockAnswerSchema.name })) {
1316
+ const did = answer.root;
1317
+ if (!answer.verified || !did || did === config.signer.did || answer.createdBy !== did || typeof answer.body?.sig !== 'string')
1318
+ continue;
1319
+ if (!members.some((member) => member.did === did))
1320
+ continue;
1321
+ if (await checkAnswer(sign, space, did, answer.body.sig)) {
1322
+ answered = did;
1323
+ break;
1324
+ }
1325
+ }
1326
+ if (answered) {
1327
+ // Whoever else the invite reached, nobody more joins with it.
1328
+ await spaces.closeInvite(space, invite).catch(() => { });
1329
+ const given = (await open.profiles()).find((profile) => profile.did === answered)?.name;
1330
+ await writeContact({ did: answered, name: given ?? name, space });
1331
+ await (await contactsRuntime()).remove(record.key);
1332
+ }
1333
+ else if (Date.now() - Date.parse(record.createdAt) > KNOCK_TTL_SECONDS * 1000 + DAY_MS) {
1334
+ await spaces.leave(space).catch(() => { });
1335
+ await (await contactsRuntime()).remove(record.key);
1336
+ }
1337
+ }
1338
+ }
1339
+ /** Writes the answer to knocks this account accepted, once it can write in the space for two */
1340
+ async function answerAccepted() {
1341
+ for (const record of await contactRecords()) {
1342
+ const { did, space, door } = record.body;
1343
+ if (typeof door !== 'string' || typeof space !== 'string')
1344
+ continue;
1345
+ if (!(await registry.get(space)))
1346
+ continue;
1347
+ const open = await runtime(space);
1348
+ if ((await open.access()).role === null)
1349
+ continue;
1350
+ const { sign } = await doorKeys(door);
1351
+ try {
1352
+ await open.put(knockAnswerSchema.name, { sig: await signAnswer(sign, space, config.signer.did) });
1353
+ }
1354
+ catch {
1355
+ continue; // The space's definition of answers hasn't arrived yet.
1356
+ }
1357
+ const { door: _answered, ...rest } = record.body;
1358
+ await writeContact({ ...rest, did });
1359
+ }
1360
+ }
1361
+ /** What the mailboxes of this node's doors hold now, opened and cached */
1362
+ async function readDoors() {
1363
+ const now = Date.now();
1364
+ for (const [id, cached] of knockCache)
1365
+ if (cached.until <= now)
1366
+ knockCache.delete(id);
1367
+ const found = [];
1368
+ for (const record of await doorRecords()) {
1369
+ const { id: doorId, relays } = record.body;
1370
+ const keys = await doorKeys(doorId);
1371
+ // Every relay, in full: a door holds 64 knocks at most, and a knock
1372
+ // cleared from another device should go from here too. What was opened
1373
+ // once is not opened again.
1374
+ const held = new Map();
1375
+ for (const result of await Promise.allSettled(relays.map((relay) => mailbox.fetch(relay, keys.topic)))) {
1376
+ if (result.status !== 'fulfilled')
1377
+ continue;
1378
+ for (const item of result.value) {
1379
+ // A relay's id is only its word: file the knock under the hash of what it is.
1380
+ const id = await knockId(item.blob);
1381
+ const seen = held.get(id);
1382
+ if (!seen || item.at < seen.at)
1383
+ held.set(id, { blob: item.blob, at: item.at });
1384
+ }
1385
+ }
1386
+ for (const [id, item] of held) {
1387
+ let cached = knockCache.get(id);
1388
+ if (!cached) {
1389
+ cached = { opened: await openKnock(keys.key, item.blob, item.at, provider), door: doorId, until: item.at + KNOCK_TTL_SECONDS * 1000 };
1390
+ knockCache.set(id, cached);
1391
+ }
1392
+ if (cached.opened)
1393
+ found.push({ id, door: doorId, opened: cached.opened });
1394
+ }
1395
+ }
1396
+ return found;
1397
+ }
1398
+ const doors = Object.freeze({
1399
+ async list() {
1400
+ if (!config.contactKey || agentSession)
1401
+ return [];
1402
+ return Promise.all((await doorRecords()).map(doorView));
1403
+ },
1404
+ async open(options = {}) {
1405
+ requireContactKey('Opening a door');
1406
+ if (!contactsSpaceId)
1407
+ await contactsRuntime();
1408
+ const relays = [...(options.relays ?? (config.network?.relays ?? []).filter((url) => checkRelays([url]) === null))].slice(0, MAX_DOOR_RELAYS);
1409
+ if (relays.length === 0)
1410
+ throw new Error('A door needs a relay to hold its knocks, and this node has none.');
1411
+ const name = clip((options.name ?? (await ownName()) ?? '').trim(), 64);
1412
+ const label = options.label ? clip(options.label.trim(), 64) : '';
1413
+ const id = base64UrlEncode(globalThis.crypto.getRandomValues(new Uint8Array(16)));
1414
+ // Checks the relays and name the way a knocker will.
1415
+ const keys = await doorKeys(id);
1416
+ encodeDoorCode({ key: keys.publicKey, sign: keys.signPublic, relays, ...(name ? { name } : {}) });
1417
+ const open = await contactsRuntime();
1418
+ await ensureDefined(open, doorSchema);
1419
+ return doorView(await open.put(doorSchema.name, { id, relays, ...(name ? { name } : {}), ...(label ? { label } : {}) }));
1420
+ },
1421
+ async close(id) {
1422
+ const found = (await doorRecords()).find((record) => record.body.id === id);
1423
+ if (found)
1424
+ await (await contactsRuntime()).remove(found.key);
1425
+ for (const [knock, cached] of knockCache)
1426
+ if (cached.door === id)
1427
+ knockCache.delete(knock);
1428
+ },
1429
+ async clear(id) {
1430
+ requireContactKey('Clearing a door');
1431
+ const found = (await doorRecords()).find((record) => record.body.id === id);
1432
+ if (!found)
1433
+ throw new Error('There is no such door');
1434
+ if ((await purgeDoor(found.body, null)) === 0)
1435
+ throw new Error("None of the door's relays could be reached to clear it.");
1436
+ for (const [knock, cached] of knockCache)
1437
+ if (cached.door === id)
1438
+ knockCache.delete(knock);
1439
+ },
1440
+ async knock(code, options = {}) {
1441
+ requireContactKey('Knocking on a door');
1442
+ requireEverywhere('Knocking on a door');
1443
+ const door = parseDoorCode(code);
1444
+ for (const mine of await doors.list())
1445
+ if (mine.key === door.key)
1446
+ throw new Error('That is one of your own doors');
1447
+ if (!contactsSpaceId)
1448
+ await contactsRuntime();
1449
+ const mine = (await ownName()) ?? 'Someone';
1450
+ const theirs = door.name ?? 'Contact';
1451
+ const pair = await spaces.create({ name: `${clip(mine, 64)} & ${theirs}`, visibility: 'private', ...team });
1452
+ let invite;
1453
+ try {
1454
+ // Defined now, by the one who may: the answer is written by someone who joins as an editor.
1455
+ await ensureDefined(await runtime(pair.id), knockAnswerSchema);
1456
+ invite = await spaces.invite(pair.id, { role: 'editor' });
1457
+ const blob = await sealKnock(door.key, { from: config.signer.did, name: mine, invite, ...(options.note ? { note: options.note } : {}) }, { did: session.did, key: session.key, proof: session.proof() }, provider);
1458
+ const topic = await doorTopic(door.sign);
1459
+ const left = await Promise.allSettled(door.relays.map((relay) => mailbox.drop(relay, topic, blob, KNOCK_TTL_SECONDS)));
1460
+ if (!left.some((result) => result.status === 'fulfilled')) {
1461
+ const reasons = left.map((result) => (result.status === 'rejected' ? String(result.reason?.message ?? result.reason) : '')).filter(Boolean);
1462
+ throw new Error(`None of their door's relays took the knock. ${reasons.join('; ')}`);
1463
+ }
1464
+ }
1465
+ catch (error) {
1466
+ await spaces.leave(pair.id).catch(() => { });
1467
+ throw error;
1468
+ }
1469
+ const open = await contactsRuntime();
1470
+ await ensureDefined(open, knockSchema);
1471
+ await open.put(knockSchema.name, { space: pair.id, name: theirs, door: door.key, sign: door.sign, invite });
1472
+ return { space: pair.id };
1473
+ },
1474
+ async knocks() {
1475
+ requireContactKey('Reading knocks');
1476
+ await settleSent().catch(() => { });
1477
+ await answerAccepted().catch(() => { });
1478
+ const blocked = new Set((await contactRecords()).filter((record) => record.body.blocked === true).map((record) => record.body.did));
1479
+ const found = [];
1480
+ for (const { id, door, opened } of await readDoors()) {
1481
+ if (opened.from === config.signer.did || blocked.has(opened.from))
1482
+ continue;
1483
+ // Already accepted, here or on another device.
1484
+ if (await registry.get(opened.pairSpace))
1485
+ continue;
1486
+ found.push(Object.freeze({
1487
+ id,
1488
+ door,
1489
+ from: opened.from,
1490
+ name: opened.name,
1491
+ ...(opened.note ? { note: opened.note } : {}),
1492
+ pairSpace: opened.pairSpace,
1493
+ at: new Date(opened.at).toISOString(),
1494
+ }));
1495
+ }
1496
+ return found.sort((a, b) => b.at.localeCompare(a.at));
1497
+ },
1498
+ async sent() {
1499
+ await settleSent().catch(() => { });
1500
+ return (await sentRecords()).map((record) => Object.freeze({ space: record.body.space, name: record.body.name, at: record.createdAt }));
1501
+ },
1502
+ async accept(id) {
1503
+ requireContactKey('Opening the door to someone');
1504
+ requireEverywhere('Opening the door to someone');
1505
+ if (!knockCache.get(id)?.opened)
1506
+ await readDoors();
1507
+ const cached = knockCache.get(id);
1508
+ const knocked = cached?.opened;
1509
+ if (!cached || !knocked)
1510
+ throw new Error('That knock is gone: it expired, or the door was closed or cleared.');
1511
+ await spaces.join(knocked.invite);
1512
+ const known = await contacts.get(knocked.from);
1513
+ const contact = await writeContact({ did: knocked.from, name: known?.name ?? knocked.name, space: knocked.pairSpace, door: cached.door });
1514
+ await answerAccepted().catch(() => { });
1515
+ // Done with: nobody needs it in the mailboxes any more.
1516
+ const door = (await doorRecords()).find((record) => record.body.id === cached.door);
1517
+ if (door)
1518
+ await purgeDoor(door.body, [id]).catch(() => 0);
1519
+ knockCache.delete(id);
1520
+ return contact;
1521
+ },
1522
+ async dismiss(id) {
1523
+ requireContactKey('Dismissing a knock');
1524
+ if (!knockCache.has(id))
1525
+ await readDoors();
1526
+ const cached = knockCache.get(id);
1527
+ if (!cached)
1528
+ return;
1529
+ const door = (await doorRecords()).find((record) => record.body.id === cached.door);
1530
+ if (door)
1531
+ await purgeDoor(door.body, [id]);
1532
+ knockCache.delete(id);
1533
+ },
1534
+ });
1230
1535
  const collections = Object.freeze({
1231
1536
  async list(spaceId) {
1232
1537
  return (await runtime(spaceId)).collections();
@@ -1460,6 +1765,18 @@ export async function createNode(config) {
1460
1765
  accept: person('accept contact requests'),
1461
1766
  others: person('look inside a contact\'s space'),
1462
1767
  }),
1768
+ // Doors are the person's: an agent has none and knocks on none.
1769
+ doors: Object.freeze({
1770
+ list: async () => [],
1771
+ open: person('open a door'),
1772
+ close: person('close a door'),
1773
+ knock: person('knock on anyone\'s door'),
1774
+ clear: person('clear a door'),
1775
+ knocks: person('read knocks'),
1776
+ sent: person('look at knocks'),
1777
+ accept: person('open the door to anyone'),
1778
+ dismiss: person('dismiss a knock'),
1779
+ }),
1463
1780
  delegation: () => note,
1464
1781
  iceServers: node.iceServers,
1465
1782
  delegate: person('pass its access on'),
@@ -1483,6 +1800,7 @@ export async function createNode(config) {
1483
1800
  hosting,
1484
1801
  notifications,
1485
1802
  contacts,
1803
+ doors,
1486
1804
  asAgent,
1487
1805
  delegation: () => current,
1488
1806
  iceServers: async () => (mesh ? mesh.iceServers() : (config.network?.iceServers ?? DEFAULT_ICE_SERVERS)),