@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.
@@ -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 { Encryptor, StarfishClient } from '@drakkar.software/starfish-client';
3
- import { readIndexRooms, pushIndexSeed } from './object-index.js';
4
- import type { SeedRoom } from './object-index.js';
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
- // ── readIndexRooms ─────────────────────────────────────────────────────────────
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('readIndexRooms — plaintext (null encryptor)', () => {
29
- it('returns rooms from a plaintext objects doc', async () => {
30
- const client = makeClient(plainIndexDoc);
31
- const result = await readIndexRooms(client, null, '/pull/spaces/sp-test/objects/_index', spaceId);
32
- expect(result).not.toBeNull();
33
- expect(result!.rooms.length).toBeGreaterThan(0);
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('returns null when pull returns no data', async () => {
37
- const client = makeClient(null);
38
- const result = await readIndexRooms(client, null, '/pull/x', spaceId);
39
- expect(result).toBeNull();
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
- describe('readIndexRooms encrypted (with encryptor)', () => {
44
- it('calls encryptor.decrypt and projects rooms', async () => {
45
- const enc = makeEncryptor(plainIndexDoc);
46
- const client = makeClient({ _encrypted: true, ct: 'abc' });
47
- const result = await readIndexRooms(client, enc, '/pull/x', spaceId);
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('returns null on decrypt error', async () => {
54
- const enc = {
55
- decrypt: vi.fn().mockRejectedValue(new Error('bad key')),
56
- encrypt: vi.fn(),
57
- } as unknown as Encryptor;
58
- const client = makeClient({ _encrypted: true });
59
- const result = await readIndexRooms(client, enc, '/pull/x', spaceId);
60
- expect(result).toBeNull();
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
- // ── pushIndexSeed ──────────────────────────────────────────────────────────────
81
+ // ── updateObjectIndex ─────────────────────────────────────────────────────────
65
82
 
66
- const seedRooms: SeedRoom[] = [{ id: 'sp-test-general', name: 'general', kind: 'channel', category: 'CHANNELS' }];
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
- describe('pushIndexSeed plaintext', () => {
69
- it('pushes plaintext objects when encryptor is null', async () => {
70
- const client = makeClient(null, null as unknown as string);
71
- (client.pull as ReturnType<typeof vi.fn>).mockResolvedValue(null);
72
- await pushIndexSeed(client, null, spaceId, seedRooms);
73
- expect(client.push).toHaveBeenCalled();
74
- const [, payload] = (client.push as ReturnType<typeof vi.fn>).mock.calls[0] as [string, Record<string, unknown>];
75
- expect(Array.isArray(payload.objects)).toBe(true);
76
- expect((payload as { _encrypted?: boolean })._encrypted).toBeUndefined();
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 idempotent when plaintext {objects} already exists', async () => {
80
- const client = makeClient(plainIndexDoc);
81
- await pushIndexSeed(client, null, spaceId, seedRooms);
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
- describe('pushIndexSeed encrypted', () => {
87
- it('pushes encrypted payload when encryptor provided', async () => {
88
- const enc = makeEncryptor({});
89
- (enc.encrypt as ReturnType<typeof vi.fn>).mockResolvedValue({ _encrypted: true, ct: 'x' });
90
- const client = makeClient(null, null as unknown as string);
91
- (client.pull as ReturnType<typeof vi.fn>).mockResolvedValue(null);
92
- await pushIndexSeed(client, enc, spaceId, seedRooms);
93
- expect(enc.encrypt).toHaveBeenCalled();
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
- expect((payload as { _encrypted?: boolean })._encrypted).toBe(true);
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
- it('is idempotent when encrypted doc already exists', async () => {
100
- const enc = makeEncryptor({});
101
- const client = makeClient({ _encrypted: true, ct: 'existing' });
102
- await pushIndexSeed(client, enc, spaceId, seedRooms);
103
- expect(client.push).not.toHaveBeenCalled();
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
  });