@drakkar.software/octospaces-sdk 0.1.0 → 0.4.3
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/CHANGELOG.md +200 -0
- package/dist/index.d.ts +481 -274
- package/dist/index.js +1000 -493
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/core/types.ts +50 -83
- package/src/index.ts +62 -28
- package/src/objects/objects.test.ts +55 -95
- package/src/objects/objects.ts +23 -136
- package/src/spaces/members.test.ts +10 -3
- package/src/spaces/members.ts +86 -49
- package/src/spaces/nodes.test.ts +225 -0
- package/src/spaces/nodes.ts +427 -0
- package/src/spaces/object-index.test.ts +127 -71
- package/src/spaces/object-index.ts +61 -107
- package/src/spaces/registry.test.ts +59 -46
- package/src/spaces/registry.ts +28 -47
- package/src/sync/client.ts +20 -15
- package/src/sync/pairing.ts +10 -12
- package/src/sync/paths.test.ts +124 -16
- package/src/sync/paths.ts +73 -32
- package/src/sync/space-access-store.ts +17 -0
- package/src/sync/space-access.ts +112 -67
- package/src/utils/invite-preview.test.ts +169 -0
- package/src/utils/invite-preview.ts +101 -0
- package/src/utils/live-sync-bus.test.ts +116 -0
- package/src/utils/live-sync-bus.ts +71 -0
- package/src/utils/search-match.test.ts +149 -0
- package/src/utils/search-match.ts +145 -0
|
@@ -0,0 +1,427 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-node creation, access management, and invite flows.
|
|
3
|
+
*
|
|
4
|
+
* Nodes are the atomic content units of a space (rooms in OctoChat, pages/projects in
|
|
5
|
+
* OctoVault). Each node carries two independent axes:
|
|
6
|
+
* - `access`: `'public' | 'space' | 'invite'` — who may reach the node.
|
|
7
|
+
* - `enc`: `boolean` — whether content is E2EE under the SPACE-WIDE keyring.
|
|
8
|
+
*
|
|
9
|
+
* Invalid combo: `access:'public'` + `enc:true` is rejected outright.
|
|
10
|
+
*
|
|
11
|
+
* Encryption uses ONE space keyring (at `spaces/{spaceId}/_keyring`). Any space member
|
|
12
|
+
* holding the keyring can decrypt ALL `enc` nodes in the space — the keyring is coarse-
|
|
13
|
+
* grained by design. For `access:'invite'` + `enc:true` nodes, inviting someone to the
|
|
14
|
+
* node also grants them the space key (and thus access to all enc content in the space).
|
|
15
|
+
*
|
|
16
|
+
* Invite flows mirror the space membership flows in `members.ts` but scoped per node.
|
|
17
|
+
*
|
|
18
|
+
* DIRECT INVITE:
|
|
19
|
+
* - `enc` node: owner adds invitee to space keyring + mints space cap → invitee calls
|
|
20
|
+
* `acceptNodeInvite`, storing the space cap.
|
|
21
|
+
* - `invite+plaintext` node: owner mints per-node narrow cap (nodeMemberScope) →
|
|
22
|
+
* invitee calls `acceptNodeInvite`, storing the per-node cap.
|
|
23
|
+
*
|
|
24
|
+
* LINK INVITE:
|
|
25
|
+
* - `enc` node: owner adds ephemeral KEM to space keyring; link cap uses spaceMemberScope.
|
|
26
|
+
* - `invite+plaintext` node: ephemeral keypair, narrow per-node cap (nodeMemberScope).
|
|
27
|
+
* - Bearer: `joinNodeByLink` — stores per-node `{kind:'link'}` entry.
|
|
28
|
+
*/
|
|
29
|
+
import { generateDeviceKeys } from '@drakkar.software/starfish-identities';
|
|
30
|
+
import { addCollectionRecipient } from '@drakkar.software/starfish-keyring';
|
|
31
|
+
import { mintMemberCap } from '@drakkar.software/starfish-sharing';
|
|
32
|
+
|
|
33
|
+
import type { NodeAccess, ObjectNode, ObjectType } from '../core/types.js';
|
|
34
|
+
import { ownerEnsureKeyring } from '../sync/client.js';
|
|
35
|
+
import type { Session } from '../sync/identity.js';
|
|
36
|
+
import { ownerTrustedAdders } from '../sync/identity.js';
|
|
37
|
+
import {
|
|
38
|
+
keyringName,
|
|
39
|
+
keyringPull,
|
|
40
|
+
keyringPush,
|
|
41
|
+
nodeMemberScope,
|
|
42
|
+
spaceMemberScope,
|
|
43
|
+
userIdFromEdPub,
|
|
44
|
+
} from '../sync/paths.js';
|
|
45
|
+
import {
|
|
46
|
+
getSpaceClient,
|
|
47
|
+
} from '../sync/space-access.js';
|
|
48
|
+
import {
|
|
49
|
+
getNodeAccessEntry,
|
|
50
|
+
saveNodeAccessEntry,
|
|
51
|
+
saveSpaceAccessEntry,
|
|
52
|
+
} from '../sync/space-access-store.js';
|
|
53
|
+
import { sealToSelf } from '../sync/account-seal.js';
|
|
54
|
+
import { toBase64Url, fromBase64Url } from '../sync/base64url.js';
|
|
55
|
+
import { addObject } from '../objects/objects.js';
|
|
56
|
+
import { updateObjectIndex } from './object-index.js';
|
|
57
|
+
import { addSpaceMember, readSpaces } from './registry.js';
|
|
58
|
+
import { randomId } from '../core/ids.js';
|
|
59
|
+
import type { JoinRequest } from './members.js';
|
|
60
|
+
|
|
61
|
+
// ── helpers ──────────────────────────────────────────────────────────────────
|
|
62
|
+
|
|
63
|
+
function isAlreadyPresentRecipient(err: unknown): boolean {
|
|
64
|
+
return err instanceof Error && /already present in epoch/.test(err.message);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// ── createNode ────────────────────────────────────────────────────────────────
|
|
68
|
+
|
|
69
|
+
export interface CreateNodeInput {
|
|
70
|
+
type: ObjectType;
|
|
71
|
+
title: string;
|
|
72
|
+
emoji?: string;
|
|
73
|
+
parentId?: string | null;
|
|
74
|
+
/** Who may reach this node. Default: `'space'`. */
|
|
75
|
+
access?: NodeAccess;
|
|
76
|
+
/** Whether node content is E2EE under the space-wide keyring. Default: `false`. */
|
|
77
|
+
enc?: boolean;
|
|
78
|
+
/** App-specific metadata. */
|
|
79
|
+
meta?: Record<string, unknown>;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Create a new node in a space's object index.
|
|
84
|
+
*
|
|
85
|
+
* - Rejects the invalid combo `public+enc`.
|
|
86
|
+
* - For `enc` nodes, ensures the space-wide keyring exists (minted once per space,
|
|
87
|
+
* idempotent on subsequent creates).
|
|
88
|
+
* - Returns the created node as it was inserted into the index.
|
|
89
|
+
*/
|
|
90
|
+
export async function createNode(
|
|
91
|
+
session: Session,
|
|
92
|
+
spaceId: string,
|
|
93
|
+
input: CreateNodeInput,
|
|
94
|
+
reg?: { owner: string | null; members: string[] } | null,
|
|
95
|
+
): Promise<ObjectNode> {
|
|
96
|
+
const access = input.access ?? 'space';
|
|
97
|
+
const enc = input.enc ?? false;
|
|
98
|
+
if (access === 'public' && enc) throw new Error('public+enc is not a valid combination.');
|
|
99
|
+
|
|
100
|
+
const nodeId = `obj-${randomId()}`;
|
|
101
|
+
|
|
102
|
+
if (enc) {
|
|
103
|
+
// Ensure the space-wide keyring exists (idempotent — minted once per space).
|
|
104
|
+
const client = getSpaceClient(spaceId, session);
|
|
105
|
+
await ownerEnsureKeyring(
|
|
106
|
+
client,
|
|
107
|
+
session.keys,
|
|
108
|
+
keyringPull(spaceId),
|
|
109
|
+
keyringPush(spaceId),
|
|
110
|
+
ownerTrustedAdders(session),
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
let createdNode: ObjectNode | null = null;
|
|
115
|
+
|
|
116
|
+
await updateObjectIndex(session, spaceId, (nodes, now) => {
|
|
117
|
+
const { nodes: next, node } = addObject(nodes, {
|
|
118
|
+
id: nodeId,
|
|
119
|
+
type: input.type,
|
|
120
|
+
title: input.title,
|
|
121
|
+
...(input.emoji ? { emoji: input.emoji } : {}),
|
|
122
|
+
parentId: input.parentId ?? null,
|
|
123
|
+
...(input.meta ? { meta: input.meta } : {}),
|
|
124
|
+
access,
|
|
125
|
+
enc: enc || undefined,
|
|
126
|
+
}, now);
|
|
127
|
+
createdNode = next.find((n) => n.id === nodeId) ?? node;
|
|
128
|
+
return next;
|
|
129
|
+
}, reg);
|
|
130
|
+
|
|
131
|
+
if (!createdNode) throw new Error('createNode: index update did not produce a node');
|
|
132
|
+
return createdNode;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// ── setNodeAccess ─────────────────────────────────────────────────────────────
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Patch the `access`/`enc` axes of a node in the index.
|
|
139
|
+
*
|
|
140
|
+
* - Rejects `public+enc`.
|
|
141
|
+
* - For enabling `enc`, ensures the space keyring exists (idempotent).
|
|
142
|
+
* - Content migration (moving between `objpub`/`objdoc`/`objinv`) is the caller's
|
|
143
|
+
* responsibility — this only flips the metadata flags.
|
|
144
|
+
*/
|
|
145
|
+
export async function setNodeAccess(
|
|
146
|
+
session: Session,
|
|
147
|
+
spaceId: string,
|
|
148
|
+
nodeId: string,
|
|
149
|
+
patch: { access?: NodeAccess; enc?: boolean },
|
|
150
|
+
reg?: { owner: string | null; members: string[] } | null,
|
|
151
|
+
): Promise<void> {
|
|
152
|
+
if (patch.access === 'public' && patch.enc) throw new Error('public+enc is not valid.');
|
|
153
|
+
|
|
154
|
+
if (patch.enc) {
|
|
155
|
+
// Ensure the space-wide keyring exists (idempotent).
|
|
156
|
+
const client = getSpaceClient(spaceId, session);
|
|
157
|
+
await ownerEnsureKeyring(
|
|
158
|
+
client,
|
|
159
|
+
session.keys,
|
|
160
|
+
keyringPull(spaceId),
|
|
161
|
+
keyringPush(spaceId),
|
|
162
|
+
ownerTrustedAdders(session),
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
await updateObjectIndex(session, spaceId, (nodes, now) => {
|
|
167
|
+
const idx = nodes.findIndex((n) => n.id === nodeId);
|
|
168
|
+
if (idx < 0) return null;
|
|
169
|
+
const cur = nodes[idx]!;
|
|
170
|
+
|
|
171
|
+
const next: ObjectNode = { ...cur, updatedAt: now };
|
|
172
|
+
|
|
173
|
+
if (patch.access !== undefined) {
|
|
174
|
+
if (patch.access === 'space') {
|
|
175
|
+
delete (next as unknown as Record<string, unknown>).access;
|
|
176
|
+
} else {
|
|
177
|
+
next.access = patch.access;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
if (patch.enc !== undefined) {
|
|
182
|
+
if (!patch.enc) {
|
|
183
|
+
delete (next as unknown as Record<string, unknown>).enc;
|
|
184
|
+
} else {
|
|
185
|
+
next.enc = true;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// Re-validate after applying both patches
|
|
190
|
+
if (next.access === 'public' && next.enc) throw new Error('public+enc is not valid.');
|
|
191
|
+
|
|
192
|
+
const unchanged =
|
|
193
|
+
next.access === cur.access &&
|
|
194
|
+
(next.enc ?? false) === (cur.enc ?? false);
|
|
195
|
+
if (unchanged) return null;
|
|
196
|
+
|
|
197
|
+
return nodes.map((n, i) => (i === idx ? next : n));
|
|
198
|
+
}, reg);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// ── Direct invite ─────────────────────────────────────────────────────────────
|
|
202
|
+
|
|
203
|
+
export interface NodeInviteBundle {
|
|
204
|
+
spaceId: string;
|
|
205
|
+
nodeId: string;
|
|
206
|
+
nodeName: string;
|
|
207
|
+
/** Space-level member cap (always present — grants index read access). */
|
|
208
|
+
cap: unknown;
|
|
209
|
+
/** Per-node narrow cap (only for `invite+plaintext` nodes). */
|
|
210
|
+
nodeCap?: unknown;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Owner: invite an identity to a specific node.
|
|
215
|
+
*
|
|
216
|
+
* - For `enc` nodes: adds the invitee to the space-wide keyring (granting decryption
|
|
217
|
+
* access to ALL enc nodes in the space) and mints a space-level member cap.
|
|
218
|
+
* - For `invite+plaintext` nodes: mints both a space-level cap (index) and a
|
|
219
|
+
* narrow per-node cap (`nodeMemberScope`, covers `objinv` content).
|
|
220
|
+
*
|
|
221
|
+
* Returns the invite bundle JSON; pass to the invitee who calls `acceptNodeInvite`.
|
|
222
|
+
*/
|
|
223
|
+
export async function inviteToNode(
|
|
224
|
+
session: Session,
|
|
225
|
+
spaceId: string,
|
|
226
|
+
nodeId: string,
|
|
227
|
+
requestJson: string,
|
|
228
|
+
node: { enc?: boolean },
|
|
229
|
+
nodeName?: string,
|
|
230
|
+
): Promise<string> {
|
|
231
|
+
const req = JSON.parse(requestJson) as JoinRequest;
|
|
232
|
+
if (!req.edPub || !req.kemPub || !req.userId) throw new Error('Invalid join request.');
|
|
233
|
+
|
|
234
|
+
if (node.enc) {
|
|
235
|
+
// Add invitee's KEM key to the SPACE-WIDE keyring (grants access to all enc nodes).
|
|
236
|
+
try {
|
|
237
|
+
await addCollectionRecipient(
|
|
238
|
+
session.chatClient,
|
|
239
|
+
keyringName(spaceId),
|
|
240
|
+
{ subKem: req.kemPub, userId: req.userId, label: req.userId.slice(0, 8) },
|
|
241
|
+
{ edPriv: session.keys.edPriv, edPub: session.keys.edPub, kemPriv: session.keys.kemPriv },
|
|
242
|
+
{ trustedAdders: [session.keys.edPub] },
|
|
243
|
+
);
|
|
244
|
+
} catch (err) {
|
|
245
|
+
if (!isAlreadyPresentRecipient(err)) throw err;
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// Always ensure space membership (for index access)
|
|
250
|
+
await addSpaceMember(session.accountClient, spaceId, session.userId, req.userId);
|
|
251
|
+
|
|
252
|
+
const spaceCap = await mintMemberCap(
|
|
253
|
+
session.keys.edPriv,
|
|
254
|
+
session.keys.edPub,
|
|
255
|
+
{ edPubHex: req.edPub, kemPubHex: req.kemPub, userIdHex: req.userId },
|
|
256
|
+
'chat',
|
|
257
|
+
spaceMemberScope(spaceId, true),
|
|
258
|
+
);
|
|
259
|
+
|
|
260
|
+
const bundle: NodeInviteBundle = {
|
|
261
|
+
spaceId,
|
|
262
|
+
nodeId,
|
|
263
|
+
nodeName: nodeName ?? nodeId,
|
|
264
|
+
cap: spaceCap,
|
|
265
|
+
};
|
|
266
|
+
|
|
267
|
+
if (!node.enc) {
|
|
268
|
+
// invite+plaintext: also mint narrow per-node cap for objinv content
|
|
269
|
+
const perNodeCap = await mintMemberCap(
|
|
270
|
+
session.keys.edPriv,
|
|
271
|
+
session.keys.edPub,
|
|
272
|
+
{ edPubHex: req.edPub, kemPubHex: req.kemPub, userIdHex: req.userId },
|
|
273
|
+
'chat',
|
|
274
|
+
nodeMemberScope(spaceId, nodeId, true),
|
|
275
|
+
);
|
|
276
|
+
bundle.nodeCap = perNodeCap;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
return JSON.stringify(bundle);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* Invitee: accept a direct node invite — store the cap(s) and register access.
|
|
284
|
+
* Returns the nodeId.
|
|
285
|
+
*/
|
|
286
|
+
export async function acceptNodeInvite(session: Session, bundleJson: string): Promise<string> {
|
|
287
|
+
const bundle = JSON.parse(bundleJson) as Partial<NodeInviteBundle>;
|
|
288
|
+
const cap = bundle.cap as { kind?: string; sub?: string } | undefined;
|
|
289
|
+
if (!cap || !bundle.spaceId || !bundle.nodeId) throw new Error('Invalid node invite.');
|
|
290
|
+
if (cap.kind !== 'member') throw new Error('Invalid node invite.');
|
|
291
|
+
if (!cap.sub || cap.sub !== session.keys.edPub) {
|
|
292
|
+
throw new Error('This invite was issued for a different identity.');
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
const capJson = JSON.stringify(cap);
|
|
296
|
+
// Store space-level cap so the invitee can read the index
|
|
297
|
+
saveSpaceAccessEntry(bundle.spaceId, { kind: 'member', cap: capJson });
|
|
298
|
+
|
|
299
|
+
if (bundle.nodeCap) {
|
|
300
|
+
// invite+plaintext: also store narrow per-node cap
|
|
301
|
+
const nodeCapJson = JSON.stringify(bundle.nodeCap);
|
|
302
|
+
saveNodeAccessEntry(bundle.spaceId, bundle.nodeId, { kind: 'member', cap: nodeCapJson });
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
return bundle.nodeId;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// ── Link-based node invite ────────────────────────────────────────────────────
|
|
309
|
+
|
|
310
|
+
/** A node invite link token (v:1). */
|
|
311
|
+
export interface NodeInviteLinkToken {
|
|
312
|
+
v: 1;
|
|
313
|
+
spaceId: string;
|
|
314
|
+
nodeId: string;
|
|
315
|
+
nodeName: string;
|
|
316
|
+
/** Cap scope depends on `enc`: spaceMemberScope for enc nodes, nodeMemberScope for plaintext. */
|
|
317
|
+
cap: unknown;
|
|
318
|
+
/** The ephemeral subject's Ed25519 private key (hex). */
|
|
319
|
+
key: string;
|
|
320
|
+
write: boolean;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
export function encodeNodeInviteLink(origin: string, token: NodeInviteLinkToken): string {
|
|
324
|
+
const base = origin.replace(/\/+$/, '');
|
|
325
|
+
return `${base}/join/node#${toBase64Url(JSON.stringify(token))}`;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
export function decodeNodeInviteLink(fragment: string): NodeInviteLinkToken {
|
|
329
|
+
const frag = fragment.startsWith('#') ? fragment.slice(1) : fragment;
|
|
330
|
+
const tok = JSON.parse(fromBase64Url(frag)) as Partial<NodeInviteLinkToken>;
|
|
331
|
+
if (!tok || !tok.spaceId || !tok.nodeId || !tok.cap || !tok.key) {
|
|
332
|
+
throw new Error('That node invite link is malformed or incomplete.');
|
|
333
|
+
}
|
|
334
|
+
return {
|
|
335
|
+
v: 1,
|
|
336
|
+
spaceId: tok.spaceId,
|
|
337
|
+
nodeId: tok.nodeId,
|
|
338
|
+
nodeName: tok.nodeName ?? tok.nodeId,
|
|
339
|
+
cap: tok.cap,
|
|
340
|
+
key: tok.key,
|
|
341
|
+
write: !!tok.write,
|
|
342
|
+
};
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* Owner: create a shareable invite link for a specific node.
|
|
347
|
+
*
|
|
348
|
+
* - For `enc` nodes: adds ephemeral KEM to the space-wide keyring; the link cap uses
|
|
349
|
+
* `spaceMemberScope` so the bearer can read the keyring and decrypt enc content.
|
|
350
|
+
* - For `invite+plaintext` nodes: narrow per-node cap (`nodeMemberScope`), no keyring.
|
|
351
|
+
*
|
|
352
|
+
* Anyone with the link can access the node; revoke by calling
|
|
353
|
+
* `removeSpaceMember(ephemeralUserId)` (and rotating the space keyring for enc nodes).
|
|
354
|
+
*/
|
|
355
|
+
export async function createNodeInviteLink(
|
|
356
|
+
session: Session,
|
|
357
|
+
spaceId: string,
|
|
358
|
+
nodeId: string,
|
|
359
|
+
nodeName: string,
|
|
360
|
+
node: { enc?: boolean },
|
|
361
|
+
write: boolean,
|
|
362
|
+
origin: string,
|
|
363
|
+
): Promise<{ token: NodeInviteLinkToken; link: string }> {
|
|
364
|
+
const ek = generateDeviceKeys();
|
|
365
|
+
const ephemeralUserId = await userIdFromEdPub(ek.edPub);
|
|
366
|
+
|
|
367
|
+
await addSpaceMember(session.accountClient, spaceId, session.userId, ephemeralUserId);
|
|
368
|
+
|
|
369
|
+
if (node.enc) {
|
|
370
|
+
// Add ephemeral KEM to the SPACE-WIDE keyring
|
|
371
|
+
try {
|
|
372
|
+
await addCollectionRecipient(
|
|
373
|
+
session.chatClient,
|
|
374
|
+
keyringName(spaceId),
|
|
375
|
+
{ subKem: ek.kemPub, userId: ephemeralUserId, label: ephemeralUserId.slice(0, 8) },
|
|
376
|
+
{ edPriv: session.keys.edPriv, edPub: session.keys.edPub, kemPriv: session.keys.kemPriv },
|
|
377
|
+
{ trustedAdders: [session.keys.edPub] },
|
|
378
|
+
);
|
|
379
|
+
} catch (err) {
|
|
380
|
+
if (!isAlreadyPresentRecipient(err)) throw err;
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
// enc nodes need space-scoped cap (must reach the space keyring);
|
|
385
|
+
// plaintext invite nodes use the narrow per-node cap.
|
|
386
|
+
const cap = await mintMemberCap(
|
|
387
|
+
session.keys.edPriv,
|
|
388
|
+
session.keys.edPub,
|
|
389
|
+
{ edPubHex: ek.edPub, kemPubHex: ek.kemPub, userIdHex: ephemeralUserId },
|
|
390
|
+
'chat',
|
|
391
|
+
node.enc
|
|
392
|
+
? spaceMemberScope(spaceId, write)
|
|
393
|
+
: nodeMemberScope(spaceId, nodeId, write),
|
|
394
|
+
);
|
|
395
|
+
|
|
396
|
+
const token: NodeInviteLinkToken = { v: 1, spaceId, nodeId, nodeName, cap, key: ek.edPriv, write };
|
|
397
|
+
return { token, link: encodeNodeInviteLink(origin, token) };
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/**
|
|
401
|
+
* Any user: access a node by redeeming an invite link token.
|
|
402
|
+
* Stores the per-node link entry locally and seals it into the synced `_spaces` doc.
|
|
403
|
+
*/
|
|
404
|
+
export async function joinNodeByLink(session: Session, token: NodeInviteLinkToken): Promise<string> {
|
|
405
|
+
const accessPayload = { cap: token.cap, key: token.key, write: token.write };
|
|
406
|
+
const sealed = await sealToSelf(session, JSON.stringify(accessPayload));
|
|
407
|
+
|
|
408
|
+
// Persist sealed entry into _spaces.pubAccess keyed by spaceId:nodeId
|
|
409
|
+
const { updateSpacesDoc } = await import('./registry.js');
|
|
410
|
+
await updateSpacesDoc(session.accountClient, session.userId, (cur) => ({
|
|
411
|
+
spaces: cur.spaces,
|
|
412
|
+
caps: cur.caps,
|
|
413
|
+
pubAccess: {
|
|
414
|
+
...cur.pubAccess,
|
|
415
|
+
[`${token.spaceId}:${token.nodeId}`]: sealed,
|
|
416
|
+
},
|
|
417
|
+
}));
|
|
418
|
+
|
|
419
|
+
saveNodeAccessEntry(token.spaceId, token.nodeId, {
|
|
420
|
+
kind: 'link',
|
|
421
|
+
cap: token.cap,
|
|
422
|
+
key: token.key,
|
|
423
|
+
write: token.write,
|
|
424
|
+
});
|
|
425
|
+
|
|
426
|
+
return token.nodeId;
|
|
427
|
+
}
|
|
@@ -1,105 +1,161 @@
|
|
|
1
1
|
import { describe, it, expect, vi } from 'vitest';
|
|
2
|
-
import type {
|
|
3
|
-
import {
|
|
4
|
-
import
|
|
2
|
+
import type { StarfishClient } from '@drakkar.software/starfish-client';
|
|
3
|
+
import type { ObjectNode } from '../core/types.js';
|
|
4
|
+
import { pushIndexSeed, updateObjectIndex, readObjectTree } from './object-index.js';
|
|
5
5
|
|
|
6
6
|
// ── Helpers ────────────────────────────────────────────────────────────────────
|
|
7
7
|
|
|
8
|
-
function makeClient(data: unknown, hash = 'h1'): StarfishClient {
|
|
8
|
+
function makeClient(data: unknown, hash: string | null = 'h1'): StarfishClient {
|
|
9
9
|
return {
|
|
10
|
-
pull: vi.fn().mockResolvedValue({ data, hash }),
|
|
10
|
+
pull: vi.fn().mockResolvedValue(data != null ? { data, hash } : null),
|
|
11
11
|
push: vi.fn().mockResolvedValue(undefined),
|
|
12
12
|
} as unknown as StarfishClient;
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
-
function makeEncryptor(decryptOutput: Record<string, unknown>): Encryptor {
|
|
16
|
-
return {
|
|
17
|
-
decrypt: vi.fn().mockResolvedValue(decryptOutput),
|
|
18
|
-
encrypt: vi.fn().mockImplementation(async (v: Record<string, unknown>) => ({ _encrypted: true, ...v })),
|
|
19
|
-
} as unknown as Encryptor;
|
|
20
|
-
}
|
|
21
|
-
|
|
22
15
|
const spaceId = 'sp-test';
|
|
23
|
-
const plainNodes = [{ id: 'r1', type: 'room', subtype: 'channel', parentId: null, order: 0, title: 'general', updatedAt: 1 }];
|
|
24
|
-
const plainIndexDoc = { objects: plainNodes };
|
|
25
16
|
|
|
26
|
-
|
|
17
|
+
const spaceNodes: ObjectNode[] = [
|
|
18
|
+
{ id: 'n1', type: 'page', parentId: null, order: 1, title: 'Intro', updatedAt: 1 },
|
|
19
|
+
{ id: 'n2', type: 'page', parentId: null, order: 2, title: 'About', updatedAt: 2, access: 'space' },
|
|
20
|
+
];
|
|
21
|
+
|
|
22
|
+
const mixedNodes: ObjectNode[] = [
|
|
23
|
+
{ id: 'n1', type: 'page', parentId: null, order: 1, title: 'Public Page', updatedAt: 1, access: 'public' },
|
|
24
|
+
{ id: 'n2', type: 'page', parentId: null, order: 2, title: 'Secret', emoji: '🔒', updatedAt: 2, access: 'invite' },
|
|
25
|
+
{ id: 'n3', type: 'page', parentId: null, order: 3, title: 'Members Only', updatedAt: 3 },
|
|
26
|
+
];
|
|
27
|
+
|
|
28
|
+
// ── pushIndexSeed ──────────────────────────────────────────────────────────────
|
|
27
29
|
|
|
28
|
-
describe('
|
|
29
|
-
it('
|
|
30
|
-
const client = makeClient(
|
|
31
|
-
|
|
32
|
-
expect(
|
|
33
|
-
|
|
30
|
+
describe('pushIndexSeed', () => {
|
|
31
|
+
it('pushes an empty objects array when no seed nodes provided', async () => {
|
|
32
|
+
const client = makeClient(null, null);
|
|
33
|
+
await pushIndexSeed(client, spaceId);
|
|
34
|
+
expect(client.push).toHaveBeenCalled();
|
|
35
|
+
const [, payload] = (client.push as ReturnType<typeof vi.fn>).mock.calls[0] as [string, Record<string, unknown>];
|
|
36
|
+
expect(Array.isArray(payload.objects)).toBe(true);
|
|
37
|
+
expect((payload.objects as unknown[]).length).toBe(0);
|
|
34
38
|
});
|
|
35
39
|
|
|
36
|
-
it('
|
|
37
|
-
const client = makeClient(null);
|
|
38
|
-
|
|
39
|
-
expect(
|
|
40
|
+
it('pushes provided nodes plaintext (no encryption)', async () => {
|
|
41
|
+
const client = makeClient(null, null);
|
|
42
|
+
await pushIndexSeed(client, spaceId, spaceNodes);
|
|
43
|
+
expect(client.push).toHaveBeenCalled();
|
|
44
|
+
const [, payload] = (client.push as ReturnType<typeof vi.fn>).mock.calls[0] as [string, Record<string, unknown>];
|
|
45
|
+
expect(Array.isArray(payload.objects)).toBe(true);
|
|
46
|
+
expect((payload as { _encrypted?: boolean })._encrypted).toBeUndefined();
|
|
40
47
|
});
|
|
41
|
-
});
|
|
42
48
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
const
|
|
47
|
-
|
|
48
|
-
expect(enc.decrypt).toHaveBeenCalled();
|
|
49
|
-
expect(result).not.toBeNull();
|
|
50
|
-
expect(result!.rooms.length).toBeGreaterThan(0);
|
|
49
|
+
it('writes v:2 format', async () => {
|
|
50
|
+
const client = makeClient(null, null);
|
|
51
|
+
await pushIndexSeed(client, spaceId, spaceNodes);
|
|
52
|
+
const [, payload] = (client.push as ReturnType<typeof vi.fn>).mock.calls[0] as [string, Record<string, unknown>];
|
|
53
|
+
expect(payload.v).toBe(2);
|
|
51
54
|
});
|
|
52
55
|
|
|
53
|
-
it('
|
|
54
|
-
const
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
56
|
+
it('is idempotent when an objects array already exists', async () => {
|
|
57
|
+
const client = makeClient({ v: 2, objects: spaceNodes });
|
|
58
|
+
await pushIndexSeed(client, spaceId, spaceNodes);
|
|
59
|
+
expect(client.push).not.toHaveBeenCalled();
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('strips title and emoji from invite nodes before storage', async () => {
|
|
63
|
+
const client = makeClient(null, null);
|
|
64
|
+
await pushIndexSeed(client, spaceId, mixedNodes);
|
|
65
|
+
const [, payload] = (client.push as ReturnType<typeof vi.fn>).mock.calls[0] as [string, Record<string, unknown>];
|
|
66
|
+
const stored = payload.objects as ObjectNode[];
|
|
67
|
+
|
|
68
|
+
// public node: title preserved
|
|
69
|
+
expect(stored.find((n) => n.id === 'n1')?.title).toBe('Public Page');
|
|
70
|
+
|
|
71
|
+
// invite node: title stripped to '', emoji omitted
|
|
72
|
+
const inviteStored = stored.find((n) => n.id === 'n2');
|
|
73
|
+
expect(inviteStored?.title).toBe('');
|
|
74
|
+
expect(inviteStored).not.toHaveProperty('emoji');
|
|
75
|
+
|
|
76
|
+
// space node (default): title preserved
|
|
77
|
+
expect(stored.find((n) => n.id === 'n3')?.title).toBe('Members Only');
|
|
61
78
|
});
|
|
62
79
|
});
|
|
63
80
|
|
|
64
|
-
// ──
|
|
81
|
+
// ── updateObjectIndex ─────────────────────────────────────────────────────────
|
|
65
82
|
|
|
66
|
-
|
|
83
|
+
describe('updateObjectIndex', () => {
|
|
84
|
+
const fakeSession = {
|
|
85
|
+
userId: 'alice',
|
|
86
|
+
keys: { edPriv: 'priv', edPub: 'pub', kemPriv: 'kempriv', kemPub: 'kempub' },
|
|
87
|
+
chatClient: makeClient({ v: 2, objects: spaceNodes }),
|
|
88
|
+
accountClient: makeClient(null),
|
|
89
|
+
} as unknown as import('../sync/identity.js').Session;
|
|
67
90
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
expect(
|
|
91
|
+
it('calls the mutator with current nodes and writes the result', async () => {
|
|
92
|
+
const mutator = vi.fn().mockImplementation((nodes: ObjectNode[]) => [
|
|
93
|
+
...nodes,
|
|
94
|
+
{ id: 'n-new', type: 'page', parentId: null, order: 99, title: 'New', updatedAt: 100 },
|
|
95
|
+
]);
|
|
96
|
+
|
|
97
|
+
await updateObjectIndex(fakeSession, spaceId, mutator);
|
|
98
|
+
|
|
99
|
+
expect(mutator).toHaveBeenCalled();
|
|
100
|
+
const [nodes] = mutator.mock.calls[0] as [ObjectNode[]];
|
|
101
|
+
expect(nodes).toHaveLength(spaceNodes.length);
|
|
77
102
|
});
|
|
78
103
|
|
|
79
|
-
it('is
|
|
80
|
-
const client = makeClient(
|
|
81
|
-
|
|
104
|
+
it('is a no-op when the mutator returns null', async () => {
|
|
105
|
+
const client = makeClient({ v: 2, objects: spaceNodes });
|
|
106
|
+
const session = { ...fakeSession, chatClient: client } as unknown as import('../sync/identity.js').Session;
|
|
107
|
+
await updateObjectIndex(session, spaceId, () => null);
|
|
82
108
|
expect(client.push).not.toHaveBeenCalled();
|
|
83
109
|
});
|
|
84
|
-
});
|
|
85
110
|
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
const
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
111
|
+
it('strips invite titles before pushing', async () => {
|
|
112
|
+
const client = makeClient({ v: 2, objects: [] });
|
|
113
|
+
const session = { ...fakeSession, chatClient: client } as unknown as import('../sync/identity.js').Session;
|
|
114
|
+
|
|
115
|
+
await updateObjectIndex(session, spaceId, (nodes, now) => [
|
|
116
|
+
...nodes,
|
|
117
|
+
{ id: 'inv-1', type: 'page', parentId: null, order: 1, title: 'Hidden', emoji: '🔒', updatedAt: now, access: 'invite' as const },
|
|
118
|
+
]);
|
|
119
|
+
|
|
94
120
|
expect(client.push).toHaveBeenCalled();
|
|
95
121
|
const [, payload] = (client.push as ReturnType<typeof vi.fn>).mock.calls[0] as [string, Record<string, unknown>];
|
|
96
|
-
|
|
122
|
+
const stored = payload.objects as ObjectNode[];
|
|
123
|
+
const inv = stored.find((n) => n.id === 'inv-1');
|
|
124
|
+
expect(inv?.title).toBe('');
|
|
125
|
+
expect(inv).not.toHaveProperty('emoji');
|
|
97
126
|
});
|
|
127
|
+
});
|
|
98
128
|
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
129
|
+
// ── readObjectTree ─────────────────────────────────────────────────────────────
|
|
130
|
+
|
|
131
|
+
describe('readObjectTree', () => {
|
|
132
|
+
it('returns the objects array from the index', async () => {
|
|
133
|
+
const client = makeClient({ v: 2, objects: spaceNodes });
|
|
134
|
+
const session = {
|
|
135
|
+
userId: 'alice',
|
|
136
|
+
keys: { edPriv: 'priv', edPub: 'pub', kemPriv: 'kempriv', kemPub: 'kempub' },
|
|
137
|
+
chatClient: client,
|
|
138
|
+
accountClient: makeClient(null),
|
|
139
|
+
} as unknown as import('../sync/identity.js').Session;
|
|
140
|
+
|
|
141
|
+
const result = await readObjectTree(session, spaceId);
|
|
142
|
+
expect(result).toHaveLength(spaceNodes.length);
|
|
143
|
+
expect(result[0]?.id).toBe('n1');
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it('returns empty array when index is missing', async () => {
|
|
147
|
+
const client = {
|
|
148
|
+
pull: vi.fn().mockRejectedValue(new Error('not found')),
|
|
149
|
+
push: vi.fn(),
|
|
150
|
+
} as unknown as StarfishClient;
|
|
151
|
+
const session = {
|
|
152
|
+
userId: 'alice',
|
|
153
|
+
keys: { edPriv: 'priv', edPub: 'pub', kemPriv: 'kempriv', kemPub: 'kempub' },
|
|
154
|
+
chatClient: client,
|
|
155
|
+
accountClient: makeClient(null),
|
|
156
|
+
} as unknown as import('../sync/identity.js').Session;
|
|
157
|
+
|
|
158
|
+
const result = await readObjectTree(session, spaceId);
|
|
159
|
+
expect(result).toEqual([]);
|
|
104
160
|
});
|
|
105
161
|
});
|