@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.
@@ -1,156 +1,94 @@
1
1
  /**
2
2
  * Headless reads + create-time seeding of a space's unified OBJECT INDEX.
3
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.
4
+ * The index at `spaces/{spaceId}/objects/_index` is always PLAINTEXT (member-gated).
5
+ * For `invite` nodes the title/emoji are stripped before storage so non-invited
6
+ * members see only the structural fields (id, type, parentId, order, access, enc).
7
+ * Invited members read the real title from the node's content doc.
8
+ *
9
+ * Encryption lives at the node content level, not here.
7
10
  */
8
11
  import { ConflictError } from '@drakkar.software/starfish-client';
9
- import type { Encryptor, StarfishClient } from '@drakkar.software/starfish-client';
10
12
 
11
- import type { ObjectNode, Room, SpaceVisibility } from '../core/types.js';
13
+ import type { ObjectNode } from '../core/types.js';
12
14
  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
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
- }
16
+ import { getSpaceClient } from '../sync/space-access.js';
50
17
 
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;
18
+ /** Strip title/emoji from invite nodes before writing to the index. */
19
+ function serializeForIndex(node: ObjectNode): ObjectNode {
20
+ if (node.access === 'invite') {
21
+ const { emoji: _e, ...rest } = node;
22
+ return { ...rest, title: '' };
66
23
  }
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 ?? [];
24
+ return node;
79
25
  }
80
26
 
81
27
  /**
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).
28
+ * Write the create-time seed into a space's index doc.
29
+ * Idempotent: a no-op if the index doc already exists.
30
+ * Pass `nodes` to seed with initial objects; defaults to an empty index.
85
31
  */
86
32
  export async function pushIndexSeed(
87
- client: StarfishClient,
88
- encryptor: Encryptor | null,
33
+ client: import('@drakkar.software/starfish-client').StarfishClient,
89
34
  spaceId: string,
90
- rooms: SeedRoom[],
35
+ nodes: ObjectNode[] = [],
91
36
  ): Promise<void> {
92
37
  const res = await client.pull(objIndexPull(spaceId)).catch(() => null);
93
38
  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);
39
+ if (Array.isArray(existing?.objects)) return;
40
+ await client.push(
41
+ objIndexPush(spaceId),
42
+ { v: 2, objects: nodes.map(serializeForIndex), updatedAt: Date.now() },
43
+ res?.hash ?? null,
44
+ );
100
45
  }
101
46
 
102
47
  /**
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.
48
+ * Seed a brand-new space's index as the OWNER. Always plaintext.
49
+ * Pass `nodes` to seed with initial objects; defaults to an empty index.
106
50
  */
107
51
  export async function seedSpaceObjectIndex(
108
52
  session: Session,
109
53
  spaceId: string,
110
- rooms: SeedRoom[],
111
- opts?: { visibility?: SpaceVisibility },
54
+ nodes: ObjectNode[] = [],
112
55
  ): 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);
56
+ const client = getSpaceClient(spaceId, session);
57
+ await pushIndexSeed(client, spaceId, nodes);
119
58
  }
120
59
 
121
60
  /**
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.
61
+ * Headless read-modify-write of a space's unified OBJECT INDEX.
62
+ * Always plaintext. Retries up to 3 times on ConflictError.
63
+ *
64
+ * The mutator receives the current nodes with real (or empty, for invite) titles.
65
+ * Before writing back, invite nodes have their title/emoji stripped again.
125
66
  */
