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