@drakkar.software/octospaces-sdk 0.1.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.
Files changed (52) hide show
  1. package/dist/index.d.ts +972 -0
  2. package/dist/index.js +1656 -0
  3. package/dist/index.js.map +1 -0
  4. package/dist/platform/index.d.ts +9 -0
  5. package/dist/platform/index.js +111 -0
  6. package/dist/platform/index.js.map +1 -0
  7. package/dist/platform/index.native.d.ts +9 -0
  8. package/dist/platform/index.native.js +106 -0
  9. package/dist/platform/index.native.js.map +1 -0
  10. package/package.json +50 -0
  11. package/src/core/adapters.ts +34 -0
  12. package/src/core/config.ts +87 -0
  13. package/src/core/ids.test.ts +45 -0
  14. package/src/core/ids.ts +29 -0
  15. package/src/core/space-access-error.ts +13 -0
  16. package/src/core/storage-types.ts +71 -0
  17. package/src/core/types.ts +162 -0
  18. package/src/index.ts +221 -0
  19. package/src/objects/objects.test.ts +288 -0
  20. package/src/objects/objects.ts +296 -0
  21. package/src/platform/index.native.ts +3 -0
  22. package/src/platform/index.ts +3 -0
  23. package/src/platform/kv.native.ts +23 -0
  24. package/src/platform/kv.ts +29 -0
  25. package/src/platform/platform.native.ts +16 -0
  26. package/src/platform/platform.ts +10 -0
  27. package/src/spaces/members.test.ts +87 -0
  28. package/src/spaces/members.ts +271 -0
  29. package/src/spaces/object-index.test.ts +105 -0
  30. package/src/spaces/object-index.ts +160 -0
  31. package/src/spaces/registry.test.ts +111 -0
  32. package/src/spaces/registry.ts +466 -0
  33. package/src/sync/account-seal.test.ts +70 -0
  34. package/src/sync/account-seal.ts +80 -0
  35. package/src/sync/base64.ts +89 -0
  36. package/src/sync/base64url.ts +22 -0
  37. package/src/sync/client.ts +301 -0
  38. package/src/sync/fetch-timeout.test.ts +26 -0
  39. package/src/sync/fetch-timeout.ts +23 -0
  40. package/src/sync/identity.ts +158 -0
  41. package/src/sync/pairing.ts +103 -0
  42. package/src/sync/paths.test.ts +135 -0
  43. package/src/sync/paths.ts +177 -0
  44. package/src/sync/profile-cache.ts +34 -0
  45. package/src/sync/pull-cache.test.ts +55 -0
  46. package/src/sync/pull-cache.ts +33 -0
  47. package/src/sync/space-access-store.test.ts +129 -0
  48. package/src/sync/space-access-store.ts +117 -0
  49. package/src/sync/space-access.ts +136 -0
  50. package/tsconfig.json +17 -0
  51. package/tsup.config.ts +40 -0
  52. package/vitest.config.ts +12 -0