126
67
  export async function updateObjectIndex(
127
68
  session: Session,
128
69
  spaceId: string,
129
70
  mutator: (nodes: ObjectNode[], now: number) => ObjectNode[] | null,
130
- reg?: { owner: string | null; members: string[]; visibility?: SpaceVisibility } | null,
71
+ reg?: { owner: string | null; members: string[] } | null,
131
72
  ): Promise<void> {
132
- const { client, encryptor } = await getSpaceAccess(spaceId, session, reg ?? null);
73
+ void reg; // no longer needed for encryption; kept for API compat during migration
74
+ const client = getSpaceClient(spaceId, session);
133
75
  const pullPath = objIndexPull(spaceId);
134
76
  const pushPath = objIndexPush(spaceId);
135
77
  const MAX_ATTEMPTS = 3;
136
78
  for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) {
137
79
  const res = await client.pull(pullPath).catch(() => null);
138
80
  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
81
+ const cur: ObjectNode[] = Array.isArray((raw as { objects?: unknown })?.objects)
82
+ ? (raw as { objects: ObjectNode[] }).objects
146
83
  : [];
147
84
  const next = mutator(cur, Date.now());
148
85
  if (!next) return;
149
- const payload = encryptor
150
- ? await encryptor.encrypt({ objects: next }) as Record<string, unknown>
151
- : { objects: next };
152
86
  try {
153
- await client.push(pushPath, payload, res?.hash ?? null);
87
+ await client.push(
88
+ pushPath,
89
+ { v: 2, objects: next.map(serializeForIndex), updatedAt: Date.now() },
90
+ res?.hash ?? null,
91
+ );
154
92
  return;
155
93
  } catch (err) {
156
94
  if (err instanceof ConflictError && attempt < MAX_ATTEMPTS - 1) continue;
@@ -158,3 +96,19 @@ export async function updateObjectIndex(
158
96
  }
159
97
  }
160
98
  }
99
+
100
+ /**
101
+ * Read the current object tree (read-only, no mutation). Returns the stored
102
+ * nodes (titles are empty for invite nodes the caller is not invited to).
103
+ */
104
+ export async function readObjectTree(
105
+ session: Session,
106
+ spaceId: string,
107
+ ): Promise<ObjectNode[]> {
108
+ const client = getSpaceClient(spaceId, session);
109
+ const res = await client.pull(objIndexPull(spaceId)).catch(() => null);
110
+ const raw = res?.data as Record<string, unknown> | undefined;
111
+ return Array.isArray((raw as { objects?: unknown })?.objects)
112
+ ? (raw as { objects: ObjectNode[] }).objects
113
+ : [];
114
+ }
@@ -1,36 +1,24 @@
1
1
  import { describe, it, expect, vi } from 'vitest';
2
2
  import type { StarfishClient } from '@drakkar.software/starfish-client';
3
- import { readRooms, writeRooms, addSpaceMember, removeSpaceMember } from './registry.js';
3
+ import { readSpaceAccess, writeSpaceAccess, addSpaceMember, removeSpaceMember } from './registry.js';
4
4
 
5
5
  // ── Fake client ────────────────────────────────────────────────────────────────
6
6
 
7
- function makeRoomsClient(data: unknown, hash = 'h1'): StarfishClient {
7
+ function makeAccessClient(data: unknown, hash = 'h1'): StarfishClient {
8
8
  return {
9
9
  pull: vi.fn().mockResolvedValue({ data, hash }),
10
10
  push: vi.fn().mockResolvedValue(undefined),
11
11
  } as unknown as StarfishClient;
12
12
  }
13
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
- });
14
+ // ── readSpaceAccess ────────────────────────────────────────────────────────────
28
15
 
