@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.
Files changed (199) hide show
  1. package/README.md +322 -45
  2. package/dist/doors/doors.d.ts +139 -0
  3. package/dist/doors/doors.d.ts.map +1 -0
  4. package/dist/doors/doors.js +210 -0
  5. package/dist/doors/doors.js.map +1 -0
  6. package/dist/doors/index.d.ts +9 -0
  7. package/dist/doors/index.d.ts.map +1 -0
  8. package/dist/doors/index.js +7 -0
  9. package/dist/doors/index.js.map +1 -0
  10. package/dist/identity/contact-key.d.ts +68 -0
  11. package/dist/identity/contact-key.d.ts.map +1 -0
  12. package/dist/identity/contact-key.js +185 -0
  13. package/dist/identity/contact-key.js.map +1 -0
  14. package/dist/index.d.ts +16 -7
  15. package/dist/index.d.ts.map +1 -1
  16. package/dist/index.js +10 -5
  17. package/dist/index.js.map +1 -1
  18. package/dist/network/index.d.ts +2 -0
  19. package/dist/network/index.d.ts.map +1 -1
  20. package/dist/network/index.js +1 -0
  21. package/dist/network/index.js.map +1 -1
  22. package/dist/network/mailbox.d.ts +49 -0
  23. package/dist/network/mailbox.d.ts.map +1 -0
  24. package/dist/network/mailbox.js +125 -0
  25. package/dist/network/mailbox.js.map +1 -0
  26. package/dist/network/mesh.d.ts +6 -0
  27. package/dist/network/mesh.d.ts.map +1 -1
  28. package/dist/network/mesh.js +11 -1
  29. package/dist/network/mesh.js.map +1 -1
  30. package/dist/network/multi-signaling.d.ts +12 -2
  31. package/dist/network/multi-signaling.d.ts.map +1 -1
  32. package/dist/network/multi-signaling.js +86 -14
  33. package/dist/network/multi-signaling.js.map +1 -1
  34. package/dist/network/peer-auth.d.ts +50 -16
  35. package/dist/network/peer-auth.d.ts.map +1 -1
  36. package/dist/network/peer-auth.js +52 -15
  37. package/dist/network/peer-auth.js.map +1 -1
  38. package/dist/node/carrier.d.ts +87 -0
  39. package/dist/node/carrier.d.ts.map +1 -1
  40. package/dist/node/carrier.js +180 -59
  41. package/dist/node/carrier.js.map +1 -1
  42. package/dist/node/copy.d.ts.map +1 -1
  43. package/dist/node/copy.js +6 -3
  44. package/dist/node/copy.js.map +1 -1
  45. package/dist/node/host.d.ts +117 -0
  46. package/dist/node/host.d.ts.map +1 -0
  47. package/dist/node/host.js +192 -0
  48. package/dist/node/host.js.map +1 -0
  49. package/dist/node/index.d.ts +6 -2
  50. package/dist/node/index.d.ts.map +1 -1
  51. package/dist/node/index.js +1 -0
  52. package/dist/node/index.js.map +1 -1
  53. package/dist/node/node.d.ts.map +1 -1
  54. package/dist/node/node.js +958 -25
  55. package/dist/node/node.js.map +1 -1
  56. package/dist/node/space-runtime.d.ts +42 -25
  57. package/dist/node/space-runtime.d.ts.map +1 -1
  58. package/dist/node/space-runtime.js +734 -35
  59. package/dist/node/space-runtime.js.map +1 -1
  60. package/dist/node/types.d.ts +375 -3
  61. package/dist/node/types.d.ts.map +1 -1
  62. package/dist/privacy/space-encryption.d.ts +16 -0
  63. package/dist/privacy/space-encryption.d.ts.map +1 -1
  64. package/dist/privacy/space-encryption.js +40 -0
  65. package/dist/privacy/space-encryption.js.map +1 -1
  66. package/dist/query/engine.js +1 -1
  67. package/dist/query/engine.js.map +1 -1
  68. package/dist/query/types.d.ts +7 -0
  69. package/dist/query/types.d.ts.map +1 -1
  70. package/dist/query/types.js.map +1 -1
  71. package/dist/react/use-query.d.ts +1 -1
  72. package/dist/react/use-query.d.ts.map +1 -1
  73. package/dist/records/topics.d.ts +21 -0
  74. package/dist/records/topics.d.ts.map +1 -0
  75. package/dist/records/topics.js +86 -0
  76. package/dist/records/topics.js.map +1 -0
  77. package/dist/schema/collection-def.d.ts +7 -0
  78. package/dist/schema/collection-def.d.ts.map +1 -1
  79. package/dist/schema/collection-def.js +4 -0
  80. package/dist/schema/collection-def.js.map +1 -1
  81. package/dist/schema/expression.d.ts +2 -0
  82. package/dist/schema/expression.d.ts.map +1 -1
  83. package/dist/schema/expression.js +1 -0
  84. package/dist/schema/expression.js.map +1 -1
  85. package/dist/schemas/contacts.d.ts +214 -0
  86. package/dist/schemas/contacts.d.ts.map +1 -0
  87. package/dist/schemas/contacts.js +98 -0
  88. package/dist/schemas/contacts.js.map +1 -0
  89. package/dist/schemas/index.d.ts +2 -0
  90. package/dist/schemas/index.d.ts.map +1 -1
  91. package/dist/schemas/index.js +1 -0
  92. package/dist/schemas/index.js.map +1 -1
  93. package/dist/session/auth.d.ts.map +1 -1
  94. package/dist/session/auth.js +15 -3
  95. package/dist/session/auth.js.map +1 -1
  96. package/dist/session/connect.d.ts +25 -1
  97. package/dist/session/connect.d.ts.map +1 -1
  98. package/dist/session/connect.js +9 -2
  99. package/dist/session/connect.js.map +1 -1
  100. package/dist/session/hosting.d.ts +155 -0
  101. package/dist/session/hosting.d.ts.map +1 -0
  102. package/dist/session/hosting.js +160 -0
  103. package/dist/session/hosting.js.map +1 -0
  104. package/dist/space/account-registry.d.ts +6 -0
  105. package/dist/space/account-registry.d.ts.map +1 -1
  106. package/dist/space/account-registry.js +16 -4
  107. package/dist/space/account-registry.js.map +1 -1
  108. package/dist/space/notify.d.ts +88 -0
  109. package/dist/space/notify.d.ts.map +1 -0
  110. package/dist/space/notify.js +127 -0
  111. package/dist/space/notify.js.map +1 -0
  112. package/dist/space/pass.d.ts +6 -0
  113. package/dist/space/pass.d.ts.map +1 -1
  114. package/dist/space/pass.js +5 -2
  115. package/dist/space/pass.js.map +1 -1
  116. package/dist/space/roles.d.ts +71 -0
  117. package/dist/space/roles.d.ts.map +1 -1
  118. package/dist/space/roles.js +114 -0
  119. package/dist/space/roles.js.map +1 -1
  120. package/dist/space/space-access.d.ts +24 -0
  121. package/dist/space/space-access.d.ts.map +1 -1
  122. package/dist/space/space-access.js +24 -0
  123. package/dist/space/space-access.js.map +1 -1
  124. package/dist/space/space-manager.d.ts +35 -1
  125. package/dist/space/space-manager.d.ts.map +1 -1
  126. package/dist/space/space-manager.js +72 -25
  127. package/dist/space/space-manager.js.map +1 -1
  128. package/dist/storage/blob/memory.d.ts +9 -0
  129. package/dist/storage/blob/memory.d.ts.map +1 -0
  130. package/dist/storage/blob/memory.js +20 -0
  131. package/dist/storage/blob/memory.js.map +1 -0
  132. package/dist/storage/blob/s3.d.ts +16 -0
  133. package/dist/storage/blob/s3.d.ts.map +1 -0
  134. package/dist/storage/blob/s3.js +86 -0
  135. package/dist/storage/blob/s3.js.map +1 -0
  136. package/dist/storage/blob-store.d.ts +26 -0
  137. package/dist/storage/blob-store.d.ts.map +1 -0
  138. package/dist/storage/blob-store.js +2 -0
  139. package/dist/storage/blob-store.js.map +1 -0
  140. package/dist/storage/encrypted-adapter.d.ts +3 -3
  141. package/dist/storage/encrypted-adapter.js +3 -3
  142. package/dist/storage/folder-adapter.d.ts +8 -8
  143. package/dist/storage/folder-adapter.d.ts.map +1 -1
  144. package/dist/storage/folder-adapter.js +7 -7
  145. package/dist/storage/folder-reconcile.d.ts +12 -14
  146. package/dist/storage/folder-reconcile.d.ts.map +1 -1
  147. package/dist/storage/folder-reconcile.js +20 -14
  148. package/dist/storage/folder-reconcile.js.map +1 -1
  149. package/dist/storage/index.d.ts +0 -1
  150. package/dist/storage/index.d.ts.map +1 -1
  151. package/dist/storage/index.js +0 -1
  152. package/dist/storage/index.js.map +1 -1
  153. package/dist/storage/indexeddb-adapter.d.ts.map +1 -1
  154. package/dist/storage/indexeddb-adapter.js +8 -9
  155. package/dist/storage/indexeddb-adapter.js.map +1 -1
  156. package/dist/storage/mirror.d.ts +68 -0
  157. package/dist/storage/mirror.d.ts.map +1 -0
  158. package/dist/storage/mirror.js +174 -0
  159. package/dist/storage/mirror.js.map +1 -0
  160. package/dist/storage/segment.d.ts +20 -0
  161. package/dist/storage/segment.d.ts.map +1 -0
  162. package/dist/storage/segment.js +27 -0
  163. package/dist/storage/segment.js.map +1 -0
  164. package/dist/storage/storage-provider.d.ts +36 -40
  165. package/dist/storage/storage-provider.d.ts.map +1 -1
  166. package/dist/storage/storage-provider.js +208 -117
  167. package/dist/storage/storage-provider.js.map +1 -1
  168. package/dist/sync/index.d.ts +1 -1
  169. package/dist/sync/index.d.ts.map +1 -1
  170. package/dist/sync/index.js +1 -1
  171. package/dist/sync/index.js.map +1 -1
  172. package/dist/sync/negentropy.d.ts +68 -0
  173. package/dist/sync/negentropy.d.ts.map +1 -0
  174. package/dist/sync/negentropy.js +379 -0
  175. package/dist/sync/negentropy.js.map +1 -0
  176. package/dist/sync/sync-engine.d.ts +45 -13
  177. package/dist/sync/sync-engine.d.ts.map +1 -1
  178. package/dist/sync/sync-engine.js +290 -207
  179. package/dist/sync/sync-engine.js.map +1 -1
  180. package/dist/sync/sync-messages.d.ts +42 -32
  181. package/dist/sync/sync-messages.d.ts.map +1 -1
  182. package/dist/sync/sync-messages.js +1 -1
  183. package/dist/sync/sync-messages.js.map +1 -1
  184. package/dist/types.d.ts +9 -0
  185. package/dist/types.d.ts.map +1 -1
  186. package/dist/types.js.map +1 -1
  187. package/dist/utils/hash.d.ts +9 -0
  188. package/dist/utils/hash.d.ts.map +1 -1
  189. package/dist/utils/hash.js +33 -0
  190. package/dist/utils/hash.js.map +1 -1
  191. package/package.json +8 -3
  192. package/dist/storage/mst.d.ts +0 -121
  193. package/dist/storage/mst.d.ts.map +0 -1
  194. package/dist/storage/mst.js +0 -402
  195. package/dist/storage/mst.js.map +0 -1
  196. package/dist/sync/anti-entropy.d.ts +0 -49
  197. package/dist/sync/anti-entropy.d.ts.map +0 -1
  198. package/dist/sync/anti-entropy.js +0 -62
  199. 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 { CARRIER_COLLECTION, deriveAccountRegistry, MEMBERSHIP_COLLECTION, PROFILE_COLLECTION, PROFILE_KEY, } from '../space/account-registry.js';
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
- async function remember(spaceId) {
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
- if (await open.get(membershipKey(spaceId)))
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 registry.createInvite(spaceId, config.signer.did);
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 (carrySpaces.has(spaceId))
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) => !carrySpaces.has(record.space.id)).map(summarize);
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 registry.createInvite(spaceId, config.signer.did);
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 registry.createInvite(spaceId, config.signer.did);
658
+ return inviteTo(spaceId);
495
659
  }
496
660
  const { secret } = await open.openInvite(role);
497
- return registry.createInvite(spaceId, config.signer.did, { secret, role });
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 someone reading, the space needs a new key.');
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 public read key. The welcome is signed by
604
- // the key this node introduces itself with.
605
- const readKey = record.space.visibility === 'private' ? (record.space.readKey ?? '') : null;
606
- return createServerAuth(spaceId, readKey, sessionKeys.privateKey, provider);
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
- return runQuery({
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)),