@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/CHANGELOG.md +200 -0
- package/dist/index.d.ts +481 -274
- package/dist/index.js +1000 -493
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/core/types.ts +50 -83
- package/src/index.ts +62 -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/src/utils/invite-preview.test.ts +169 -0
- package/src/utils/invite-preview.ts +101 -0
- package/src/utils/live-sync-bus.test.ts +116 -0
- package/src/utils/live-sync-bus.ts +71 -0
- package/src/utils/search-match.test.ts +149 -0
- package/src/utils/search-match.ts +145 -0
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.
|
|
@@ -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
|
-
|
|
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
|
|
23
|
-
*
|
|
24
|
-
*
|
|
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
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
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
|
-
/**
|
|
59
|
-
|
|
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
|
|
122
|
-
//
|
|
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
|
-
/**
|
|
125
|
-
|
|
126
|
-
export type ObjectType =
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
135
|
-
|
|
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,
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
51
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
116
|
-
export {
|
|
117
|
-
|
|
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
|
-
|
|
146
|
-
|
|
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
|
|
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: '
|
|
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
|
+
|