16
+ describe('readSpaceAccess', () => {
29
17
  it('returns owner, members, name, image', async () => {
30
- const client = makeRoomsClient({
18
+ const client = makeAccessClient({
31
19
  v: 1, owner: 'alice', members: ['bob'], name: 'My Space', image: 'data:image/png;base64,abc',
32
20
  });
33
- const result = await readRooms(client, 'sp-x');
21
+ const result = await readSpaceAccess(client, 'sp-x');
34
22
  expect(result.owner).toBe('alice');
35
23
  expect(result.members).toEqual(['bob']);
36
24
  expect(result.name).toBe('My Space');
@@ -38,74 +26,99 @@ describe('readRooms', () => {
38
26
  });
39
27
 
40
28
  it('returns null owner/name/image for missing fields', async () => {
41
- const client = makeRoomsClient({});
42
- const result = await readRooms(client, 'sp-empty');
29
+ const client = makeAccessClient({});
30
+ const result = await readSpaceAccess(client, 'sp-empty');
43
31
  expect(result.owner).toBeNull();
44
32
  expect(result.name).toBeNull();
45
33
  expect(result.image).toBeNull();
46
34
  expect(result.members).toEqual([]);
47
35
  });
48
- });
49
36
 
50
- // ── writeRooms ─────────────────────────────────────────────────────────────────
37
+ it('returns the hash from the pull response', async () => {
38
+ const client = makeAccessClient({ v: 1, owner: 'alice', members: [] }, 'hash-abc');
39
+ const result = await readSpaceAccess(client, 'sp-1');
40
+ expect(result.hash).toBe('hash-abc');
41
+ });
51
42
 
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');
43
+ it('does NOT return a visibility field', async () => {
44
+ const client = makeAccessClient({ v: 1, owner: 'alice', members: [] });
45
+ const result = await readSpaceAccess(client, 'sp-x');
46
+ expect(result).not.toHaveProperty('visibility');
58
47
  });
48
+ });
59
49
 
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' });
50
+ // ── writeSpaceAccess ───────────────────────────────────────────────────────────
51
+
52
+ describe('writeSpaceAccess', () => {
53
+ it('writes owner and members', async () => {
54
+ const client = makeAccessClient(null);
55
+ await writeSpaceAccess(client, 'sp-x', 'alice', ['bob'], null);
63
56
  const [, doc] = (client.push as ReturnType<typeof vi.fn>).mock.calls[0] as [string, Record<string, unknown>];
64
- expect(doc).toHaveProperty('visibility', 'public');
57
+ expect(doc).toHaveProperty('owner', 'alice');
58
+ expect((doc as { members: string[] }).members).toContain('bob');
65
59
  });
66
60
 
67
61
  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' });
62
+ const client = makeAccessClient(null);
63
+ await writeSpaceAccess(client, 'sp-x', 'alice', ['bob'], null, { name: 'Test', image: 'data:x' });
70
64
  const [, doc] = (client.push as ReturnType<typeof vi.fn>).mock.calls[0] as [string, Record<string, unknown>];
71
65
  expect(doc).toHaveProperty('name', 'Test');
72
66
  expect(doc).toHaveProperty('image', 'data:x');
73
67
  });
68
+
69
+ it('does NOT write a visibility field', async () => {
70
+ const client = makeAccessClient(null);
71
+ await writeSpaceAccess(client, 'sp-x', 'alice', [], null);
72
+ const [, doc] = (client.push as ReturnType<typeof vi.fn>).mock.calls[0] as [string, Record<string, unknown>];
73
+ expect(doc).not.toHaveProperty('visibility');
74
+ });
74
75
  });
75
76
 
76
77
  // ── addSpaceMember / removeSpaceMember ────────────────────────────────────────
77
78
 
78
79
  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');
80
+ it('adds a member to the roster', async () => {
81
+ const client = makeAccessClient({ v: 1, owner: 'alice', members: [] });
82
+ await addSpaceMember(client, 'sp-x', 'alice', 'bob');
82
83
  const [, doc] = (client.push as ReturnType<typeof vi.fn>).mock.calls[0] as [string, Record<string, unknown>];
83
84
  expect((doc as { members: string[] }).members).toContain('bob');
84
- expect(doc).toHaveProperty('visibility', 'public');
85
85
  });
86
86
 
87
87
  it('is a no-op when member already present', async () => {
88
- const client = makeRoomsClient({ v: 1, owner: 'alice', members: ['bob'] });
88
+ const client = makeAccessClient({ v: 1, owner: 'alice', members: ['bob'] });
89
89
  await addSpaceMember(client, 'sp-x', 'alice', 'bob');
90
90
  expect(client.push).not.toHaveBeenCalled();
91
91
  });
92
+
93
+ it('preserves name and image when adding a member', async () => {
94
+ const client = makeAccessClient({ v: 1, owner: 'alice', members: [], name: 'S', image: 'data:i' });
95
+ await addSpaceMember(client, 'sp-x', 'alice', 'bob');
96
+ const [, doc] = (client.push as ReturnType<typeof vi.fn>).mock.calls[0] as [string, Record<string, unknown>];
97
+ expect(doc).toHaveProperty('name', 'S');
98
+ expect(doc).toHaveProperty('image', 'data:i');
99
+ });
92
100
  });
93
101
 
94
102
  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');