@@ -0,0 +1,271 @@
1
+ /**
2
+ * Space membership — both keyring-based (private spaces) and link-based (public spaces).
3
+ *
4
+ * PRIVATE join: the owner adds the invitee to the space keyring, records them in the
5
+ * roster, and mints a space-scoped member cap. The invitee verifies keyring access on
6
+ * accept and stores a `{kind:'member'}` entry.
7
+ *
8
+ * PUBLIC (link) join: the owner mints an ephemeral Ed/KEM keypair whose *private* key
9
+ * ships inside a URL-fragment token, adds the ephemeral userId to the roster so the
10
+ * server grants `space:member`, and mints a member cap scoped to that ephemeral subject.
11
+ * Any bearer of the link stores a `{kind:'link'}` entry. Revocation = `removeSpaceMember`.
12
+ */
13
+ import { generateDeviceKeys } from '@drakkar.software/starfish-identities';
14
+ import { addCollectionRecipient } from '@drakkar.software/starfish-keyring';
15
+ import { mintMemberCap } from '@drakkar.software/starfish-sharing';
16
+
17
+ import type { Space } from '../core/types.js';
18
+ import { buildEncryptor, makeClient } from '../sync/client.js';
19
+ import type { Session } from '../sync/identity.js';
20
+ import {
21
+ getSpaceAccessEntry,
22
+ hydrateSpaceAccessStore,
23
+ localSpaceAccessEntries,
24
+ saveSpaceAccessEntry,
25
+ } from '../sync/space-access-store.js';
26
+ import { keyringName, spaceMemberScope, userIdFromEdPub } from '../sync/paths.js';
27
+ import { addJoinedSpaceWithCap, addJoinedSpaceWithLinkAccess, addSpaceMember, readSpaces, updateSpacesDoc } from './registry.js';
28
+ import { sealToSelf, unsealFromSelf } from '../sync/account-seal.js';
29
+ import { toBase64Url, fromBase64Url } from '../sync/base64url.js';
30
+
31
+ export interface JoinRequest {
32
+ edPub: string;
33
+ kemPub: string;
34
+ userId: string;
35
+ }
36
+
37
+ export function makeJoinRequest(session: Session): string {
38
+ const req: JoinRequest = { edPub: session.keys.edPub, kemPub: session.keys.kemPub, userId: session.userId };
39
+ return JSON.stringify(req);
40
+ }
41
+
42
+ interface SpaceInvite {
43
+ spaceId: string;
44
+ spaceName: string;
45
+ cap: unknown;
46
+ }
47
+
48
+ function isAlreadyPresentRecipient(err: unknown): boolean {
49
+ return err instanceof Error && /already present in epoch/.test(err.message);
50
+ }
51
+
52
+ /**
53
+ * Owner-side: add a recipient's KEM key to a SPACE keyring (one keyring → every
54
+ * object in the space). Reused by {@link inviteToSpace} and by device pairing.
55
+ */
56
+ export async function addDeviceToSpaceKeyring(
57
+ session: Session,
58
+ spaceId: string,
59
+ recipient: { kemPub: string; userId: string },
60
+ ): Promise<void> {
61
+ try {
62
+ await addCollectionRecipient(
63
+ session.chatClient,
64
+ keyringName(spaceId),
65
+ { subKem: recipient.kemPub, userId: recipient.userId, label: recipient.userId.slice(0, 8) },
66
+ { edPriv: session.keys.edPriv, edPub: session.keys.edPub, kemPriv: session.keys.kemPriv },
67
+ { trustedAdders: [session.keys.edPub] },
68
+ );
69
+ } catch (err) {
70
+ if (!isAlreadyPresentRecipient(err)) throw err;
71
+ }
72
+ }
73
+
74
+ /**
75
+ * Owner: invite an identity into a PRIVATE space. Adds them to the keyring, records
76
+ * them in the roster, and mints a single space-scoped member cap.
77
+ * Returns the invite bundle JSON.
78
+ */
79
+ export async function inviteToSpace(
80
+ session: Session,
81
+ spaceId: string,
82
+ requestJson: string,
83
+ canWrite = true,
84
+ spaceName?: string,
85
+ ): Promise<string> {
86
+ const req = JSON.parse(requestJson) as JoinRequest;
87
+ if (!req.edPub || !req.kemPub || !req.userId) throw new Error('That is not a valid join request.');
88
+ await addDeviceToSpaceKeyring(session, spaceId, { kemPub: req.kemPub, userId: req.userId });
89
+ await addSpaceMember(session.accountClient, spaceId, session.userId, req.userId);
90
+ // NOTE: 'chat' is the cap collection the deployed server's space-member enricher recognises.
91
+ const cap = await mintMemberCap(
92
+ session.keys.edPriv,
93
+ session.keys.edPub,
94
+ { edPubHex: req.edPub, kemPubHex: req.kemPub, userIdHex: req.userId },
95
+ 'chat',
96
+ spaceMemberScope(spaceId, canWrite),
97
+ );
98
+ let name = spaceName?.trim();
99
+ if (!name) {
100
+ const { spaces } = await readSpaces(session.accountClient, session.userId);
101
+ name = spaces.find((s) => s.id === spaceId)?.name ?? 'Space';
102
+ }
103
+ const invite: SpaceInvite = { spaceId, spaceName: name, cap };
104
+ return JSON.stringify(invite);
105
+ }
106
+
107
+ /**
108
+ * Invitee: accept a PRIVATE space invite — verify keyring access, store the cap,
109
+ * and register the space. Returns the joined space.
110
+ */
111
+ export async function acceptSpaceInvite(session: Session, inviteJson: string): Promise<Space> {
112
+ const inv = JSON.parse(inviteJson) as Partial<SpaceInvite>;
113
+ const cap = inv.cap as { kind?: string; sub?: string; iss?: string } | undefined;
114
+ if (!cap || !inv.spaceId) throw new Error('That is not a valid space invite.');
115
+ if (cap.kind !== 'member') throw new Error('That is not a valid space invite.');
116
+ if (!cap.sub || cap.sub !== session.keys.edPub) {
117
+ throw new Error('This invite was issued for a different identity.');
118
+ }
119
+ if (!cap.iss) throw new Error('This invite is missing its issuer.');
120
+ const spaceId = inv.spaceId;
121
+ const client = makeClient(cap, session.keys.edPriv);
122
+ const enc = await buildEncryptor(client, session.keys, spaceId, [cap.iss]);
123
+ if (!enc) throw new Error("Accepted, but you're not in the space keyring yet — ask the owner to re-invite.");
124
+ const capJson = JSON.stringify(cap);
125
+ const name = inv.spaceName?.trim() || `space-${spaceId.slice(-6)}`;
126
+ const space: Space = { id: spaceId, name, short: name.slice(0, 2).toUpperCase(), members: 1 };
127
+ await addJoinedSpaceWithCap(session.accountClient, session.userId, space, capJson);
128
+ saveSpaceAccessEntry(spaceId, { kind: 'member', cap: capJson });
129
+ return space;
130
+ }
131
+
132
+ // ── Link-based joins (public spaces) ─────────────────────────────────────────
133
+
134
+ /** A space invite link token (v:1, no ownerId — derive from cap.iss instead). */
135
+ export interface SpaceInviteLinkToken {
136
+ v: 1;
137
+ spaceId: string;
138
+ spaceName: string;
139
+ cap: unknown;
140
+ /** The throwaway ephemeral subject's Ed25519 private key (hex). */
141
+ key: string;
142
+ write: boolean;
143
+ }
144
+
145
+ export function encodeSpaceInviteLink(origin: string, token: SpaceInviteLinkToken): string {
146
+ const base = origin.replace(/\/+$/, '');
147
+ return `${base}/join#${toBase64Url(JSON.stringify(token))}`;
148
+ }
149
+
150
+ export function decodeSpaceInviteLink(fragment: string): SpaceInviteLinkToken {
151
+ const frag = fragment.startsWith('#') ? fragment.slice(1) : fragment;
152
+ const tok = JSON.parse(fromBase64Url(frag)) as Partial<SpaceInviteLinkToken>;
153
+ if (!tok || !tok.spaceId || !tok.cap || !tok.key) {
154
+ throw new Error('That space invite link is malformed or incomplete.');
155
+ }
156
+ return {
157
+ v: 1,
158
+ spaceId: tok.spaceId,
159
+ spaceName: tok.spaceName ?? 'Space',
160
+ cap: tok.cap,
161
+ key: tok.key,
162
+ write: !!tok.write,
163
+ };
164
+ }
165
+
166
+ /**
167
+ * Owner: create a shareable invite link for a PUBLIC space.
168
+ *
169
+ * Mints an ephemeral Ed/KEM keypair, adds its userId to the roster (so the server
170
+ * grants `space:member` to any bearer), and encodes the private key + cap in the URL.
171
+ * Anyone with the link can join; revoke by calling `removeSpaceMember(ephemeralUserId)`.
172
+ */
173
+ export async function createSpaceInviteLink(
174
+ session: Session,
175
+ spaceId: string,
176
+ spaceName: string,
177
+ write: boolean,
178
+ origin: string,
179
+ ): Promise<{ token: SpaceInviteLinkToken; link: string }> {
180
+ const ek = generateDeviceKeys();
181
+ const ephemeralUserId = await userIdFromEdPub(ek.edPub);
182
+ const cap = await mintMemberCap(
183
+ session.keys.edPriv,
184
+ session.keys.edPub,
185
+ { edPubHex: ek.edPub, kemPubHex: ek.kemPub, userIdHex: ephemeralUserId },
186
+ 'chat',
187
+ spaceMemberScope(spaceId, write),
188
+ );
189
+ // Add the ephemeral userId to the roster so the server grants `space:member`
190
+ await addSpaceMember(session.accountClient, spaceId, session.userId, ephemeralUserId);
191
+ const token: SpaceInviteLinkToken = { v: 1, spaceId, spaceName, cap, key: ek.edPriv, write };
192
+ return { token, link: encodeSpaceInviteLink(origin, token) };
193
+ }
194
+
195
+ /**
196
+ * Any user: join a PUBLIC space by redeeming an invite link token.
197
+ * Stores the link credential locally and seals it into the synced `_spaces` doc.
198
+ */
199
+ export async function joinSpaceByLink(session: Session, token: SpaceInviteLinkToken): Promise<Space> {
200
+ const cap = token.cap as { iss?: string };
201
+ const ownerId = cap.iss ? await userIdFromEdPub(cap.iss) : undefined;
202
+ const name = token.spaceName.trim() || `space-${token.spaceId.slice(-6)}`;
203
+ const space: Space = {
204
+ id: token.spaceId,
205
+ name,
206
+ short: name.slice(0, 2).toUpperCase(),
207
+ members: 1,
208
+ visibility: 'public',
209
+ ...(ownerId ? { ownerId } : {}),
210
+ write: token.write,
211
+ };
212
+ const accessPayload = { cap: token.cap, key: token.key, write: token.write };
213
+ const sealed = await sealToSelf(session, JSON.stringify(accessPayload));
214
+ await addJoinedSpaceWithLinkAccess(session.accountClient, session.userId, space, sealed);
215
+ saveSpaceAccessEntry(token.spaceId, { kind: 'link', cap: token.cap, key: token.key, write: token.write });
216
+ return space;
217
+ }
218
+
219
+ /**
220
+ * Single sign-in hydration: merges server-side caps (plaintext member caps from
221
+ * `_spaces.caps`) and sealed link access (from `_spaces.pubAccess`) into the
222
+ * unified space-access store. Call once on sign-in / account switch.
223
+ * Backfills any local-only entries to the server.
224
+ */
225
+ export async function recoverSpaceAccess(
226
+ session: Session,
227
+ server: { caps: Record<string, string>; pubAccess: Record<string, import('../sync/account-seal.js').SealedBlob> },
228
+ ): Promise<void> {
229
+ // Unseal link access blobs
230
+ const linkAccess: Record<string, { cap: unknown; key: string; write: boolean }> = {};
231
+ for (const [spaceId, sealed] of Object.entries(server.pubAccess)) {
232
+ try {
233
+ const raw = await unsealFromSelf(session, sealed);
234
+ const parsed = JSON.parse(raw) as { cap: unknown; key: string; write: boolean };
235
+ if (parsed.cap && parsed.key) linkAccess[spaceId] = parsed;
236
+ } catch (e) {
237
+ console.error('[octospaces] recoverSpaceAccess: failed to unseal', spaceId, e);
238
+ }
239
+ }
240
+
241
+ await hydrateSpaceAccessStore(session.userId, server.caps, linkAccess);
242
+
243
+ // Backfill local-only entries to the server
244
+ const local = localSpaceAccessEntries();
245
+ const missingMemberCaps = Object.entries(local)
246
+ .filter(([id, e]) => e.kind === 'member' && !(id in server.caps));
247
+ const missingLinks = Object.entries(local)
248
+ .filter(([id, e]) => e.kind === 'link' && !(id in server.pubAccess));
249
+
250
+ if (missingMemberCaps.length === 0 && missingLinks.length === 0) return;
251
+
252
+ try {
253
+ const newCaps: Record<string, string> = {};
254
+ for (const [id, e] of missingMemberCaps) if (e.kind === 'member') newCaps[id] = e.cap;
255
+
256
+ const newPubAccess: Record<string, import('../sync/account-seal.js').SealedBlob> = {};
257
+ for (const [id, e] of missingLinks) {
258
+ if (e.kind === 'link') {
259
+ newPubAccess[id] = await sealToSelf(session, JSON.stringify({ cap: e.cap, key: e.key, write: e.write }));
260
+ }
261
+ }
262
+
263
+ await updateSpacesDoc(session.accountClient, session.userId, (cur) => ({
264
+ spaces: cur.spaces,
265
+ caps: { ...cur.caps, ...newCaps },
266
+ pubAccess: { ...cur.pubAccess, ...newPubAccess },
267
+ }));
268
+ } catch (e) {
269
+ console.error('[octospaces] recoverSpaceAccess: backfill failed', e);
270
+ }
271
+ }
@@ -0,0 +1,105 @@
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';
5
+
6
+ // ── Helpers ────────────────────────────────────────────────────────────────────
7
+
8
+ function makeClient(data: unknown, hash = 'h1'): StarfishClient {
9
+ return {
10
+ pull: vi.fn().mockResolvedValue({ data, hash }),
11
+ push: vi.fn().mockResolvedValue(undefined),
12
+ } as unknown as StarfishClient;
13
+ }
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
+ 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
+
26
+ // ── readIndexRooms ─────────────────────────────────────────────────────────────
27
+
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);
34
+ });
35
+
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
+ });
41
+ });
42
+
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);
51
+ });
52
+
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();
61
+ });
62
+ });
63
+
64
+ // ── pushIndexSeed ──────────────────────────────────────────────────────────────
65
+
66
+ const seedRooms: SeedRoom[] = [{ id: 'sp-test-general', name: 'general', kind: 'channel', category: 'CHANNELS' }];
67
+
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();
77
+ });
78
+
79
+ it('is idempotent when plaintext {objects} already exists', async () => {
80
+ const client = makeClient(plainIndexDoc);
81
+ await pushIndexSeed(client, null, spaceId, seedRooms);
82
+ expect(client.push).not.toHaveBeenCalled();
83
+ });
84
+ });
85
+
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();
94
+ expect(client.push).toHaveBeenCalled();
95
+ 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);
97
+ });
98
+
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();
104
+ });
105
+ });
@@ -0,0 +1,160 @@
1
+ /**
2
+ * Headless reads + create-time seeding of a space's unified OBJECT INDEX.
3
+ *
4
+ * Both PRIVATE (encrypted) and PUBLIC (plaintext) spaces store their room/category
5
+ * tree in `spaces/{spaceId}/objects/_index`. The `encryptor` parameter is null for
6
+ * public spaces — the helpers pass data through unchanged.
7
+ */
8
+ import { ConflictError } from '@drakkar.software/starfish-client';
9
+ import type { Encryptor, StarfishClient } from '@drakkar.software/starfish-client';
10
+
11
+ import type { ObjectNode, Room, SpaceVisibility } from '../core/types.js';
12
+ import type { Session } from '../sync/identity.js';
13
+ import { DEFAULT_CATEGORY, objectsToRoomCategories, seedIndexNodes } from '../objects/objects.js';
14
+ import type { SeedRoom } from '../objects/objects.js';
15
+ import { objIndexPull, objIndexPush } from '../sync/paths.js';
16
+ import { buildSpaceAccess, getSpaceAccess } from '../sync/space-access.js';
17
+
18
+ export type { SeedRoom };
19
+
20
+ function indexNodes(plain: Record<string, unknown>): ObjectNode[] {
21
+ return Array.isArray((plain as { objects?: unknown }).objects)
22
+ ? (plain as { objects: ObjectNode[] }).objects
23
+ : [];
24
+ }
25
+
26
+ /**
27
+ * Pull + (private: decrypt) + project a space's object index into the legacy
28
+ * `{ rooms, categories }` shape. `encryptor` is null for a PUBLIC space.
29
+ * Returns null on failure or an empty index.
30
+ */
31
+ export async function readIndexRooms(
32
+ client: StarfishClient,
33
+ encryptor: Encryptor | null,
34
+ indexPath: string,
35
+ spaceId: string,
36
+ ): Promise<{ rooms: Room[]; categories: string[] } | null> {
37
+ try {
38
+ const res = await client.pull(indexPath).catch(() => null);
39
+ if (!res?.data) return null;
40
+ const plain = encryptor
41
+ ? await encryptor.decrypt(res.data as Record<string, unknown>)
42
+ : (res.data as Record<string, unknown>);
43
+ const cats = objectsToRoomCategories(indexNodes(plain), spaceId, DEFAULT_CATEGORY);
44
+ if (!cats) return null;
45
+ return { rooms: cats.flatMap((c) => c.rooms), categories: cats.map((c) => c.name) };
46
+ } catch {
47
+ return null;
48
+ }
49
+ }
50
+
51
+ /**
52
+ * Read a space's index rooms + categories, resolving access automatically.
53
+ * Pass `reg` (from `readRooms`) so the accessor picks the right auth mode.
54
+ */
55
+ export async function readSpaceIndexRooms(
56
+ session: Session,
57
+ spaceId: string,
58
+ reg: { owner: string | null; members: string[]; visibility?: SpaceVisibility },
59
+ ): Promise<{ rooms: Room[]; categories: string[] } | null> {
60
+ if (reg.owner === null && reg.visibility !== 'public') return null;
61
+ try {
62
+ const { encryptor, client } = await getSpaceAccess(spaceId, session, reg);
63
+ return await readIndexRooms(client, encryptor, objIndexPull(spaceId), spaceId);
64
+ } catch {
65
+ return null;
66
+ }
67
+ }
68
+
69
+ /** SOFT read — never throws, never mints. Returns an empty array on any failure. */
70
+ export async function readSpaceRooms(
71
+ session: Session,
72
+ spaceId: string,
73
+ hint?: { visibility?: SpaceVisibility },
74
+ ): Promise<Room[]> {
75
+ const access = await buildSpaceAccess(session, spaceId, hint).catch(() => null);
76
+ if (!access) return [];
77
+ const idx = await readIndexRooms(access.client, access.encryptor, objIndexPull(spaceId), spaceId);
78
+ return idx?.rooms ?? [];
79
+ }
80
+
81
+ /**
82
+ * Write the create-time seed into a space's index doc with an already-open access handle.
83
+ * Accepts a nullable encryptor — plaintext push when null (public spaces).
84
+ * Idempotent: a no-op if the index doc already exists (either encrypted or plaintext).
85
+ */
86
+ export async function pushIndexSeed(
87
+ client: StarfishClient,
88
+ encryptor: Encryptor | null,
89
+ spaceId: string,
90
+ rooms: SeedRoom[],
91
+ ): Promise<void> {
92
+ const res = await client.pull(objIndexPull(spaceId)).catch(() => null);
93
+ const existing = res?.data as Record<string, unknown> | undefined;
94
+ if (existing?._encrypted || Array.isArray(existing?.objects)) return;
95
+ const nodes = seedIndexNodes(rooms, Date.now());
96
+ const payload = encryptor
97
+ ? await encryptor.encrypt({ objects: nodes }) as Record<string, unknown>
98
+ : { objects: nodes };
99
+ await client.push(objIndexPush(spaceId), payload, res?.hash ?? null);
100
+ }
101
+
102
+ /**
103
+ * Seed a brand-new space's index as the OWNER.
104
+ * For private spaces: opens (minting if needed) the space keyring.
105
+ * For public spaces: pushes plaintext nodes.
106
+ */
107
+ export async function seedSpaceObjectIndex(
108
+ session: Session,
109
+ spaceId: string,
110
+ rooms: SeedRoom[],
111
+ opts?: { visibility?: SpaceVisibility },
112
+ ): Promise<void> {
113
+ const { encryptor, client } = await getSpaceAccess(spaceId, session, {
114
+ owner: session.userId,
115
+ members: [],
116
+ visibility: opts?.visibility,
117
+ });
118
+ await pushIndexSeed(client, encryptor, spaceId, rooms);
119
+ }
120
+
121
+ /**
122
+ * Headless read-modify-write of a space's unified OBJECT INDEX. Works for both
123
+ * private (encrypt round-trip) and public (plaintext) spaces. Retries up to 3 times
124
+ * on ConflictError.
125
+ */
126
+ export async function updateObjectIndex(
127
+ session: Session,
128
+ spaceId: string,
129
+ mutator: (nodes: ObjectNode[], now: number) => ObjectNode[] | null,
130
+ reg?: { owner: string | null; members: string[]; visibility?: SpaceVisibility } | null,
131
+ ): Promise<void> {
132
+ const { client, encryptor } = await getSpaceAccess(spaceId, session, reg ?? null);
133
+ const pullPath = objIndexPull(spaceId);
134
+ const pushPath = objIndexPush(spaceId);
135
+ const MAX_ATTEMPTS = 3;
136
+ for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) {
137
+ const res = await client.pull(pullPath).catch(() => null);
138
+ const raw = res?.data as Record<string, unknown> | undefined;
139
+ const plain = raw
140
+ ? encryptor
141
+ ? await encryptor.decrypt(raw)
142
+ : raw
143
+ : {};
144
+ const cur = Array.isArray((plain as { objects?: unknown }).objects)
145
+ ? (plain as { objects: ObjectNode[] }).objects
146
+ : [];
147
+ const next = mutator(cur, Date.now());
148
+ if (!next) return;
149
+ const payload = encryptor
150
+ ? await encryptor.encrypt({ objects: next }) as Record<string, unknown>
151
+ : { objects: next };
152
+ try {
153
+ await client.push(pushPath, payload, res?.hash ?? null);
154
+ return;
155
+ } catch (err) {
156
+ if (err instanceof ConflictError && attempt < MAX_ATTEMPTS - 1) continue;
157
+ throw err;
158
+ }
159
+ }
160
+ }
@@ -0,0 +1,111 @@
1
+ import { describe, it, expect, vi } from 'vitest';
2
+ import type { StarfishClient } from '@drakkar.software/starfish-client';
3
+ import { readRooms, writeRooms, addSpaceMember, removeSpaceMember } from './registry.js';
4
+
5
+ // ── Fake client ────────────────────────────────────────────────────────────────
6
+
7
+ function makeRoomsClient(data: unknown, hash = 'h1'): StarfishClient {
8
+ return {
9
+ pull: vi.fn().mockResolvedValue({ data, hash }),
10
+ push: vi.fn().mockResolvedValue(undefined),
11
+ } as unknown as StarfishClient;
12
+ }
13
+
14
+ // ── readRooms ──────────────────────────────────────────────────────────────────
15
+
16
+ describe('readRooms', () => {
17
+ it('returns visibility null for a private space doc', async () => {
18
+ const client = makeRoomsClient({ v: 1, owner: 'alice', members: [] });
19
+ const result = await readRooms(client, 'sp-private');
20
+ expect(result.visibility).toBeNull();
21
+ });
22
+
23
+ it('returns visibility "public" when doc has visibility:"public"', async () => {
24
+ const client = makeRoomsClient({ v: 1, owner: 'alice', members: [], visibility: 'public' });
25
+ const result = await readRooms(client, 'sp-pub');
26
+ expect(result.visibility).toBe('public');
27
+ });
28
+
29
+ it('returns owner, members, name, image', async () => {
30
+ const client = makeRoomsClient({
31
+ v: 1, owner: 'alice', members: ['bob'], name: 'My Space', image: 'data:image/png;base64,abc',
32
+ });
33
+ const result = await readRooms(client, 'sp-x');
34
+ expect(result.owner).toBe('alice');
35
+ expect(result.members).toEqual(['bob']);
36
+ expect(result.name).toBe('My Space');
37
+ expect(result.image).toBe('data:image/png;base64,abc');
38
+ });
39
+
40
+ it('returns null owner/name/image for missing fields', async () => {
41
+ const client = makeRoomsClient({});
42
+ const result = await readRooms(client, 'sp-empty');
43
+ expect(result.owner).toBeNull();
44
+ expect(result.name).toBeNull();
45
+ expect(result.image).toBeNull();
46
+ expect(result.members).toEqual([]);
47
+ });
48
+ });
49
+
50
+ // ── writeRooms ─────────────────────────────────────────────────────────────────
51
+
52
+ describe('writeRooms', () => {
53
+ it('omits visibility field for a private space', async () => {
54
+ const client = makeRoomsClient(null);
55
+ await writeRooms(client, 'sp-priv', 'alice', [], null);
56
+ const [, doc] = (client.push as ReturnType<typeof vi.fn>).mock.calls[0] as [string, Record<string, unknown>];
57
+ expect(doc).not.toHaveProperty('visibility');
58
+ });
59
+
60
+ it('emits visibility:"public" for a public space', async () => {
61
+ const client = makeRoomsClient(null);
62
+ await writeRooms(client, 'sp-pub', 'alice', [], null, { visibility: 'public' });
63
+ const [, doc] = (client.push as ReturnType<typeof vi.fn>).mock.calls[0] as [string, Record<string, unknown>];
64
+ expect(doc).toHaveProperty('visibility', 'public');
65
+ });
66
+
67
+ it('preserves name and image', async () => {
68
+ const client = makeRoomsClient(null);
69
+ await writeRooms(client, 'sp-x', 'alice', ['bob'], null, { name: 'Test', image: 'data:x' });
70
+ const [, doc] = (client.push as ReturnType<typeof vi.fn>).mock.calls[0] as [string, Record<string, unknown>];
71
+ expect(doc).toHaveProperty('name', 'Test');
72
+ expect(doc).toHaveProperty('image', 'data:x');
73
+ });
74
+ });
75
+
76
+ // ── addSpaceMember / removeSpaceMember ────────────────────────────────────────
77
+
78
+ describe('addSpaceMember', () => {
79
+ it('adds a member and preserves visibility', async () => {
80
+ const client = makeRoomsClient({ v: 1, owner: 'alice', members: [], visibility: 'public' });
81
+ await addSpaceMember(client, 'sp-pub', 'alice', 'bob');
82
+ const [, doc] = (client.push as ReturnType<typeof vi.fn>).mock.calls[0] as [string, Record<string, unknown>];
83
+ expect((doc as { members: string[] }).members).toContain('bob');
84
+ expect(doc).toHaveProperty('visibility', 'public');
85
+ });
86
+
87
+ it('is a no-op when member already present', async () => {
88
+ const client = makeRoomsClient({ v: 1, owner: 'alice', members: ['bob'] });
89
+ await addSpaceMember(client, 'sp-x', 'alice', 'bob');
90
+ expect(client.push).not.toHaveBeenCalled();
91
+ });
92
+ });
93
+
94
+ describe('removeSpaceMember', () => {
95
+ it('removes a member and preserves name/image/visibility', async () => {
96
+ const client = makeRoomsClient({
97
+ v: 1, owner: 'alice', members: ['bob', 'carol'], name: 'S', visibility: 'public',
98
+ });
99
+ await removeSpaceMember(client, 'sp-pub', 'bob');
100
+ const [, doc] = (client.push as ReturnType<typeof vi.fn>).mock.calls[0] as [string, Record<string, unknown>];
101
+ expect((doc as { members: string[] }).members).not.toContain('bob');
102
+ expect((doc as { members: string[] }).members).toContain('carol');
103
+ expect(doc).toHaveProperty('visibility', 'public');
104
+ });
105
+
106
+ it('is a no-op when member is not in the roster', async () => {
107
+ const client = makeRoomsClient({ v: 1, owner: 'alice', members: ['carol'] });
108
+ await removeSpaceMember(client, 'sp-x', 'unknown');
109
+ expect(client.push).not.toHaveBeenCalled();
110
+ });
111
+ });