@drakkar.software/octospaces-sdk 0.1.0

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.
Files changed (52) hide show
  1. package/dist/index.d.ts +972 -0
  2. package/dist/index.js +1656 -0
  3. package/dist/index.js.map +1 -0
  4. package/dist/platform/index.d.ts +9 -0
  5. package/dist/platform/index.js +111 -0
  6. package/dist/platform/index.js.map +1 -0
  7. package/dist/platform/index.native.d.ts +9 -0
  8. package/dist/platform/index.native.js +106 -0
  9. package/dist/platform/index.native.js.map +1 -0
  10. package/package.json +50 -0
  11. package/src/core/adapters.ts +34 -0
  12. package/src/core/config.ts +87 -0
  13. package/src/core/ids.test.ts +45 -0
  14. package/src/core/ids.ts +29 -0
  15. package/src/core/space-access-error.ts +13 -0
  16. package/src/core/storage-types.ts +71 -0
  17. package/src/core/types.ts +162 -0
  18. package/src/index.ts +221 -0
  19. package/src/objects/objects.test.ts +288 -0
  20. package/src/objects/objects.ts +296 -0
  21. package/src/platform/index.native.ts +3 -0
  22. package/src/platform/index.ts +3 -0
  23. package/src/platform/kv.native.ts +23 -0
  24. package/src/platform/kv.ts +29 -0
  25. package/src/platform/platform.native.ts +16 -0
  26. package/src/platform/platform.ts +10 -0
  27. package/src/spaces/members.test.ts +87 -0
  28. package/src/spaces/members.ts +271 -0
  29. package/src/spaces/object-index.test.ts +105 -0
  30. package/src/spaces/object-index.ts +160 -0
  31. package/src/spaces/registry.test.ts +111 -0
  32. package/src/spaces/registry.ts +466 -0
  33. package/src/sync/account-seal.test.ts +70 -0
  34. package/src/sync/account-seal.ts +80 -0
  35. package/src/sync/base64.ts +89 -0
  36. package/src/sync/base64url.ts +22 -0
  37. package/src/sync/client.ts +301 -0
  38. package/src/sync/fetch-timeout.test.ts +26 -0
  39. package/src/sync/fetch-timeout.ts +23 -0
  40. package/src/sync/identity.ts +158 -0
  41. package/src/sync/pairing.ts +103 -0
  42. package/src/sync/paths.test.ts +135 -0
  43. package/src/sync/paths.ts +177 -0
  44. package/src/sync/profile-cache.ts +34 -0
  45. package/src/sync/pull-cache.test.ts +55 -0
  46. package/src/sync/pull-cache.ts +33 -0
  47. package/src/sync/space-access-store.test.ts +129 -0
  48. package/src/sync/space-access-store.ts +117 -0
  49. package/src/sync/space-access.ts +136 -0
  50. package/tsconfig.json +17 -0
  51. package/tsup.config.ts +40 -0
  52. package/vitest.config.ts +12 -0