103
+ it('removes a member from the roster', async () => {
104
+ const client = makeAccessClient({ v: 1, owner: 'alice', members: ['bob', 'carol'], name: 'S' });
105
+ await removeSpaceMember(client, 'sp-x', 'bob');
100
106
  const [, doc] = (client.push as ReturnType<typeof vi.fn>).mock.calls[0] as [string, Record<string, unknown>];
101
107
  expect((doc as { members: string[] }).members).not.toContain('bob');
102
108
  expect((doc as { members: string[] }).members).toContain('carol');
103
- expect(doc).toHaveProperty('visibility', 'public');
104
109
  });
105
110
 
106
111
  it('is a no-op when member is not in the roster', async () => {
107
- const client = makeRoomsClient({ v: 1, owner: 'alice', members: ['carol'] });
112
+ const client = makeAccessClient({ v: 1, owner: 'alice', members: ['carol'] });
108
113
  await removeSpaceMember(client, 'sp-x', 'unknown');
109
114
  expect(client.push).not.toHaveBeenCalled();
110
115
  });
116
+
117
+ it('preserves name and image when removing a member', async () => {
118
+ const client = makeAccessClient({ v: 1, owner: 'alice', members: ['bob'], name: 'S', image: 'data:i' });
119
+ await removeSpaceMember(client, 'sp-x', 'bob');
120
+ const [, doc] = (client.push as ReturnType<typeof vi.fn>).mock.calls[0] as [string, Record<string, unknown>];
121
+ expect(doc).toHaveProperty('name', 'S');
122
+ expect(doc).toHaveProperty('image', 'data:i');
123
+ });
111
124
  });
@@ -1,30 +1,26 @@
1
1
  /**
2
- * Space + room registries (plaintext metadata docs). A user's spaces live at
2
+ * Space registries (plaintext metadata docs). A user's spaces live at
3
3
  * `user/<userId>/_spaces`; each space's ACCESS RECORD (owner/members + shared
4
- * name/image) at `spaces/<spaceId>/_rooms`. The room/category LIST no longer lives
5
- * here — it moved to the encrypted unified object index (`objects/_index`, see
6
- * `object-index.ts`); `_rooms` is now just the owner-only access record.
4
+ * name/image) at `spaces/<spaceId>/_access`. The object tree lives in the plaintext
5
+ * unified object index (`objects/_index`, see `object-index.ts`); `_access` is the
6
+ * owner-only access record. Spaces are neutral containers visibility and encryption
7
+ * are per-node properties (see `ObjectNode.access` / `ObjectNode.enc`).
7
8
  */
8
9
  import { ConflictError, StarfishHttpError } from '@drakkar.software/starfish-client';
9
10
  import type { StarfishClient } from '@drakkar.software/starfish-client';
10
11
 
11
- import type { ArchivedDms, CapMap, DmMap, MutePrefs, PubAccessMap, ReadPrefs, Room, Space, SpaceVisibility } from '../core/types.js';
12
+ import type { ArchivedDms, CapMap, DmMap, MutePrefs, PubAccessMap, ReadPrefs, Space } from '../core/types.js';
12
13
  import type { SealedBlob } from '../sync/account-seal.js';
13
14
  import { randomId } from '../core/ids.js';
14
15
  import type { Session } from '../sync/identity.js';
15
- import { DEFAULT_CATEGORY } from '../objects/objects.js';
16
16
  import { seedSpaceObjectIndex } from './object-index.js';
17
- import { roomsRegistryPull, roomsRegistryPush, spacesPull, spacesPush } from '../sync/paths.js';
17
+ import { spaceAccessPull, spaceAccessPush, spacesPull, spacesPush } from '../sync/paths.js';
18
18
 
19
- // Re-export so existing `import { DEFAULT_CATEGORY } from './registry'` consumers keep working.
20
- export { DEFAULT_CATEGORY };
21
-
22
- /** Owner-set, SHARED space identity, persisted in the `_rooms` registry doc
23
- * (plaintext — NOT E2EE). `image` is a data URI. Both optional for back-compat. */
19
+ /** Owner-set, SHARED space identity, persisted in the `_access` registry doc
20
+ * (plaintext NOT E2EE). `image` is a data URI. All fields optional for back-compat. */
24
21
  export interface SpaceMeta {
25
22
  name?: string | null;
26
23
  image?: string | null;
27
- visibility?: SpaceVisibility;
28
24
  }
