@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@drakkar.software/octospaces-sdk",
3
- "version": "0.1.0",
3
+ "version": "0.4.3",
4
4
  "description": "Shared headless spaces core — identity, registry, objects, and sync plumbing.",
5
5
  "type": "module",
6
6
  "sideEffects": false,
package/src/core/types.ts CHANGED
@@ -1,11 +1,16 @@
1
1
  /**
2
- * Domain model for OctoSpaces — space + object types shared by the SDK and any UI.
2
+ * Domain model for OctoSpaces — space + generic object-tree types shared by the SDK.
3
3
  *
4
4
  * A space's contents are modelled as a tree of typed {@link ObjectNode}s stored in
5
- * a union-merged index at `spaces/{spaceId}/objects/_index`. Everything — rooms,
6
- * categories, docs, projects, tasks is an `ObjectNode` discriminated by `type`.
7
- * Apps extend the model by adding their own `ObjectType` strings; the generic
8
- * primitives here are app-neutral.
5
+ * a union-merged index at `spaces/{spaceId}/objects/_index`. The SDK is deliberately
6
+ * domain-agnostic: it defines only the generic {@link ObjectNode} envelope and the
7
+ * {@link ObjectType} string alias. Apps (OctoChat, OctoVault, …) declare their own
8
+ * type strings and descriptors — they are not defined here.
9
+ *
10
+ * Visibility and confidentiality are **per-node** properties, not per-space:
11
+ * - {@link NodeAccess} controls who may reach a node's content.
12
+ * - `enc` controls whether the node's content is E2EE under its own keyring.
13
+ * A space is a neutral container; its `_access` record holds only the owner + roster.
9
14
  */
10
15
 
11
16
  // Re-export SealedBlob so consumers get it from one place.
@@ -16,20 +21,19 @@ export type ID = string;
16
21
  /** A user's presence indicator. The theme maps each to a color (app-side). */
17
22
  export type PresenceStatus = 'online' | 'away' | 'dnd' | 'offline';
18
23
 
19
- /** A security item's verification state. The theme maps each to a color (app-side). */
20
- export type VerificationLevel = 'verified' | 'pending' | 'unverified';
24
+ /** A security item's verification state. The theme maps each to a color (app-side).
25
+ * `none` = unknown / not yet verified; maps to a neutral/muted color in the theme. */
26
+ export type VerificationLevel = 'verified' | 'pending' | 'unverified' | 'none';
21
27
 
22
- /** Maps a joined private space's id → its owner-issued member cap-cert (serialized
23
- * JSON). Persisted both in device-local kv (`member-caps.ts`) and, for durability,
24
- * in the user's own synced `_spaces` doc so a fresh device re-hydrates it. */
28
+ /** Maps a joined space's id → its owner-issued member cap-cert (serialized JSON).
29
+ * Persisted both in device-local kv and, for durability, in the user's own synced
30
+ * `_spaces` doc so a fresh device re-hydrates it. */
25
31
  export type CapMap = Record<string, string>;
26
32
 
27
- /** Maps a joined PUBLIC space's id → its invitation credential (the owner-signed cap
28
- * plus the link's ephemeral private key) SEALED to the account's own key. Unlike a
29
- * member cap (safe in the clear see {@link CapMap}), a public-join credential
30
- * embeds a bearer secret, so it is sealed before riding in the plaintext `_spaces`
31
- * doc. Recovered on any device with the same seed. See `account-seal.ts` and
32
- * `space-access-store.ts`. */
33
+ /** Maps a joined link-access key → its sealed invitation credential (cap + ephemeral
34
+ * private key) SEALED to the account's own key. Keys are either `spaceId` (space-level
35
+ * link) or `${spaceId}:${nodeId}` (per-node invite link). Sealed because it embeds a
36
+ * bearer secret; recovered on any device with the same seed. */
33
37
  export type PubAccessMap = Record<string, import('../sync/account-seal.js').SealedBlob>;
