@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
|
@@ -1,3 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module node/space-runtime
|
|
3
|
+
* One open space: its store, its gatekeeper, its peers, its sync.
|
|
4
|
+
*
|
|
5
|
+
* A space is the unit of storage and of sync — its own store, its own
|
|
6
|
+
* rooms and sockets — so two spaces never mix, and a peer you share one space
|
|
7
|
+
* with learns nothing about the others.
|
|
8
|
+
*
|
|
9
|
+
* **A record keeps its key; changes are versions.** Editing a record writes its
|
|
10
|
+
* next version — same key, `seq` one higher, `prev` naming the one replaced —
|
|
11
|
+
* and deleting it writes a version marked deleted. Which version is current is
|
|
12
|
+
* decided by `seq` and id alone (`records/version.ts`), never by a clock, so
|
|
13
|
+
* every node agrees and nothing replayed can roll a record back.
|
|
14
|
+
*
|
|
15
|
+
* **Who may write is the space's access history** (`space/roles.ts`): roles,
|
|
16
|
+
* members, invites, revoked notes and collection definitions, as records every
|
|
17
|
+
* version points into with `seen`. A version stands when its author held what
|
|
18
|
+
* its collection's rules ask, as of what it saw, and nothing it had not seen
|
|
19
|
+
* took that away. A version that stops standing — its author was removed, and
|
|
20
|
+
* the remover never saw it — is passed over when reading, and the version
|
|
21
|
+
* before it counts again, where the store still has one.
|
|
22
|
+
*/
|
|
23
|
+
import { contactKeyPair, isContactPublicKey, openSealed, sealFor } from '../identity/contact-key.js';
|
|
1
24
|
import { createSchemaEngine } from '../schema/schema-engine.js';
|
|
2
25
|
import { parseUCAN, resolveDelegationRoot } from '../identity/ucan.js';
|
|
3
26
|
import { isAgentNote } from '../identity/agent-note.js';
|
|
@@ -8,33 +31,35 @@ import { newRecordKey, nextVersion, RECORD_KEY_PATTERN } from '../records/versio
|
|
|
8
31
|
import { checkLinks } from '../records/links.js';
|
|
9
32
|
import { allows, changedFixedField, describeWho, onePerKey, permissionName } from '../records/rules.js';
|
|
10
33
|
import { reconcileFolder } from '../storage/folder-reconcile.js';
|
|
34
|
+
import { createMirror } from '../storage/mirror.js';
|
|
11
35
|
import { createCryptoGate } from '../validation/crypto-gate.js';
|
|
12
36
|
import { createStructuralGate } from '../validation/structural-gate.js';
|
|
13
37
|
import { createStatefulGate } from '../validation/stateful-gate.js';
|
|
14
38
|
import { createCapabilityGate } from '../validation/capability-gate.js';
|
|
15
39
|
import { createValidationEngine } from '../validation/validation-engine.js';
|
|
16
|
-
import { encryptExpression, decryptExpression } from '../privacy/space-encryption.js';
|
|
40
|
+
import { encryptExpression, decryptExpression, generateSpaceKey, openWith, sealWith, spaceKeyBytes, spaceKeyFromRaw, } from '../privacy/space-encryption.js';
|
|
17
41
|
import { createNetworkManager } from '../network/network-manager.js';
|
|
18
42
|
import { createMesh } from '../network/mesh.js';
|
|
19
43
|
import { createWebSocketTransport } from '../network/ws-transport.js';
|
|
20
44
|
import { createClientAuth, createMeshAuth } from '../network/peer-auth.js';
|
|
21
|
-
import { deriveInviteKey, deriveReadKey, generateInviteSecret, inviteKey as inviteRecordKey, memberKey, revokeKey, roleKey, signInvite, verifyInvite, } from '../space/space-access.js';
|
|
22
|
-
import { ACCESS_COLLECTIONS, DEFINITION_COLLECTION, INVITE_COLLECTION, MEMBER_COLLECTION, REVOKE_COLLECTION, ROLE_COLLECTION, checkRole, replayAccess, roleHolds, standing, } from '../space/roles.js';
|
|
45
|
+
import { BOX_COLLECTION, MEMBER_KEY_COLLECTION, SPACE_KEY_RECORD, SPACE_RELAYS_RECORD, SPACE_KEEPERS_RECORD, memberKeyRecordKey, boxContext, boxKey, earlierKeysContext, membershipContext, deriveInviteKey, deriveReadKey, generateInviteSecret, inviteKey as inviteRecordKey, memberKey, revokeKey, roleKey, signInvite, verifyInvite, } from '../space/space-access.js';
|
|
46
|
+
import { ACCESS_COLLECTIONS, DEFINITION_COLLECTION, INVITE_COLLECTION, KEY_COLLECTION, MANAGE, MAX_RELAYS, RELAYS_COLLECTION, checkRelays, KEEPERS_COLLECTION, checkKeepers, MEMBER_COLLECTION, REVOKE_COLLECTION, ROLE_COLLECTION, checkRole, readers, replayAccess, roleHolds, standing, } from '../space/roles.js';
|
|
23
47
|
import { createSyncEngine } from '../sync/sync-engine.js';
|
|
24
48
|
import { CATALOG_COLLECTION, checkStoredCollection, toJsonSchema, validateJsonSchema, } from '../schema/collection-def.js';
|
|
25
49
|
import { CARRIER_COLLECTION, MEMBERSHIP_COLLECTION, PROFILE_COLLECTION } from '../space/account-registry.js';
|
|
26
50
|
import { PASS_COLLECTION } from '../space/pass.js';
|
|
27
|
-
import { base32Encode, cidFromBytes, sha256 } from '../utils/hash.js';
|
|
28
|
-
import {
|
|
51
|
+
import { base32Encode, cidFromBytes, cidOfDigest, sha256 } from '../utils/hash.js';
|
|
52
|
+
import { sameTags, tagsFor, topicKey, topicTag } from '../records/topics.js';
|
|
53
|
+
import { base64UrlDecode, base64UrlEncode, utf8Decode, utf8Encode } from '../utils/encoding.js';
|
|
29
54
|
/** Collections the node writes itself, through their own calls — never through `put` */
|
|
30
|
-
const MANAGED = new Set([MEMBERSHIP_COLLECTION, PROFILE_COLLECTION, CARRIER_COLLECTION, PASS_COLLECTION, ...ACCESS_COLLECTIONS]);
|
|
55
|
+
const MANAGED = new Set([MEMBERSHIP_COLLECTION, PROFILE_COLLECTION, CARRIER_COLLECTION, PASS_COLLECTION, BOX_COLLECTION, MEMBER_KEY_COLLECTION, ...ACCESS_COLLECTIONS]);
|
|
31
56
|
/**
|
|
32
57
|
* Access records a peer without the space key must still be able to judge:
|
|
33
58
|
* who holds which role decides every write. Kept in the clear, even in a
|
|
34
59
|
* private space. Collection definitions stay sealed — a peer that cannot
|
|
35
60
|
* read the records has no use for their rules.
|
|
36
61
|
*/
|
|
37
|
-
const IN_THE_CLEAR = new Set([ROLE_COLLECTION, MEMBER_COLLECTION, INVITE_COLLECTION, REVOKE_COLLECTION]);
|
|
62
|
+
const IN_THE_CLEAR = new Set([ROLE_COLLECTION, MEMBER_COLLECTION, INVITE_COLLECTION, REVOKE_COLLECTION, KEY_COLLECTION, BOX_COLLECTION, MEMBER_KEY_COLLECTION, RELAYS_COLLECTION, KEEPERS_COLLECTION]);
|
|
38
63
|
/** Which collection each access record key belongs to */
|
|
39
64
|
const ACCESS_KEYS = [
|
|
40
65
|
['role:', ROLE_COLLECTION],
|
|
@@ -42,6 +67,9 @@ const ACCESS_KEYS = [
|
|
|
42
67
|
['invite:', INVITE_COLLECTION],
|
|
43
68
|
['revoke:', REVOKE_COLLECTION],
|
|
44
69
|
['collection:', DEFINITION_COLLECTION],
|
|
70
|
+
['key:', KEY_COLLECTION],
|
|
71
|
+
['relays:', RELAYS_COLLECTION],
|
|
72
|
+
['keepers:', KEEPERS_COLLECTION],
|
|
45
73
|
];
|
|
46
74
|
/** How many records a keep list may name */
|
|
47
75
|
const MAX_KEEP = 10_000;
|
|
@@ -93,19 +121,53 @@ function isFolderAdapter(adapter) {
|
|
|
93
121
|
return typeof adapter.reload === 'function';
|
|
94
122
|
}
|
|
95
123
|
const isStringList = (value) => Array.isArray(value) && value.every((v) => typeof v === 'string');
|
|
124
|
+
// ─── What a node holding part of a space remembers ──────────────────
|
|
125
|
+
/** Where it is kept in the space's store */
|
|
126
|
+
const CACHE_KEY = 'cache';
|
|
127
|
+
/** One entry per write of this node's still waiting for keepers: `pending/<id>` */
|
|
128
|
+
const PENDING_PREFIX = 'pending/';
|
|
129
|
+
async function loadCacheState(adapter) {
|
|
130
|
+
try {
|
|
131
|
+
const bytes = await adapter.get(CACHE_KEY);
|
|
132
|
+
const saved = bytes ? JSON.parse(utf8Decode(bytes)) : {};
|
|
133
|
+
const numbers = (value) => Object.fromEntries(Object.entries(typeof value === 'object' && value !== null ? value : {}).filter(([, v]) => typeof v === 'number'));
|
|
134
|
+
return { settled: saved.settled === true, used: numbers(saved.used), level: numbers(saved.level) };
|
|
135
|
+
}
|
|
136
|
+
catch {
|
|
137
|
+
return { settled: false, used: {}, level: {} };
|
|
138
|
+
}
|
|
139
|
+
}
|
|
96
140
|
export async function openSpaceRuntime(deps) {
|
|
97
141
|
const { record, provider, signer, schemas, session, emit } = deps;
|
|
98
|
-
const { space
|
|
142
|
+
const { space } = record;
|
|
143
|
+
let closed = false;
|
|
144
|
+
/**
|
|
145
|
+
* Every key of the space this node holds, by id. Encrypted bodies name the
|
|
146
|
+
* key they were sealed with; the history names which one is current.
|
|
147
|
+
*/
|
|
148
|
+
const keyring = new Map();
|
|
149
|
+
for (const held of record.keys ?? [])
|
|
150
|
+
keyring.set(held.id, held);
|
|
151
|
+
if (record.key)
|
|
152
|
+
keyring.set(record.key.id, record.key);
|
|
153
|
+
/** This account's member key here, which new keys are sealed to */
|
|
154
|
+
const memberPair = deps.memberKey ? await contactKeyPair(deps.memberKey) : null;
|
|
99
155
|
/** An invite waiting to be used — forgotten here once it is, whatever the stored record still says */
|
|
100
156
|
let waitingInvite = record.invite !== null;
|
|
101
157
|
const adapter = await deps.stores(`spaces/${space.id}`);
|
|
102
|
-
|
|
103
|
-
//
|
|
104
|
-
//
|
|
105
|
-
const
|
|
106
|
-
const
|
|
107
|
-
|
|
108
|
-
|
|
158
|
+
const storage = createStorageProvider(adapter);
|
|
159
|
+
// What this node holds of the space (see "Holding part of the space" below).
|
|
160
|
+
// Here, before anything can ask: sync asks on every hello.
|
|
161
|
+
const cache = deps.cache ?? null;
|
|
162
|
+
const cacheState = await loadCacheState(adapter);
|
|
163
|
+
// The collections the app declared are held from the start.
|
|
164
|
+
for (const collection of cache?.collections ?? [])
|
|
165
|
+
if (!collection.startsWith('sys.'))
|
|
166
|
+
cacheState.used[collection] ??= Date.now();
|
|
167
|
+
// A node that may hold part of the space starts by holding only what it
|
|
168
|
+
// uses: whether the space names keepers is only known once it has caught up.
|
|
169
|
+
let partial = cache !== null;
|
|
170
|
+
const holds = () => (partial ? new Set(Object.keys(cacheState.used)) : 'all');
|
|
109
171
|
const resolvePublicKey = async (did) => provider.importPublicKey(didToPublicKey(did).publicKeyBytes);
|
|
110
172
|
const validation = createValidationEngine({
|
|
111
173
|
cryptoGate: createCryptoGate(provider),
|
|
@@ -155,10 +217,11 @@ export async function openSpaceRuntime(deps) {
|
|
|
155
217
|
async function openBody(expression) {
|
|
156
218
|
if (!looksEncrypted(expression.body))
|
|
157
219
|
return { body: expression.body, links: expression.links ?? [], encrypted: false };
|
|
158
|
-
|
|
220
|
+
const sealedWith = keyring.get(String(expression.body.keyId));
|
|
221
|
+
if (!sealedWith)
|
|
159
222
|
return { body: null, links: [], encrypted: true };
|
|
160
223
|
try {
|
|
161
|
-
const opened = (await decryptExpression(expression,
|
|
224
|
+
const opened = (await decryptExpression(expression, sealedWith)).body;
|
|
162
225
|
const links = checkLinks(opened?.links ?? []) === null ? (opened?.links ?? []) : [];
|
|
163
226
|
return { body: opened?.body ?? null, links, encrypted: true };
|
|
164
227
|
}
|
|
@@ -198,6 +261,9 @@ export async function openSpaceRuntime(deps) {
|
|
|
198
261
|
creator: space.creator,
|
|
199
262
|
roles: space.roles,
|
|
200
263
|
creatorRole: space.creatorRole,
|
|
264
|
+
...(space.visibility === 'private' && space.encryptionKeyId && space.readKey
|
|
265
|
+
? { key: { keyId: space.encryptionKeyId, readKey: space.readKey } }
|
|
266
|
+
: {}),
|
|
201
267
|
};
|
|
202
268
|
/** Events, by version id — building one checks signatures and invites, so they are kept */
|
|
203
269
|
const events = new Map();
|
|
@@ -275,6 +341,24 @@ export async function openSpaceRuntime(deps) {
|
|
|
275
341
|
return null;
|
|
276
342
|
return { ...base, keep, kind: 'invite', inviteKey: inviteDid, role: body.role, open: body.open };
|
|
277
343
|
}
|
|
344
|
+
case KEY_COLLECTION: {
|
|
345
|
+
if (version.key !== SPACE_KEY_RECORD || typeof body.keyId !== 'string' || body.keyId.length > 64)
|
|
346
|
+
return null;
|
|
347
|
+
if (typeof body.readKey !== 'string' || !body.readKey.startsWith('did:key:'))
|
|
348
|
+
return null;
|
|
349
|
+
return { ...base, keep: [], kind: 'key', keyId: body.keyId, readKey: body.readKey };
|
|
350
|
+
}
|
|
351
|
+
case RELAYS_COLLECTION: {
|
|
352
|
+
if (version.key !== SPACE_RELAYS_RECORD || !isStringList(body.relays) || body.relays.length > MAX_RELAYS)
|
|
353
|
+
return null;
|
|
354
|
+
return { ...base, keep: [], kind: 'relays', relays: body.relays };
|
|
355
|
+
}
|
|
356
|
+
case KEEPERS_COLLECTION: {
|
|
357
|
+
const copies = body.copies ?? null;
|
|
358
|
+
if (version.key !== SPACE_KEEPERS_RECORD || checkKeepers(body.keepers, copies) !== null)
|
|
359
|
+
return null;
|
|
360
|
+
return { ...base, keep: [], kind: 'keepers', keepers: body.keepers, copies: copies };
|
|
361
|
+
}
|
|
278
362
|
case REVOKE_COLLECTION: {
|
|
279
363
|
if (typeof body.note !== 'string')
|
|
280
364
|
return null;
|
|
@@ -294,6 +378,23 @@ export async function openSpaceRuntime(deps) {
|
|
|
294
378
|
return null;
|
|
295
379
|
}
|
|
296
380
|
let accessCache = null;
|
|
381
|
+
/** The read key readers must prove now — kept at hand, since a handshake asks for it without waiting */
|
|
382
|
+
let currentReadKey = space.readKey ?? '';
|
|
383
|
+
/**
|
|
384
|
+
* Where the members meet: the relays the space names once it names some,
|
|
385
|
+
* and until then the ones the invite that brought it here named. Handed to
|
|
386
|
+
* the mesh for this space's room alone.
|
|
387
|
+
*/
|
|
388
|
+
let relays = record.relays ?? [];
|
|
389
|
+
let meshRoom = null;
|
|
390
|
+
function useRelays(named) {
|
|
391
|
+
if (named.length === 0 || (named.length === relays.length && named.every((url, i) => url === relays[i])))
|
|
392
|
+
return;
|
|
393
|
+
relays = [...named];
|
|
394
|
+
if (meshRoom)
|
|
395
|
+
deps.mesh?.useRelays(meshRoom, relays);
|
|
396
|
+
void deps.onRelays?.(relays).catch(() => { });
|
|
397
|
+
}
|
|
297
398
|
const access = () => (accessCache ??= loadAccess());
|
|
298
399
|
async function loadAccess() {
|
|
299
400
|
const found = [];
|
|
@@ -312,6 +413,8 @@ export async function openSpaceRuntime(deps) {
|
|
|
312
413
|
}
|
|
313
414
|
const history = replayAccess(accessGenesis, found);
|
|
314
415
|
reportRole(history.current);
|
|
416
|
+
currentReadKey = history.current.keys.at(-1)?.readKey ?? currentReadKey;
|
|
417
|
+
useRelays(history.current.relays);
|
|
315
418
|
return { history, events: found };
|
|
316
419
|
}
|
|
317
420
|
let reportedRole;
|
|
@@ -349,7 +452,7 @@ export async function openSpaceRuntime(deps) {
|
|
|
349
452
|
}
|
|
350
453
|
return found;
|
|
351
454
|
}
|
|
352
|
-
/** The rules in force for a collection in one state of the history — null when there are none to judge by */
|
|
455
|
+
/** The rules and topics in force for a collection in one state of the history — null when there are none to judge by */
|
|
353
456
|
async function rulesAt(state, collection) {
|
|
354
457
|
if (collection.startsWith('sys.'))
|
|
355
458
|
return null;
|
|
@@ -357,7 +460,41 @@ export async function openSpaceRuntime(deps) {
|
|
|
357
460
|
if (!entry)
|
|
358
461
|
return null;
|
|
359
462
|
const found = await definitionIn(entry.event);
|
|
360
|
-
return typeof found === 'string' ? null : { rules: found.definition.rules ?? {} };
|
|
463
|
+
return typeof found === 'string' ? null : { rules: found.definition.rules ?? {}, topics: found.definition.topics ?? [] };
|
|
464
|
+
}
|
|
465
|
+
// ─── Topic tags ────────────────────────────────────────────────────
|
|
466
|
+
//
|
|
467
|
+
// Keyed from the key a record's body was sealed with, in a private space;
|
|
468
|
+
// from the space id in a public one (`records/topics.ts`).
|
|
469
|
+
const topicKeys = new Map();
|
|
470
|
+
/** The tag key for one of the space's keys, or the public one; null when this node doesn't hold that key */
|
|
471
|
+
function tagKey(keyId) {
|
|
472
|
+
const name = keyId ?? 'public';
|
|
473
|
+
let found = topicKeys.get(name);
|
|
474
|
+
if (!found) {
|
|
475
|
+
const held = keyId === null ? null : keyring.get(keyId);
|
|
476
|
+
if (keyId !== null && !held)
|
|
477
|
+
return null;
|
|
478
|
+
found = topicKey(held ? { spaceKey: held.key } : { spaceId: space.id });
|
|
479
|
+
topicKeys.set(name, found);
|
|
480
|
+
}
|
|
481
|
+
return found;
|
|
482
|
+
}
|
|
483
|
+
/**
|
|
484
|
+
* Why a version's tags don't match its body under the topics in force, or
|
|
485
|
+
* null when they do — or when this node can't read the body to tell.
|
|
486
|
+
*/
|
|
487
|
+
async function tagProblem(expression, topics) {
|
|
488
|
+
if (topics.length === 0 && !expression.tags?.length)
|
|
489
|
+
return null;
|
|
490
|
+
const opened = await openBody(expression);
|
|
491
|
+
if (opened.body === null)
|
|
492
|
+
return null;
|
|
493
|
+
const key = tagKey(opened.encrypted ? String(expression.body.keyId) : null);
|
|
494
|
+
if (!key)
|
|
495
|
+
return null;
|
|
496
|
+
const expected = await tagsFor(await key, expression.collection, topics, opened.body);
|
|
497
|
+
return sameTags(expression.tags, expected) ? null : { ok: false, reason: `Its topic tags don't match what it says` };
|
|
361
498
|
}
|
|
362
499
|
const standings = new Map();
|
|
363
500
|
/**
|
|
@@ -426,6 +563,11 @@ export async function openSpaceRuntime(deps) {
|
|
|
426
563
|
const judged = history.judge({ id: expression.id, root, seen, note: await noteOf(expression) }, needs);
|
|
427
564
|
if (!judged.ok)
|
|
428
565
|
return judged;
|
|
566
|
+
if (found && !expression.deleted) {
|
|
567
|
+
const problem = await tagProblem(expression, found.topics);
|
|
568
|
+
if (problem)
|
|
569
|
+
return problem;
|
|
570
|
+
}
|
|
429
571
|
if (!rules || expression.deleted)
|
|
430
572
|
return STANDS;
|
|
431
573
|
if (expression.seq === 0 && rules.onePer) {
|
|
@@ -624,6 +766,7 @@ export async function openSpaceRuntime(deps) {
|
|
|
624
766
|
definedBy: entry?.definedBy ?? null,
|
|
625
767
|
permissions: definition?.permissions ?? [],
|
|
626
768
|
rules: definition?.rules ?? {},
|
|
769
|
+
topics: definition?.topics ?? [],
|
|
627
770
|
...(definition?.screen !== undefined ? { screen: definition.screen } : {}),
|
|
628
771
|
records,
|
|
629
772
|
});
|
|
@@ -663,6 +806,7 @@ export async function openSpaceRuntime(deps) {
|
|
|
663
806
|
const result = new Map();
|
|
664
807
|
// By key: the current version may be anyone's.
|
|
665
808
|
for (const [key, versions] of await storage.histories('profile:')) {
|
|
809
|
+
let found = null;
|
|
666
810
|
for (const version of versions) {
|
|
667
811
|
if (version.collection !== PROFILE_COLLECTION)
|
|
668
812
|
continue;
|
|
@@ -673,24 +817,342 @@ export async function openSpaceRuntime(deps) {
|
|
|
673
817
|
continue;
|
|
674
818
|
if (version.deleted)
|
|
675
819
|
break; // they took it down
|
|
676
|
-
const
|
|
677
|
-
if (
|
|
678
|
-
|
|
820
|
+
const body = (await openBody(version)).body;
|
|
821
|
+
if (!found) {
|
|
822
|
+
const name = body?.name;
|
|
823
|
+
if (typeof name !== 'string' || !name.trim())
|
|
824
|
+
break;
|
|
825
|
+
found = { did: verdict.root, name: name.trim().slice(0, 64), updatedAt: version.createdAt };
|
|
826
|
+
}
|
|
827
|
+
// The contact key from the newest version that has one: it never changes, and an app
|
|
828
|
+
// without it that wrote a newer version first must not hide it.
|
|
829
|
+
if (isContactPublicKey(body?.contactKey)) {
|
|
830
|
+
found = { ...found, contactKey: body.contactKey };
|
|
831
|
+
break;
|
|
679
832
|
}
|
|
680
|
-
break;
|
|
681
833
|
}
|
|
834
|
+
if (found)
|
|
835
|
+
result.set(found.did, found);
|
|
682
836
|
}
|
|
683
837
|
return result;
|
|
684
838
|
}
|
|
839
|
+
/**
|
|
840
|
+
* Each member's member key, from their own record of it — kept in the
|
|
841
|
+
* clear, so someone who holds only an older key of the space can still say
|
|
842
|
+
* where to seal the new one.
|
|
843
|
+
*/
|
|
844
|
+
let memberKeysCache = null;
|
|
845
|
+
const memberKeyMap = () => (memberKeysCache ??= loadMemberKeys());
|
|
846
|
+
async function loadMemberKeys() {
|
|
847
|
+
const found = new Map();
|
|
848
|
+
// By key: the current version may be anyone's.
|
|
849
|
+
for (const [key, versions] of await storage.histories('memberkey:')) {
|
|
850
|
+
for (const version of versions) {
|
|
851
|
+
if (version.collection !== MEMBER_KEY_COLLECTION || version.deleted)
|
|
852
|
+
continue;
|
|
853
|
+
const verdict = await judge(version);
|
|
854
|
+
if (!verdict.verified || !verdict.root || (await memberKeyRecordKey(verdict.root)) !== key)
|
|
855
|
+
continue;
|
|
856
|
+
if (!(await standingOf(version)).ok)
|
|
857
|
+
continue;
|
|
858
|
+
const body = version.body;
|
|
859
|
+
if (isContactPublicKey(body?.key)) {
|
|
860
|
+
found.set(verdict.root, body.key);
|
|
861
|
+
break;
|
|
862
|
+
}
|
|
863
|
+
}
|
|
864
|
+
}
|
|
865
|
+
return found;
|
|
866
|
+
}
|
|
685
867
|
/** Records changed: everything derived from them may have too. */
|
|
686
868
|
const recordsChanged = () => {
|
|
687
869
|
linkIndexCache = null;
|
|
688
870
|
catalogCache = null;
|
|
689
871
|
profilesCache = null;
|
|
872
|
+
memberKeysCache = null;
|
|
690
873
|
accessCache = null;
|
|
691
874
|
standings.clear();
|
|
692
875
|
emit({ type: 'records', space: space.id });
|
|
876
|
+
keepUp();
|
|
877
|
+
for (const mirror of mirrors)
|
|
878
|
+
mirror.changed();
|
|
879
|
+
// The access history may name keepers now, or none.
|
|
880
|
+
void refreshHolds().catch(() => { });
|
|
693
881
|
};
|
|
882
|
+
// ─── The space's keys ──────────────────────────────────────────────
|
|
883
|
+
//
|
|
884
|
+
// A private space's key changes when someone loses their place in it
|
|
885
|
+
// (`sys.key`, part of the access history): whoever manages the space makes
|
|
886
|
+
// a new one, and seals a copy to each member's member key (`sys.box`). The
|
|
887
|
+
// key record carries every earlier key sealed under the new one, so holding
|
|
888
|
+
// the current key opens everything written before it — which is how
|
|
889
|
+
// someone who joins later reads the old records, and someone removed reads
|
|
890
|
+
// nothing new. This is how Keybase changes a team's key.
|
|
891
|
+
/** The key the history names now, if this node holds it */
|
|
892
|
+
function currentKey(state) {
|
|
893
|
+
const epoch = state.keys.at(-1);
|
|
894
|
+
return epoch ? (keyring.get(epoch.keyId) ?? null) : null;
|
|
895
|
+
}
|
|
896
|
+
/** The newest key this node holds that the history names — what it proves it may read with while behind */
|
|
897
|
+
function newestKey(state) {
|
|
898
|
+
for (const epoch of [...state.keys].reverse()) {
|
|
899
|
+
const held = keyring.get(epoch.keyId);
|
|
900
|
+
if (held)
|
|
901
|
+
return held;
|
|
902
|
+
}
|
|
903
|
+
return record.key;
|
|
904
|
+
}
|
|
905
|
+
/** Box and key record versions already opened, so each is tried once */
|
|
906
|
+
const opened = new Set();
|
|
907
|
+
/** Takes in what this node can open: boxes sealed to its member key, and the earlier keys each key record carries */
|
|
908
|
+
async function learnKeys() {
|
|
909
|
+
const { history } = await access();
|
|
910
|
+
const epochs = history.current.keys;
|
|
911
|
+
const learned = [];
|
|
912
|
+
const take = (found) => {
|
|
913
|
+
if (keyring.has(found.id))
|
|
914
|
+
return false;
|
|
915
|
+
keyring.set(found.id, found);
|
|
916
|
+
learned.push(found);
|
|
917
|
+
return true;
|
|
918
|
+
};
|
|
919
|
+
if (memberPair && epochs.some((epoch) => !keyring.has(epoch.keyId))) {
|
|
920
|
+
for (const versions of (await storage.histories('box:')).values()) {
|
|
921
|
+
for (const version of versions) {
|
|
922
|
+
if (version.collection !== BOX_COLLECTION || version.deleted || opened.has(version.id))
|
|
923
|
+
continue;
|
|
924
|
+
const body = version.body;
|
|
925
|
+
if (body?.to !== deps.rootDid || typeof body.keyId !== 'string' || typeof body.sealed !== 'string')
|
|
926
|
+
continue;
|
|
927
|
+
const epoch = epochs.find((known) => known.keyId === body.keyId);
|
|
928
|
+
if (!epoch || keyring.has(epoch.keyId))
|
|
929
|
+
continue;
|
|
930
|
+
opened.add(version.id);
|
|
931
|
+
const content = (await openSealed(memberPair.privateKey, body.sealed, boxContext(space.id, epoch.keyId, deps.rootDid)));
|
|
932
|
+
if (typeof content?.key !== 'string')
|
|
933
|
+
continue;
|
|
934
|
+
// Whoever sealed it, it is the key the history names or it is nothing.
|
|
935
|
+
const found = await spaceKeyFromRaw(base64UrlDecode(content.key)).catch(() => null);
|
|
936
|
+
if (found?.id === epoch.keyId && (await deriveReadKey(found, provider)).did === epoch.readKey)
|
|
937
|
+
take(found);
|
|
938
|
+
}
|
|
939
|
+
}
|
|
940
|
+
}
|
|
941
|
+
// Each newer key opens the ones before it — which may open more.
|
|
942
|
+
let more = true;
|
|
943
|
+
while (more) {
|
|
944
|
+
more = false;
|
|
945
|
+
for (const versions of (await storage.histories('key:')).values()) {
|
|
946
|
+
for (const version of versions) {
|
|
947
|
+
const body = version.body;
|
|
948
|
+
if (version.collection !== KEY_COLLECTION || opened.has(version.id) || typeof body?.keyId !== 'string')
|
|
949
|
+
continue;
|
|
950
|
+
const holder = keyring.get(body.keyId);
|
|
951
|
+
if (!holder)
|
|
952
|
+
continue;
|
|
953
|
+
opened.add(version.id);
|
|
954
|
+
const earlier = await openWith(holder, body.earlier, earlierKeysContext(space.id, holder.id));
|
|
955
|
+
if (!isStringList(earlier))
|
|
956
|
+
continue;
|
|
957
|
+
for (const raw of earlier.slice(0, 1000)) {
|
|
958
|
+
const found = await spaceKeyFromRaw(base64UrlDecode(raw)).catch(() => null);
|
|
959
|
+
if (found && take(found))
|
|
960
|
+
more = true;
|
|
961
|
+
}
|
|
962
|
+
}
|
|
963
|
+
}
|
|
964
|
+
}
|
|
965
|
+
if (learned.length === 0)
|
|
966
|
+
return;
|
|
967
|
+
const writeWith = newestKey((await access()).history.current);
|
|
968
|
+
if (writeWith)
|
|
969
|
+
await deps.onKeys?.([...keyring.values()], writeWith.id);
|
|
970
|
+
recordsChanged();
|
|
971
|
+
}
|
|
972
|
+
/** Makes a new key current, with every key before it sealed inside, and seals it to each member */
|
|
973
|
+
async function rotateKey() {
|
|
974
|
+
if (space.visibility !== 'private')
|
|
975
|
+
throw new Error('A public space has no key to change');
|
|
976
|
+
const { history } = await access();
|
|
977
|
+
if (!roleHolds(standing(history.current, deps.rootDid), MANAGE))
|
|
978
|
+
throw new Error(`Only someone who manages "${space.name}" can change its key`);
|
|
979
|
+
if (!currentKey(history.current))
|
|
980
|
+
throw new Error(`This device doesn't hold the current key of "${space.name}" yet`);
|
|
981
|
+
const next = await generateSpaceKey();
|
|
982
|
+
const earlier = await Promise.all([...keyring.values()].map(async (held) => base64UrlEncode(await spaceKeyBytes(held))));
|
|
983
|
+
const body = {
|
|
984
|
+
keyId: next.id,
|
|
985
|
+
readKey: (await deriveReadKey(next, provider)).did,
|
|
986
|
+
earlier: await sealWith(next, earlier, earlierKeysContext(space.id, next.id)),
|
|
987
|
+
};
|
|
988
|
+
keyring.set(next.id, next);
|
|
989
|
+
try {
|
|
990
|
+
await upsert(KEY_COLLECTION, SPACE_KEY_RECORD, body);
|
|
991
|
+
}
|
|
992
|
+
catch (error) {
|
|
993
|
+
keyring.delete(next.id);
|
|
994
|
+
throw error;
|
|
995
|
+
}
|
|
996
|
+
await deps.onKeys?.([...keyring.values()], next.id);
|
|
997
|
+
await boxForMembers();
|
|
998
|
+
}
|
|
999
|
+
/**
|
|
1000
|
+
* Seals the current key to each member who has no copy from someone who
|
|
1001
|
+
* manages the space. Members show their member key on their profile; one
|
|
1002
|
+
* who hasn't yet gets their copy once they do.
|
|
1003
|
+
*/
|
|
1004
|
+
async function boxForMembers() {
|
|
1005
|
+
const { history } = await access();
|
|
1006
|
+
const state = history.current;
|
|
1007
|
+
const epoch = state.keys.at(-1);
|
|
1008
|
+
const key = currentKey(state);
|
|
1009
|
+
if (!epoch || !key)
|
|
1010
|
+
return;
|
|
1011
|
+
const boxed = new Set();
|
|
1012
|
+
for (const versions of (await storage.histories('box:')).values()) {
|
|
1013
|
+
for (const version of versions) {
|
|
1014
|
+
const body = version.body;
|
|
1015
|
+
if (version.collection !== BOX_COLLECTION || body?.keyId !== epoch.keyId || typeof body.to !== 'string')
|
|
1016
|
+
continue;
|
|
1017
|
+
const { root } = await judge(version);
|
|
1018
|
+
if (root && (root === deps.rootDid || roleHolds(standing(state, root), MANAGE)))
|
|
1019
|
+
boxed.add(body.to);
|
|
1020
|
+
}
|
|
1021
|
+
}
|
|
1022
|
+
const memberKeys = await memberKeyMap();
|
|
1023
|
+
const raw = base64UrlEncode(await spaceKeyBytes(key));
|
|
1024
|
+
for (const did of [...readers(state)].sort()) {
|
|
1025
|
+
const publicKey = memberKeys.get(did);
|
|
1026
|
+
if (boxed.has(did) || !publicKey)
|
|
1027
|
+
continue;
|
|
1028
|
+
const sealed = await sealFor(publicKey, { key: raw }, boxContext(space.id, epoch.keyId, did));
|
|
1029
|
+
await upsert(BOX_COLLECTION, await boxKey(epoch.keyId, did, deps.rootDid), { keyId: epoch.keyId, to: did, sealed });
|
|
1030
|
+
}
|
|
1031
|
+
}
|
|
1032
|
+
/**
|
|
1033
|
+
* Keeps the keys in step with the history: says where to seal this
|
|
1034
|
+
* account's copy, learns what it can, and — managing the space — changes
|
|
1035
|
+
* the key when due and seals it to whoever lacks it.
|
|
1036
|
+
*/
|
|
1037
|
+
async function upkeep() {
|
|
1038
|
+
if (isAgentNote(session.proof())) {
|
|
1039
|
+
if (space.visibility === 'private')
|
|
1040
|
+
await learnKeys();
|
|
1041
|
+
return;
|
|
1042
|
+
}
|
|
1043
|
+
await nameRelays();
|
|
1044
|
+
if (space.visibility !== 'private')
|
|
1045
|
+
return;
|
|
1046
|
+
if (deps.peopleOnly) {
|
|
1047
|
+
await learnKeys();
|
|
1048
|
+
return;
|
|
1049
|
+
}
|
|
1050
|
+
const mine = await memberKeyMap();
|
|
1051
|
+
if (memberPair && mine.get(deps.rootDid) !== memberPair.publicKey && !(await cannotWrite())) {
|
|
1052
|
+
await upsert(MEMBER_KEY_COLLECTION, await memberKeyRecordKey(deps.rootDid), { key: memberPair.publicKey });
|
|
1053
|
+
}
|
|
1054
|
+
await learnKeys();
|
|
1055
|
+
const { history } = await access();
|
|
1056
|
+
const state = history.current;
|
|
1057
|
+
if (!currentKey(state))
|
|
1058
|
+
return;
|
|
1059
|
+
if (!roleHolds(standing(state, deps.rootDid), MANAGE))
|
|
1060
|
+
return;
|
|
1061
|
+
if (state.keyDue)
|
|
1062
|
+
await rotateKey();
|
|
1063
|
+
else
|
|
1064
|
+
await boxForMembers();
|
|
1065
|
+
}
|
|
1066
|
+
/**
|
|
1067
|
+
* A space nobody has said where to meet yet: whoever manages it names the
|
|
1068
|
+
* relays this node uses, so the invites and devices that follow meet there.
|
|
1069
|
+
*/
|
|
1070
|
+
async function nameRelays() {
|
|
1071
|
+
const own = (deps.network?.relays ?? []).filter((url) => checkRelays([url]) === null).slice(0, MAX_RELAYS);
|
|
1072
|
+
if (own.length === 0)
|
|
1073
|
+
return;
|
|
1074
|
+
const { history } = await access();
|
|
1075
|
+
if (history.current.relays.length > 0 || !roleHolds(standing(history.current, deps.rootDid), MANAGE))
|
|
1076
|
+
return;
|
|
1077
|
+
await upsert(RELAYS_COLLECTION, SPACE_RELAYS_RECORD, { relays: own });
|
|
1078
|
+
}
|
|
1079
|
+
let keysRunning = null;
|
|
1080
|
+
let keysAgain = false;
|
|
1081
|
+
/** Runs the upkeep once more after whatever changed — one run at a time */
|
|
1082
|
+
function keepUp() {
|
|
1083
|
+
if (closed)
|
|
1084
|
+
return;
|
|
1085
|
+
if (keysRunning) {
|
|
1086
|
+
keysAgain = true;
|
|
1087
|
+
return;
|
|
1088
|
+
}
|
|
1089
|
+
keysRunning = (async () => {
|
|
1090
|
+
do {
|
|
1091
|
+
keysAgain = false;
|
|
1092
|
+
await upkeep().catch(() => {
|
|
1093
|
+
// A write refused, a peer's junk: the next change tries again.
|
|
1094
|
+
});
|
|
1095
|
+
} while (keysAgain && !closed);
|
|
1096
|
+
})().finally(() => {
|
|
1097
|
+
keysRunning = null;
|
|
1098
|
+
});
|
|
1099
|
+
}
|
|
1100
|
+
/** How this node proves it may read, and checks the peers that connect — against the history at that moment */
|
|
1101
|
+
const readAccess = space.visibility !== 'private'
|
|
1102
|
+
? null
|
|
1103
|
+
: {
|
|
1104
|
+
async key() {
|
|
1105
|
+
const held = newestKey((await access()).history.current);
|
|
1106
|
+
// A carrier holds the read key pair without the key it comes from: it may be served the space, and cannot open it.
|
|
1107
|
+
return held ? deriveReadKey(held, provider) : (record.read ?? null);
|
|
1108
|
+
},
|
|
1109
|
+
current: () => currentReadKey,
|
|
1110
|
+
async membership() {
|
|
1111
|
+
const held = newestKey((await access()).history.current);
|
|
1112
|
+
return held ? sealWith(held, session.proof(), membershipContext(space.id)) : null;
|
|
1113
|
+
},
|
|
1114
|
+
async admits(peerDid, readKey, membership) {
|
|
1115
|
+
const { history } = await access();
|
|
1116
|
+
const epoch = history.current.keys.find((known) => known.readKey === readKey);
|
|
1117
|
+
const held = epoch ? keyring.get(epoch.keyId) : undefined;
|
|
1118
|
+
if (!held)
|
|
1119
|
+
return false;
|
|
1120
|
+
const { account } = await accountOf(peerDid, await openWith(held, membership, membershipContext(space.id)));
|
|
1121
|
+
return account !== null && readers(history.current).has(account);
|
|
1122
|
+
},
|
|
1123
|
+
};
|
|
1124
|
+
// ─── Mirrors ───────────────────────────────────────────────────────
|
|
1125
|
+
//
|
|
1126
|
+
// Every mirror is one more peer: what it holds goes through the same gates
|
|
1127
|
+
// as a peer's records, and what this node holds goes out to it.
|
|
1128
|
+
const mirrors = [];
|
|
1129
|
+
for (const [index, store] of (deps.mirrors ?? []).entries()) {
|
|
1130
|
+
mirrors.push(await createMirror({
|
|
1131
|
+
store,
|
|
1132
|
+
space: space.id,
|
|
1133
|
+
storage,
|
|
1134
|
+
state: await deps.stores(`mirrors/${space.id}/${index}`),
|
|
1135
|
+
accept: async (version) => {
|
|
1136
|
+
const verdict = await admit(version);
|
|
1137
|
+
if (!verdict.ok)
|
|
1138
|
+
return verdict.later ? 'later' : 'refused';
|
|
1139
|
+
await storage.addExpression(version);
|
|
1140
|
+
return 'stored';
|
|
1141
|
+
},
|
|
1142
|
+
}));
|
|
1143
|
+
}
|
|
1144
|
+
/** Reads every mirror in the background: a space that opens from nothing — a host that lost its disk — fills in as it goes */
|
|
1145
|
+
const pullMirrors = () => Promise.all(mirrors.map((mirror) => mirror
|
|
1146
|
+
.pull()
|
|
1147
|
+
.then(({ added }) => {
|
|
1148
|
+
if (added === 0)
|
|
1149
|
+
return;
|
|
1150
|
+
recordsChanged();
|
|
1151
|
+
announceSoon();
|
|
1152
|
+
})
|
|
1153
|
+
.catch(() => {
|
|
1154
|
+
// Out of reach now; what this node holds goes out on the next change.
|
|
1155
|
+
})));
|
|
694
1156
|
// ─── Peers ─────────────────────────────────────────────────────────
|
|
695
1157
|
let connection = 'offline';
|
|
696
1158
|
let rejected = 0;
|
|
@@ -699,6 +1161,8 @@ export async function openSpaceRuntime(deps) {
|
|
|
699
1161
|
const routes = new Map();
|
|
700
1162
|
const sync = createSyncEngine({
|
|
701
1163
|
storageProvider: storage,
|
|
1164
|
+
self: session.did,
|
|
1165
|
+
holds,
|
|
702
1166
|
sendToPeer: (peerId, message) => {
|
|
703
1167
|
routes.get(peerId)?.send(peerId, { type: 'sync', from: session.did, payload: message });
|
|
704
1168
|
},
|
|
@@ -723,9 +1187,10 @@ export async function openSpaceRuntime(deps) {
|
|
|
723
1187
|
sync.notifyPeers(connectedPeers());
|
|
724
1188
|
}, 100);
|
|
725
1189
|
};
|
|
726
|
-
sync.on('expression-received', () => {
|
|
1190
|
+
sync.on('expression-received', (version) => {
|
|
727
1191
|
recordsChanged();
|
|
728
1192
|
announceSoon();
|
|
1193
|
+
deps.onArrived?.(version);
|
|
729
1194
|
});
|
|
730
1195
|
sync.on('rejected', (peer, _expression, reason) => {
|
|
731
1196
|
rejected += 1;
|
|
@@ -734,15 +1199,16 @@ export async function openSpaceRuntime(deps) {
|
|
|
734
1199
|
const net = deps.network;
|
|
735
1200
|
if (net) {
|
|
736
1201
|
const room = encodeURIComponent(space.id);
|
|
737
|
-
//
|
|
738
|
-
|
|
1202
|
+
// Who is at the other end is asked of the history at each handshake: the space's key may change while it is open.
|
|
1203
|
+
await access();
|
|
739
1204
|
if (deps.mesh) {
|
|
1205
|
+
meshRoom = await relayRoom(space.id);
|
|
1206
|
+
deps.mesh.useRelays(meshRoom, relays);
|
|
740
1207
|
// Every peer met through a relay proves who it is, and in a private space that it may read.
|
|
741
|
-
|
|
742
|
-
networks.push(deps.mesh.join(await relayRoom(space.id), auth));
|
|
1208
|
+
networks.push(deps.mesh.join(meshRoom, createMeshAuth(space.id, session, readAccess, provider)));
|
|
743
1209
|
}
|
|
744
1210
|
// Both sides of a socket to a node prove who they are; in a private space the client also proves it may read.
|
|
745
|
-
const authenticator = net.nodes?.length ? createClientAuth(space.id, session,
|
|
1211
|
+
const authenticator = net.nodes?.length ? createClientAuth(space.id, session, readAccess, provider) : null;
|
|
746
1212
|
for (const node of net.nodes ?? []) {
|
|
747
1213
|
const url = `${node}${node.includes('?') ? '&' : '?'}space=${room}`;
|
|
748
1214
|
networks.push(createNetworkManager({
|
|
@@ -835,6 +1301,8 @@ export async function openSpaceRuntime(deps) {
|
|
|
835
1301
|
emit({ type: 'status', space: space.id });
|
|
836
1302
|
});
|
|
837
1303
|
}
|
|
1304
|
+
// Whether to hold part of the space is settled before the first hello says what this node holds.
|
|
1305
|
+
await refreshHolds().catch(() => { });
|
|
838
1306
|
if (networks.length > 0) {
|
|
839
1307
|
connection = 'connecting';
|
|
840
1308
|
// Online as soon as one way in works: a node that is slow to answer, or
|
|
@@ -882,10 +1350,172 @@ export async function openSpaceRuntime(deps) {
|
|
|
882
1350
|
? new globalThis.BroadcastChannel(`weave-node:${deps.rootDid}:${space.id}`)
|
|
883
1351
|
: null;
|
|
884
1352
|
if (channel) {
|
|
885
|
-
channel.onmessage = () =>
|
|
1353
|
+
channel.onmessage = () => {
|
|
1354
|
+
// The other tab wrote to the store underneath: what sync compares is read again.
|
|
1355
|
+
storage.invalidate();
|
|
1356
|
+
recordsChanged();
|
|
1357
|
+
};
|
|
886
1358
|
// In Node a channel holds the process open; it must never be the only thing doing so.
|
|
887
1359
|
channel.unref?.();
|
|
888
1360
|
}
|
|
1361
|
+
// ─── Holding part of the space ─────────────────────────────────────
|
|
1362
|
+
//
|
|
1363
|
+
// With `cache` set, and once the space names a keeper, this node holds only
|
|
1364
|
+
// the collections it uses, besides the space's own. Sync takes in nothing
|
|
1365
|
+
// else. Its own writes are pending until enough keepers have them, and a
|
|
1366
|
+
// collection holding a pending write is never dropped. Without a keeper the
|
|
1367
|
+
// space is held whole: then this node may be one of its copies.
|
|
1368
|
+
const HOUR = 3_600_000;
|
|
1369
|
+
const DAY = 24 * HOUR;
|
|
1370
|
+
const declared = new Set((cache?.collections ?? []).filter((c) => !c.startsWith('sys.')));
|
|
1371
|
+
let cacheTimer = null;
|
|
1372
|
+
const saveCache = () => {
|
|
1373
|
+
if (cacheTimer || !cache)
|
|
1374
|
+
return;
|
|
1375
|
+
cacheTimer = setTimeout(() => {
|
|
1376
|
+
cacheTimer = null;
|
|
1377
|
+
void adapter.put(CACHE_KEY, utf8Encode(JSON.stringify(cacheState))).catch(() => { });
|
|
1378
|
+
}, 500);
|
|
1379
|
+
cacheTimer.unref?.();
|
|
1380
|
+
};
|
|
1381
|
+
const flushCache = async () => {
|
|
1382
|
+
if (!cacheTimer)
|
|
1383
|
+
return;
|
|
1384
|
+
clearTimeout(cacheTimer);
|
|
1385
|
+
cacheTimer = null;
|
|
1386
|
+
await adapter.put(CACHE_KEY, utf8Encode(JSON.stringify(cacheState))).catch(() => { });
|
|
1387
|
+
};
|
|
1388
|
+
saveCache();
|
|
1389
|
+
/**
|
|
1390
|
+
* Holds part of the space from the moment it names a keeper, and all of it
|
|
1391
|
+
* again if it stops — once this node has caught up enough to know.
|
|
1392
|
+
*/
|
|
1393
|
+
async function refreshHolds() {
|
|
1394
|
+
if (!cache || closed)
|
|
1395
|
+
return;
|
|
1396
|
+
const next = (await access()).history.current.keepers.length > 0 || !cacheState.settled;
|
|
1397
|
+
if (next === partial)
|
|
1398
|
+
return;
|
|
1399
|
+
partial = next;
|
|
1400
|
+
// Peers learn what this node holds now; anything newly held comes across.
|
|
1401
|
+
sync.notifyPeers(connectedPeers());
|
|
1402
|
+
emit({ type: 'status', space: space.id });
|
|
1403
|
+
}
|
|
1404
|
+
/** Collections a query or a screen uses: held from now on. Whether all of them are here yet. */
|
|
1405
|
+
function use(collections) {
|
|
1406
|
+
if (!cache)
|
|
1407
|
+
return true;
|
|
1408
|
+
const now = Date.now();
|
|
1409
|
+
let added = false;
|
|
1410
|
+
for (const collection of collections) {
|
|
1411
|
+
if (typeof collection !== 'string' || collection.startsWith('sys.'))
|
|
1412
|
+
continue;
|
|
1413
|
+
const was = cacheState.used[collection];
|
|
1414
|
+
if (was === undefined)
|
|
1415
|
+
added = true;
|
|
1416
|
+
if (was === undefined || now - was > HOUR) {
|
|
1417
|
+
cacheState.used[collection] = now;
|
|
1418
|
+
saveCache();
|
|
1419
|
+
}
|
|
1420
|
+
}
|
|
1421
|
+
if (added && partial)
|
|
1422
|
+
sync.notifyPeers(connectedPeers());
|
|
1423
|
+
return !partial || collections.every((c) => c.startsWith('sys.') || cacheState.level[c] !== undefined);
|
|
1424
|
+
}
|
|
1425
|
+
/** A write of this node's, waiting for keepers — only on a node that may hold part of a space */
|
|
1426
|
+
async function markPending(version) {
|
|
1427
|
+
if (!cache || version.author !== session.did || version.collection.startsWith('sys.'))
|
|
1428
|
+
return;
|
|
1429
|
+
const pending = { collection: version.collection, by: [] };
|
|
1430
|
+
await adapter.put(`${PENDING_PREFIX}${version.id}`, utf8Encode(JSON.stringify(pending)));
|
|
1431
|
+
}
|
|
1432
|
+
/**
|
|
1433
|
+
* A keeper has some of this node's pending writes: those it named, or every
|
|
1434
|
+
* one in a collection it is level on. Once enough keepers have one, it
|
|
1435
|
+
* stops being pending. Only keepers the space names count.
|
|
1436
|
+
*/
|
|
1437
|
+
async function confirm(peer, which) {
|
|
1438
|
+
if (!cache)
|
|
1439
|
+
return;
|
|
1440
|
+
const state = (await access()).history.current;
|
|
1441
|
+
if (!state.keepers.some((keeper) => keeper.did === peer))
|
|
1442
|
+
return;
|
|
1443
|
+
const target = Math.min(state.keepers.length, Math.max(state.copies ?? 2, cache.copies ?? 0));
|
|
1444
|
+
for (const name of await adapter.list(PENDING_PREFIX)) {
|
|
1445
|
+
const id = name.slice(PENDING_PREFIX.length);
|
|
1446
|
+
if (which.ids && !which.ids.has(id))
|
|
1447
|
+
continue;
|
|
1448
|
+
const bytes = await adapter.get(name);
|
|
1449
|
+
if (!bytes)
|
|
1450
|
+
continue;
|
|
1451
|
+
const pending = JSON.parse(utf8Decode(bytes));
|
|
1452
|
+
if (which.collection !== undefined && pending.collection !== which.collection)
|
|
1453
|
+
continue;
|
|
1454
|
+
if (!pending.by.includes(peer))
|
|
1455
|
+
pending.by.push(peer);
|
|
1456
|
+
if (pending.by.length >= target)
|
|
1457
|
+
await adapter.delete(name);
|
|
1458
|
+
else
|
|
1459
|
+
await adapter.put(name, utf8Encode(JSON.stringify(pending)));
|
|
1460
|
+
}
|
|
1461
|
+
}
|
|
1462
|
+
sync.on('synced', (_peer, full) => {
|
|
1463
|
+
if (!cache || !full || cacheState.settled)
|
|
1464
|
+
return;
|
|
1465
|
+
// Caught up with a node holding everything: the access history here is the space's now.
|
|
1466
|
+
cacheState.settled = true;
|
|
1467
|
+
saveCache();
|
|
1468
|
+
void refreshHolds().catch(() => { });
|
|
1469
|
+
});
|
|
1470
|
+
sync.on('stored', (peer, ids) => {
|
|
1471
|
+
void confirm(peer, { ids: new Set(ids) }).catch(() => { });
|
|
1472
|
+
});
|
|
1473
|
+
sync.on('level', (peer, collection, full) => {
|
|
1474
|
+
if (cache && full && cacheState.level[collection] === undefined) {
|
|
1475
|
+
cacheState.level[collection] = Date.now();
|
|
1476
|
+
saveCache();
|
|
1477
|
+
// A query that said "not complete" says so no longer.
|
|
1478
|
+
if (partial)
|
|
1479
|
+
emit({ type: 'records', space: space.id });
|
|
1480
|
+
}
|
|
1481
|
+
void confirm(peer, { collection }).catch(() => { });
|
|
1482
|
+
});
|
|
1483
|
+
/**
|
|
1484
|
+
* Drops collections no query has touched for a while: their records go,
|
|
1485
|
+
* and they are no longer held. Never one the app declared, nor one holding
|
|
1486
|
+
* a write still waiting for keepers.
|
|
1487
|
+
*/
|
|
1488
|
+
async function dropUnused() {
|
|
1489
|
+
if (!cache || !partial || closed)
|
|
1490
|
+
return;
|
|
1491
|
+
const cutoff = Date.now() - (cache.unusedAfterDays ?? 30) * DAY;
|
|
1492
|
+
const waiting = new Set();
|
|
1493
|
+
for (const name of await adapter.list(PENDING_PREFIX)) {
|
|
1494
|
+
const bytes = await adapter.get(name);
|
|
1495
|
+
if (bytes)
|
|
1496
|
+
waiting.add(JSON.parse(utf8Decode(bytes)).collection);
|
|
1497
|
+
}
|
|
1498
|
+
let dropped = false;
|
|
1499
|
+
for (const [collection, at] of Object.entries(cacheState.used)) {
|
|
1500
|
+
if (at > cutoff || declared.has(collection) || waiting.has(collection))
|
|
1501
|
+
continue;
|
|
1502
|
+
// No longer held first, so nothing syncs it back while it goes.
|
|
1503
|
+
delete cacheState.used[collection];
|
|
1504
|
+
delete cacheState.level[collection];
|
|
1505
|
+
for (const item of (await storage.items(collection)).items)
|
|
1506
|
+
await storage.removeExpression(cidOfDigest(item.id));
|
|
1507
|
+
dropped = true;
|
|
1508
|
+
}
|
|
1509
|
+
if (!dropped)
|
|
1510
|
+
return;
|
|
1511
|
+
saveCache();
|
|
1512
|
+
recordsChanged();
|
|
1513
|
+
sync.notifyPeers(connectedPeers());
|
|
1514
|
+
}
|
|
1515
|
+
// When the app opens the space, then every few hours while it stays open.
|
|
1516
|
+
const dropTimer = cache ? setInterval(() => void dropUnused().catch(() => { }), 6 * HOUR) : null;
|
|
1517
|
+
const tidying = cache ? dropUnused().catch(() => { }) : Promise.resolve();
|
|
1518
|
+
dropTimer?.unref?.();
|
|
889
1519
|
/** Why this account cannot write here at all, or null when it can */
|
|
890
1520
|
async function cannotWrite() {
|
|
891
1521
|
const { history } = await access();
|
|
@@ -914,6 +1544,7 @@ export async function openSpaceRuntime(deps) {
|
|
|
914
1544
|
throw new Error(problem);
|
|
915
1545
|
}
|
|
916
1546
|
let payload = null;
|
|
1547
|
+
let tags = [];
|
|
917
1548
|
if (!deleted) {
|
|
918
1549
|
// Refused here, where the writer can fix it. On arrival a misfit is kept
|
|
919
1550
|
// and flagged instead — see `conforms`.
|
|
@@ -925,13 +1556,26 @@ export async function openSpaceRuntime(deps) {
|
|
|
925
1556
|
// signature and relay the record, they just cannot read it.
|
|
926
1557
|
payload = body;
|
|
927
1558
|
if (space.visibility === 'private' && !IN_THE_CLEAR.has(collection)) {
|
|
928
|
-
|
|
929
|
-
|
|
1559
|
+
// Always the key the history names now: one someone removed still holds would do nothing.
|
|
1560
|
+
const key = currentKey((await access()).history.current);
|
|
1561
|
+
if (!key) {
|
|
1562
|
+
throw new Error(keyring.size
|
|
1563
|
+
? `The key of "${space.name}" changed, and the new one hasn't reached this device yet — connect to someone in the space first`
|
|
1564
|
+
: 'This private space has no key on this node');
|
|
1565
|
+
}
|
|
930
1566
|
// Body and links sealed together: a relay learns neither.
|
|
931
1567
|
const content = links.length ? { body, links } : { body };
|
|
932
1568
|
const sealed = await encryptExpression({ id: '', author: '', collection, createdAt: '', body: content, signature: '', key: version.key, seq: version.seq }, key);
|
|
933
1569
|
payload = sealed.body;
|
|
934
1570
|
}
|
|
1571
|
+
// Worked out from the body, with the key it is sealed with: what every reader will check.
|
|
1572
|
+
const topics = collection.startsWith('sys.') ? [] : ((await catalog()).get(collection)?.definition.topics ?? []);
|
|
1573
|
+
if (topics.length) {
|
|
1574
|
+
const sealedWith = looksEncrypted(payload) ? String(payload.keyId) : null;
|
|
1575
|
+
const key = tagKey(sealedWith);
|
|
1576
|
+
if (key)
|
|
1577
|
+
tags = await tagsFor(await key, collection, topics, body);
|
|
1578
|
+
}
|
|
935
1579
|
}
|
|
936
1580
|
// Whether superseded versions are kept is the writer's decision, carried
|
|
937
1581
|
// on the version — never each reader's, or nodes that had seen different
|
|
@@ -939,6 +1583,8 @@ export async function openSpaceRuntime(deps) {
|
|
|
939
1583
|
// history keeps everything: it is replayed whole.
|
|
940
1584
|
const retain = ACCESS_COLLECTIONS.has(collection) ||
|
|
941
1585
|
collection === PROFILE_COLLECTION ||
|
|
1586
|
+
collection === BOX_COLLECTION ||
|
|
1587
|
+
collection === MEMBER_KEY_COLLECTION ||
|
|
942
1588
|
(await catalog()).get(collection)?.definition.history === 'all';
|
|
943
1589
|
const { history, events: held } = await access();
|
|
944
1590
|
const signed = await signer.sign(createExpression({
|
|
@@ -953,6 +1599,7 @@ export async function openSpaceRuntime(deps) {
|
|
|
953
1599
|
seen: history.heads(),
|
|
954
1600
|
// In the clear only where the body is: a private space sealed them above.
|
|
955
1601
|
...(space.visibility === 'public' && !deleted && links.length ? { links } : {}),
|
|
1602
|
+
...(tags.length ? { tags } : {}),
|
|
956
1603
|
}), writer.key);
|
|
957
1604
|
// The same verdict every other peer will reach: refused here, with the reason, rather than there.
|
|
958
1605
|
const judged = await judge(signed);
|
|
@@ -972,6 +1619,7 @@ export async function openSpaceRuntime(deps) {
|
|
|
972
1619
|
throw new Error(stands.reason);
|
|
973
1620
|
}
|
|
974
1621
|
await storage.addExpression(signed);
|
|
1622
|
+
await markPending(signed);
|
|
975
1623
|
channel?.postMessage('changed');
|
|
976
1624
|
sync.onLocalChange(signed);
|
|
977
1625
|
recordsChanged();
|
|
@@ -1041,8 +1689,13 @@ export async function openSpaceRuntime(deps) {
|
|
|
1041
1689
|
const creator = !!first && (await judge(first)).root === deps.rootDid;
|
|
1042
1690
|
return allows(who, { member: true, creator, can: (permission) => roleHolds(role, permissionName(collection, permission)) });
|
|
1043
1691
|
}
|
|
1692
|
+
// Once at open, then after every change.
|
|
1693
|
+
keepUp();
|
|
1694
|
+
const pulling = pullMirrors();
|
|
1044
1695
|
return Object.freeze({
|
|
1045
1696
|
async list(options = {}) {
|
|
1697
|
+
if (options.collection)
|
|
1698
|
+
use([options.collection]);
|
|
1046
1699
|
const current = (await everyCurrent(options.collection)).filter((e) => options.collection || !e.collection.startsWith('sys.'));
|
|
1047
1700
|
const shown = [];
|
|
1048
1701
|
for (const version of current) {
|
|
@@ -1090,9 +1743,37 @@ export async function openSpaceRuntime(deps) {
|
|
|
1090
1743
|
if (await cannotWrite())
|
|
1091
1744
|
return;
|
|
1092
1745
|
const name = profile.name.trim().slice(0, 64);
|
|
1093
|
-
|
|
1746
|
+
const mine = (await profileMap()).get(deps.rootDid);
|
|
1747
|
+
// An app without the contact key must not take away the one another device published.
|
|
1748
|
+
const contactKey = profile.contactKey ?? mine?.contactKey;
|
|
1749
|
+
if (!name || (mine?.name === name && mine.contactKey === contactKey))
|
|
1094
1750
|
return;
|
|
1095
|
-
await upsert(PROFILE_COLLECTION, await profileKey(deps.rootDid), { name });
|
|
1751
|
+
await upsert(PROFILE_COLLECTION, await profileKey(deps.rootDid), { name, ...(contactKey ? { contactKey } : {}) });
|
|
1752
|
+
},
|
|
1753
|
+
rotateKey,
|
|
1754
|
+
readAccess: () => readAccess,
|
|
1755
|
+
async setRelays(named) {
|
|
1756
|
+
const problem = checkRelays(named);
|
|
1757
|
+
if (problem)
|
|
1758
|
+
throw new Error(problem);
|
|
1759
|
+
await upsert(RELAYS_COLLECTION, SPACE_RELAYS_RECORD, { relays: [...named] });
|
|
1760
|
+
},
|
|
1761
|
+
async topicTag(collection, field, value) {
|
|
1762
|
+
let keyId = null;
|
|
1763
|
+
if (space.visibility === 'private') {
|
|
1764
|
+
const key = currentKey((await access()).history.current);
|
|
1765
|
+
if (!key)
|
|
1766
|
+
throw new Error(`This device doesn't hold the current key of "${space.name}" yet`);
|
|
1767
|
+
keyId = key.id;
|
|
1768
|
+
}
|
|
1769
|
+
return topicTag(await tagKey(keyId), collection, field, value);
|
|
1770
|
+
},
|
|
1771
|
+
async setKeepers(keepers, copies = null) {
|
|
1772
|
+
const named = keepers.map((k) => ({ did: k.did, name: k.name }));
|
|
1773
|
+
const problem = checkKeepers(named, copies);
|
|
1774
|
+
if (problem)
|
|
1775
|
+
throw new Error(problem);
|
|
1776
|
+
await upsert(KEEPERS_COLLECTION, SPACE_KEEPERS_RECORD, { keepers: named, copies });
|
|
1096
1777
|
},
|
|
1097
1778
|
async update(recordKey, body, options = {}) {
|
|
1098
1779
|
const current = await requireLive(recordKey);
|
|
@@ -1102,6 +1783,8 @@ export async function openSpaceRuntime(deps) {
|
|
|
1102
1783
|
return view(await write(current.collection, body, await after(recordKey), false, links, options.as ? { as: options.as } : {}));
|
|
1103
1784
|
},
|
|
1104
1785
|
async linked(recordKey, options = {}) {
|
|
1786
|
+
if (options.collection)
|
|
1787
|
+
use([options.collection]);
|
|
1105
1788
|
const pointing = (await linkIndex()).get(recordKey) ?? [];
|
|
1106
1789
|
const keys = [...new Set(pointing.filter((p) => !options.rel || p.rel === options.rel).map((p) => p.from))];
|
|
1107
1790
|
const found = [];
|
|
@@ -1152,6 +1835,7 @@ export async function openSpaceRuntime(deps) {
|
|
|
1152
1835
|
...(input.links !== undefined ? { links: input.links } : {}),
|
|
1153
1836
|
...(input.permissions !== undefined ? { permissions: input.permissions } : {}),
|
|
1154
1837
|
...(input.rules !== undefined ? { rules: input.rules } : {}),
|
|
1838
|
+
...(input.topics !== undefined ? { topics: input.topics } : {}),
|
|
1155
1839
|
...(input.screen !== undefined ? { screen: input.screen } : {}),
|
|
1156
1840
|
};
|
|
1157
1841
|
const problem = checkStoredCollection(definition);
|
|
@@ -1186,6 +1870,10 @@ export async function openSpaceRuntime(deps) {
|
|
|
1186
1870
|
invites: [...state.invites].map(([inviteDid, invite]) => ({ key: inviteDid, role: invite.role, open: invite.open })),
|
|
1187
1871
|
role,
|
|
1188
1872
|
heads: history.heads(),
|
|
1873
|
+
key: state.keys.length ? { changes: state.keys.length - 1, held: currentKey(state) !== null } : null,
|
|
1874
|
+
relays: state.relays.length ? state.relays : relays,
|
|
1875
|
+
keepers: state.keepers,
|
|
1876
|
+
copies: state.copies,
|
|
1189
1877
|
});
|
|
1190
1878
|
},
|
|
1191
1879
|
async setMember(did, role) {
|
|
@@ -1272,10 +1960,13 @@ export async function openSpaceRuntime(deps) {
|
|
|
1272
1960
|
connection,
|
|
1273
1961
|
peers: connectedPeers(),
|
|
1274
1962
|
accounts: Object.fromEntries([...knownAccounts].flatMap(([peer, known]) => (known.account && routes.has(peer) ? [[peer, known.account]] : []))),
|
|
1275
|
-
|
|
1963
|
+
fingerprint: await storage.fingerprint(),
|
|
1276
1964
|
rejected,
|
|
1965
|
+
holds: partial ? Object.keys(cacheState.used).sort() : 'all',
|
|
1966
|
+
pending: (await adapter.list(PENDING_PREFIX)).length,
|
|
1277
1967
|
};
|
|
1278
1968
|
},
|
|
1969
|
+
use,
|
|
1279
1970
|
async send(message, to) {
|
|
1280
1971
|
const encoded = JSON.stringify(message ?? null);
|
|
1281
1972
|
if (encoded.length > MAX_LIVE_BYTES)
|
|
@@ -1287,6 +1978,14 @@ export async function openSpaceRuntime(deps) {
|
|
|
1287
1978
|
}
|
|
1288
1979
|
},
|
|
1289
1980
|
async close() {
|
|
1981
|
+
closed = true;
|
|
1982
|
+
if (dropTimer)
|
|
1983
|
+
clearInterval(dropTimer);
|
|
1984
|
+
await tidying;
|
|
1985
|
+
await flushCache();
|
|
1986
|
+
await keysRunning;
|
|
1987
|
+
await pulling;
|
|
1988
|
+
await Promise.all(mirrors.map((mirror) => mirror.close()));
|
|
1290
1989
|
if (watchTimer)
|
|
1291
1990
|
clearInterval(watchTimer);
|
|
1292
1991
|
if (announceTimer)
|