29
25
 
30
26
  /** A resolved name/image update fanned out so the SpacesProvider adopts a
@@ -302,36 +298,25 @@ function newSpaceId(): string {
302
298
  return `sp-${randomId()}`;
303
299
  }
304
300
 
305
- export function normalizeCategories(rooms: Room[], stored: unknown): string[] {
306
- const distinct: string[] = [];
307
- for (const r of rooms) if (r.category && !distinct.includes(r.category)) distinct.push(r.category);
308
- const list = Array.isArray(stored) ? stored.filter((c): c is string => typeof c === 'string') : [];
309
- if (!list.length) return distinct;
310
- const result = [...list];
311
- for (const c of distinct) if (!result.includes(c)) result.push(c);
312
- return result;
313
- }
314
-
315
- export async function readRooms(
301
+ export async function readSpaceAccess(
316
302
  client: StarfishClient,
317
303
  spaceId: string,
318
- ): Promise<{ owner: string | null; members: string[]; visibility: SpaceVisibility | null; name: string | null; image: string | null; hash: string | null }> {
319
- const res = await client.pull(roomsRegistryPull(spaceId)).catch((err: unknown) => {
304
+ ): Promise<{ owner: string | null; members: string[]; name: string | null; image: string | null; hash: string | null }> {
305
+ const res = await client.pull(spaceAccessPull(spaceId)).catch((err: unknown) => {
320
306
  if (err instanceof StarfishHttpError && err.status === 404) return null;
321
307
  throw err;
322
308
  });
323
- const data = res?.data as { owner?: string; members?: unknown[]; visibility?: string; name?: string; image?: string } | undefined;
309
+ const data = res?.data as { owner?: string; members?: unknown[]; name?: string; image?: string } | undefined;
324
310
  return {
325
311
  owner: typeof data?.owner === 'string' ? data.owner : null,
326
312
  members: Array.isArray(data?.members) ? data!.members!.filter((m): m is string => typeof m === 'string') : [],
327
- visibility: data?.visibility === 'public' ? 'public' : null,
328
313
  name: typeof data?.name === 'string' ? data.name : null,
329
314
  image: typeof data?.image === 'string' ? data.image : null,
330
315
  hash: res?.hash ?? null,
331
316
  };
332
317
  }
333
318
 
334
- export async function writeRooms(
319
+ export async function writeSpaceAccess(
335
320
  client: StarfishClient,
336
321
  spaceId: string,
337
322
  owner: string,
@@ -341,10 +326,13 @@ export async function writeRooms(
341
326
  ): Promise<void> {
342
327
  const name = meta?.name?.trim() || undefined;
343
328
  const image = meta?.image || undefined;
344
- const visibility = meta?.visibility === 'public' ? 'public' : undefined;
345
329
  await client.push(
346
- roomsRegistryPush(spaceId),
347
- { v: 1, owner, members, ...(visibility ? { visibility } : {}), ...(name ? { name } : {}), ...(image ? { image } : {}) },
330
+ spaceAccessPush(spaceId),
331
+ {
332
+ v: 1, owner, members,
333
+ ...(name ? { name } : {}),
334
+ ...(image ? { image } : {}),
335
+ },
348
336
  hash,
349
337
  );
350
338
  }
@@ -355,9 +343,9 @@ export async function addSpaceMember(
355
343
  ownerUserId: string,
356
344
  memberUserId: string,
357
345
  ): Promise<void> {
358
- const { owner, members, visibility, name, image, hash } = await readRooms(client, spaceId);
346
+ const { owner, members, name, image, hash } = await readSpaceAccess(client, spaceId);
359
347
  if (memberUserId === (owner ?? ownerUserId) || members.includes(memberUserId)) return;
360
- await writeRooms(client, spaceId, owner ?? ownerUserId, [...members, memberUserId], hash, { name, image, visibility: visibility ?? undefined });
348
+ await writeSpaceAccess(client, spaceId, owner ?? ownerUserId, [...members, memberUserId], hash, { name, image });
361
349
  }
362
350
 
363
351
  /** Remove a member from the space roster (used for link revocation). */
