@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/CHANGELOG.md +200 -0
- package/dist/index.d.ts +337 -273
- package/dist/index.js +812 -469
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/core/types.ts +47 -81
- package/src/index.ts +42 -28
- package/src/objects/objects.test.ts +55 -95
- package/src/objects/objects.ts +23 -136
- package/src/spaces/members.test.ts +10 -3
- package/src/spaces/members.ts +86 -49
- package/src/spaces/nodes.test.ts +225 -0
- package/src/spaces/nodes.ts +427 -0
- package/src/spaces/object-index.test.ts +127 -71
- package/src/spaces/object-index.ts +61 -107
- package/src/spaces/registry.test.ts +59 -46
- package/src/spaces/registry.ts +28 -47
- package/src/sync/client.ts +20 -15
- package/src/sync/pairing.ts +10 -12
- package/src/sync/paths.test.ts +124 -16
- package/src/sync/paths.ts +73 -32
- package/src/sync/space-access-store.ts +17 -0
- package/src/sync/space-access.ts +112 -67
package/package.json
CHANGED
package/src/core/types.ts
CHANGED
|
@@ -1,11 +1,16 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Domain model for OctoSpaces — space + object types shared by the SDK
|
|
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`.
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
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
|
|
23
|
-
*
|
|
24
|
-
*
|
|
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
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
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
|
-
/**
|
|
59
|
-
|
|
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
|
|
122
|
-
//
|
|
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
|
-
/**
|
|
125
|
-
|
|
126
|
-
export type ObjectType =
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
135
|
-
|
|
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,
|
|
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
|
|
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
|
-
|
|
51
|
-
|
|
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
|
-
//
|
|
116
|
-
export {
|
|
117
|
-
|
|
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
|
-
|
|
146
|
-
|
|
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
|
|
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: '
|
|
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: '
|
|
78
|
-
makeNode({ id: '
|
|
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('
|
|
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: '
|
|
127
|
+
const { nodes, node } = addObject([], { type: 'page', title: 'Intro' }, NOW);
|
|
151
128
|
expect(nodes).toHaveLength(1);
|
|
152
|
-
expect(node.title).toBe('
|
|
153
|
-
expect(node.type).toBe('
|
|
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: '
|
|
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: '
|
|
176
|
-
makeNode({ id: '
|
|
177
|
-
makeNode({ id: '
|
|
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, '
|
|
180
|
-
const
|
|
181
|
-
expect(
|
|
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: '
|
|
187
|
-
makeNode({ id: 'child', type: '
|
|
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
|
-
|
|
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
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
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
|
-
|
|
236
|
-
|
|
237
|
-
expect(
|
|
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
|
-
|
|
241
|
-
|
|
242
|
-
|
|
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
|
-
|
|
245
|
-
|
|
246
|
-
|
|
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
|
-
|
|
251
|
-
|
|
252
|
-
|
|
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('
|
|
256
|
-
const
|
|
257
|
-
|
|
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('
|
|
268
|
-
it('
|
|
269
|
-
const
|
|
270
|
-
const
|
|
271
|
-
|
|
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('
|
|
276
|
-
const
|
|
277
|
-
|
|
278
|
-
|
|
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
|
+
|