34
38
 
35
39
  /** Maps a DM peer's userId → the private DM-space id shared with them. */
@@ -55,9 +59,8 @@ export interface ReadPrefs {
55
59
  rooms: Record<string, ReadValue>;
56
60
  }
57
61
 
58
- /** Whether a space encrypts its content client-side. */
59
- export type SpaceVisibility = 'private' | 'public';
60
-
62
+ /** A joined or listed space. Visibility and encryption are per-node (see ObjectNode),
63
+ * not per-space a space is a neutral container. */
61
64
  export interface Space {
62
65
  id: ID;
63
66
  name: string;
@@ -67,96 +70,60 @@ export interface Space {
67
70
  image?: string;
68
71
  members: number;
69
72
  unread?: number;
70
- /** 'private' (E2EE keyring, the default) or 'public' (plaintext, joined via a
71
- * space-wide invitation link). Absent ⇒ treat as 'private' (back-compat). */
72
- visibility?: SpaceVisibility;
73
- /** Public spaces only: the owner's userId (derived from the cap issuer). */
74
- ownerId?: string;
75
- /** Public spaces only (joiner side): whether this identity's invite link grants write. */
76
- write?: boolean;
77
- }
78
-
79
- /** Legacy room kind — used while apps migrate their content onto objects.
80
- * New code should use {@link RoomSubtype} directly. */
81
- export type RoomKind = 'channel' | 'dm' | 'automated';
82
-
83
- /** Scheduled-fetch cadence for automated rooms. */
84
- export type AutomationSchedule =
85
- | { kind: 'interval'; everyMin: number }
86
- | { kind: 'daily'; hour: number; minute: number }
87
- | { kind: 'weekly'; weekday: number; hour: number; minute: number }
88
- | { kind: 'cron'; expression: string };
89
-
90
- /** Stored, synced configuration of an `automated` room. */
91
- export interface AutomationMeta {
92
- providerId: string;
93
- params: Record<string, unknown>;
94
- intervalMin: number;
95
- schedule?: AutomationSchedule;
96
- onOpen?: boolean;
97
- enabled: boolean;
98
- credential: import('../sync/account-seal.js').SealedBlob;
99
- botUserId?: string;
100
- runOnDeviceId: string | null;
101
- lastRunAt: number | null;
102
- lastFetchHash?: string | null;
103
- lastError: string | null;
104
- }
105
-
106
- /** A transitional Room shape (used while apps migrate onto the object model). */
107
- export interface Room {
108
- id: ID;
109
- spaceId: ID;
110
- category: string;
111
- name: string;
112
- kind: RoomKind;
113
- topic?: string;
114
- unread?: number;
115
- mention?: boolean;
116
- avatar?: string;
117
- automation?: AutomationMeta;
118
73
  }
119
74
 
120
75
  // ── Object model ─────────────────────────────────────────────────────────────
121
- // Everything in a space rooms, categories, docs, projects, etc. is an
122
- // ObjectNode with a type. Apps extend ObjectType with their own strings.
76
+ // Everything in a space is an ObjectNode with a type string. Apps (OctoChat,
77
+ // OctoVault, …) define their own type strings; none are baked into the SDK.
123
78
 
124
- /** The builtin object types. Custom types ride the same `string` field. */
125
- export type BuiltinObjectType = 'room' | 'category' | 'automation' | 'doc' | 'project' | 'task';
126
- export type ObjectType = BuiltinObjectType | (string & {});
79
+ /** Any string an app assigns as an object's type. No builtins are defined here
80
+ * each app declares its own type strings in its local SDK. */
81
+ export type ObjectType = string;
127
82
 
128
- /** Runtime set of builtin type strings for renderer branching. */
129
- export const BUILTIN_OBJECT_TYPES: readonly BuiltinObjectType[] = ['room', 'category', 'automation', 'doc', 'project', 'task'];
130
-
131
- /** How an object's content syncs. Builtins infer this; custom types set it explicitly. */
83
+ /** How an object's content syncs. Apps may set this per-type explicitly. */
132
84
  export type ObjectContentKind = 'merge' | 'append' | 'none';
133
85
 
134
- /** When `type === 'room'`, the room flavour. */
135
- export type RoomSubtype = 'channel' | 'dm' | 'automation';
86
+ /** Who may read a node's content (independent from whether it is E2EE):
87
+ * - `'public'` — world-readable; anonymous users may access the content. The node
88
+ * is listed in the global object directory (`_index/objects/{shard}`).
89
+ * - `'space'` — any member of the parent space. The default for new nodes.
90
+ * - `'invite'` — only members explicitly invited to this node (via its own per-node
91
+ * keyring for E2EE nodes, or via a per-node cap for plaintext nodes).
92
+ * Non-invited space members see a placeholder row (no title/emoji). */
93
+ export type NodeAccess = 'public' | 'space' | 'invite';
136
94
 
137
95
  /**
138
96
  * One entry in a space's object index (`spaces/{spaceId}/objects/_index`).
139
97
  * Identity + tree position + light metadata ONLY — heavy content (messages, doc
140
- * blocks, project event log) lives in a per-object content doc keyed by `id`.
98
+ * blocks, event logs) lives in per-object content docs keyed by `id`.
99
+ *
100
+ * The index doc is always **plaintext** (member-gated). For `invite` nodes the
101
+ * `title` and `emoji` fields are stored empty in the index — only invited members
102
+ * can recover the real title from the node's content doc or encrypted keyring entry.
141
103
  */
142
104
  export interface ObjectNode {
143
105
  id: ID;
144
106
  type: ObjectType;
145
- subtype?: RoomSubtype;
146
107
  parentId: ID | null;
147
108
  order: number;
148
109
  title: string;
149
110
  emoji?: string;
150
111
  updatedAt: number;
151
112
  archived?: boolean;
152
- automation?: AutomationMeta;
153
113
  contentKind?: ObjectContentKind;
114
+ /** Who may access this node's content. Absent ⇒ `'space'`. */
115
+ access?: NodeAccess;
116
+ /** True ⇒ this node's content is E2EE under the SPACE-WIDE keyring at
117
+ * `spaces/{spaceId}/_keyring`. All `enc` nodes in a space share one CEK.
118
+ * The combination `public + enc` is invalid. */
119
+ enc?: boolean;
120
+ /** App-specific fields. Apps store type-specific metadata here. */
154
121
  meta?: Record<string, unknown>;
155
122
  }
156
123
 
157
- /** The object-index doc: the union-merged list of every object in a space. */
124
+ /** The object-index doc stored at `spaces/{spaceId}/objects/_index`. */
158
125
  export interface ObjectsIndex {
159
- v: 1;
126
+ v: 1 | 2;
160
127
  objects: ObjectNode[];
161
128
  updatedAt: number;
162
129
  }
package/src/index.ts CHANGED
@@ -11,23 +11,22 @@ export type { KvAdapter } from './core/adapters.js';
11
11
  // Domain types
12
12
  export type {
13
13
  ID,
14
+ NodeAccess,
14
15
  ObjectNode,
15
16
  ObjectType,
16
17
  ObjectsIndex,
17
18
  ObjectContentKind,
18
- RoomSubtype,
19
- AutomationMeta,
20
- Room,
21
- RoomKind,
22
19
  Space,
23
- SpaceVisibility,
24
20
  CapMap,
25
21
  PubAccessMap,
26
22
  DmMap,
23
+ MuteValue,
27
24
  MutePrefs,
25
+ ReadValue,
28
26
  ReadPrefs,
29
27
  ArchivedDms,
30
- BUILTIN_OBJECT_TYPES,
28
+ PresenceStatus,
29
+ VerificationLevel,
31
30
  } from './core/types.js';
32
31
 
33
32
  // Ids
@@ -38,30 +37,45 @@ export {
38
37
  OBJECT_COLLECTIONS,
39
38
  ownerScope,
40
39
  spaceMemberScope,
40
+ nodeMemberScope,
41
41
  accountScope,
42
42
  linkedDeviceScope,
43
43
  keyringName,
44
44
  keyringPull,
45
45
  keyringPush,
46
+ objIndexName,
46
47
  objIndexPull,
47
48
  objIndexPush,
49
+ objPubName,
50
+ objPubPull,
51
+ objPubPush,
52
+ objInvName,
53
+ objInvPull,
54
+ objInvPush,
55
+ objectDirName,
56
+ objectDirPull,
48
57
  spacesPull,
49
58
  spacesPush,
50
- roomsRegistryPull,
51
- roomsRegistryPush,
59
+ spaceAccessPull,
60
+ spaceAccessPush,
52
61
  profilePull,
53
62
  profilePush,
63
+ objLogName,
54
64
  objLogPull,
55
65
  objLogPush,
66
+ objDocName,
56
67
  objDocPull,
57
68
  objDocPush,
69
+ objectBlobName,
58
70
  objectBlobPull,
59
71
  objectBlobPush,
72
+ typesIndexName,
60
73
  typesIndexPull,
61
74
  typesIndexPush,
75
+ attachmentName,
62
76
  attachmentPull,
63
77
  attachmentPush,
64
- spaceIndexPull,
78
+ spaceIdFromRoomId,
65
79
  userIdFromEdPub,
66
80
  bytesToHex,
67
81
  } from './sync/paths.js';
@@ -112,9 +126,15 @@ export type {
112
126
  export { sealToSelf, unsealFromSelf, sealToRecipient, unsealFromRecipient } from './sync/account-seal.js';
113
127
  export type { SealedBlob } from './sync/account-seal.js';
114
128
 
115
- // Space access (replaces SpaceEncryptor)
116
- export { SpaceAccessError, getSpaceAccess, buildSpaceAccess, clearSpaceAccessCache } from './sync/space-access.js';
117
- export type { SpaceAccessHandle } from './sync/space-access.js';
129
+ // Node access (per-node encryptor + client resolver, replaces per-space access)
130
+ export {
131
+ SpaceAccessError,
132
+ getSpaceClient,
133
+ getNodeAccess,
134
+ buildNodeAccess,
135
+ clearNodeAccessCache,
136
+ } from './sync/space-access.js';
137
+ export type { NodeAccessHandle } from './sync/space-access.js';
118
138
 
119
139
  // Space access store (replaces member-caps + pubspace-caps)
120
140
  export {
@@ -122,6 +142,9 @@ export {
122
142
  getSpaceAccessEntry,
123
143
  saveSpaceAccessEntry,
124
144
  removeSpaceAccessEntry,
145
+ getNodeAccessEntry,
146
+ saveNodeAccessEntry,
147
+ removeNodeAccessEntry,
125
148
  localSpaceAccessEntries,
126
149
  memberCapsFromStore,
127
150
  linkAccessFromStore,
@@ -131,7 +154,6 @@ export type { SpaceAccessEntry, SpaceAccessMap } from './sync/space-access-store
131
154
 
132
155
  // Registry
133
156
  export {
134
- DEFAULT_CATEGORY,
135
157
  readSpaces,
136
158
  updateSpacesDoc,
137
159
  updateMutesDoc,
@@ -142,19 +164,17 @@ export {
142
164
  setDmMapping,
143
165
  writeSpaces,
144
166
  reorderSpaces,
145
- readRooms,
146
- writeRooms,
167
+ readSpaceAccess,
168
+ writeSpaceAccess,
147
169
  addSpaceMember,
148
170
  removeSpaceMember,
149
171
  addJoinedSpace,
150
172
  addJoinedSpaceWithCap,
151
173
  addJoinedSpaceWithLinkAccess,
152
174
  createSpace,
153
- normalizeCategories,
154
175
  reconcileSpaceMeta,
155
176
  onSpaceMeta,
156
177
  broadcastSpaceMeta,
157
- CategoryError,
158
178
  } from './spaces/registry.js';
159
179
  export type { SpaceMeta, SpaceMetaUpdate } from './spaces/registry.js';
160
180
 
@@ -163,18 +183,30 @@ export {
163
183
  makeJoinRequest,
164
184
  inviteToSpace,
165
185
  acceptSpaceInvite,
166
- addDeviceToSpaceKeyring,
167
186
  encodeSpaceInviteLink,
168
187
  decodeSpaceInviteLink,
169
188
  createSpaceInviteLink,
170
189
  joinSpaceByLink,
171
190
  recoverSpaceAccess,
191
+ addDeviceToSpaceKeyring,
172
192
  } from './spaces/members.js';
173
193
  export type { JoinRequest, SpaceInviteLinkToken } from './spaces/members.js';
174
194
 
195
+ // Nodes (per-node creation + access management + invite flows)
196
+ export {
197
+ createNode,
198
+ setNodeAccess,
199
+ inviteToNode,
200
+ acceptNodeInvite,
201
+ createNodeInviteLink,
202
+ decodeNodeInviteLink,
203
+ encodeNodeInviteLink,
204
+ joinNodeByLink,
205
+ } from './spaces/nodes.js';
206
+ export type { CreateNodeInput, NodeInviteBundle, NodeInviteLinkToken } from './spaces/nodes.js';
207
+
175
208
  // Object core
176
209
  export {
177
- categoryId,
178
210
  buildTree,
179
211
  breadcrumbs,
180
212
  ancestors,
@@ -185,22 +217,15 @@ export {
185
217
  reparentObject,
186
218
  reorderObjects,
187
219
  archiveObject,
188
- seedIndexNodes,
189
- objectsToRoomCategories,
190
- excludeAutomatedRooms,
191
- roomKindToSubtype,
192
- subtypeToRoomKind,
193
220
  } from './objects/objects.js';
194
- export type { ObjectTreeNode, NewObjectInput, AdaptedCategory, SeedRoom } from './objects/objects.js';
221
+ export type { ObjectTreeNode, NewObjectInput } from './objects/objects.js';
195
222
 
196
223
  // Object index
197
224
  export {
198
- readIndexRooms,
199
- readSpaceIndexRooms,
200
- readSpaceRooms,
201
225
  pushIndexSeed,
202
226
  seedSpaceObjectIndex,
203
227
  updateObjectIndex,
228
+ readObjectTree,
204
229
  } from './spaces/object-index.js';
205
230
 
206
231
  // Pairing
@@ -219,3 +244,12 @@ export { fetchWithTimeout, CONNECT_TIMEOUT_MS } from './sync/fetch-timeout.js';
219
244
  // Base64
220
245
  export { starfishBase64 } from './sync/base64.js';
221
246
  export { toBase64Url, fromBase64Url } from './sync/base64url.js';
247
+
248
+ // Utilities
249
+ export { matchTitle, rankResults, fold, isWordStart } from './utils/search-match.js';
250
+ export type { MatchRange, TitleMatch, RankedResult } from './utils/search-match.js';
251
+
252
+ export { registerPull, dispatchDocChange, emitSseStatus, onSseStatus, clearLiveSyncBus } from './utils/live-sync-bus.js';
253
+
254
+ export { previewInvite } from './utils/invite-preview.js';
255
+ export type { InvitePreview } from './utils/invite-preview.js';
@@ -6,18 +6,11 @@ import {
6
6
  breadcrumbs,
7
7
  buildTree,
8
8
  ancestors,
9
- categoryId,
10
- DEFAULT_CATEGORY,
11
9
  nextOrder,
12
10
  patchObject,
13
11
  reparentObject,
14
12
  reorderObjects,
15
- seedIndexNodes,
16
13
  subtreeIds,
17
- objectsToRoomCategories,
18
- excludeAutomatedRooms,
19
- roomKindToSubtype,
20
- subtypeToRoomKind,
21
14
  } from './objects.js';
22
15
 
23
16
  const NOW = 1_700_000_000_000;
@@ -25,7 +18,7 @@ const NOW = 1_700_000_000_000;
25
18
  function makeNode(overrides: Partial<ObjectNode> = {}): ObjectNode {
26
19
  return {
27
20
  id: 'n1',
28
- type: 'room',
21
+ type: 'item',
29
22
  parentId: null,
30
23
  order: 1,
31
24
  title: 'Test',
@@ -34,22 +27,6 @@ function makeNode(overrides: Partial<ObjectNode> = {}): ObjectNode {
34
27
  };
35
28
  }
36
29
 
37
- describe('DEFAULT_CATEGORY', () => {
38
- it('is CHANNELS', () => { expect(DEFAULT_CATEGORY).toBe('CHANNELS'); });
39
- });
40
-
41
- describe('categoryId', () => {
42
- it('is deterministic for the same name', () => {
43
- expect(categoryId('Channels')).toBe(categoryId('Channels'));
44
- });
45
- it('differs for different names', () => {
46
- expect(categoryId('Alpha')).not.toBe(categoryId('Beta'));
47
- });
48
- it('starts with cat-', () => {
49
- expect(categoryId('test')).toMatch(/^cat-/);
50
- });
51
- });
52
-
53
30
  describe('nextOrder', () => {
54
31
  it('returns 1 for empty sibling list', () => {
55
32
  expect(nextOrder([])).toBe(1);
@@ -74,13 +51,13 @@ describe('buildTree', () => {
74
51
 
75
52
  it('nests children under their parent', () => {
76
53
  const nodes: ObjectNode[] = [
77
- makeNode({ id: 'cat', type: 'category', parentId: null, order: 1 }),
78
- makeNode({ id: 'room', type: 'room', parentId: 'cat', order: 1 }),
54
+ makeNode({ id: 'folder', type: 'folder', parentId: null, order: 1 }),
55
+ makeNode({ id: 'page', type: 'page', parentId: 'folder', order: 1 }),
79
56
  ];
80
57
  const tree = buildTree(nodes);
81
58
  expect(tree).toHaveLength(1);
82
59
  expect(tree[0].children).toHaveLength(1);
83
- expect(tree[0].children[0].id).toBe('room');
60
+ expect(tree[0].children[0].id).toBe('page');
84
61
  });
85
62
 
86
63
  it('excludes archived nodes by default', () => {
@@ -147,17 +124,22 @@ describe('subtreeIds', () => {
147
124
 
148
125
  describe('addObject', () => {
149
126
  it('appends a new node with correct order', () => {
150
- const { nodes, node } = addObject([], { type: 'room', title: 'general' }, NOW);
127
+ const { nodes, node } = addObject([], { type: 'page', title: 'Intro' }, NOW);
151
128
  expect(nodes).toHaveLength(1);
152
- expect(node.title).toBe('general');
153
- expect(node.type).toBe('room');
129
+ expect(node.title).toBe('Intro');
130
+ expect(node.type).toBe('page');
154
131
  expect(node.order).toBe(1);
155
132
  });
156
133
 
157
134
  it('respects provided id', () => {
158
- const { node } = addObject([], { id: 'my-id', type: 'category', title: 'Channels' }, NOW);
135
+ const { node } = addObject([], { id: 'my-id', type: 'folder', title: 'Docs' }, NOW);
159
136
  expect(node.id).toBe('my-id');
160
137
  });
138
+
139
+ it('passes meta through to the node', () => {
140
+ const { node } = addObject([], { type: 'task', title: 'Fix bug', meta: { priority: 'high' } }, NOW);
141
+ expect(node.meta).toEqual({ priority: 'high' });
142
+ });
161
143
  });
162
144
 
163
145
  describe('patchObject', () => {
@@ -172,19 +154,19 @@ describe('patchObject', () => {
172
154
  describe('reparentObject', () => {
173
155
  it('moves a node to a new parent', () => {
174
156
  const nodes: ObjectNode[] = [
175
- makeNode({ id: 'cat-a', type: 'category', parentId: null }),
176
- makeNode({ id: 'cat-b', type: 'category', parentId: null }),
177
- makeNode({ id: 'room', type: 'room', parentId: 'cat-a' }),
157
+ makeNode({ id: 'folder-a', type: 'folder', parentId: null }),
158
+ makeNode({ id: 'folder-b', type: 'folder', parentId: null }),
159
+ makeNode({ id: 'page', type: 'page', parentId: 'folder-a' }),
178
160
  ];
179
- const result = reparentObject(nodes, 'room', 'cat-b', NOW);
180
- const room = result.find(n => n.id === 'room')!;
181
- expect(room.parentId).toBe('cat-b');
161
+ const result = reparentObject(nodes, 'page', 'folder-b', NOW);
162
+ const page = result.find(n => n.id === 'page')!;
163
+ expect(page.parentId).toBe('folder-b');
182
164
  });
183
165
 
184
166
  it('rejects making a node its own descendant', () => {
185
167
  const nodes: ObjectNode[] = [
186
- makeNode({ id: 'parent', type: 'category', parentId: null }),
187
- makeNode({ id: 'child', type: 'room', parentId: 'parent' }),
168
+ makeNode({ id: 'parent', type: 'folder', parentId: null }),
169
+ makeNode({ id: 'child', type: 'page', parentId: 'parent' }),
188
170
  ];
189
171
  const result = reparentObject(nodes, 'parent', 'child', NOW);
190
172
  expect(result).toBe(nodes); // unchanged
@@ -214,75 +196,53 @@ describe('archiveObject', () => {
214
196
  });
215
197
  });
216
198
 
217
- describe('seedIndexNodes', () => {
218
- it('creates category + room nodes', () => {
219
- const nodes = seedIndexNodes([{ id: 'r1', name: 'general', kind: 'channel', category: 'CHANNELS' }], NOW);
220
- expect(nodes.some(n => n.type === 'category')).toBe(true);
221
- expect(nodes.some(n => n.type === 'room')).toBe(true);
222
- });
199
+ // ── access / enc fields ───────────────────────────────────────────────────────
223
200
 
224
- it('dedupes categories', () => {
225
- const rooms = [
226
- { id: 'r1', name: 'general', kind: 'channel' as const, category: 'CHANNELS' },
227
- { id: 'r2', name: 'random', kind: 'channel' as const, category: 'CHANNELS' },
228
- ];
229
- const nodes = seedIndexNodes(rooms, NOW);
230
- const cats = nodes.filter(n => n.type === 'category');
231
- expect(cats).toHaveLength(1);
201
+ describe('addObject — access / enc', () => {
202
+ it('omits access field when not provided (defaults to space)', () => {
203
+ const { node } = addObject([], { type: 'page', title: 'Space' }, NOW);
204
+ expect(node).not.toHaveProperty('access');
232
205
  });
233
- });
234
206
 
235
- describe('roomKindToSubtype / subtypeToRoomKind', () => {
236
- it('channel channel', () => {
237
- expect(roomKindToSubtype('channel')).toBe('channel');
238
- expect(subtypeToRoomKind('channel')).toBe('channel');
207
+ it('omits access field when access is "space"', () => {
208
+ const { node } = addObject([], { type: 'page', title: 'Space', access: 'space' }, NOW);
209
+ expect(node).not.toHaveProperty('access');
239
210
  });
240
- it('dm ↔ dm', () => {
241
- expect(roomKindToSubtype('dm')).toBe('dm');
242
- expect(subtypeToRoomKind('dm')).toBe('dm');
211
+
212
+ it('sets access:"public" on the node', () => {
213
+ const { node } = addObject([], { type: 'page', title: 'Public', access: 'public' }, NOW);
214
+ expect(node.access).toBe('public');
243
215
  });
244
- it('automated ↔ automation', () => {
245
- expect(roomKindToSubtype('automated')).toBe('automation');
246
- expect(subtypeToRoomKind('automation')).toBe('automated');
216
+
217
+ it('sets access:"invite" on the node', () => {
218
+ const { node } = addObject([], { type: 'page', title: 'Private', access: 'invite' }, NOW);
219
+ expect(node.access).toBe('invite');
247
220
  });
248
- });
249
221
 
250
- describe('objectsToRoomCategories', () => {
251
- it('returns null for empty index', () => {
252
- expect(objectsToRoomCategories([], 'sp-1', 'CHANNELS')).toBeNull();
222
+ it('omits enc field when not provided or false', () => {
223
+ const { node: n1 } = addObject([], { type: 'page', title: 'T' }, NOW);
224
+ const { node: n2 } = addObject([], { type: 'page', title: 'T', enc: false }, NOW);
225
+ expect(n1).not.toHaveProperty('enc');
226
+ expect(n2).not.toHaveProperty('enc');
253
227
  });
254
228
 
255
- it('groups rooms under their category', () => {
256
- const nodes: ObjectNode[] = [
257
- makeNode({ id: 'cat', type: 'category', parentId: null, order: 1, title: 'CHANNELS' }),
258
- makeNode({ id: 'room', type: 'room', parentId: 'cat', order: 1, title: 'general' }),
259
- ];
260
- const cats = objectsToRoomCategories(nodes, 'sp-1', 'CHANNELS')!;
261
- expect(cats).toHaveLength(1);
262
- expect(cats[0].name).toBe('CHANNELS');
263
- expect(cats[0].rooms[0].name).toBe('general');
229
+ it('sets enc:true on the node', () => {
230
+ const { node } = addObject([], { type: 'page', title: 'E2EE', enc: true }, NOW);
231
+ expect(node.enc).toBe(true);
264
232
  });
265
233
  });
266
234
 
267
- describe('excludeAutomatedRooms', () => {
268
- it('removes categories that held ONLY automated rooms', () => {
269
- const cats = [{ name: 'C', rooms: [{ id: 'r', spaceId: 's', category: 'C', name: 'bot', kind: 'automated' as const }] }];
270
- const result = excludeAutomatedRooms(cats);
271
- // A category whose ONLY room was automated gets dropped entirely.
272
- expect(result).toHaveLength(0);
235
+ describe('patchObject — access / enc', () => {
236
+ it('patches access field', () => {
237
+ const nodes = [makeNode({ id: 'x', title: 'X' })];
238
+ const patched = patchObject(nodes, 'x', { access: 'public' }, NOW);
239
+ expect(patched[0].access).toBe('public');
273
240
  });
274
241
 
275
- it('keeps categories that still have non-automated rooms', () => {
276
- const cats = [{
277
- name: 'C',
278
- rooms: [
279
- { id: 'r1', spaceId: 's', category: 'C', name: 'bot', kind: 'automated' as const },
280
- { id: 'r2', spaceId: 's', category: 'C', name: 'general', kind: 'channel' as const },
281
- ],
282
- }];
283
- const result = excludeAutomatedRooms(cats);
284
- expect(result).toHaveLength(1);
285
- expect(result[0].rooms).toHaveLength(1);
286
- expect(result[0].rooms[0].name).toBe('general');
242
+ it('patches enc field', () => {
243
+ const nodes = [makeNode({ id: 'x', title: 'X' })];
244
+ const patched = patchObject(nodes, 'x', { enc: true }, NOW);
245
+ expect(patched[0].enc).toBe(true);
287
246
  });
288
247
  });
248
+