@@ -366,9 +354,9 @@ export async function removeSpaceMember(
366
354
  spaceId: string,
367
355
  memberUserId: string,
368
356
  ): Promise<void> {
369
- const { owner, members, visibility, name, image, hash } = await readRooms(client, spaceId);
357
+ const { owner, members, name, image, hash } = await readSpaceAccess(client, spaceId);
370
358
  if (!members.includes(memberUserId)) return;
371
- await writeRooms(client, spaceId, owner ?? memberUserId, members.filter((m) => m !== memberUserId), hash, { name, image, visibility: visibility ?? undefined });
359
+ await writeSpaceAccess(client, spaceId, owner ?? memberUserId, members.filter((m) => m !== memberUserId), hash, { name, image });
372
360
  }
373
361
 
374
362
  export async function addJoinedSpace(client: StarfishClient, userId: string, space: Space): Promise<void> {
@@ -406,36 +394,29 @@ export async function addJoinedSpaceWithLinkAccess(
406
394
  }
407
395
 
408
396
  /**
409
- * Create a new space owned by the identity. Seeds ONE generic `general` object node
410
- * into the object index (encrypted for private, plaintext for public).
411
- *
412
- * `opts.visibility` defaults to `'private'`.
397
+ * Create a new space owned by the identity. Seeds an empty plaintext object index.
398
+ * Apps populate the index with their own object types after creation using `createNode`.
413
399
  */
414
400
  export async function createSpace(
415
401
  session: Session,
416
402
  name: string,
417
- opts?: { visibility?: SpaceVisibility },
418
403
  ): Promise<Space> {
419
404
  const { accountClient, userId } = session;
420
405
  const { spaces, hash } = await readSpaces(accountClient, userId);
421
406
  const trimmed = name.trim() || 'New Space';
422
- const visibility = opts?.visibility ?? 'private';
423
407
  const id = newSpaceId();
424
408
  const space: Space = {
425
409
  id,
426
410
  name: trimmed,
427
411
  short: trimmed.slice(0, 2).toUpperCase(),
428
412
  members: 1,
429
- ...(visibility === 'public' ? { visibility: 'public', ownerId: userId, write: true } : {}),
430
413
  };
431
- await writeRooms(accountClient, id, userId, [], null, { name: trimmed, visibility: visibility === 'public' ? 'public' : undefined });
432
- await seedSpaceObjectIndex(session, id, [{ id: `${id}-general`, name: 'general', kind: 'channel', category: DEFAULT_CATEGORY }], { visibility });
414
+ await writeSpaceAccess(accountClient, id, userId, [], null, { name: trimmed });
415
+ await seedSpaceObjectIndex(session, id);
433
416
  await writeSpaces(accountClient, userId, [...spaces, space], hash);
434
417
  return space;
435
418
  }
436
419
 
437
- export class CategoryError extends Error {}
438
-
439
420
  export async function reconcileSpaceMeta(
440
421
  client: StarfishClient,
441
422
  userId: string,
@@ -12,7 +12,7 @@ import { getSyncBase, getSyncNamespace, getSyncPrefix, getOnServerReachable } fr
12
12
  import { fetchWithTimeout } from './fetch-timeout.js';
13
13
  import { pullCache, PULL_CACHE_MAX_AGE_MS } from './pull-cache.js';
14
14
  import { cacheProfile, loadCachedProfile } from './profile-cache.js';
15
- import { keyringPull, keyringPush, profilePull, profilePush } from './paths.js';
15
+ import { profilePull, profilePush } from './paths.js';
16
16
  import { SpaceAccessError } from '../core/space-access-error.js';
17
17
 
18
18
  export interface DeviceKeys {
@@ -48,24 +48,25 @@ export function makeClient(cap: unknown, devEdPrivHex: string, namespaceOverride
48
48
  }
49
49
 
50
50
  /**
51
- * Open a SPACE's decryptor, throwing a descriptive error per failure mode
51
+ * Open a node's decryptor, throwing a descriptive error per failure mode
52
52
  * (unreachable server / no keyring yet / not a recipient).
53
53
  *
54
- * A `SpaceAccessError` is a hard access denial; any other thrown error is a
55
- * transient offline state.
54
+ * `keyringPullPath` is the full `/pull/.../_keyring` path (e.g. from
55
+ * `keyringPull(spaceId)`). A `SpaceAccessError` is a hard access
56
+ * denial; any other thrown error is a transient offline state.
56
57
  */
57
58
  export async function openEncryptor(
58
59
  client: StarfishClient,
59
60
  keys: DeviceKeys,
60
- spaceId: string,
61
+ keyringPullPath: string,
61
62
  trustedAdders: string[],
62
63
  ): Promise<Encryptor> {
63
- const res = await client.pull(keyringPull(spaceId)).catch(() => {
64
- throw new Error('Could not reach the server to fetch space keys.');
64
+ const res = await client.pull(keyringPullPath).catch(() => {
65
+ throw new Error('Could not reach the server to fetch node keys.');
65
66
  });
66
67
  const keyring = res?.data as unknown as Keyring | undefined;
67
68
  if (!keyring || !keyring.epochs) {
68
- throw new SpaceAccessError('This space has no keyring yet — ask the owner to open it first.');
69
+ throw new SpaceAccessError('This node has no keyring yet — ask the owner to create it first.');
69
70
  }
70
71
  try {
71
72
  const enc = await createKeyringEncryptor(
@@ -75,7 +76,7 @@ export async function openEncryptor(
75
76
  );
76
77
  return enc as unknown as Encryptor;
77
78
  } catch {
78
- throw new SpaceAccessError("You're not a recipient of this space's keyring yet — ask the owner to re-invite.");
79
+ throw new SpaceAccessError("You're not a recipient of this node's keyring yet — ask the owner to invite you.");
79
80
  }
80
81
  }
81
82
 
@@ -83,33 +84,37 @@ export async function openEncryptor(
83
84
  export async function buildEncryptor(
84
85
  client: StarfishClient,
85
86
  keys: DeviceKeys,
86
- spaceId: string,
87
+ keyringPullPath: string,
87
88
  trustedAdders: string[],
88
89
  ): Promise<Encryptor | null> {
89
90
  try {
90
- return await openEncryptor(client, keys, spaceId, trustedAdders);
91
+ return await openEncryptor(client, keys, keyringPullPath, trustedAdders);
91
92
  } catch {
92
93
  return null;
93
94
  }
94
95
  }
95
96
 
96
97
  /**
97
- * Owner-side: create the SPACE keyring if missing, return an encryptor.
98
+ * Owner-side: create a per-node keyring if missing, return an encryptor.
99
+ *
100
+ * `keyringPullPath` / `keyringPushPath` are the full `/pull|push/.../_keyring`
101
+ * paths (e.g. from `keyringPull`/`keyringPush`).
98
102
  */
99
103
  export async function ownerEnsureKeyring(
100
104
  client: StarfishClient,
101
105
  keys: DeviceKeys,
102
- spaceId: string,
106
+ keyringPullPath: string,
107
+ keyringPushPath: string,
103
108
  trustedAdders: string[] = [keys.edPub],
104
109
  ): Promise<Encryptor> {
105
- const krRes = await client.pull(keyringPull(spaceId)).catch(() => null);
110
+ const krRes = await client.pull(keyringPullPath).catch(() => null);
106
111
  let keyring = krRes?.data as unknown as Keyring | undefined;
107
112
  if (!keyring || !keyring.epochs) {
108
113
  const created = await createKeyring({ edPrivHex: keys.edPriv, edPubHex: keys.edPub }, [
109
114
  { subKemHex: keys.kemPub },
110
115
  ]);
111
116
  keyring = created.keyring;
112
- await client.push(keyringPush(spaceId), keyring as unknown as Record<string, unknown>, krRes?.hash ?? null);
117
+ await client.push(keyringPushPath, keyring as unknown as Record<string, unknown>, krRes?.hash ?? null);
113
118
  }
114
119
  const enc = await createKeyringEncryptor(
115
120
  keyring,