@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
|
@@ -1,156 +1,94 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Headless reads + create-time seeding of a space's unified OBJECT INDEX.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
4
|
+
* The index at `spaces/{spaceId}/objects/_index` is always PLAINTEXT (member-gated).
|
|
5
|
+
* For `invite` nodes the title/emoji are stripped before storage so non-invited
|
|
6
|
+
* members see only the structural fields (id, type, parentId, order, access, enc).
|
|
7
|
+
* Invited members read the real title from the node's content doc.
|
|
8
|
+
*
|
|
9
|
+
* Encryption lives at the node content level, not here.
|
|
7
10
|
*/
|
|
8
11
|
import { ConflictError } from '@drakkar.software/starfish-client';
|
|
9
|
-
import type { Encryptor, StarfishClient } from '@drakkar.software/starfish-client';
|
|
10
12
|
|
|
11
|
-
import type { ObjectNode
|
|
13
|
+
import type { ObjectNode } from '../core/types.js';
|
|
12
14
|
import type { Session } from '../sync/identity.js';
|
|
13
|
-
import { DEFAULT_CATEGORY, objectsToRoomCategories, seedIndexNodes } from '../objects/objects.js';
|
|
14
|
-
import type { SeedRoom } from '../objects/objects.js';
|
|
15
15
|
import { objIndexPull, objIndexPush } from '../sync/paths.js';
|
|
16
|
-
import {
|
|
17
|
-
|
|
18
|
-
export type { SeedRoom };
|
|
19
|
-
|
|
20
|
-
function indexNodes(plain: Record<string, unknown>): ObjectNode[] {
|
|
21
|
-
return Array.isArray((plain as { objects?: unknown }).objects)
|
|
22
|
-
? (plain as { objects: ObjectNode[] }).objects
|
|
23
|
-
: [];
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
/**
|
|
27
|
-
* Pull + (private: decrypt) + project a space's object index into the legacy
|
|
28
|
-
* `{ rooms, categories }` shape. `encryptor` is null for a PUBLIC space.
|
|
29
|
-
* Returns null on failure or an empty index.
|
|
30
|
-
*/
|
|
31
|
-
export async function readIndexRooms(
|
|
32
|
-
client: StarfishClient,
|
|
33
|
-
encryptor: Encryptor | null,
|
|
34
|
-
indexPath: string,
|
|
35
|
-
spaceId: string,
|
|
36
|
-
): Promise<{ rooms: Room[]; categories: string[] } | null> {
|
|
37
|
-
try {
|
|
38
|
-
const res = await client.pull(indexPath).catch(() => null);
|
|
39
|
-
if (!res?.data) return null;
|
|
40
|
-
const plain = encryptor
|
|
41
|
-
? await encryptor.decrypt(res.data as Record<string, unknown>)
|
|
42
|
-
: (res.data as Record<string, unknown>);
|
|
43
|
-
const cats = objectsToRoomCategories(indexNodes(plain), spaceId, DEFAULT_CATEGORY);
|
|
44
|
-
if (!cats) return null;
|
|
45
|
-
return { rooms: cats.flatMap((c) => c.rooms), categories: cats.map((c) => c.name) };
|
|
46
|
-
} catch {
|
|
47
|
-
return null;
|
|
48
|
-
}
|
|
49
|
-
}
|
|
16
|
+
import { getSpaceClient } from '../sync/space-access.js';
|
|
50
17
|
|
|
51
|
-
/**
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
session: Session,
|
|
57
|
-
spaceId: string,
|
|
58
|
-
reg: { owner: string | null; members: string[]; visibility?: SpaceVisibility },
|
|
59
|
-
): Promise<{ rooms: Room[]; categories: string[] } | null> {
|
|
60
|
-
if (reg.owner === null && reg.visibility !== 'public') return null;
|
|
61
|
-
try {
|
|
62
|
-
const { encryptor, client } = await getSpaceAccess(spaceId, session, reg);
|
|
63
|
-
return await readIndexRooms(client, encryptor, objIndexPull(spaceId), spaceId);
|
|
64
|
-
} catch {
|
|
65
|
-
return null;
|
|
18
|
+
/** Strip title/emoji from invite nodes before writing to the index. */
|
|
19
|
+
function serializeForIndex(node: ObjectNode): ObjectNode {
|
|
20
|
+
if (node.access === 'invite') {
|
|
21
|
+
const { emoji: _e, ...rest } = node;
|
|
22
|
+
return { ...rest, title: '' };
|
|
66
23
|
}
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
/** SOFT read — never throws, never mints. Returns an empty array on any failure. */
|
|
70
|
-
export async function readSpaceRooms(
|
|
71
|
-
session: Session,
|
|
72
|
-
spaceId: string,
|
|
73
|
-
hint?: { visibility?: SpaceVisibility },
|
|
74
|
-
): Promise<Room[]> {
|
|
75
|
-
const access = await buildSpaceAccess(session, spaceId, hint).catch(() => null);
|
|
76
|
-
if (!access) return [];
|
|
77
|
-
const idx = await readIndexRooms(access.client, access.encryptor, objIndexPull(spaceId), spaceId);
|
|
78
|
-
return idx?.rooms ?? [];
|
|
24
|
+
return node;
|
|
79
25
|
}
|
|
80
26
|
|
|
81
27
|
/**
|
|
82
|
-
* Write the create-time seed into a space's index doc
|
|
83
|
-
*
|
|
84
|
-
*
|
|
28
|
+
* Write the create-time seed into a space's index doc.
|
|
29
|
+
* Idempotent: a no-op if the index doc already exists.
|
|
30
|
+
* Pass `nodes` to seed with initial objects; defaults to an empty index.
|
|
85
31
|
*/
|
|
86
32
|
export async function pushIndexSeed(
|
|
87
|
-
client: StarfishClient,
|
|
88
|
-
encryptor: Encryptor | null,
|
|
33
|
+
client: import('@drakkar.software/starfish-client').StarfishClient,
|
|
89
34
|
spaceId: string,
|
|
90
|
-
|
|
35
|
+
nodes: ObjectNode[] = [],
|
|
91
36
|
): Promise<void> {
|
|
92
37
|
const res = await client.pull(objIndexPull(spaceId)).catch(() => null);
|
|
93
38
|
const existing = res?.data as Record<string, unknown> | undefined;
|
|
94
|
-
if (
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
39
|
+
if (Array.isArray(existing?.objects)) return;
|
|
40
|
+
await client.push(
|
|
41
|
+
objIndexPush(spaceId),
|
|
42
|
+
{ v: 2, objects: nodes.map(serializeForIndex), updatedAt: Date.now() },
|
|
43
|
+
res?.hash ?? null,
|
|
44
|
+
);
|
|
100
45
|
}
|
|
101
46
|
|
|
102
47
|
/**
|
|
103
|
-
* Seed a brand-new space's index as the OWNER.
|
|
104
|
-
*
|
|
105
|
-
* For public spaces: pushes plaintext nodes.
|
|
48
|
+
* Seed a brand-new space's index as the OWNER. Always plaintext.
|
|
49
|
+
* Pass `nodes` to seed with initial objects; defaults to an empty index.
|
|
106
50
|
*/
|
|
107
51
|
export async function seedSpaceObjectIndex(
|
|
108
52
|
session: Session,
|
|
109
53
|
spaceId: string,
|
|
110
|
-
|
|
111
|
-
opts?: { visibility?: SpaceVisibility },
|
|
54
|
+
nodes: ObjectNode[] = [],
|
|
112
55
|
): Promise<void> {
|
|
113
|
-
const
|
|
114
|
-
|
|
115
|
-
members: [],
|
|
116
|
-
visibility: opts?.visibility,
|
|
117
|
-
});
|
|
118
|
-
await pushIndexSeed(client, encryptor, spaceId, rooms);
|
|
56
|
+
const client = getSpaceClient(spaceId, session);
|
|
57
|
+
await pushIndexSeed(client, spaceId, nodes);
|
|
119
58
|
}
|
|
120
59
|
|
|
121
60
|
/**
|
|
122
|
-
* Headless read-modify-write of a space's unified OBJECT INDEX.
|
|
123
|
-
*
|
|
124
|
-
*
|
|
61
|
+
* Headless read-modify-write of a space's unified OBJECT INDEX.
|
|
62
|
+
* Always plaintext. Retries up to 3 times on ConflictError.
|
|
63
|
+
*
|
|
64
|
+
* The mutator receives the current nodes with real (or empty, for invite) titles.
|
|
65
|
+
* Before writing back, invite nodes have their title/emoji stripped again.
|
|
125
66
|
*/
|
|
126
67
|
export async function updateObjectIndex(
|
|
127
68
|
session: Session,
|
|
128
69
|
spaceId: string,
|
|
129
70
|
mutator: (nodes: ObjectNode[], now: number) => ObjectNode[] | null,
|
|
130
|
-
reg?: { owner: string | null; members: string[]
|
|
71
|
+
reg?: { owner: string | null; members: string[] } | null,
|
|
131
72
|
): Promise<void> {
|
|
132
|
-
|
|
73
|
+
void reg; // no longer needed for encryption; kept for API compat during migration
|
|
74
|
+
const client = getSpaceClient(spaceId, session);
|
|
133
75
|
const pullPath = objIndexPull(spaceId);
|
|
134
76
|
const pushPath = objIndexPush(spaceId);
|
|
135
77
|
const MAX_ATTEMPTS = 3;
|
|
136
78
|
for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) {
|
|
137
79
|
const res = await client.pull(pullPath).catch(() => null);
|
|
138
80
|
const raw = res?.data as Record<string, unknown> | undefined;
|
|
139
|
-
const
|
|
140
|
-
?
|
|
141
|
-
? await encryptor.decrypt(raw)
|
|
142
|
-
: raw
|
|
143
|
-
: {};
|
|
144
|
-
const cur = Array.isArray((plain as { objects?: unknown }).objects)
|
|
145
|
-
? (plain as { objects: ObjectNode[] }).objects
|
|
81
|
+
const cur: ObjectNode[] = Array.isArray((raw as { objects?: unknown })?.objects)
|
|
82
|
+
? (raw as { objects: ObjectNode[] }).objects
|
|
146
83
|
: [];
|
|
147
84
|
const next = mutator(cur, Date.now());
|
|
148
85
|
if (!next) return;
|
|
149
|
-
const payload = encryptor
|
|
150
|
-
? await encryptor.encrypt({ objects: next }) as Record<string, unknown>
|
|
151
|
-
: { objects: next };
|
|
152
86
|
try {
|
|
153
|
-
await client.push(
|
|
87
|
+
await client.push(
|
|
88
|
+
pushPath,
|
|
89
|
+
{ v: 2, objects: next.map(serializeForIndex), updatedAt: Date.now() },
|
|
90
|
+
res?.hash ?? null,
|
|
91
|
+
);
|
|
154
92
|
return;
|
|
155
93
|
} catch (err) {
|
|
156
94
|
if (err instanceof ConflictError && attempt < MAX_ATTEMPTS - 1) continue;
|
|
@@ -158,3 +96,19 @@ export async function updateObjectIndex(
|
|
|
158
96
|
}
|
|
159
97
|
}
|
|
160
98
|
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Read the current object tree (read-only, no mutation). Returns the stored
|
|
102
|
+
* nodes (titles are empty for invite nodes the caller is not invited to).
|
|
103
|
+
*/
|
|
104
|
+
export async function readObjectTree(
|
|
105
|
+
session: Session,
|
|
106
|
+
spaceId: string,
|
|
107
|
+
): Promise<ObjectNode[]> {
|
|
108
|
+
const client = getSpaceClient(spaceId, session);
|
|
109
|
+
const res = await client.pull(objIndexPull(spaceId)).catch(() => null);
|
|
110
|
+
const raw = res?.data as Record<string, unknown> | undefined;
|
|
111
|
+
return Array.isArray((raw as { objects?: unknown })?.objects)
|
|
112
|
+
? (raw as { objects: ObjectNode[] }).objects
|
|
113
|
+
: [];
|
|
114
|
+
}
|
|
@@ -1,36 +1,24 @@
|
|
|
1
1
|
import { describe, it, expect, vi } from 'vitest';
|
|
2
2
|
import type { StarfishClient } from '@drakkar.software/starfish-client';
|
|
3
|
-
import {
|
|
3
|
+
import { readSpaceAccess, writeSpaceAccess, addSpaceMember, removeSpaceMember } from './registry.js';
|
|
4
4
|
|
|
5
5
|
// ── Fake client ────────────────────────────────────────────────────────────────
|
|
6
6
|
|
|
7
|
-
function
|
|
7
|
+
function makeAccessClient(data: unknown, hash = 'h1'): StarfishClient {
|
|
8
8
|
return {
|
|
9
9
|
pull: vi.fn().mockResolvedValue({ data, hash }),
|
|
10
10
|
push: vi.fn().mockResolvedValue(undefined),
|
|
11
11
|
} as unknown as StarfishClient;
|
|
12
12
|
}
|
|
13
13
|
|
|
14
|
-
// ──
|
|
15
|
-
|
|
16
|
-
describe('readRooms', () => {
|
|
17
|
-
it('returns visibility null for a private space doc', async () => {
|
|
18
|
-
const client = makeRoomsClient({ v: 1, owner: 'alice', members: [] });
|
|
19
|
-
const result = await readRooms(client, 'sp-private');
|
|
20
|
-
expect(result.visibility).toBeNull();
|
|
21
|
-
});
|
|
22
|
-
|
|
23
|
-
it('returns visibility "public" when doc has visibility:"public"', async () => {
|
|
24
|
-
const client = makeRoomsClient({ v: 1, owner: 'alice', members: [], visibility: 'public' });
|
|
25
|
-
const result = await readRooms(client, 'sp-pub');
|
|
26
|
-
expect(result.visibility).toBe('public');
|
|
27
|
-
});
|
|
14
|
+
// ── readSpaceAccess ────────────────────────────────────────────────────────────
|
|
28
15
|
|
|
16
|
+
describe('readSpaceAccess', () => {
|
|
29
17
|
it('returns owner, members, name, image', async () => {
|
|
30
|
-
const client =
|
|
18
|
+
const client = makeAccessClient({
|
|
31
19
|
v: 1, owner: 'alice', members: ['bob'], name: 'My Space', image: 'data:image/png;base64,abc',
|
|
32
20
|
});
|
|
33
|
-
const result = await
|
|
21
|
+
const result = await readSpaceAccess(client, 'sp-x');
|
|
34
22
|
expect(result.owner).toBe('alice');
|
|
35
23
|
expect(result.members).toEqual(['bob']);
|
|
36
24
|
expect(result.name).toBe('My Space');
|
|
@@ -38,74 +26,99 @@ describe('readRooms', () => {
|
|
|
38
26
|
});
|
|
39
27
|
|
|
40
28
|
it('returns null owner/name/image for missing fields', async () => {
|
|
41
|
-
const client =
|
|
42
|
-
const result = await
|
|
29
|
+
const client = makeAccessClient({});
|
|
30
|
+
const result = await readSpaceAccess(client, 'sp-empty');
|
|
43
31
|
expect(result.owner).toBeNull();
|
|
44
32
|
expect(result.name).toBeNull();
|
|
45
33
|
expect(result.image).toBeNull();
|
|
46
34
|
expect(result.members).toEqual([]);
|
|
47
35
|
});
|
|
48
|
-
});
|
|
49
36
|
|
|
50
|
-
|
|
37
|
+
it('returns the hash from the pull response', async () => {
|
|
38
|
+
const client = makeAccessClient({ v: 1, owner: 'alice', members: [] }, 'hash-abc');
|
|
39
|
+
const result = await readSpaceAccess(client, 'sp-1');
|
|
40
|
+
expect(result.hash).toBe('hash-abc');
|
|
41
|
+
});
|
|
51
42
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
const
|
|
55
|
-
|
|
56
|
-
const [, doc] = (client.push as ReturnType<typeof vi.fn>).mock.calls[0] as [string, Record<string, unknown>];
|
|
57
|
-
expect(doc).not.toHaveProperty('visibility');
|
|
43
|
+
it('does NOT return a visibility field', async () => {
|
|
44
|
+
const client = makeAccessClient({ v: 1, owner: 'alice', members: [] });
|
|
45
|
+
const result = await readSpaceAccess(client, 'sp-x');
|
|
46
|
+
expect(result).not.toHaveProperty('visibility');
|
|
58
47
|
});
|
|
48
|
+
});
|
|
59
49
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
50
|
+
// ── writeSpaceAccess ───────────────────────────────────────────────────────────
|
|
51
|
+
|
|
52
|
+
describe('writeSpaceAccess', () => {
|
|
53
|
+
it('writes owner and members', async () => {
|
|
54
|
+
const client = makeAccessClient(null);
|
|
55
|
+
await writeSpaceAccess(client, 'sp-x', 'alice', ['bob'], null);
|
|
63
56
|
const [, doc] = (client.push as ReturnType<typeof vi.fn>).mock.calls[0] as [string, Record<string, unknown>];
|
|
64
|
-
expect(doc).toHaveProperty('
|
|
57
|
+
expect(doc).toHaveProperty('owner', 'alice');
|
|
58
|
+
expect((doc as { members: string[] }).members).toContain('bob');
|
|
65
59
|
});
|
|
66
60
|
|
|
67
61
|
it('preserves name and image', async () => {
|
|
68
|
-
const client =
|
|
69
|
-
await
|
|
62
|
+
const client = makeAccessClient(null);
|
|
63
|
+
await writeSpaceAccess(client, 'sp-x', 'alice', ['bob'], null, { name: 'Test', image: 'data:x' });
|
|
70
64
|
const [, doc] = (client.push as ReturnType<typeof vi.fn>).mock.calls[0] as [string, Record<string, unknown>];
|
|
71
65
|
expect(doc).toHaveProperty('name', 'Test');
|
|
72
66
|
expect(doc).toHaveProperty('image', 'data:x');
|
|
73
67
|
});
|
|
68
|
+
|
|
69
|
+
it('does NOT write a visibility field', async () => {
|
|
70
|
+
const client = makeAccessClient(null);
|
|
71
|
+
await writeSpaceAccess(client, 'sp-x', 'alice', [], null);
|
|
72
|
+
const [, doc] = (client.push as ReturnType<typeof vi.fn>).mock.calls[0] as [string, Record<string, unknown>];
|
|
73
|
+
expect(doc).not.toHaveProperty('visibility');
|
|
74
|
+
});
|
|
74
75
|
});
|
|
75
76
|
|
|
76
77
|
// ── addSpaceMember / removeSpaceMember ────────────────────────────────────────
|
|
77
78
|
|
|
78
79
|
describe('addSpaceMember', () => {
|
|
79
|
-
it('adds a member
|
|
80
|
-
const client =
|
|
81
|
-
await addSpaceMember(client, 'sp-
|
|
80
|
+
it('adds a member to the roster', async () => {
|
|
81
|
+
const client = makeAccessClient({ v: 1, owner: 'alice', members: [] });
|
|
82
|
+
await addSpaceMember(client, 'sp-x', 'alice', 'bob');
|
|
82
83
|
const [, doc] = (client.push as ReturnType<typeof vi.fn>).mock.calls[0] as [string, Record<string, unknown>];
|
|
83
84
|
expect((doc as { members: string[] }).members).toContain('bob');
|
|
84
|
-
expect(doc).toHaveProperty('visibility', 'public');
|
|
85
85
|
});
|
|
86
86
|
|
|
87
87
|
it('is a no-op when member already present', async () => {
|
|
88
|
-
const client =
|
|
88
|
+
const client = makeAccessClient({ v: 1, owner: 'alice', members: ['bob'] });
|
|
89
89
|
await addSpaceMember(client, 'sp-x', 'alice', 'bob');
|
|
90
90
|
expect(client.push).not.toHaveBeenCalled();
|
|
91
91
|
});
|
|
92
|
+
|
|
93
|
+
it('preserves name and image when adding a member', async () => {
|
|
94
|
+
const client = makeAccessClient({ v: 1, owner: 'alice', members: [], name: 'S', image: 'data:i' });
|
|
95
|
+
await addSpaceMember(client, 'sp-x', 'alice', 'bob');
|
|
96
|
+
const [, doc] = (client.push as ReturnType<typeof vi.fn>).mock.calls[0] as [string, Record<string, unknown>];
|
|
97
|
+
expect(doc).toHaveProperty('name', 'S');
|
|
98
|
+
expect(doc).toHaveProperty('image', 'data:i');
|
|
99
|
+
});
|
|
92
100
|
});
|
|
93
101
|
|
|
94
102
|
describe('removeSpaceMember', () => {
|
|
95
|
-
it('removes a member
|
|
96
|
-
const client =
|
|
97
|
-
|
|
98
|
-
});
|
|
99
|
-
await removeSpaceMember(client, 'sp-pub', 'bob');
|
|
103
|
+
it('removes a member from the roster', async () => {
|
|
104
|
+
const client = makeAccessClient({ v: 1, owner: 'alice', members: ['bob', 'carol'], name: 'S' });
|
|
105
|
+
await removeSpaceMember(client, 'sp-x', 'bob');
|
|
100
106
|
const [, doc] = (client.push as ReturnType<typeof vi.fn>).mock.calls[0] as [string, Record<string, unknown>];
|
|
101
107
|
expect((doc as { members: string[] }).members).not.toContain('bob');
|
|
102
108
|
expect((doc as { members: string[] }).members).toContain('carol');
|
|
103
|
-
expect(doc).toHaveProperty('visibility', 'public');
|
|
104
109
|
});
|
|
105
110
|
|
|
106
111
|
it('is a no-op when member is not in the roster', async () => {
|
|
107
|
-
const client =
|
|
112
|
+
const client = makeAccessClient({ v: 1, owner: 'alice', members: ['carol'] });
|
|
108
113
|
await removeSpaceMember(client, 'sp-x', 'unknown');
|
|
109
114
|
expect(client.push).not.toHaveBeenCalled();
|
|
110
115
|
});
|
|
116
|
+
|
|
117
|
+
it('preserves name and image when removing a member', async () => {
|
|
118
|
+
const client = makeAccessClient({ v: 1, owner: 'alice', members: ['bob'], name: 'S', image: 'data:i' });
|
|
119
|
+
await removeSpaceMember(client, 'sp-x', 'bob');
|
|
120
|
+
const [, doc] = (client.push as ReturnType<typeof vi.fn>).mock.calls[0] as [string, Record<string, unknown>];
|
|
121
|
+
expect(doc).toHaveProperty('name', 'S');
|
|
122
|
+
expect(doc).toHaveProperty('image', 'data:i');
|
|
123
|
+
});
|
|
111
124
|
});
|
package/src/spaces/registry.ts
CHANGED
|
@@ -1,30 +1,26 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Space
|
|
2
|
+
* Space registries (plaintext metadata docs). A user's spaces live at
|
|
3
3
|
* `user/<userId>/_spaces`; each space's ACCESS RECORD (owner/members + shared
|
|
4
|
-
* name/image) at `spaces/<spaceId>/
|
|
5
|
-
*
|
|
6
|
-
*
|
|
4
|
+
* name/image) at `spaces/<spaceId>/_access`. The object tree lives in the plaintext
|
|
5
|
+
* unified object index (`objects/_index`, see `object-index.ts`); `_access` is the
|
|
6
|
+
* owner-only access record. Spaces are neutral containers — visibility and encryption
|
|
7
|
+
* are per-node properties (see `ObjectNode.access` / `ObjectNode.enc`).
|
|
7
8
|
*/
|
|
8
9
|
import { ConflictError, StarfishHttpError } from '@drakkar.software/starfish-client';
|
|
9
10
|
import type { StarfishClient } from '@drakkar.software/starfish-client';
|
|
10
11
|
|
|
11
|
-
import type { ArchivedDms, CapMap, DmMap, MutePrefs, PubAccessMap, ReadPrefs,
|
|
12
|
+
import type { ArchivedDms, CapMap, DmMap, MutePrefs, PubAccessMap, ReadPrefs, Space } from '../core/types.js';
|
|
12
13
|
import type { SealedBlob } from '../sync/account-seal.js';
|
|
13
14
|
import { randomId } from '../core/ids.js';
|
|
14
15
|
import type { Session } from '../sync/identity.js';
|
|
15
|
-
import { DEFAULT_CATEGORY } from '../objects/objects.js';
|
|
16
16
|
import { seedSpaceObjectIndex } from './object-index.js';
|
|
17
|
-
import {
|
|
17
|
+
import { spaceAccessPull, spaceAccessPush, spacesPull, spacesPush } from '../sync/paths.js';
|
|
18
18
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
/** Owner-set, SHARED space identity, persisted in the `_rooms` registry doc
|
|
23
|
-
* (plaintext — NOT E2EE). `image` is a data URI. Both optional for back-compat. */
|
|
19
|
+
/** Owner-set, SHARED space identity, persisted in the `_access` registry doc
|
|
20
|
+
* (plaintext — NOT E2EE). `image` is a data URI. All fields optional for back-compat. */
|
|
24
21
|
export interface SpaceMeta {
|
|
25
22
|
name?: string | null;
|
|
26
23
|
image?: string | null;
|
|
27
|
-
visibility?: SpaceVisibility;
|
|
28
24
|
}
|
|
29
25
|
|
|
30
26
|
/** A resolved name/image update fanned out so the SpacesProvider adopts a
|
|
@@ -302,36 +298,25 @@ function newSpaceId(): string {
|
|
|
302
298
|
return `sp-${randomId()}`;
|
|
303
299
|
}
|
|
304
300
|
|
|
305
|
-
export function
|
|
306
|
-
const distinct: string[] = [];
|
|
307
|
-
for (const r of rooms) if (r.category && !distinct.includes(r.category)) distinct.push(r.category);
|
|
308
|
-
const list = Array.isArray(stored) ? stored.filter((c): c is string => typeof c === 'string') : [];
|
|
309
|
-
if (!list.length) return distinct;
|
|
310
|
-
const result = [...list];
|
|
311
|
-
for (const c of distinct) if (!result.includes(c)) result.push(c);
|
|
312
|
-
return result;
|
|
313
|
-
}
|
|
314
|
-
|
|
315
|
-
export async function readRooms(
|
|
301
|
+
export async function readSpaceAccess(
|
|
316
302
|
client: StarfishClient,
|
|
317
303
|
spaceId: string,
|
|
318
|
-
): Promise<{ owner: string | null; members: string[];
|
|
319
|
-
const res = await client.pull(
|
|
304
|
+
): Promise<{ owner: string | null; members: string[]; name: string | null; image: string | null; hash: string | null }> {
|
|
305
|
+
const res = await client.pull(spaceAccessPull(spaceId)).catch((err: unknown) => {
|
|
320
306
|
if (err instanceof StarfishHttpError && err.status === 404) return null;
|
|
321
307
|
throw err;
|
|
322
308
|
});
|
|
323
|
-
const data = res?.data as { owner?: string; members?: unknown[];
|
|
309
|
+
const data = res?.data as { owner?: string; members?: unknown[]; name?: string; image?: string } | undefined;
|
|
324
310
|
return {
|
|
325
311
|
owner: typeof data?.owner === 'string' ? data.owner : null,
|
|
326
312
|
members: Array.isArray(data?.members) ? data!.members!.filter((m): m is string => typeof m === 'string') : [],
|
|
327
|
-
visibility: data?.visibility === 'public' ? 'public' : null,
|
|
328
313
|
name: typeof data?.name === 'string' ? data.name : null,
|
|
329
314
|
image: typeof data?.image === 'string' ? data.image : null,
|
|
330
315
|
hash: res?.hash ?? null,
|
|
331
316
|
};
|
|
332
317
|
}
|
|
333
318
|
|
|
334
|
-
export async function
|
|
319
|
+
export async function writeSpaceAccess(
|
|
335
320
|
client: StarfishClient,
|
|
336
321
|
spaceId: string,
|
|
337
322
|
owner: string,
|
|
@@ -341,10 +326,13 @@ export async function writeRooms(
|
|
|
341
326
|
): Promise<void> {
|
|
342
327
|
const name = meta?.name?.trim() || undefined;
|
|
343
328
|
const image = meta?.image || undefined;
|
|
344
|
-
const visibility = meta?.visibility === 'public' ? 'public' : undefined;
|
|
345
329
|
await client.push(
|
|
346
|
-
|
|
347
|
-
{
|
|
330
|
+
spaceAccessPush(spaceId),
|
|
331
|
+
{
|
|
332
|
+
v: 1, owner, members,
|
|
333
|
+
...(name ? { name } : {}),
|
|
334
|
+
...(image ? { image } : {}),
|
|
335
|
+
},
|
|
348
336
|
hash,
|
|
349
337
|
);
|
|
350
338
|
}
|
|
@@ -355,9 +343,9 @@ export async function addSpaceMember(
|
|
|
355
343
|
ownerUserId: string,
|
|
356
344
|
memberUserId: string,
|
|
357
345
|
): Promise<void> {
|
|
358
|
-
const { owner, members,
|
|
346
|
+
const { owner, members, name, image, hash } = await readSpaceAccess(client, spaceId);
|
|
359
347
|
if (memberUserId === (owner ?? ownerUserId) || members.includes(memberUserId)) return;
|
|
360
|
-
await
|
|
348
|
+
await writeSpaceAccess(client, spaceId, owner ?? ownerUserId, [...members, memberUserId], hash, { name, image });
|
|
361
349
|
}
|
|
362
350
|
|
|
363
351
|
/** Remove a member from the space roster (used for link revocation). */
|
|
@@ -366,9 +354,9 @@ export async function removeSpaceMember(
|
|
|
366
354
|
spaceId: string,
|
|
367
355
|
memberUserId: string,
|
|
368
356
|
): Promise<void> {
|
|
369
|
-
const { owner, members,
|
|
357
|
+
const { owner, members, name, image, hash } = await readSpaceAccess(client, spaceId);
|
|
370
358
|
if (!members.includes(memberUserId)) return;
|
|
371
|
-
await
|
|
359
|
+
await writeSpaceAccess(client, spaceId, owner ?? memberUserId, members.filter((m) => m !== memberUserId), hash, { name, image });
|
|
372
360
|
}
|
|
373
361
|
|
|
374
362
|
export async function addJoinedSpace(client: StarfishClient, userId: string, space: Space): Promise<void> {
|
|
@@ -406,36 +394,29 @@ export async function addJoinedSpaceWithLinkAccess(
|
|
|
406
394
|
}
|
|
407
395
|
|
|
408
396
|
/**
|
|
409
|
-
* Create a new space owned by the identity. Seeds
|
|
410
|
-
*
|
|
411
|
-
*
|
|
412
|
-
* `opts.visibility` defaults to `'private'`.
|
|
397
|
+
* Create a new space owned by the identity. Seeds an empty plaintext object index.
|
|
398
|
+
* Apps populate the index with their own object types after creation using `createNode`.
|
|
413
399
|
*/
|
|
414
400
|
export async function createSpace(
|
|
415
401
|
session: Session,
|
|
416
402
|
name: string,
|
|
417
|
-
opts?: { visibility?: SpaceVisibility },
|
|
418
403
|
): Promise<Space> {
|
|
419
404
|
const { accountClient, userId } = session;
|
|
420
405
|
const { spaces, hash } = await readSpaces(accountClient, userId);
|
|
421
406
|
const trimmed = name.trim() || 'New Space';
|
|
422
|
-
const visibility = opts?.visibility ?? 'private';
|
|
423
407
|
const id = newSpaceId();
|
|
424
408
|
const space: Space = {
|
|
425
409
|
id,
|
|
426
410
|
name: trimmed,
|
|
427
411
|
short: trimmed.slice(0, 2).toUpperCase(),
|
|
428
412
|
members: 1,
|
|
429
|
-
...(visibility === 'public' ? { visibility: 'public', ownerId: userId, write: true } : {}),
|
|
430
413
|
};
|
|
431
|
-
await
|
|
432
|
-
await seedSpaceObjectIndex(session, id
|
|
414
|
+
await writeSpaceAccess(accountClient, id, userId, [], null, { name: trimmed });
|
|
415
|
+
await seedSpaceObjectIndex(session, id);
|
|
433
416
|
await writeSpaces(accountClient, userId, [...spaces, space], hash);
|
|
434
417
|
return space;
|
|
435
418
|
}
|
|
436
419
|
|
|
437
|
-
export class CategoryError extends Error {}
|
|
438
|
-
|
|
439
420
|
export async function reconcileSpaceMeta(
|
|
440
421
|
client: StarfishClient,
|
|
441
422
|
userId: string,
|
package/src/sync/client.ts
CHANGED
|
@@ -12,7 +12,7 @@ import { getSyncBase, getSyncNamespace, getSyncPrefix, getOnServerReachable } fr
|
|
|
12
12
|
import { fetchWithTimeout } from './fetch-timeout.js';
|
|
13
13
|
import { pullCache, PULL_CACHE_MAX_AGE_MS } from './pull-cache.js';
|
|
14
14
|
import { cacheProfile, loadCachedProfile } from './profile-cache.js';
|
|
15
|
-
import {
|
|
15
|
+
import { profilePull, profilePush } from './paths.js';
|
|
16
16
|
import { SpaceAccessError } from '../core/space-access-error.js';
|
|
17
17
|
|
|
18
18
|
export interface DeviceKeys {
|
|
@@ -48,24 +48,25 @@ export function makeClient(cap: unknown, devEdPrivHex: string, namespaceOverride
|
|
|
48
48
|
}
|
|
49
49
|
|
|
50
50
|
/**
|
|
51
|
-
* Open a
|
|
51
|
+
* Open a node's decryptor, throwing a descriptive error per failure mode
|
|
52
52
|
* (unreachable server / no keyring yet / not a recipient).
|
|
53
53
|
*
|
|
54
|
-
*
|
|
55
|
-
*
|
|
54
|
+
* `keyringPullPath` is the full `/pull/.../_keyring` path (e.g. from
|
|
55
|
+
* `keyringPull(spaceId)`). A `SpaceAccessError` is a hard access
|
|
56
|
+
* denial; any other thrown error is a transient offline state.
|
|
56
57
|
*/
|
|
57
58
|
export async function openEncryptor(
|
|
58
59
|
client: StarfishClient,
|
|
59
60
|
keys: DeviceKeys,
|
|
60
|
-
|
|
61
|
+
keyringPullPath: string,
|
|
61
62
|
trustedAdders: string[],
|
|
62
63
|
): Promise<Encryptor> {
|
|
63
|
-
const res = await client.pull(
|
|
64
|
-
throw new Error('Could not reach the server to fetch
|
|
64
|
+
const res = await client.pull(keyringPullPath).catch(() => {
|
|
65
|
+
throw new Error('Could not reach the server to fetch node keys.');
|
|
65
66
|
});
|
|
66
67
|
const keyring = res?.data as unknown as Keyring | undefined;
|
|
67
68
|
if (!keyring || !keyring.epochs) {
|
|
68
|
-
throw new SpaceAccessError('This
|
|
69
|
+
throw new SpaceAccessError('This node has no keyring yet — ask the owner to create it first.');
|
|
69
70
|
}
|
|
70
71
|
try {
|
|
71
72
|
const enc = await createKeyringEncryptor(
|
|
@@ -75,7 +76,7 @@ export async function openEncryptor(
|
|
|
75
76
|
);
|
|
76
77
|
return enc as unknown as Encryptor;
|
|
77
78
|
} catch {
|
|
78
|
-
throw new SpaceAccessError("You're not a recipient of this
|
|
79
|
+
throw new SpaceAccessError("You're not a recipient of this node's keyring yet — ask the owner to invite you.");
|
|
79
80
|
}
|
|
80
81
|
}
|
|
81
82
|
|
|
@@ -83,33 +84,37 @@ export async function openEncryptor(
|
|
|
83
84
|
export async function buildEncryptor(
|
|
84
85
|
client: StarfishClient,
|
|
85
86
|
keys: DeviceKeys,
|
|
86
|
-
|
|
87
|
+
keyringPullPath: string,
|
|
87
88
|
trustedAdders: string[],
|
|
88
89
|
): Promise<Encryptor | null> {
|
|
89
90
|
try {
|
|
90
|
-
return await openEncryptor(client, keys,
|
|
91
|
+
return await openEncryptor(client, keys, keyringPullPath, trustedAdders);
|
|
91
92
|
} catch {
|
|
92
93
|
return null;
|
|
93
94
|
}
|
|
94
95
|
}
|
|
95
96
|
|
|
96
97
|
/**
|
|
97
|
-
* Owner-side: create
|
|
98
|
+
* Owner-side: create a per-node keyring if missing, return an encryptor.
|
|
99
|
+
*
|
|
100
|
+
* `keyringPullPath` / `keyringPushPath` are the full `/pull|push/.../_keyring`
|
|
101
|
+
* paths (e.g. from `keyringPull`/`keyringPush`).
|
|
98
102
|
*/
|
|
99
103
|
export async function ownerEnsureKeyring(
|
|
100
104
|
client: StarfishClient,
|
|
101
105
|
keys: DeviceKeys,
|
|
102
|
-
|
|
106
|
+
keyringPullPath: string,
|
|
107
|
+
keyringPushPath: string,
|
|
103
108
|
trustedAdders: string[] = [keys.edPub],
|
|
104
109
|
): Promise<Encryptor> {
|
|
105
|
-
const krRes = await client.pull(
|
|
110
|
+
const krRes = await client.pull(keyringPullPath).catch(() => null);
|
|
106
111
|
let keyring = krRes?.data as unknown as Keyring | undefined;
|
|
107
112
|
if (!keyring || !keyring.epochs) {
|
|
108
113
|
const created = await createKeyring({ edPrivHex: keys.edPriv, edPubHex: keys.edPub }, [
|
|
109
114
|
{ subKemHex: keys.kemPub },
|
|
110
115
|
]);
|
|
111
116
|
keyring = created.keyring;
|
|
112
|
-
await client.push(
|
|
117
|
+
await client.push(keyringPushPath, keyring as unknown as Record<string, unknown>, krRes?.hash ?? null);
|
|
113
118
|
}
|
|
114
119
|
const enc = await createKeyringEncryptor(
|
|
115
120
|
keyring,
|