@drakkar.software/octospaces-sdk 0.1.0 → 0.4.1

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.1",
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.
@@ -19,17 +24,15 @@ export type PresenceStatus = 'online' | 'away' | 'dnd' | 'offline';
19
24
  /** A security item's verification state. The theme maps each to a color (app-side). */
20
25
  export type VerificationLevel = 'verified' | 'pending' | 'unverified';
21
26
 
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. */
27
+ /** Maps a joined space's id → its owner-issued member cap-cert (serialized JSON).
28
+ * Persisted both in device-local kv and, for durability, in the user's own synced
29
+ * `_spaces` doc so a fresh device re-hydrates it. */
25
30
  export type CapMap = Record<string, string>;
26
31
 
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`. */
32
+ /** Maps a joined link-access key → its sealed invitation credential (cap + ephemeral
33
+ * private key) SEALED to the account's own key. Keys are either `spaceId` (space-level
34
+ * link) or `${spaceId}:${nodeId}` (per-node invite link). Sealed because it embeds a
35
+ * bearer secret; recovered on any device with the same seed. */
33
36
  export type PubAccessMap = Record<string, import('../sync/account-seal.js').SealedBlob>;
34
37
 
35
38
  /** Maps a DM peer's userId → the private DM-space id shared with them. */
@@ -55,9 +58,8 @@ export interface ReadPrefs {
55
58
  rooms: Record<string, ReadValue>;
56
59
  }
57
60
 
58
- /** Whether a space encrypts its content client-side. */
59
- export type SpaceVisibility = 'private' | 'public';
60
-
61
+ /** A joined or listed space. Visibility and encryption are per-node (see ObjectNode),
62
+ * not per-space a space is a neutral container. */
61
63
  export interface Space {
62
64
  id: ID;
63
65
  name: string;
@@ -67,96 +69,60 @@ export interface Space {
67
69
  image?: string;
68
70
  members: number;
69
71
  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
72
  }
119
73
 
120
74
  // ── 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.
75
+ // Everything in a space is an ObjectNode with a type string. Apps (OctoChat,
76
+ // OctoVault, …) define their own type strings; none are baked into the SDK.
123
77
 
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 & {});
78
+ /** Any string an app assigns as an object's type. No builtins are defined here
79
+ * each app declares its own type strings in its local SDK. */
80
+ export type ObjectType = string;
127
81
 
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. */
82
+ /** How an object's content syncs. Apps may set this per-type explicitly. */
132
83
  export type ObjectContentKind = 'merge' | 'append' | 'none';
133
84
 
134
- /** When `type === 'room'`, the room flavour. */
135
- export type RoomSubtype = 'channel' | 'dm' | 'automation';
85
+ /** Who may read a node's content (independent from whether it is E2EE):
86
+ * - `'public'` — world-readable; anonymous users may access the content. The node
87
+ * is listed in the global object directory (`_index/objects/{shard}`).
88
+ * - `'space'` — any member of the parent space. The default for new nodes.
89
+ * - `'invite'` — only members explicitly invited to this node (via its own per-node
90
+ * keyring for E2EE nodes, or via a per-node cap for plaintext nodes).
91
+ * Non-invited space members see a placeholder row (no title/emoji). */
92
+ export type NodeAccess = 'public' | 'space' | 'invite';
136
93
 
137
94
  /**
138
95
  * One entry in a space's object index (`spaces/{spaceId}/objects/_index`).
139
96
  * 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`.
97
+ * blocks, event logs) lives in per-object content docs keyed by `id`.
98
+ *
99
+ * The index doc is always **plaintext** (member-gated). For `invite` nodes the
100
+ * `title` and `emoji` fields are stored empty in the index — only invited members
101
+ * can recover the real title from the node's content doc or encrypted keyring entry.
141
102
  */
142
103
  export interface ObjectNode {
143
104
  id: ID;
144
105
  type: ObjectType;
145
- subtype?: RoomSubtype;
146
106
  parentId: ID | null;
147
107
  order: number;
148
108
  title: string;
149
109
  emoji?: string;
150
110
  updatedAt: number;
151
111
  archived?: boolean;
152
- automation?: AutomationMeta;
153
112
  contentKind?: ObjectContentKind;
113
+ /** Who may access this node's content. Absent ⇒ `'space'`. */
114
+ access?: NodeAccess;
115
+ /** True ⇒ this node's content is E2EE under the SPACE-WIDE keyring at
116
+ * `spaces/{spaceId}/_keyring`. All `enc` nodes in a space share one CEK.
117
+ * The combination `public + enc` is invalid. */
118
+ enc?: boolean;
119
+ /** App-specific fields. Apps store type-specific metadata here. */
154
120
  meta?: Record<string, unknown>;
155
121
  }
156
122
 
157
- /** The object-index doc: the union-merged list of every object in a space. */
123
+ /** The object-index doc stored at `spaces/{spaceId}/objects/_index`. */
158
124
  export interface ObjectsIndex {
159
- v: 1;
125
+ v: 1 | 2;
160
126
  objects: ObjectNode[];
161
127
  updatedAt: number;
162
128
  }
package/src/index.ts CHANGED
@@ -11,23 +11,18 @@ 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,
27
23
  MutePrefs,
28
24
  ReadPrefs,
29
25
  ArchivedDms,
30
- BUILTIN_OBJECT_TYPES,
31
26
  } from './core/types.js';
32
27
 
33
28
  // Ids