package/src/index.ts ADDED
@@ -0,0 +1,221 @@
1
+ /** @drakkar.software/octospaces-sdk — public surface */
2
+
3
+ // Configuration
4
+ export { configureOctoSpaces, getSyncBase, getSyncNamespace, getSyncPrefix, getSharedSpacesNamespace } from './core/config.js';
5
+ export type { OctoSpacesConfig } from './core/config.js';
6
+
7
+ // KV adapter
8
+ export { configureKv, kvGet, kvSet, kvRemove } from './core/adapters.js';
9
+ export type { KvAdapter } from './core/adapters.js';
10
+
11
+ // Domain types
12
+ export type {
13
+ ID,
14
+ ObjectNode,
15
+ ObjectType,
16
+ ObjectsIndex,
17
+ ObjectContentKind,
18
+ RoomSubtype,
19
+ AutomationMeta,
20
+ Room,
21
+ RoomKind,
22
+ Space,
23
+ SpaceVisibility,
24
+ CapMap,
25
+ PubAccessMap,
26
+ DmMap,
27
+ MutePrefs,
28
+ ReadPrefs,
29
+ ArchivedDms,
30
+ BUILTIN_OBJECT_TYPES,
31
+ } from './core/types.js';
32
+
33
+ // Ids
34
+ export { randomId, roomSlug } from './core/ids.js';
35
+
36
+ // Paths / cap scopes
37
+ export {
38
+ OBJECT_COLLECTIONS,
39
+ ownerScope,
40
+ spaceMemberScope,
41
+ accountScope,
42
+ linkedDeviceScope,
43
+ keyringName,
44
+ keyringPull,
45
+ keyringPush,
46
+ objIndexPull,
47
+ objIndexPush,
48
+ spacesPull,
49
+ spacesPush,
50
+ roomsRegistryPull,
51
+ roomsRegistryPush,
52
+ profilePull,
53
+ profilePush,
54
+ objLogPull,
55
+ objLogPush,
56
+ objDocPull,
57
+ objDocPush,
58
+ objectBlobPull,
59
+ objectBlobPush,
60
+ typesIndexPull,
61
+ typesIndexPush,
62
+ attachmentPull,
63
+ attachmentPush,
64
+ spaceIndexPull,
65
+ userIdFromEdPub,
66
+ bytesToHex,
67
+ } from './sync/paths.js';
68
+
69
+ // Client
70
+ export {
71
+ makeClient,
72
+ capProviderFor,
73
+ openEncryptor,
74
+ buildEncryptor,
75
+ ownerEnsureKeyring,
76
+ readProfile,
77
+ readPseudo,
78
+ readProfiles,
79
+ writeProfile,
80
+ writePseudo,
81
+ ensureProfileKeys,
82
+ buildAuthHeaders,
83
+ ensurePseudo,
84
+ } from './sync/client.js';
85
+ export type { DeviceKeys, PublicProfile } from './sync/client.js';
86
+
87
+ // Identity / session
88
+ export {
89
+ buildSession,
90
+ buildLinkedSession,
91
+ deriveSession,
92
+ rootIdentityOf,
93
+ ownerTrustedAdders,
94
+ generateSeedWords,
95
+ isValidSeed,
96
+ fingerprintFromUserId,
97
+ } from './sync/identity.js';
98
+ export type { Session, LinkedIdentity } from './sync/identity.js';
99
+
100
+ // Storage types
101
+ export type {
102
+ DerivedIdentity,
103
+ PersistedSession,
104
+ Vault,
105
+ VaultLoad,
106
+ UnlockMethod,
107
+ PasskeyEnrollment,
108
+ SeedLock,
109
+ } from './core/storage-types.js';
110
+
111
+ // Sealed blobs
112
+ export { sealToSelf, unsealFromSelf, sealToRecipient, unsealFromRecipient } from './sync/account-seal.js';
113
+ export type { SealedBlob } from './sync/account-seal.js';
114
+
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
+
119
+ // Space access store (replaces member-caps + pubspace-caps)
120
+ export {
121
+ hydrateSpaceAccessStore,
122
+ getSpaceAccessEntry,
123
+ saveSpaceAccessEntry,
124
+ removeSpaceAccessEntry,
125
+ localSpaceAccessEntries,
126
+ memberCapsFromStore,
127
+ linkAccessFromStore,
128
+ clearSpaceAccessStore,
129
+ } from './sync/space-access-store.js';
130
+ export type { SpaceAccessEntry, SpaceAccessMap } from './sync/space-access-store.js';
131
+
132
+ // Registry
133
+ export {
134
+ DEFAULT_CATEGORY,
135
+ readSpaces,
136
+ updateSpacesDoc,
137
+ updateMutesDoc,
138
+ updateReadsDoc,
139
+ updateDmsDoc,
140
+ updateQuickReactionsDoc,
141
+ updateArchivedDmsDoc,
142
+ setDmMapping,
143
+ writeSpaces,
144
+ reorderSpaces,
145
+ readRooms,
146
+ writeRooms,
147
+ addSpaceMember,
148
+ removeSpaceMember,
149
+ addJoinedSpace,
150
+ addJoinedSpaceWithCap,
151
+ addJoinedSpaceWithLinkAccess,
152
+ createSpace,
153
+ normalizeCategories,
154
+ reconcileSpaceMeta,
155
+ onSpaceMeta,
156
+ broadcastSpaceMeta,
157
+ CategoryError,
158
+ } from './spaces/registry.js';
159
+ export type { SpaceMeta, SpaceMetaUpdate } from './spaces/registry.js';
160
+
161
+ // Members
162
+ export {
163
+ makeJoinRequest,
164
+ inviteToSpace,
165
+ acceptSpaceInvite,
166
+ addDeviceToSpaceKeyring,
167
+ encodeSpaceInviteLink,
168
+ decodeSpaceInviteLink,
169
+ createSpaceInviteLink,
170
+ joinSpaceByLink,
171
+ recoverSpaceAccess,
172
+ } from './spaces/members.js';
173
+ export type { JoinRequest, SpaceInviteLinkToken } from './spaces/members.js';
174
+
175
+ // Object core
176
+ export {
177
+ categoryId,
178
+ buildTree,
179
+ breadcrumbs,
180
+ ancestors,
181
+ subtreeIds,
182
+ nextOrder,
183
+ addObject,
184
+ patchObject,
185
+ reparentObject,
186
+ reorderObjects,
187
+ archiveObject,
188
+ seedIndexNodes,
189
+ objectsToRoomCategories,
190
+ excludeAutomatedRooms,
191
+ roomKindToSubtype,
192
+ subtypeToRoomKind,
193
+ } from './objects/objects.js';
194
+ export type { ObjectTreeNode, NewObjectInput, AdaptedCategory, SeedRoom } from './objects/objects.js';
195
+
196
+ // Object index
197
+ export {
198
+ readIndexRooms,
199
+ readSpaceIndexRooms,
200
+ readSpaceRooms,
201
+ pushIndexSeed,
202
+ seedSpaceObjectIndex,
203
+ updateObjectIndex,
204
+ } from './spaces/object-index.js';
205
+
206
+ // Pairing
207
+ export { startDevicePairing, completeDevicePairing, PAIR_PREFIX } from './sync/pairing.js';
208
+ export type { PairResult } from './sync/pairing.js';
209
+
210
+ // Pull cache
211
+ export { pullCache, PULL_CACHE_MAX_AGE_MS } from './sync/pull-cache.js';
212
+
213
+ // Profile cache
214
+ export { cacheProfile, loadCachedProfile } from './sync/profile-cache.js';
215
+
216
+ // Fetch
217
+ export { fetchWithTimeout, CONNECT_TIMEOUT_MS } from './sync/fetch-timeout.js';
218
+
219
+ // Base64
220
+ export { starfishBase64 } from './sync/base64.js';
221
+ export { toBase64Url, fromBase64Url } from './sync/base64url.js';
@@ -0,0 +1,288 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import type { ObjectNode } from '../core/types.js';
3
+ import {
4
+ addObject,
5
+ archiveObject,
6
+ breadcrumbs,
7
+ buildTree,
8
+ ancestors,
9
+ categoryId,
10
+ DEFAULT_CATEGORY,
11
+ nextOrder,
12
+ patchObject,
13
+ reparentObject,
14
+ reorderObjects,
15
+ seedIndexNodes,
16
+ subtreeIds,
17
+ objectsToRoomCategories,
18
+ excludeAutomatedRooms,
19
+ roomKindToSubtype,
20
+ subtypeToRoomKind,
21
+ } from './objects.js';
22
+
23
+ const NOW = 1_700_000_000_000;
24
+
25
+ function makeNode(overrides: Partial<ObjectNode> = {}): ObjectNode {
26
+ return {
27
+ id: 'n1',
28
+ type: 'room',
29
+ parentId: null,
30
+ order: 1,
31
+ title: 'Test',
32
+ updatedAt: NOW,
33
+ ...overrides,
34
+ };
35
+ }
36
+
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
+ describe('nextOrder', () => {
54
+ it('returns 1 for empty sibling list', () => {
55
+ expect(nextOrder([])).toBe(1);
56
+ });
57
+ it('returns max+1', () => {
58
+ const siblings = [makeNode({ order: 3 }), makeNode({ id: 'n2', order: 7 })];
59
+ expect(nextOrder(siblings)).toBe(8);
60
+ });
61
+ });
62
+
63
+ describe('buildTree', () => {
64
+ it('builds a flat list for root nodes', () => {
65
+ const nodes: ObjectNode[] = [
66
+ makeNode({ id: 'a', order: 1 }),
67
+ makeNode({ id: 'b', order: 2 }),
68
+ ];
69
+ const tree = buildTree(nodes);
70
+ expect(tree).toHaveLength(2);
71
+ expect(tree[0].id).toBe('a');
72
+ expect(tree[1].id).toBe('b');
73
+ });
74
+
75
+ it('nests children under their parent', () => {
76
+ const nodes: ObjectNode[] = [
77
+ makeNode({ id: 'cat', type: 'category', parentId: null, order: 1 }),
78
+ makeNode({ id: 'room', type: 'room', parentId: 'cat', order: 1 }),
79
+ ];
80
+ const tree = buildTree(nodes);
81
+ expect(tree).toHaveLength(1);
82
+ expect(tree[0].children).toHaveLength(1);
83
+ expect(tree[0].children[0].id).toBe('room');
84
+ });
85
+
86
+ it('excludes archived nodes by default', () => {
87
+ const nodes: ObjectNode[] = [
88
+ makeNode({ id: 'a', archived: true }),
89
+ makeNode({ id: 'b' }),
90
+ ];
91
+ const tree = buildTree(nodes);
92
+ expect(tree).toHaveLength(1);
93
+ expect(tree[0].id).toBe('b');
94
+ });
95
+
96
+ it('repairs orphans (parentId missing) → reparents to root', () => {
97
+ const nodes: ObjectNode[] = [
98
+ makeNode({ id: 'orphan', parentId: 'nonexistent' }),
99
+ ];
100
+ const tree = buildTree(nodes);
101
+ expect(tree).toHaveLength(1);
102
+ expect(tree[0].id).toBe('orphan');
103
+ });
104
+
105
+ it('repairs cycles → reparents cycle node to root', () => {
106
+ // a → b → a is a cycle; both should appear at root without infinite loop
107
+ const nodes: ObjectNode[] = [
108
+ makeNode({ id: 'a', parentId: 'b', order: 1 }),
109
+ makeNode({ id: 'b', parentId: 'a', order: 2 }),
110
+ ];
111
+ const tree = buildTree(nodes);
112
+ expect(tree.length).toBeGreaterThan(0);
113
+ });
114
+ });
115
+
116
+ describe('breadcrumbs + ancestors', () => {
117
+ const nodes: ObjectNode[] = [
118
+ makeNode({ id: 'root', parentId: null, order: 1 }),
119
+ makeNode({ id: 'child', parentId: 'root', order: 1 }),
120
+ makeNode({ id: 'grandchild', parentId: 'child', order: 1 }),
121
+ ];
122
+
123
+ it('breadcrumbs returns root→self', () => {
124
+ const trail = breadcrumbs(nodes, 'grandchild');
125
+ expect(trail.map(n => n.id)).toEqual(['root', 'child', 'grandchild']);
126
+ });
127
+
128
+ it('ancestors returns root→parent (exclusive)', () => {
129
+ const trail = ancestors(nodes, 'grandchild');
130
+ expect(trail.map(n => n.id)).toEqual(['root', 'child']);
131
+ });
132
+ });
133
+
134
+ describe('subtreeIds', () => {
135
+ const nodes: ObjectNode[] = [
136
+ makeNode({ id: 'root', parentId: null }),
137
+ makeNode({ id: 'child1', parentId: 'root' }),
138
+ makeNode({ id: 'child2', parentId: 'root' }),
139
+ makeNode({ id: 'grandchild', parentId: 'child1' }),
140
+ ];
141
+
142
+ it('includes self and all descendants', () => {
143
+ const ids = subtreeIds(nodes, 'root');
144
+ expect([...ids].sort()).toEqual(['child1', 'child2', 'grandchild', 'root'].sort());
145
+ });
146
+ });
147
+
148
+ describe('addObject', () => {
149
+ it('appends a new node with correct order', () => {
150
+ const { nodes, node } = addObject([], { type: 'room', title: 'general' }, NOW);
151
+ expect(nodes).toHaveLength(1);
152
+ expect(node.title).toBe('general');
153
+ expect(node.type).toBe('room');
154
+ expect(node.order).toBe(1);
155
+ });
156
+
157
+ it('respects provided id', () => {
158
+ const { node } = addObject([], { id: 'my-id', type: 'category', title: 'Channels' }, NOW);
159
+ expect(node.id).toBe('my-id');
160
+ });
161
+ });
162
+
163
+ describe('patchObject', () => {
164
+ it('updates title and bumps updatedAt', () => {
165
+ const nodes = [makeNode({ id: 'x', title: 'old', updatedAt: 0 })];
166
+ const patched = patchObject(nodes, 'x', { title: 'new' }, NOW);
167
+ expect(patched[0].title).toBe('new');
168
+ expect(patched[0].updatedAt).toBe(NOW);
169
+ });
170
+ });
171
+
172
+ describe('reparentObject', () => {
173
+ it('moves a node to a new parent', () => {
174
+ 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' }),
178
+ ];
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');
182
+ });
183
+
184
+ it('rejects making a node its own descendant', () => {
185
+ const nodes: ObjectNode[] = [
186
+ makeNode({ id: 'parent', type: 'category', parentId: null }),
187
+ makeNode({ id: 'child', type: 'room', parentId: 'parent' }),
188
+ ];
189
+ const result = reparentObject(nodes, 'parent', 'child', NOW);
190
+ expect(result).toBe(nodes); // unchanged
191
+ });
192
+ });
193
+
194
+ describe('reorderObjects', () => {
195
+ it('applies explicit order values', () => {
196
+ const nodes = [makeNode({ id: 'x', order: 1 }), makeNode({ id: 'y', order: 2 })];
197
+ const result = reorderObjects(nodes, { x: 5, y: 3 }, NOW);
198
+ const x = result.find(n => n.id === 'x')!;
199
+ const y = result.find(n => n.id === 'y')!;
200
+ expect(x.order).toBe(5);
201
+ expect(y.order).toBe(3);
202
+ });
203
+ });
204
+
205
+ describe('archiveObject', () => {
206
+ it('archives a node and its subtree', () => {
207
+ const nodes: ObjectNode[] = [
208
+ makeNode({ id: 'parent', parentId: null }),
209
+ makeNode({ id: 'child', parentId: 'parent' }),
210
+ ];
211
+ const result = archiveObject(nodes, 'parent', NOW);
212
+ expect(result.find(n => n.id === 'parent')!.archived).toBe(true);
213
+ expect(result.find(n => n.id === 'child')!.archived).toBe(true);
214
+ });
215
+ });
216
+
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
+ });
223
+
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);
232
+ });
233
+ });
234
+
235
+ describe('roomKindToSubtype / subtypeToRoomKind', () => {
236
+ it('channel ↔ channel', () => {
237
+ expect(roomKindToSubtype('channel')).toBe('channel');
238
+ expect(subtypeToRoomKind('channel')).toBe('channel');
239
+ });
240
+ it('dm ↔ dm', () => {
241
+ expect(roomKindToSubtype('dm')).toBe('dm');
242
+ expect(subtypeToRoomKind('dm')).toBe('dm');
243
+ });
244
+ it('automated ↔ automation', () => {
245
+ expect(roomKindToSubtype('automated')).toBe('automation');
246
+ expect(subtypeToRoomKind('automation')).toBe('automated');
247
+ });
248
+ });
249
+
250
+ describe('objectsToRoomCategories', () => {
251
+ it('returns null for empty index', () => {
252
+ expect(objectsToRoomCategories([], 'sp-1', 'CHANNELS')).toBeNull();
253
+ });
254
+
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');
264
+ });
265
+ });
266
+
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);
273
+ });
274
+
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');
287
+ });
288
+ });