@weaveprotocol/core 0.1.2 → 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 +322 -45
- package/dist/doors/doors.d.ts +139 -0
- package/dist/doors/doors.d.ts.map +1 -0
- package/dist/doors/doors.js +210 -0
- package/dist/doors/doors.js.map +1 -0
- package/dist/doors/index.d.ts +9 -0
- package/dist/doors/index.d.ts.map +1 -0
- package/dist/doors/index.js +7 -0
- package/dist/doors/index.js.map +1 -0
- package/dist/identity/contact-key.d.ts +68 -0
- package/dist/identity/contact-key.d.ts.map +1 -0
- package/dist/identity/contact-key.js +185 -0
- package/dist/identity/contact-key.js.map +1 -0
- package/dist/index.d.ts +16 -7
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +10 -5
- package/dist/index.js.map +1 -1
- package/dist/network/index.d.ts +2 -0
- package/dist/network/index.d.ts.map +1 -1
- package/dist/network/index.js +1 -0
- package/dist/network/index.js.map +1 -1
- package/dist/network/mailbox.d.ts +49 -0
- package/dist/network/mailbox.d.ts.map +1 -0
- package/dist/network/mailbox.js +125 -0
- package/dist/network/mailbox.js.map +1 -0
- package/dist/network/mesh.d.ts +6 -0
- package/dist/network/mesh.d.ts.map +1 -1
- package/dist/network/mesh.js +11 -1
- package/dist/network/mesh.js.map +1 -1
- package/dist/network/multi-signaling.d.ts +12 -2
- package/dist/network/multi-signaling.d.ts.map +1 -1
- package/dist/network/multi-signaling.js +86 -14
- package/dist/network/multi-signaling.js.map +1 -1
- package/dist/network/peer-auth.d.ts +50 -16
- package/dist/network/peer-auth.d.ts.map +1 -1
- package/dist/network/peer-auth.js +52 -15
- package/dist/network/peer-auth.js.map +1 -1
- package/dist/node/carrier.d.ts +87 -0
- package/dist/node/carrier.d.ts.map +1 -1
- package/dist/node/carrier.js +180 -59
- package/dist/node/carrier.js.map +1 -1
- package/dist/node/copy.d.ts.map +1 -1
- package/dist/node/copy.js +6 -3
- package/dist/node/copy.js.map +1 -1
- package/dist/node/host.d.ts +117 -0
- package/dist/node/host.d.ts.map +1 -0
- package/dist/node/host.js +192 -0
- package/dist/node/host.js.map +1 -0
- package/dist/node/index.d.ts +6 -2
- package/dist/node/index.d.ts.map +1 -1
- package/dist/node/index.js +1 -0
- package/dist/node/index.js.map +1 -1
- package/dist/node/node.d.ts.map +1 -1
- package/dist/node/node.js +958 -25
- package/dist/node/node.js.map +1 -1
- package/dist/node/space-runtime.d.ts +42 -25
- package/dist/node/space-runtime.d.ts.map +1 -1
- package/dist/node/space-runtime.js +734 -35
- package/dist/node/space-runtime.js.map +1 -1
- package/dist/node/types.d.ts +375 -3
- package/dist/node/types.d.ts.map +1 -1
- package/dist/privacy/space-encryption.d.ts +16 -0
- package/dist/privacy/space-encryption.d.ts.map +1 -1
- package/dist/privacy/space-encryption.js +40 -0
- package/dist/privacy/space-encryption.js.map +1 -1
- package/dist/query/engine.js +1 -1
- package/dist/query/engine.js.map +1 -1
- package/dist/query/types.d.ts +7 -0
- package/dist/query/types.d.ts.map +1 -1
- package/dist/query/types.js.map +1 -1
- package/dist/react/use-query.d.ts +1 -1
- package/dist/react/use-query.d.ts.map +1 -1
- package/dist/records/topics.d.ts +21 -0
- package/dist/records/topics.d.ts.map +1 -0
- package/dist/records/topics.js +86 -0
- package/dist/records/topics.js.map +1 -0
- package/dist/schema/collection-def.d.ts +7 -0
- package/dist/schema/collection-def.d.ts.map +1 -1
- package/dist/schema/collection-def.js +4 -0
- package/dist/schema/collection-def.js.map +1 -1
- package/dist/schema/expression.d.ts +2 -0
- package/dist/schema/expression.d.ts.map +1 -1
- package/dist/schema/expression.js +1 -0
- package/dist/schema/expression.js.map +1 -1
- package/dist/schemas/contacts.d.ts +214 -0
- package/dist/schemas/contacts.d.ts.map +1 -0
- package/dist/schemas/contacts.js +98 -0
- package/dist/schemas/contacts.js.map +1 -0
- package/dist/schemas/index.d.ts +2 -0
- package/dist/schemas/index.d.ts.map +1 -1
- package/dist/schemas/index.js +1 -0
- package/dist/schemas/index.js.map +1 -1
- package/dist/session/auth.d.ts.map +1 -1
- package/dist/session/auth.js +15 -3
- package/dist/session/auth.js.map +1 -1
- package/dist/session/connect.d.ts +25 -1
- package/dist/session/connect.d.ts.map +1 -1
- package/dist/session/connect.js +9 -2
- package/dist/session/connect.js.map +1 -1
- package/dist/session/hosting.d.ts +155 -0
- package/dist/session/hosting.d.ts.map +1 -0
- package/dist/session/hosting.js +160 -0
- package/dist/session/hosting.js.map +1 -0
- package/dist/space/account-registry.d.ts +6 -0
- package/dist/space/account-registry.d.ts.map +1 -1
- package/dist/space/account-registry.js +16 -4
- package/dist/space/account-registry.js.map +1 -1
- package/dist/space/notify.d.ts +88 -0
- package/dist/space/notify.d.ts.map +1 -0
- package/dist/space/notify.js +127 -0
- package/dist/space/notify.js.map +1 -0
- package/dist/space/pass.d.ts +6 -0
- package/dist/space/pass.d.ts.map +1 -1
- package/dist/space/pass.js +5 -2
- package/dist/space/pass.js.map +1 -1
- package/dist/space/roles.d.ts +71 -0
- package/dist/space/roles.d.ts.map +1 -1
- package/dist/space/roles.js +114 -0
- package/dist/space/roles.js.map +1 -1
- package/dist/space/space-access.d.ts +24 -0
- package/dist/space/space-access.d.ts.map +1 -1
- package/dist/space/space-access.js +24 -0
- package/dist/space/space-access.js.map +1 -1
- package/dist/space/space-manager.d.ts +35 -1
- package/dist/space/space-manager.d.ts.map +1 -1
- package/dist/space/space-manager.js +72 -25
- package/dist/space/space-manager.js.map +1 -1
- package/dist/storage/blob/memory.d.ts +9 -0
- package/dist/storage/blob/memory.d.ts.map +1 -0
- package/dist/storage/blob/memory.js +20 -0
- package/dist/storage/blob/memory.js.map +1 -0
- package/dist/storage/blob/s3.d.ts +16 -0
- package/dist/storage/blob/s3.d.ts.map +1 -0
- package/dist/storage/blob/s3.js +86 -0
- package/dist/storage/blob/s3.js.map +1 -0
- package/dist/storage/blob-store.d.ts +26 -0
- package/dist/storage/blob-store.d.ts.map +1 -0
- package/dist/storage/blob-store.js +2 -0
- package/dist/storage/blob-store.js.map +1 -0
- package/dist/storage/encrypted-adapter.d.ts +3 -3
- package/dist/storage/encrypted-adapter.js +3 -3
- package/dist/storage/folder-adapter.d.ts +8 -8
- package/dist/storage/folder-adapter.d.ts.map +1 -1
- package/dist/storage/folder-adapter.js +7 -7
- package/dist/storage/folder-reconcile.d.ts +12 -14
- package/dist/storage/folder-reconcile.d.ts.map +1 -1
- package/dist/storage/folder-reconcile.js +20 -14
- package/dist/storage/folder-reconcile.js.map +1 -1
- package/dist/storage/index.d.ts +0 -1
- package/dist/storage/index.d.ts.map +1 -1
- package/dist/storage/index.js +0 -1
- package/dist/storage/index.js.map +1 -1
- package/dist/storage/indexeddb-adapter.d.ts.map +1 -1
- package/dist/storage/indexeddb-adapter.js +8 -9
- package/dist/storage/indexeddb-adapter.js.map +1 -1
- package/dist/storage/mirror.d.ts +68 -0
- package/dist/storage/mirror.d.ts.map +1 -0
- package/dist/storage/mirror.js +174 -0
- package/dist/storage/mirror.js.map +1 -0
- package/dist/storage/segment.d.ts +20 -0
- package/dist/storage/segment.d.ts.map +1 -0
- package/dist/storage/segment.js +27 -0
- package/dist/storage/segment.js.map +1 -0
- package/dist/storage/storage-provider.d.ts +36 -40
- package/dist/storage/storage-provider.d.ts.map +1 -1
- package/dist/storage/storage-provider.js +208 -117
- package/dist/storage/storage-provider.js.map +1 -1
- package/dist/sync/index.d.ts +1 -1
- package/dist/sync/index.d.ts.map +1 -1
- package/dist/sync/index.js +1 -1
- package/dist/sync/index.js.map +1 -1
- package/dist/sync/negentropy.d.ts +68 -0
- package/dist/sync/negentropy.d.ts.map +1 -0
- package/dist/sync/negentropy.js +379 -0
- package/dist/sync/negentropy.js.map +1 -0
- package/dist/sync/sync-engine.d.ts +45 -13
- package/dist/sync/sync-engine.d.ts.map +1 -1
- package/dist/sync/sync-engine.js +290 -207
- package/dist/sync/sync-engine.js.map +1 -1
- package/dist/sync/sync-messages.d.ts +42 -32
- package/dist/sync/sync-messages.d.ts.map +1 -1
- package/dist/sync/sync-messages.js +1 -1
- package/dist/sync/sync-messages.js.map +1 -1
- package/dist/types.d.ts +9 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js.map +1 -1
- package/dist/utils/hash.d.ts +9 -0
- package/dist/utils/hash.d.ts.map +1 -1
- package/dist/utils/hash.js +33 -0
- package/dist/utils/hash.js.map +1 -1
- package/package.json +8 -3
- package/dist/storage/mst.d.ts +0 -121
- package/dist/storage/mst.d.ts.map +0 -1
- package/dist/storage/mst.js +0 -402
- package/dist/storage/mst.js.map +0 -1
- package/dist/sync/anti-entropy.d.ts +0 -49
- package/dist/sync/anti-entropy.d.ts.map +0 -1
- package/dist/sync/anti-entropy.js +0 -62
- package/dist/sync/anti-entropy.js.map +0 -1
package/dist/node/node.js
CHANGED
|
@@ -15,21 +15,31 @@
|
|
|
15
15
|
* for one signature an hour, never one per write.
|
|
16
16
|
*/
|
|
17
17
|
import { runQuery } from '../query/engine.js';
|
|
18
|
-
import { nameOf } from '../query/types.js';
|
|
18
|
+
import { nameOf, plainQuery } from '../query/types.js';
|
|
19
19
|
import { createP256Provider } from '../identity/crypto-p256.js';
|
|
20
20
|
import { publicKeyToDid, P256_MULTICODEC } from '../identity/did.js';
|
|
21
21
|
import { delegateCapabilities, parseUCAN, verifyUCAN } from '../identity/ucan.js';
|
|
22
22
|
import { isAgentNote } from '../identity/agent-note.js';
|
|
23
23
|
import { createSigner } from '../schema/signer.js';
|
|
24
24
|
import { createSchemaEngine } from '../schema/schema-engine.js';
|
|
25
|
-
import { createSpaceManager, parseSpaceInvite } from '../space/space-manager.js';
|
|
25
|
+
import { createSpaceManager, encodeSpaceInvite, parseSpaceInvite, } from '../space/space-manager.js';
|
|
26
|
+
import { checkRelays, MANAGE, MAX_KEEPERS, MAX_RELAYS, roleHolds } from '../space/roles.js';
|
|
26
27
|
import { meshFor, noteCid, openSpaceRuntime } from './space-runtime.js';
|
|
27
28
|
import { createServerAuth } from '../network/peer-auth.js';
|
|
28
29
|
import { DEFAULT_ICE_SERVERS } from '../network/rtc-transport.js';
|
|
29
30
|
import { deriveInviteKey } from '../space/space-access.js';
|
|
30
|
-
import { base64UrlDecode } from '../utils/encoding.js';
|
|
31
|
-
import {
|
|
31
|
+
import { base64UrlDecode, base64UrlEncode } from '../utils/encoding.js';
|
|
32
|
+
import { onePerKey } from '../records/rules.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';
|
|
37
|
+
import { team } from '../space/presets.js';
|
|
38
|
+
import { CARRIER_COLLECTION, deriveAccountRegistry, deriveContactsSpace, MEMBERSHIP_COLLECTION, PROFILE_COLLECTION, PROFILE_KEY, } from '../space/account-registry.js';
|
|
32
39
|
import { CARRY_CLOSED_KEY, makePass, PASS_COLLECTION, passKey } from '../space/pass.js';
|
|
40
|
+
import { carriedFor, checkNotify, NOTIFY_COLLECTION, SUBSCRIPTION_COLLECTION } from '../space/notify.js';
|
|
41
|
+
import { createHostClient, describeHost, HOSTING_COLLECTION, HostError, newSubscriptionSeed, payLink, readStatus, subscriptionKey, } from '../session/hosting.js';
|
|
42
|
+
import { base32Encode, sha256 } from '../utils/hash.js';
|
|
33
43
|
/** Everything a session key may do, across every space the node holds */
|
|
34
44
|
export const SESSION_CAPABILITY = { with: '*', can: 'expression/*' };
|
|
35
45
|
const DEFAULT_TTL_SECONDS = 3600;
|
|
@@ -49,6 +59,22 @@ function summarize(record) {
|
|
|
49
59
|
joining: record.invite !== null,
|
|
50
60
|
});
|
|
51
61
|
}
|
|
62
|
+
/** Every collection a query reads: its own, and each `include … from` */
|
|
63
|
+
function collectionsOf(query) {
|
|
64
|
+
const plain = plainQuery(query);
|
|
65
|
+
const found = new Set();
|
|
66
|
+
if (typeof plain.collection === 'string')
|
|
67
|
+
found.add(plain.collection);
|
|
68
|
+
const walk = (includes) => {
|
|
69
|
+
for (const include of Object.values(includes ?? {})) {
|
|
70
|
+
if (typeof include?.from === 'string')
|
|
71
|
+
found.add(include.from);
|
|
72
|
+
walk(include?.include);
|
|
73
|
+
}
|
|
74
|
+
};
|
|
75
|
+
walk(plain.include);
|
|
76
|
+
return [...found];
|
|
77
|
+
}
|
|
52
78
|
/** Accepts a bare invite or a whole share link carrying one (`…#invite=…`). */
|
|
53
79
|
function bareInvite(invite) {
|
|
54
80
|
return /[#&?]invite=([^&\s]+)/.exec(invite)?.[1] ?? invite.trim();
|
|
@@ -132,6 +158,28 @@ export async function createNode(config) {
|
|
|
132
158
|
// derives for itself. Hidden from `list`; everything else treats it as a space.
|
|
133
159
|
const account = config.accountKey ? await deriveAccountRegistry(config.accountKey, config.signer.did, provider) : null;
|
|
134
160
|
const accountSpaceId = account?.space.id ?? null;
|
|
161
|
+
// The account's contacts, in a space derived the same way — or, for an app
|
|
162
|
+
// given it by its account home, named in the config. Hidden from `list` too.
|
|
163
|
+
const contactsRecord = config.accountKey ? await deriveContactsSpace(config.accountKey, config.signer.did, provider) : null;
|
|
164
|
+
const contactsSpaceId = contactsRecord?.space.id ?? config.contactsSpace ?? null;
|
|
165
|
+
// Kept in the node's own list like a joined space, so it can be shared with an app like one.
|
|
166
|
+
if (contactsRecord && !(await registry.get(contactsRecord.space.id))) {
|
|
167
|
+
await registry.join(await encodeSpaceInvite(contactsRecord, config.signer.did));
|
|
168
|
+
}
|
|
169
|
+
/** Whether a space is the account's own machinery, not one it uses */
|
|
170
|
+
const hidden = (spaceId) => carrySpaces.has(spaceId) || spaceId === contactsSpaceId;
|
|
171
|
+
/** The contact key, for a node allowed to open contact requests */
|
|
172
|
+
const contactKeys = config.contactKey ? await contactKeyPair(config.contactKey) : null;
|
|
173
|
+
/**
|
|
174
|
+
* An invite to a space, naming where its members meet: the relays the space
|
|
175
|
+
* names, or before it names any, this node's — so the joiner finds the
|
|
176
|
+
* inviter even when their app uses other relays.
|
|
177
|
+
*/
|
|
178
|
+
async function inviteTo(spaceId, options = {}) {
|
|
179
|
+
const named = (await findRecord(spaceId))?.relays ?? [];
|
|
180
|
+
const own = (config.network?.relays ?? []).filter((url) => checkRelays([url]) === null).slice(0, MAX_RELAYS);
|
|
181
|
+
return registry.createInvite(spaceId, config.signer.did, { ...options, ...(named.length ? {} : own.length ? { relays: own } : {}) });
|
|
182
|
+
}
|
|
135
183
|
async function findRecord(spaceId) {
|
|
136
184
|
return spaceId === accountSpaceId ? account : registry.get(spaceId);
|
|
137
185
|
}
|
|
@@ -184,11 +232,11 @@ export async function createNode(config) {
|
|
|
184
232
|
* all; a space not open now hears on its next open.
|
|
185
233
|
*/
|
|
186
234
|
async function publishProfile(spaceId, open) {
|
|
187
|
-
if (spaceId === accountSpaceId || agentSession)
|
|
235
|
+
if (spaceId === accountSpaceId || spaceId === contactsSpaceId || agentSession)
|
|
188
236
|
return;
|
|
189
237
|
const name = await ownName();
|
|
190
238
|
if (name)
|
|
191
|
-
await open.publishProfile({ name });
|
|
239
|
+
await open.publishProfile({ name, ...(contactKeys ? { contactKey: contactKeys.publicKey } : {}) });
|
|
192
240
|
}
|
|
193
241
|
async function publishProfileToOpenSpaces() {
|
|
194
242
|
for (const [spaceId, open] of runtimes) {
|
|
@@ -200,7 +248,7 @@ export async function createNode(config) {
|
|
|
200
248
|
return Promise.reject(new Error('Node is closed'));
|
|
201
249
|
let open = runtimes.get(spaceId);
|
|
202
250
|
if (!open) {
|
|
203
|
-
open = requireRecord(spaceId).then((record) => openSpaceRuntime({
|
|
251
|
+
open = requireRecord(spaceId).then(async (record) => openSpaceRuntime({
|
|
204
252
|
record,
|
|
205
253
|
stores: config.stores,
|
|
206
254
|
provider,
|
|
@@ -210,7 +258,9 @@ export async function createNode(config) {
|
|
|
210
258
|
rootDid: config.signer.did,
|
|
211
259
|
...(config.network ? { network: config.network } : {}),
|
|
212
260
|
...(mesh ? { mesh } : {}),
|
|
213
|
-
peopleOnly: spaceId === accountSpaceId || carrySpaces.has(spaceId),
|
|
261
|
+
peopleOnly: spaceId === accountSpaceId || spaceId === contactsSpaceId || carrySpaces.has(spaceId),
|
|
262
|
+
// The account's own spaces are always held whole: every device needs all of them.
|
|
263
|
+
...(config.cache && spaceId !== accountSpaceId && spaceId !== contactsSpaceId && !carrySpaces.has(spaceId) ? { cache: config.cache } : {}),
|
|
214
264
|
watchIntervalMs: config.watchIntervalMs ?? 2000,
|
|
215
265
|
emit: fromRuntime,
|
|
216
266
|
onRole: (role) => {
|
|
@@ -228,12 +278,27 @@ export async function createNode(config) {
|
|
|
228
278
|
return;
|
|
229
279
|
void registry.clearInvite(spaceId).then(() => emit({ type: 'spaces' }));
|
|
230
280
|
},
|
|
281
|
+
memberKey: spaceId === accountSpaceId ? null : config.accountKey ? await deriveMemberKeyBytes(config.accountKey, spaceId) : (record.memberKey ?? null),
|
|
282
|
+
onRelays: async (relays) => {
|
|
283
|
+
if (spaceId !== accountSpaceId)
|
|
284
|
+
await registry.setRelays(spaceId, relays);
|
|
285
|
+
},
|
|
286
|
+
onKeys: async (keys, current) => {
|
|
287
|
+
if (spaceId === accountSpaceId)
|
|
288
|
+
return;
|
|
289
|
+
await registry.addKeys(spaceId, keys, current);
|
|
290
|
+
// The account's other devices, and its carriers, follow the new key.
|
|
291
|
+
await remember(spaceId, { refresh: true });
|
|
292
|
+
void reconcile();
|
|
293
|
+
},
|
|
231
294
|
}));
|
|
232
295
|
// A failed open must not be cached, or the space stays broken until restart.
|
|
233
296
|
open.catch(() => runtimes.delete(spaceId));
|
|
234
297
|
void open.then((rt) => publishProfile(spaceId, rt)).catch(() => { });
|
|
235
298
|
// A revoke that arrived on an earlier visit.
|
|
236
299
|
void open.then((rt) => checkRevoked(spaceId, rt)).catch(() => { });
|
|
300
|
+
// Carriers added since this space was last open.
|
|
301
|
+
void open.then((rt) => nameKeepers(spaceId, rt)).catch(() => { });
|
|
237
302
|
runtimes.set(spaceId, open);
|
|
238
303
|
}
|
|
239
304
|
return open;
|
|
@@ -286,6 +351,15 @@ export async function createNode(config) {
|
|
|
286
351
|
// deleted: it left — and rejoining is simply the next version. Every device
|
|
287
352
|
// converges on the same answer, because it is the same record.
|
|
288
353
|
const membershipKey = (spaceId) => `space:${spaceId}`;
|
|
354
|
+
/** The space key an invite carries, if it can be read */
|
|
355
|
+
const inviteKeyOf = (invite) => {
|
|
356
|
+
try {
|
|
357
|
+
return typeof invite === 'string' ? parseSpaceInvite(invite).key : undefined;
|
|
358
|
+
}
|
|
359
|
+
catch {
|
|
360
|
+
return undefined;
|
|
361
|
+
}
|
|
362
|
+
};
|
|
289
363
|
async function memberships() {
|
|
290
364
|
if (!accountSpaceId)
|
|
291
365
|
return [];
|
|
@@ -295,15 +369,21 @@ export async function createNode(config) {
|
|
|
295
369
|
record.root === config.signer.did &&
|
|
296
370
|
(record.deleted || (typeof record.body?.space === 'string' && record.key === membershipKey(record.body.space))));
|
|
297
371
|
}
|
|
298
|
-
|
|
372
|
+
/**
|
|
373
|
+
* Records in the registry that the account belongs to a space. With
|
|
374
|
+
* `refresh`, rewrites the record if the space's key changed since, so a
|
|
375
|
+
* new device joins with the key in use now.
|
|
376
|
+
*/
|
|
377
|
+
async function remember(spaceId, options = {}) {
|
|
299
378
|
if (!accountSpaceId || agentSession)
|
|
300
379
|
return;
|
|
301
380
|
const open = await runtime(accountSpaceId);
|
|
302
|
-
|
|
303
|
-
return;
|
|
381
|
+
const known = await open.get(membershipKey(spaceId));
|
|
304
382
|
// A view-only invite: what lets the account write is its role in the
|
|
305
383
|
// space, which every device reads there. Invite secrets are never kept.
|
|
306
|
-
const invite = await
|
|
384
|
+
const invite = await inviteTo(spaceId);
|
|
385
|
+
if (known && (!options.refresh || known.deleted || inviteKeyOf(known.body?.invite) === inviteKeyOf(invite)))
|
|
386
|
+
return;
|
|
307
387
|
await open.upsertSystem(MEMBERSHIP_COLLECTION, membershipKey(spaceId), { space: spaceId, invite });
|
|
308
388
|
}
|
|
309
389
|
async function forget(spaceId) {
|
|
@@ -350,13 +430,15 @@ export async function createNode(config) {
|
|
|
350
430
|
if (!account || agentSession)
|
|
351
431
|
return;
|
|
352
432
|
const wanted = new Map();
|
|
353
|
-
// The registry too, so a restore can come through the carrier.
|
|
433
|
+
// The registry and the contacts too, so a restore can come through the carrier.
|
|
354
434
|
wanted.set(await passKey(account.space.id), await makePass(account));
|
|
435
|
+
if (contactsRecord)
|
|
436
|
+
wanted.set(await passKey(contactsRecord.space.id), await makePass(contactsRecord));
|
|
355
437
|
for (const membership of await memberships()) {
|
|
356
438
|
if (membership.deleted || !membership.body)
|
|
357
439
|
continue;
|
|
358
440
|
const spaceId = membership.body.space;
|
|
359
|
-
if (
|
|
441
|
+
if (hidden(spaceId))
|
|
360
442
|
continue;
|
|
361
443
|
const record = await registry.get(spaceId);
|
|
362
444
|
if (!record)
|
|
@@ -382,6 +464,80 @@ export async function createNode(config) {
|
|
|
382
464
|
for (const [key, record] of held)
|
|
383
465
|
if (!record.deleted)
|
|
384
466
|
await carry.removeSystem(key);
|
|
467
|
+
// Subscriptions, with each value replaced by the space's tag: the carrier matches, never learns.
|
|
468
|
+
const spaces = [];
|
|
469
|
+
for (const membership of await memberships()) {
|
|
470
|
+
if (membership.deleted || !membership.body || hidden(membership.body.space))
|
|
471
|
+
continue;
|
|
472
|
+
const record = await registry.get(membership.body.space);
|
|
473
|
+
if (record)
|
|
474
|
+
spaces.push({ id: record.space.id, key: record.key, visibility: record.space.visibility });
|
|
475
|
+
}
|
|
476
|
+
const subscriptions = new Map();
|
|
477
|
+
for (const view of await notifyRecords())
|
|
478
|
+
subscriptions.set(view.id, await carriedFor(view, spaces));
|
|
479
|
+
const carried = new Map((await carry.list({ collection: SUBSCRIPTION_COLLECTION, includeDeleted: true }))
|
|
480
|
+
.filter((record) => record.verified && record.root === config.signer.did)
|
|
481
|
+
.map((record) => [record.key, record]));
|
|
482
|
+
for (const [key, body] of subscriptions) {
|
|
483
|
+
const current = carried.get(key);
|
|
484
|
+
if (!current || current.deleted || JSON.stringify(current.body) !== JSON.stringify(body))
|
|
485
|
+
await carry.upsertSystem(SUBSCRIPTION_COLLECTION, key, body);
|
|
486
|
+
carried.delete(key);
|
|
487
|
+
}
|
|
488
|
+
for (const [key, record] of carried)
|
|
489
|
+
if (!record.deleted)
|
|
490
|
+
await carry.removeSystem(key);
|
|
491
|
+
}
|
|
492
|
+
/** The account's subscriptions, as it made them */
|
|
493
|
+
async function notifyRecords() {
|
|
494
|
+
if (!accountSpaceId)
|
|
495
|
+
return [];
|
|
496
|
+
const found = [];
|
|
497
|
+
for (const record of await (await runtime(accountSpaceId)).list({ collection: NOTIFY_COLLECTION })) {
|
|
498
|
+
if (!record.verified || record.root !== config.signer.did || !record.key.startsWith('notify:') || checkNotify(record.body) !== null)
|
|
499
|
+
continue;
|
|
500
|
+
found.push({ ...record.body, id: record.key });
|
|
501
|
+
}
|
|
502
|
+
return found.sort((a, b) => a.since.localeCompare(b.since));
|
|
503
|
+
}
|
|
504
|
+
/** Every carrier gets the subscriptions as they are now */
|
|
505
|
+
async function passSubscriptionsOn() {
|
|
506
|
+
for (const { record } of await carrierRecords()) {
|
|
507
|
+
if (!record.deleted && record.body)
|
|
508
|
+
await syncPasses(record.body).catch(() => { });
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
/**
|
|
512
|
+
* Names the account's carriers — its extensions, its hosts — as keepers of
|
|
513
|
+
* a space it manages, and stops naming ones it removed, so apps there hold
|
|
514
|
+
* only what they use. Other keepers the space names stay. Done as each space
|
|
515
|
+
* opens and whenever the carriers change; a space this account doesn't
|
|
516
|
+
* manage is left to whoever does.
|
|
517
|
+
*/
|
|
518
|
+
async function nameKeepers(spaceId, rt) {
|
|
519
|
+
if (!account || agentSession || spaceId === accountSpaceId || hidden(spaceId))
|
|
520
|
+
return;
|
|
521
|
+
const access = await rt.access();
|
|
522
|
+
if (!roleHolds(access.role, MANAGE))
|
|
523
|
+
return;
|
|
524
|
+
const carriers = await carrierRecords();
|
|
525
|
+
const active = carriers.filter(({ record }) => !record.deleted && record.body).map(({ record }) => record.body);
|
|
526
|
+
const gone = new Set();
|
|
527
|
+
for (const { record } of carriers) {
|
|
528
|
+
if (!record.deleted)
|
|
529
|
+
continue;
|
|
530
|
+
for (const version of await (await runtime(accountSpaceId)).history(record.key))
|
|
531
|
+
if (version.body?.did)
|
|
532
|
+
gone.add(version.body.did);
|
|
533
|
+
}
|
|
534
|
+
for (const carrier of active)
|
|
535
|
+
gone.delete(carrier.did);
|
|
536
|
+
const kept = access.keepers.filter((keeper) => !gone.has(keeper.did));
|
|
537
|
+
const added = active.filter((carrier) => !kept.some((keeper) => keeper.did === carrier.did)).map((c) => ({ did: c.did, name: c.name.slice(0, 80) }));
|
|
538
|
+
if (added.length === 0 && kept.length === access.keepers.length)
|
|
539
|
+
return;
|
|
540
|
+
await rt.setKeepers([...kept, ...added].slice(0, MAX_KEEPERS), access.copies);
|
|
385
541
|
}
|
|
386
542
|
/**
|
|
387
543
|
* Makes this node's spaces match the account's: join what the account
|
|
@@ -419,6 +575,9 @@ export async function createNode(config) {
|
|
|
419
575
|
}
|
|
420
576
|
for (const spaceId of carrySpaces)
|
|
421
577
|
known.add(spaceId);
|
|
578
|
+
// Derived on every device: nothing to record.
|
|
579
|
+
if (contactsSpaceId)
|
|
580
|
+
known.add(contactsSpaceId);
|
|
422
581
|
for (const membership of await memberships()) {
|
|
423
582
|
const spaceId = membership.key.slice('space:'.length);
|
|
424
583
|
known.add(spaceId);
|
|
@@ -450,6 +609,11 @@ export async function createNode(config) {
|
|
|
450
609
|
// Only a device that may write in the carry space can do this; others leave it to one that can.
|
|
451
610
|
await syncPasses(record.body).catch(() => { });
|
|
452
611
|
}
|
|
612
|
+
// Carriers came or went: the open spaces this account manages say so.
|
|
613
|
+
for (const [spaceId, open] of runtimes)
|
|
614
|
+
void open.then((rt) => nameKeepers(spaceId, rt)).catch(() => { });
|
|
615
|
+
// Not awaited: a host that is slow to answer must not hold up the rest.
|
|
616
|
+
void keepHosted().catch(() => { });
|
|
453
617
|
if (changed)
|
|
454
618
|
emit({ type: 'spaces' });
|
|
455
619
|
}
|
|
@@ -463,7 +627,7 @@ export async function createNode(config) {
|
|
|
463
627
|
}
|
|
464
628
|
const spaces = Object.freeze({
|
|
465
629
|
async list() {
|
|
466
|
-
return (await registry.list()).filter((record) => !
|
|
630
|
+
return (await registry.list()).filter((record) => !hidden(record.space.id)).map(summarize);
|
|
467
631
|
},
|
|
468
632
|
async get(spaceId) {
|
|
469
633
|
const record = await findRecord(spaceId);
|
|
@@ -483,7 +647,7 @@ export async function createNode(config) {
|
|
|
483
647
|
},
|
|
484
648
|
async invite(spaceId, options = {}) {
|
|
485
649
|
if (options.write === false)
|
|
486
|
-
return
|
|
650
|
+
return inviteTo(spaceId);
|
|
487
651
|
const open = await runtime(spaceId);
|
|
488
652
|
const { roles, role: mine } = await open.access();
|
|
489
653
|
// By default the lowest role below your own; with none below you, a view-only invite.
|
|
@@ -491,10 +655,10 @@ export async function createNode(config) {
|
|
|
491
655
|
if (!role) {
|
|
492
656
|
if (options.role !== undefined || !mine)
|
|
493
657
|
throw new Error(mine ? `There is no role "${options.role}"` : 'You hold no role here, so you can only share it to view');
|
|
494
|
-
return
|
|
658
|
+
return inviteTo(spaceId);
|
|
495
659
|
}
|
|
496
660
|
const { secret } = await open.openInvite(role);
|
|
497
|
-
return
|
|
661
|
+
return inviteTo(spaceId, { secret, role });
|
|
498
662
|
},
|
|
499
663
|
preview(invite) {
|
|
500
664
|
const parsed = parseSpaceInvite(bareInvite(invite));
|
|
@@ -507,8 +671,10 @@ export async function createNode(config) {
|
|
|
507
671
|
role: typeof parsed.invite === 'string' ? (parsed.role ?? null) : null,
|
|
508
672
|
};
|
|
509
673
|
},
|
|
510
|
-
async join(invite) {
|
|
674
|
+
async join(invite, options = {}) {
|
|
511
675
|
const record = await registry.join(bareInvite(invite));
|
|
676
|
+
if (options.memberKey)
|
|
677
|
+
await registry.setMemberKey(record.space.id, options.memberKey);
|
|
512
678
|
// A runtime opened before the key arrived would still be unable to read.
|
|
513
679
|
await closeRuntime(record.space.id);
|
|
514
680
|
await remember(record.space.id);
|
|
@@ -520,6 +686,8 @@ export async function createNode(config) {
|
|
|
520
686
|
async leave(spaceId) {
|
|
521
687
|
if (spaceId === accountSpaceId)
|
|
522
688
|
throw new Error('The account registry cannot be left');
|
|
689
|
+
if (spaceId === contactsSpaceId)
|
|
690
|
+
throw new Error('The contacts space cannot be left');
|
|
523
691
|
await forget(spaceId);
|
|
524
692
|
await closeRuntime(spaceId);
|
|
525
693
|
await registry.remove(spaceId);
|
|
@@ -587,7 +755,7 @@ export async function createNode(config) {
|
|
|
587
755
|
if (!keyOrLink.startsWith('did:key:')) {
|
|
588
756
|
const secret = parseSpaceInvite(bareInvite(keyOrLink)).invite;
|
|
589
757
|
if (!secret)
|
|
590
|
-
throw new Error('That invite is view-only — there is nothing to close. To stop
|
|
758
|
+
throw new Error('That invite is view-only — there is nothing to close. To stop it working, give the space a new key (changeKey).');
|
|
591
759
|
key = (await deriveInviteKey(base64UrlDecode(secret), provider)).did;
|
|
592
760
|
}
|
|
593
761
|
await (await runtime(spaceId)).closeInvite(key);
|
|
@@ -595,15 +763,24 @@ export async function createNode(config) {
|
|
|
595
763
|
async revoke(spaceId, token) {
|
|
596
764
|
await (await runtime(spaceId)).revoke(token);
|
|
597
765
|
},
|
|
766
|
+
async changeKey(spaceId) {
|
|
767
|
+
await (await runtime(spaceId)).rotateKey();
|
|
768
|
+
},
|
|
769
|
+
async setRelays(spaceId, relays) {
|
|
770
|
+
await (await runtime(spaceId)).setRelays(relays);
|
|
771
|
+
},
|
|
772
|
+
async setKeepers(spaceId, keepers, copies) {
|
|
773
|
+
await (await runtime(spaceId)).setKeepers(keepers, copies ?? null);
|
|
774
|
+
},
|
|
598
775
|
async authenticator(spaceId) {
|
|
599
776
|
const record = await findRecord(spaceId);
|
|
600
777
|
if (!record)
|
|
601
778
|
return null;
|
|
602
779
|
// Every peer proves its own DID; in a private space, readers are also
|
|
603
|
-
// checked against the space's
|
|
604
|
-
// the key this node introduces itself with.
|
|
605
|
-
const
|
|
606
|
-
return createServerAuth(spaceId,
|
|
780
|
+
// checked against the read key the space's history names now. The
|
|
781
|
+
// welcome is signed by the key this node introduces itself with.
|
|
782
|
+
const read = record.space.visibility === 'private' ? (await runtime(spaceId)).readAccess() : null;
|
|
783
|
+
return createServerAuth(spaceId, read, sessionKeys.privateKey, provider);
|
|
607
784
|
},
|
|
608
785
|
});
|
|
609
786
|
// With an account key, start following the registry at once: it is how this
|
|
@@ -612,6 +789,48 @@ export async function createNode(config) {
|
|
|
612
789
|
await runtime(accountSpaceId);
|
|
613
790
|
await reconcile();
|
|
614
791
|
}
|
|
792
|
+
const notifications = Object.freeze({
|
|
793
|
+
list: notifyRecords,
|
|
794
|
+
async add(when) {
|
|
795
|
+
if (!accountSpaceId)
|
|
796
|
+
throw new Error('Subscriptions need the account key');
|
|
797
|
+
const body = { ...when, label: when.label.trim(), since: when.since ?? new Date().toISOString() };
|
|
798
|
+
const problem = checkNotify(body);
|
|
799
|
+
if (problem)
|
|
800
|
+
throw new Error(problem);
|
|
801
|
+
const id = `notify:${base32Encode(globalThis.crypto.getRandomValues(new Uint8Array(10)))}`;
|
|
802
|
+
await (await runtime(accountSpaceId)).upsertSystem(NOTIFY_COLLECTION, id, body);
|
|
803
|
+
await passSubscriptionsOn();
|
|
804
|
+
return { ...body, id };
|
|
805
|
+
},
|
|
806
|
+
async update(id, changes) {
|
|
807
|
+
if (!accountSpaceId)
|
|
808
|
+
throw new Error('Subscriptions need the account key');
|
|
809
|
+
const current = (await notifyRecords()).find((view) => view.id === id);
|
|
810
|
+
if (!current)
|
|
811
|
+
throw new Error('There is no such subscription');
|
|
812
|
+
const { id: _id, ...was } = current;
|
|
813
|
+
const body = {
|
|
814
|
+
...was,
|
|
815
|
+
...(changes.label !== undefined ? { label: changes.label.trim() } : {}),
|
|
816
|
+
...(changes.paused !== undefined ? { paused: changes.paused } : {}),
|
|
817
|
+
};
|
|
818
|
+
const problem = checkNotify(body);
|
|
819
|
+
if (problem)
|
|
820
|
+
throw new Error(problem);
|
|
821
|
+
await (await runtime(accountSpaceId)).upsertSystem(NOTIFY_COLLECTION, id, body);
|
|
822
|
+
await passSubscriptionsOn();
|
|
823
|
+
return { ...body, id };
|
|
824
|
+
},
|
|
825
|
+
async remove(id) {
|
|
826
|
+
if (!accountSpaceId)
|
|
827
|
+
throw new Error('Subscriptions need the account key');
|
|
828
|
+
if (!(await notifyRecords()).some((view) => view.id === id))
|
|
829
|
+
return;
|
|
830
|
+
await (await runtime(accountSpaceId)).removeSystem(id);
|
|
831
|
+
await passSubscriptionsOn();
|
|
832
|
+
},
|
|
833
|
+
});
|
|
615
834
|
const carriers = Object.freeze({
|
|
616
835
|
async list() {
|
|
617
836
|
return (await carrierRecords())
|
|
@@ -647,6 +866,672 @@ export async function createNode(config) {
|
|
|
647
866
|
await (await runtime(accountSpaceId)).removeSystem(found.key);
|
|
648
867
|
},
|
|
649
868
|
});
|
|
869
|
+
// ─── Hosting ───────────────────────────────────────────────────────
|
|
870
|
+
//
|
|
871
|
+
// A host is a carrier the account pays for. The subscription key lives in
|
|
872
|
+
// the registry (`sys.hosting`), so every device signs as the same
|
|
873
|
+
// subscription, and any of them hands the host the carry space once it is
|
|
874
|
+
// paid — the one that paid, or the next one to notice.
|
|
875
|
+
const hostingKey = async (url) => `hosting:${Array.from((await sha256(new TextEncoder().encode(url))).subarray(0, 20), (b) => b.toString(16).padStart(2, '0')).join('')}`;
|
|
876
|
+
/** The hosting records the account wrote, live */
|
|
877
|
+
async function hostingRecords() {
|
|
878
|
+
if (!accountSpaceId)
|
|
879
|
+
return [];
|
|
880
|
+
const records = await (await runtime(accountSpaceId)).list({ collection: HOSTING_COLLECTION });
|
|
881
|
+
return records
|
|
882
|
+
.filter((record) => record.verified && record.root === config.signer.did && typeof record.body?.url === 'string' && typeof record.body.seed === 'string')
|
|
883
|
+
.map((record) => record.body);
|
|
884
|
+
}
|
|
885
|
+
async function hostClient(hosting) {
|
|
886
|
+
const key = await subscriptionKey(base64UrlDecode(hosting.seed), provider);
|
|
887
|
+
return { client: createHostClient(hosting.url, hosting.host, key, provider), subscription: key.did };
|
|
888
|
+
}
|
|
889
|
+
/** What the host at a known address says about itself — refused when it's another host now */
|
|
890
|
+
async function describeKnown(hosting) {
|
|
891
|
+
const description = await describeHost(hosting.url);
|
|
892
|
+
if (description.did !== hosting.host)
|
|
893
|
+
throw new Error("The host at this address has another key now, so it's another host");
|
|
894
|
+
return description;
|
|
895
|
+
}
|
|
896
|
+
/** The carry space shared with a host: the one the account made for it, or a new one */
|
|
897
|
+
async function carryFor(hosting) {
|
|
898
|
+
const known = (await carrierRecords()).find(({ record }) => !record.deleted && record.body?.did === hosting.host)?.record.body;
|
|
899
|
+
return known ? known.invite : (await carriers.add({ did: hosting.host, name: new URL(hosting.url).host })).invite;
|
|
900
|
+
}
|
|
901
|
+
/** Last time each host was handed the spaces, or refused them — so asking again waits a while */
|
|
902
|
+
const handedAt = new Map();
|
|
903
|
+
const HAND_AGAIN_MS = 60_000;
|
|
904
|
+
/** Handovers under way, so a second look waits for the first instead of reporting the spaces not carried */
|
|
905
|
+
const handing = new Map();
|
|
906
|
+
/**
|
|
907
|
+
* Keeps what the host signed in the registry, so every device shows it and
|
|
908
|
+
* the person holds the host's word — written only when what it says changed.
|
|
909
|
+
*/
|
|
910
|
+
async function keepReceipt(hosting, kept, status, receipt, name) {
|
|
911
|
+
if (agentSession || !accountSpaceId)
|
|
912
|
+
return;
|
|
913
|
+
const same = kept && kept.state === status.state && kept.paidUntil === status.paidUntil && kept.renews === status.renews && hosting.name === name;
|
|
914
|
+
if (same)
|
|
915
|
+
return;
|
|
916
|
+
const record = { ...hosting, name, receipt };
|
|
917
|
+
await (await runtime(accountSpaceId)).upsertSystem(HOSTING_COLLECTION, await hostingKey(hosting.url), record);
|
|
918
|
+
}
|
|
919
|
+
/** Asks a host how it stands, and hands it the spaces if it is paid for but not carrying them */
|
|
920
|
+
async function viewHosting(hosting, force = false) {
|
|
921
|
+
const { client, subscription } = await hostClient(hosting);
|
|
922
|
+
const kept = hosting.receipt ? await readStatus(hosting.receipt, hosting.host, provider) : null;
|
|
923
|
+
const base = { url: hosting.url, host: hosting.host, subscription, since: hosting.since };
|
|
924
|
+
try {
|
|
925
|
+
const description = await describeKnown(hosting);
|
|
926
|
+
let { status, receipt } = await client.status();
|
|
927
|
+
const due = force || Date.now() - (handedAt.get(hosting.url) ?? 0) > HAND_AGAIN_MS;
|
|
928
|
+
const paid = status.state === 'active' || status.state === 'grace' || (description.free && status.state !== 'lapsed');
|
|
929
|
+
const inFlight = handing.get(hosting.url);
|
|
930
|
+
if (!status.carrying && inFlight) {
|
|
931
|
+
// Another look is handing the spaces over right now: wait for it,
|
|
932
|
+
// rather than answer "not carrying" while they're on their way.
|
|
933
|
+
const handed = await inFlight.catch(() => null);
|
|
934
|
+
if (handed)
|
|
935
|
+
({ status, receipt } = handed);
|
|
936
|
+
}
|
|
937
|
+
else if (!status.carrying && paid && due && !agentSession) {
|
|
938
|
+
handedAt.set(hosting.url, Date.now());
|
|
939
|
+
const attaching = carryFor(hosting).then((invite) => client.attach(config.signer.did, invite));
|
|
940
|
+
handing.set(hosting.url, attaching);
|
|
941
|
+
try {
|
|
942
|
+
({ status, receipt } = await attaching);
|
|
943
|
+
}
|
|
944
|
+
catch (error) {
|
|
945
|
+
// Not paid after all (it lapsed in between): the status says so.
|
|
946
|
+
if (!(error instanceof HostError && error.status === 402))
|
|
947
|
+
throw error;
|
|
948
|
+
}
|
|
949
|
+
finally {
|
|
950
|
+
handing.delete(hosting.url);
|
|
951
|
+
}
|
|
952
|
+
}
|
|
953
|
+
await keepReceipt(hosting, kept, status, receipt, description.name);
|
|
954
|
+
return {
|
|
955
|
+
...base,
|
|
956
|
+
name: description.name,
|
|
957
|
+
status,
|
|
958
|
+
live: true,
|
|
959
|
+
pays: description.pay !== undefined,
|
|
960
|
+
...(description.price ? { price: description.price } : {}),
|
|
961
|
+
};
|
|
962
|
+
}
|
|
963
|
+
catch (error) {
|
|
964
|
+
return { ...base, name: hosting.name ?? new URL(hosting.url).host, status: kept, live: false, pays: false, error: error instanceof Error ? error.message : String(error) };
|
|
965
|
+
}
|
|
966
|
+
}
|
|
967
|
+
/** Every device keeps its hosts carrying: after the registry changes, ask each once more */
|
|
968
|
+
async function keepHosted() {
|
|
969
|
+
if (agentSession)
|
|
970
|
+
return;
|
|
971
|
+
for (const hosting of await hostingRecords())
|
|
972
|
+
await viewHosting(hosting).catch(() => { });
|
|
973
|
+
}
|
|
974
|
+
async function requireHosting(url) {
|
|
975
|
+
const found = (await hostingRecords()).find((hosting) => hosting.url === url);
|
|
976
|
+
if (!found)
|
|
977
|
+
throw new Error(`This account doesn't use the host at ${url}`);
|
|
978
|
+
return found;
|
|
979
|
+
}
|
|
980
|
+
/** An address a device may reach: https://, or http:// on this machine */
|
|
981
|
+
function checkAddress(address, what) {
|
|
982
|
+
const url = new URL(address);
|
|
983
|
+
const local = ['localhost', '127.0.0.1'].includes(url.hostname);
|
|
984
|
+
if (url.protocol !== 'https:' && !(url.protocol === 'http:' && local))
|
|
985
|
+
throw new Error(`${what} is reached over https://`);
|
|
986
|
+
return url;
|
|
987
|
+
}
|
|
988
|
+
const hosting = Object.freeze({
|
|
989
|
+
async list() {
|
|
990
|
+
return Promise.all((await hostingRecords()).map((known) => viewHosting(known)));
|
|
991
|
+
},
|
|
992
|
+
async use(address) {
|
|
993
|
+
if (!accountSpaceId)
|
|
994
|
+
throw new Error('Using a host needs the account key');
|
|
995
|
+
const base = checkAddress(address, 'A host').origin;
|
|
996
|
+
const known = (await hostingRecords()).find((existing) => existing.url === base);
|
|
997
|
+
if (known)
|
|
998
|
+
return viewHosting(known, true);
|
|
999
|
+
const description = await describeHost(base);
|
|
1000
|
+
const record = { url: base, host: description.did, name: description.name, seed: base64UrlEncode(newSubscriptionSeed()), since: new Date().toISOString() };
|
|
1001
|
+
await (await runtime(accountSpaceId)).upsertSystem(HOSTING_COLLECTION, await hostingKey(base), record);
|
|
1002
|
+
return viewHosting(record, true);
|
|
1003
|
+
},
|
|
1004
|
+
async payPage(url) {
|
|
1005
|
+
const known = await requireHosting(url);
|
|
1006
|
+
const description = await describeKnown(known);
|
|
1007
|
+
if (description.pay === undefined)
|
|
1008
|
+
throw new Error('This host takes no payments');
|
|
1009
|
+
const page = checkAddress(new URL(description.pay, `${known.url}/`).toString(), 'A pay page');
|
|
1010
|
+
return payLink(page.toString(), known.host, await subscriptionKey(base64UrlDecode(known.seed), provider), provider);
|
|
1011
|
+
},
|
|
1012
|
+
async stop(url) {
|
|
1013
|
+
if (!accountSpaceId)
|
|
1014
|
+
throw new Error('Stopping a host needs the account key');
|
|
1015
|
+
const known = await requireHosting(url);
|
|
1016
|
+
const { client } = await hostClient(known);
|
|
1017
|
+
await client.detach().catch(() => { });
|
|
1018
|
+
const carrier = (await carrierRecords()).find(({ record }) => !record.deleted && record.body?.did === known.host);
|
|
1019
|
+
if (carrier)
|
|
1020
|
+
await carriers.remove(carrier.space);
|
|
1021
|
+
await (await runtime(accountSpaceId)).removeSystem(await hostingKey(known.url));
|
|
1022
|
+
},
|
|
1023
|
+
});
|
|
1024
|
+
// ─── Contacts ──────────────────────────────────────────────────────
|
|
1025
|
+
//
|
|
1026
|
+
// One `std.contact` per person in the contacts space, keyed by their DID, so
|
|
1027
|
+
// there is one per person on every device. Asking someone is a
|
|
1028
|
+
// `std.contact-request` in a space you share: the invite to a new space for
|
|
1029
|
+
// two, sealed with their contact key, bound to the space it was posted in and
|
|
1030
|
+
// to who asked whom — so it opens only for them, only there, and only as
|
|
1031
|
+
// coming from the account that wrote it.
|
|
1032
|
+
// The key `onePer: ['did']` derives, so the record for someone is the same one on every device.
|
|
1033
|
+
const contactRecordKey = async (did) => (await onePerKey(contactSchema.name, contactSchema.rules.onePer, { root: config.signer.did, links: [], body: { did } }));
|
|
1034
|
+
const requestContext = (spaceId, from, to) => `weave/contact-request|${spaceId}|${from}|${to}`;
|
|
1035
|
+
async function contactsRuntime() {
|
|
1036
|
+
if (!contactsSpaceId)
|
|
1037
|
+
throw new Error('This app was not given your contacts. Connect to your account home again, and allow contacts.');
|
|
1038
|
+
return runtime(contactsSpaceId);
|
|
1039
|
+
}
|
|
1040
|
+
/** Making and joining spaces for two needs a note good for every space — whole-account access. */
|
|
1041
|
+
function requireEverywhere(what) {
|
|
1042
|
+
if (!current.payload.att.some((capability) => capability.with === '*')) {
|
|
1043
|
+
throw new Error(`${what} makes or joins a space, which needs access to your whole account. Connect to your account home again, and ask for it.`);
|
|
1044
|
+
}
|
|
1045
|
+
}
|
|
1046
|
+
/** Defines a collection in a space that has none by that name yet */
|
|
1047
|
+
async function ensureDefined(open, definition) {
|
|
1048
|
+
if ((await open.collections()).some((collection) => collection.name === definition.name && collection.version !== null))
|
|
1049
|
+
return;
|
|
1050
|
+
try {
|
|
1051
|
+
await open.define(definition);
|
|
1052
|
+
}
|
|
1053
|
+
catch {
|
|
1054
|
+
throw new Error(`This space has no ${definition.title ?? definition.name} collection yet, and you can't add one here. Ask someone who manages it.`);
|
|
1055
|
+
}
|
|
1056
|
+
}
|
|
1057
|
+
async function contactRecords() {
|
|
1058
|
+
if (!contactsSpaceId)
|
|
1059
|
+
return [];
|
|
1060
|
+
const found = [];
|
|
1061
|
+
for (const record of await (await contactsRuntime()).list({ collection: contactSchema.name })) {
|
|
1062
|
+
// Only the account writes its own list; one record per person, under the key their DID gives.
|
|
1063
|
+
if (!record.verified || record.root !== config.signer.did || typeof record.body?.did !== 'string' || typeof record.body.name !== 'string')
|
|
1064
|
+
continue;
|
|
1065
|
+
if (record.key !== (await contactRecordKey(record.body.did)))
|
|
1066
|
+
continue;
|
|
1067
|
+
found.push(record);
|
|
1068
|
+
}
|
|
1069
|
+
return found;
|
|
1070
|
+
}
|
|
1071
|
+
function contactView(record) {
|
|
1072
|
+
const body = record.body;
|
|
1073
|
+
return Object.freeze({
|
|
1074
|
+
did: body.did,
|
|
1075
|
+
name: body.name,
|
|
1076
|
+
space: typeof body.space === 'string' ? body.space : null,
|
|
1077
|
+
...(typeof body.note === 'string' ? { note: body.note } : {}),
|
|
1078
|
+
blocked: body.blocked === true,
|
|
1079
|
+
updatedAt: record.updatedAt,
|
|
1080
|
+
});
|
|
1081
|
+
}
|
|
1082
|
+
async function writeContact(body) {
|
|
1083
|
+
const open = await contactsRuntime();
|
|
1084
|
+
await ensureDefined(open, contactSchema);
|
|
1085
|
+
// One per person: writing again is the next version of their record.
|
|
1086
|
+
return contactView(await open.put(contactSchema.name, body));
|
|
1087
|
+
}
|
|
1088
|
+
/** Leaves a space for two, unless another contact still names it */
|
|
1089
|
+
async function leavePairSpace(spaceId, did) {
|
|
1090
|
+
if (!spaceId || !(await registry.get(spaceId)))
|
|
1091
|
+
return;
|
|
1092
|
+
if ((await contactRecords()).some((record) => record.body.did !== did && record.body.space === spaceId))
|
|
1093
|
+
return;
|
|
1094
|
+
await spaces.leave(spaceId);
|
|
1095
|
+
}
|
|
1096
|
+
/** A contact request, opened — or null when it isn't one for this account, from the account that wrote it */
|
|
1097
|
+
async function openRequest(spaceId, record) {
|
|
1098
|
+
if (!contactKeys || !record.verified || record.viaAgent || record.collection !== contactRequestSchema.name)
|
|
1099
|
+
return null;
|
|
1100
|
+
if (record.body?.to !== config.signer.did || typeof record.body.sealed !== 'string')
|
|
1101
|
+
return null;
|
|
1102
|
+
const from = record.root;
|
|
1103
|
+
if (!from || record.createdBy !== from || from === config.signer.did)
|
|
1104
|
+
return null;
|
|
1105
|
+
const value = (await openSealed(contactKeys.privateKey, record.body.sealed, requestContext(spaceId, from, config.signer.did)));
|
|
1106
|
+
if (typeof value?.invite !== 'string')
|
|
1107
|
+
return null;
|
|
1108
|
+
let invited;
|
|
1109
|
+
try {
|
|
1110
|
+
invited = parseSpaceInvite(value.invite);
|
|
1111
|
+
}
|
|
1112
|
+
catch {
|
|
1113
|
+
return null;
|
|
1114
|
+
}
|
|
1115
|
+
// A private space the asker made, and the key to it: anything else isn't a space for two from them.
|
|
1116
|
+
if (invited.space.creator !== from || invited.space.visibility !== 'private' || !invited.key)
|
|
1117
|
+
return null;
|
|
1118
|
+
return {
|
|
1119
|
+
from,
|
|
1120
|
+
invite: value.invite,
|
|
1121
|
+
...(typeof value.note === 'string' && value.note ? { note: value.note.slice(0, 2000) } : {}),
|
|
1122
|
+
pairSpace: invited.space.id,
|
|
1123
|
+
};
|
|
1124
|
+
}
|
|
1125
|
+
const contacts = Object.freeze({
|
|
1126
|
+
async space() {
|
|
1127
|
+
return contactsSpaceId;
|
|
1128
|
+
},
|
|
1129
|
+
async list() {
|
|
1130
|
+
return (await contactRecords()).map(contactView).sort((a, b) => a.name.localeCompare(b.name) || a.did.localeCompare(b.did));
|
|
1131
|
+
},
|
|
1132
|
+
async get(did) {
|
|
1133
|
+
const record = (await contactRecords()).find((found) => found.body.did === did);
|
|
1134
|
+
return record ? contactView(record) : null;
|
|
1135
|
+
},
|
|
1136
|
+
async put(contact) {
|
|
1137
|
+
if (!contact.did.startsWith('did:'))
|
|
1138
|
+
throw new Error('A contact needs their account DID');
|
|
1139
|
+
const name = contact.name.trim().slice(0, 200);
|
|
1140
|
+
if (!name)
|
|
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;
|
|
1144
|
+
return writeContact({
|
|
1145
|
+
did: contact.did,
|
|
1146
|
+
name,
|
|
1147
|
+
...(contact.space ? { space: contact.space } : {}),
|
|
1148
|
+
...(contact.note ? { note: contact.note.slice(0, 2000) } : {}),
|
|
1149
|
+
...(pending ? { door: pending } : {}),
|
|
1150
|
+
});
|
|
1151
|
+
},
|
|
1152
|
+
async remove(did) {
|
|
1153
|
+
const found = await contacts.get(did);
|
|
1154
|
+
if (!found)
|
|
1155
|
+
return;
|
|
1156
|
+
await leavePairSpace(found.space, did);
|
|
1157
|
+
await (await contactsRuntime()).remove(await contactRecordKey(did));
|
|
1158
|
+
},
|
|
1159
|
+
async block(did) {
|
|
1160
|
+
const found = await contacts.get(did);
|
|
1161
|
+
await leavePairSpace(found?.space ?? null, did);
|
|
1162
|
+
await writeContact({ did, name: found?.name ?? did, blocked: true, ...(found?.note ? { note: found.note } : {}) });
|
|
1163
|
+
},
|
|
1164
|
+
async ask(spaceId, did, options = {}) {
|
|
1165
|
+
requireEverywhere('Adding a contact');
|
|
1166
|
+
if (did === config.signer.did)
|
|
1167
|
+
throw new Error('That is you');
|
|
1168
|
+
if (!contactsSpaceId)
|
|
1169
|
+
await contactsRuntime();
|
|
1170
|
+
const shared = await runtime(spaceId);
|
|
1171
|
+
const profiles = await shared.profiles();
|
|
1172
|
+
const theirs = profiles.find((profile) => profile.did === did);
|
|
1173
|
+
if (!theirs?.contactKey) {
|
|
1174
|
+
throw new Error("They can't be asked here yet: their profile in this space has no contact key. It appears once they open the space in an up-to-date app.");
|
|
1175
|
+
}
|
|
1176
|
+
await ensureDefined(shared, contactRequestSchema);
|
|
1177
|
+
const mine = (await ownName()) ?? profiles.find((profile) => profile.did === config.signer.did)?.name ?? 'Me';
|
|
1178
|
+
const pair = await spaces.create({ name: `${mine} & ${theirs.name}`, visibility: 'private', ...team });
|
|
1179
|
+
const invite = await spaces.invite(pair.id, { role: 'editor' });
|
|
1180
|
+
await writeContact({ did, name: theirs.name, space: pair.id });
|
|
1181
|
+
const note = options.note?.trim().slice(0, 2000);
|
|
1182
|
+
const sealed = await sealFor(theirs.contactKey, { invite, ...(note ? { note } : {}) }, requestContext(spaceId, config.signer.did, did));
|
|
1183
|
+
const request = await shared.put(contactRequestSchema.name, { to: did, sealed });
|
|
1184
|
+
return { space: pair.id, request: request.key };
|
|
1185
|
+
},
|
|
1186
|
+
async requests(spaceId) {
|
|
1187
|
+
if (!contactKeys)
|
|
1188
|
+
throw new Error("This app can't read contact requests. Connect to your account home again, and allow contacts.");
|
|
1189
|
+
const shared = await runtime(spaceId);
|
|
1190
|
+
const blocked = new Set((await contactRecords()).filter((record) => record.body.blocked === true).map((record) => record.body.did));
|
|
1191
|
+
const names = new Map((await shared.profiles()).map((profile) => [profile.did, profile.name]));
|
|
1192
|
+
const found = [];
|
|
1193
|
+
for (const record of await shared.list({ collection: contactRequestSchema.name })) {
|
|
1194
|
+
const opened = await openRequest(spaceId, record);
|
|
1195
|
+
if (!opened || blocked.has(opened.from))
|
|
1196
|
+
continue;
|
|
1197
|
+
// Already accepted, here or on another device.
|
|
1198
|
+
if (await registry.get(opened.pairSpace))
|
|
1199
|
+
continue;
|
|
1200
|
+
found.push({
|
|
1201
|
+
space: spaceId,
|
|
1202
|
+
key: record.key,
|
|
1203
|
+
from: opened.from,
|
|
1204
|
+
name: names.get(opened.from) ?? null,
|
|
1205
|
+
...(opened.note ? { note: opened.note } : {}),
|
|
1206
|
+
pairSpace: opened.pairSpace,
|
|
1207
|
+
createdAt: record.createdAt,
|
|
1208
|
+
});
|
|
1209
|
+
}
|
|
1210
|
+
return found;
|
|
1211
|
+
},
|
|
1212
|
+
async accept(spaceId, requestKey) {
|
|
1213
|
+
requireEverywhere('Accepting a contact request');
|
|
1214
|
+
if (!contactKeys)
|
|
1215
|
+
throw new Error("This app can't read contact requests. Connect to your account home again, and allow contacts.");
|
|
1216
|
+
const shared = await runtime(spaceId);
|
|
1217
|
+
const record = await shared.get(requestKey);
|
|
1218
|
+
const opened = record ? await openRequest(spaceId, record) : null;
|
|
1219
|
+
if (!opened)
|
|
1220
|
+
throw new Error('That contact request is gone, or is not for you.');
|
|
1221
|
+
await spaces.join(opened.invite);
|
|
1222
|
+
const name = (await shared.profiles()).find((profile) => profile.did === opened.from)?.name ?? (await contacts.get(opened.from))?.name ?? opened.from;
|
|
1223
|
+
return writeContact({ did: opened.from, name, space: opened.pairSpace });
|
|
1224
|
+
},
|
|
1225
|
+
async others(did) {
|
|
1226
|
+
const found = await contacts.get(did);
|
|
1227
|
+
if (!found?.space || !(await registry.get(found.space)))
|
|
1228
|
+
return [];
|
|
1229
|
+
const open = await runtime(found.space);
|
|
1230
|
+
const [{ members }, profiles, status] = await Promise.all([open.access(), open.profiles(), open.status()]);
|
|
1231
|
+
const seen = new Set([...members.map((member) => member.did), ...profiles.map((profile) => profile.did), ...Object.values(status.accounts)]);
|
|
1232
|
+
return [...seen].filter((account) => account !== config.signer.did && account !== did).sort();
|
|
1233
|
+
},
|
|
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
|
+
});
|
|
650
1535
|
const collections = Object.freeze({
|
|
651
1536
|
async list(spaceId) {
|
|
652
1537
|
return (await runtime(spaceId)).collections();
|
|
@@ -657,6 +1542,9 @@ export async function createNode(config) {
|
|
|
657
1542
|
async delete(spaceId, name) {
|
|
658
1543
|
return (await runtime(spaceId)).undefine(name);
|
|
659
1544
|
},
|
|
1545
|
+
async tag(spaceId, collection, field, value) {
|
|
1546
|
+
return (await runtime(spaceId)).topicTag(collection, field, value);
|
|
1547
|
+
},
|
|
660
1548
|
});
|
|
661
1549
|
const accountApi = Object.freeze({
|
|
662
1550
|
async profile() {
|
|
@@ -713,11 +1601,14 @@ export async function createNode(config) {
|
|
|
713
1601
|
},
|
|
714
1602
|
async query(spaceId, query) {
|
|
715
1603
|
const space = await runtime(spaceId);
|
|
716
|
-
|
|
1604
|
+
// Asked before running, so a node holding part of the space starts fetching what it lacks.
|
|
1605
|
+
const complete = space.use(collectionsOf(query));
|
|
1606
|
+
const result = await runQuery({
|
|
717
1607
|
list: (collection) => space.list({ collection }),
|
|
718
1608
|
get: (key) => space.get(key),
|
|
719
1609
|
linked: (key, options) => space.linked(key, options),
|
|
720
1610
|
}, query);
|
|
1611
|
+
return { ...result, complete };
|
|
721
1612
|
},
|
|
722
1613
|
watch(spaceId, query, onResult, onError) {
|
|
723
1614
|
// Changes arrive in bursts during sync; one run at a time, and one more
|
|
@@ -839,6 +1730,7 @@ export async function createNode(config) {
|
|
|
839
1730
|
delete: async () => {
|
|
840
1731
|
throw new Error('An agent can\'t remove collections. Ask the person to do it.');
|
|
841
1732
|
},
|
|
1733
|
+
tag: async (spaceId, collection, field, value) => (inside(spaceId), collections.tag(spaceId, collection, field, value)),
|
|
842
1734
|
});
|
|
843
1735
|
return Object.freeze({
|
|
844
1736
|
did: config.signer.did,
|
|
@@ -848,6 +1740,43 @@ export async function createNode(config) {
|
|
|
848
1740
|
collections: agentCollections,
|
|
849
1741
|
account: Object.freeze({ profile: accountApi.profile, setName: person('rename the account'), revoke: person('revoke notes') }),
|
|
850
1742
|
carriers: Object.freeze({ list: carriers.list, add: person('add a carrier'), remove: person('remove a carrier') }),
|
|
1743
|
+
notifications: Object.freeze({
|
|
1744
|
+
list: person('look at notifications'),
|
|
1745
|
+
add: person('subscribe to anything'),
|
|
1746
|
+
update: person('change notifications'),
|
|
1747
|
+
remove: person('change notifications'),
|
|
1748
|
+
}),
|
|
1749
|
+
hosting: Object.freeze({
|
|
1750
|
+
list: person('look at hosting'),
|
|
1751
|
+
use: person('start using a host'),
|
|
1752
|
+
payPage: person('pay for hosting'),
|
|
1753
|
+
stop: person('stop using a host'),
|
|
1754
|
+
}),
|
|
1755
|
+
// The list only when the agent was given it; changing it, or asking anyone, is the person's.
|
|
1756
|
+
contacts: Object.freeze({
|
|
1757
|
+
space: async () => (contactsSpaceId && allowed(contactsSpaceId) ? contactsSpaceId : null),
|
|
1758
|
+
list: async () => (contactsSpaceId && allowed(contactsSpaceId) ? contacts.list() : []),
|
|
1759
|
+
get: async (did) => (contactsSpaceId && allowed(contactsSpaceId) ? contacts.get(did) : null),
|
|
1760
|
+
put: person('change contacts'),
|
|
1761
|
+
remove: person('change contacts'),
|
|
1762
|
+
block: person('block anyone'),
|
|
1763
|
+
ask: person('ask anyone to be a contact'),
|
|
1764
|
+
requests: person('open contact requests'),
|
|
1765
|
+
accept: person('accept contact requests'),
|
|
1766
|
+
others: person('look inside a contact\'s space'),
|
|
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
|
+
}),
|
|
851
1780
|
delegation: () => note,
|
|
852
1781
|
iceServers: node.iceServers,
|
|
853
1782
|
delegate: person('pass its access on'),
|
|
@@ -868,6 +1797,10 @@ export async function createNode(config) {
|
|
|
868
1797
|
collections,
|
|
869
1798
|
account: accountApi,
|
|
870
1799
|
carriers,
|
|
1800
|
+
hosting,
|
|
1801
|
+
notifications,
|
|
1802
|
+
contacts,
|
|
1803
|
+
doors,
|
|
871
1804
|
asAgent,
|
|
872
1805
|
delegation: () => current,
|
|
873
1806
|
iceServers: async () => (mesh ? mesh.iceServers() : (config.network?.iceServers ?? DEFAULT_ICE_SERVERS)),
|