@@ -38,6 +33,7 @@ export {
38
33
  OBJECT_COLLECTIONS,
39
34
  ownerScope,
40
35
  spaceMemberScope,
36
+ nodeMemberScope,
41
37
  accountScope,
42
38
  linkedDeviceScope,
43
39
  keyringName,
@@ -45,10 +41,18 @@ export {
45
41
  keyringPush,
46
42
  objIndexPull,
47
43
  objIndexPush,
44
+ objPubName,
45
+ objPubPull,
46
+ objPubPush,
47
+ objInvName,
48
+ objInvPull,
49
+ objInvPush,
50
+ objectDirName,
51
+ objectDirPull,
48
52
  spacesPull,
49
53
  spacesPush,
50
- roomsRegistryPull,
51
- roomsRegistryPush,
54
+ spaceAccessPull,
55
+ spaceAccessPush,
52
56
  profilePull,
53
57
  profilePush,
54
58
  objLogPull,
@@ -61,7 +65,6 @@ export {
61
65
  typesIndexPush,
62
66
  attachmentPull,
63
67
  attachmentPush,
64
- spaceIndexPull,
65
68
  userIdFromEdPub,
66
69
  bytesToHex,
67
70
  } from './sync/paths.js';
@@ -112,9 +115,15 @@ export type {
112
115
  export { sealToSelf, unsealFromSelf, sealToRecipient, unsealFromRecipient } from './sync/account-seal.js';
113
116
  export type { SealedBlob } from './sync/account-seal.js';
114
117
 
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';
118
+ // Node access (per-node encryptor + client resolver, replaces per-space access)
119
+ export {
120
+ SpaceAccessError,
121
+ getSpaceClient,
122
+ getNodeAccess,
123
+ buildNodeAccess,
124
+ clearNodeAccessCache,
125
+ } from './sync/space-access.js';
126
+ export type { NodeAccessHandle } from './sync/space-access.js';
118
127
 
119
128
  // Space access store (replaces member-caps + pubspace-caps)
120
129
  export {
@@ -122,6 +131,9 @@ export {
122
131
  getSpaceAccessEntry,
123
132
  saveSpaceAccessEntry,
124
133
  removeSpaceAccessEntry,
134
+ getNodeAccessEntry,
135
+ saveNodeAccessEntry,
136
+ removeNodeAccessEntry,
125
137
  localSpaceAccessEntries,
126
138
  memberCapsFromStore,
127
139
  linkAccessFromStore,
@@ -131,7 +143,6 @@ export type { SpaceAccessEntry, SpaceAccessMap } from './sync/space-access-store
131
143
 
132
144
  // Registry
133
145
  export {
134
- DEFAULT_CATEGORY,
135
146
  readSpaces,
136
147
  updateSpacesDoc,
137
148
  updateMutesDoc,
@@ -142,19 +153,17 @@ export {
142
153
  setDmMapping,
143
154
  writeSpaces,
144
155
  reorderSpaces,
145
- readRooms,
146
- writeRooms,
156
+ readSpaceAccess,
157
+ writeSpaceAccess,
147
158
  addSpaceMember,
148
159
  removeSpaceMember,
149
160
  addJoinedSpace,
150
161
  addJoinedSpaceWithCap,
151
162
  addJoinedSpaceWithLinkAccess,
152
163
  createSpace,
153
- normalizeCategories,
154
164
  reconcileSpaceMeta,
155
165
  onSpaceMeta,
156
166
  broadcastSpaceMeta,
157
- CategoryError,
158
167
  } from './spaces/registry.js';
159
168
  export type { SpaceMeta, SpaceMetaUpdate } from './spaces/registry.js';
160
169
 
@@ -163,18 +172,30 @@ export {
163
172
  makeJoinRequest,
164
173
  inviteToSpace,
165
174
  acceptSpaceInvite,
166
- addDeviceToSpaceKeyring,
167
175
  encodeSpaceInviteLink,
168
176
  decodeSpaceInviteLink,
169
177
  createSpaceInviteLink,
170
178
  joinSpaceByLink,
171
179
  recoverSpaceAccess,
180
+ addDeviceToSpaceKeyring,
172
181
  } from './spaces/members.js';
173
182
  export type { JoinRequest, SpaceInviteLinkToken } from './spaces/members.js';
174
183
 
184
+ // Nodes (per-node creation + access management + invite flows)
185
+ export {
186
+ createNode,
187
+ setNodeAccess,
188
+ inviteToNode,
189
+ acceptNodeInvite,
190
+ createNodeInviteLink,
191
+ decodeNodeInviteLink,
192
+ encodeNodeInviteLink,
193
+ joinNodeByLink,
194
+ } from './spaces/nodes.js';
195
+ export type { CreateNodeInput, NodeInviteBundle, NodeInviteLinkToken } from './spaces/nodes.js';
196
+
175
197
  // Object core
176
198
  export {
177
- categoryId,
178
199
  buildTree,
179
200
  breadcrumbs,
180
201
  ancestors,
@@ -185,22 +206,15 @@ export {
185
206
  reparentObject,
186
207
  reorderObjects,
187
208
  archiveObject,
188
- seedIndexNodes,
189
- objectsToRoomCategories,
190
- excludeAutomatedRooms,
191
- roomKindToSubtype,
192
- subtypeToRoomKind,
193
209
  } from './objects/objects.js';
194
- export type { ObjectTreeNode, NewObjectInput, AdaptedCategory, SeedRoom } from './objects/objects.js';
210
+ export type { ObjectTreeNode, NewObjectInput } from './objects/objects.js';
195
211
 
196
212
  // Object index
197
213
  export {
198
- readIndexRooms,
199
- readSpaceIndexRooms,
200
- readSpaceRooms,
201
214
  pushIndexSeed,
202
215
  seedSpaceObjectIndex,
203
216
  updateObjectIndex,
217
+ readObjectTree,
204
218
  } from './spaces/object-index.js';
205
219
 
206
220
  // Pairing
@@ -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
+