@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/src/objects/objects.ts
CHANGED
|
@@ -1,9 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* Generic object-tree model — pure logic over a space's object index.
|
|
3
3
|
*
|
|
4
|
-
* A space's contents
|
|
5
|
-
* {
|
|
6
|
-
* `spaces/{spaceId}/objects/_index`. THIS module is the pure, testable core:
|
|
4
|
+
* A space's contents are {@link ObjectNode}s in one union-merged index doc at
|
|
5
|
+
* `spaces/{spaceId}/objects/_index`. This module is the pure, testable core:
|
|
7
6
|
* the tree builder + merge-artifact guards, breadcrumbs, ordering, and the node
|
|
8
7
|
* reducers a `store.set` applies.
|
|
9
8
|
*
|
|
@@ -12,21 +11,10 @@
|
|
|
12
11
|
* or an orphan. The builder below is the single place those are repaired so every
|
|
13
12
|
* consumer renders a well-formed tree.
|
|
14
13
|
*
|
|
15
|
-
*
|
|
16
|
-
* the object tree into the legacy `Room`-based shape that apps still speak during their
|
|
17
|
-
* migration onto the object model. They are purely mechanical projections over
|
|
18
|
-
* `ObjectNode` and carry no domain-specific names.
|
|
14
|
+
* No domain types (room, category, task, …) are defined here. Apps define their own.
|
|
19
15
|
*/
|
|
20
|
-
import type { ID, ObjectNode, ObjectType
|
|
21
|
-
import { randomId
|
|
22
|
-
|
|
23
|
-
/** The bucket new/unfiled objects land in, and the fallback a deleted category's
|
|
24
|
-
* objects are reassigned to. */
|
|
25
|
-
export const DEFAULT_CATEGORY = 'CHANNELS';
|
|
26
|
-
|
|
27
|
-
/** Deterministic category-node id from its name, so two devices that concurrently
|
|
28
|
-
* create the SAME category mint the SAME id → the union-merge dedupes them. */
|
|
29
|
-
export const categoryId = (name: string): ID => `cat-${roomSlug(name) || randomId()}`;
|
|
16
|
+
import type { ID, NodeAccess, ObjectNode, ObjectType } from '../core/types.js';
|
|
17
|
+
import { randomId } from '../core/ids.js';
|
|
30
18
|
|
|
31
19
|
/** A node plus its resolved children — the shape a tree view renders. */
|
|
32
20
|
export interface ObjectTreeNode extends ObjectNode {
|
|
@@ -34,25 +22,6 @@ export interface ObjectTreeNode extends ObjectNode {
|
|
|
34
22
|
children: ObjectTreeNode[];
|
|
35
23
|
}
|
|
36
24
|
|
|
37
|
-
/** Map a legacy {@link Room} `kind` to the unified room {@link RoomSubtype}. */
|
|
38
|
-
export function roomKindToSubtype(kind: Room['kind']): RoomSubtype {
|
|
39
|
-
switch (kind) {
|
|
40
|
-
case 'dm': return 'dm';
|
|
41
|
-
case 'automated': return 'automation';
|
|
42
|
-
default: return 'channel';
|
|
43
|
-
}
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
/** Inverse of {@link roomKindToSubtype}. A legacy persisted `'stream'` subtype hits
|
|
47
|
-
* the `default` and reads back as a plain `'channel'` (normalization). */
|
|
48
|
-
export function subtypeToRoomKind(subtype: RoomSubtype | undefined): Room['kind'] {
|
|
49
|
-
switch (subtype) {
|
|
50
|
-
case 'dm': return 'dm';
|
|
51
|
-
case 'automation': return 'automated';
|
|
52
|
-
default: return 'channel';
|
|
53
|
-
}
|
|
54
|
-
}
|
|
55
|
-
|
|
56
25
|
function compareSiblings(a: ObjectNode, b: ObjectNode): number {
|
|
57
26
|
if (a.order !== b.order) return a.order - b.order;
|
|
58
27
|
return a.id < b.id ? -1 : a.id > b.id ? 1 : 0;
|
|
@@ -151,13 +120,17 @@ export function subtreeIds(nodes: ObjectNode[], rootId: ID): Set<ID> {
|
|
|
151
120
|
|
|
152
121
|
export interface NewObjectInput {
|
|
153
122
|
type: ObjectType;
|
|
154
|
-
subtype?: RoomSubtype;
|
|
155
123
|
parentId?: ID | null;
|
|
156
124
|
title: string;
|
|
157
125
|
emoji?: string;
|
|
158
|
-
|
|
159
|
-
|
|
126
|
+
/** App-specific metadata passed through to node.meta. */
|
|
127
|
+
meta?: Record<string, unknown>;
|
|
128
|
+
/** Provide to reuse an id (e.g. a node id derived elsewhere); else minted. */
|
|
160
129
|
id?: ID;
|
|
130
|
+
/** Who may reach this node. Absent ⇒ `'space'` (all space members). */
|
|
131
|
+
access?: NodeAccess;
|
|
132
|
+
/** Whether the node's content is E2EE under its own per-node keyring. Absent ⇒ false. */
|
|
133
|
+
enc?: boolean;
|
|
161
134
|
}
|
|
162
135
|
|
|
163
136
|
/** Append a new node under `parentId` at the end of its sibling order. */
|
|
@@ -167,19 +140,25 @@ export function addObject(nodes: ObjectNode[], input: NewObjectInput, now: numbe
|
|
|
167
140
|
const node: ObjectNode = {
|
|
168
141
|
id: input.id ?? `obj-${randomId()}`,
|
|
169
142
|
type: input.type,
|
|
170
|
-
...(input.subtype ? { subtype: input.subtype } : {}),
|
|
171
143
|
parentId,
|
|
172
144
|
order: nextOrder(siblings),
|
|
173
145
|
title: input.title,
|
|
174
146
|
...(input.emoji ? { emoji: input.emoji } : {}),
|
|
175
147
|
updatedAt: now,
|
|
176
|
-
...(input.
|
|
148
|
+
...(input.meta ? { meta: input.meta } : {}),
|
|
149
|
+
...(input.access && input.access !== 'space' ? { access: input.access } : {}),
|
|
150
|
+
...(input.enc ? { enc: true as const } : {}),
|
|
177
151
|
};
|
|
178
152
|
return { nodes: [...nodes, node], node };
|
|
179
153
|
}
|
|
180
154
|
|
|
181
|
-
/** Patch a node's mutable metadata (title/emoji/
|
|
182
|
-
export function patchObject(
|
|
155
|
+
/** Patch a node's mutable metadata (title/emoji/meta/access/enc), bumping `updatedAt`. */
|
|
156
|
+
export function patchObject(
|
|
157
|
+
nodes: ObjectNode[],
|
|
158
|
+
id: ID,
|
|
159
|
+
patch: Partial<Pick<ObjectNode, 'title' | 'emoji' | 'meta' | 'access' | 'enc'>>,
|
|
160
|
+
now: number,
|
|
161
|
+
): ObjectNode[] {
|
|
183
162
|
return nodes.map((n) => (n.id === id ? { ...n, ...patch, updatedAt: now } : n));
|
|
184
163
|
}
|
|
185
164
|
|
|
@@ -202,95 +181,3 @@ export function archiveObject(nodes: ObjectNode[], id: ID, now: number): ObjectN
|
|
|
202
181
|
return nodes.map((n) => (ids.has(n.id) ? { ...n, archived: true, updatedAt: now } : n));
|
|
203
182
|
}
|
|
204
183
|
|
|
205
|
-
// ── Transitional bridges (Room-based projections) ─────────────────────────────
|
|
206
|
-
// Used while apps migrate their content onto the generic object model. Both apps
|
|
207
|
-
// still speak the legacy `Room` type; these projections stay until that migration
|
|
208
|
-
// is complete.
|
|
209
|
-
|
|
210
|
-
/** The category→rooms grouping the legacy UI consumes. */
|
|
211
|
-
export interface AdaptedCategory {
|
|
212
|
-
name: string;
|
|
213
|
-
rooms: Room[];
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
/**
|
|
217
|
-
* Project the room/category nodes of an index into the legacy `{ name, rooms }[]`
|
|
218
|
-
* shape that app UIs still consume. Category nodes become buckets; room nodes become
|
|
219
|
-
* {@link Room}s grouped under their parent category (or `fallbackCategory` at root).
|
|
220
|
-
* Returns null when the index holds no room/category nodes yet.
|
|
221
|
-
*
|
|
222
|
-
* @deprecated Use the object tree directly once apps complete their migration.
|
|
223
|
-
*/
|
|
224
|
-
export function objectsToRoomCategories(nodes: ObjectNode[], spaceId: string, fallbackCategory: string): AdaptedCategory[] | null {
|
|
225
|
-
const live = nodes.filter((n) => !n.archived);
|
|
226
|
-
const cats = live.filter((n) => n.type === 'category').slice().sort(compareSiblings);
|
|
227
|
-
const rooms = live.filter((n) => n.type === 'room');
|
|
228
|
-
if (cats.length === 0 && rooms.length === 0) return null;
|
|
229
|
-
|
|
230
|
-
const titleById = new Map<ID, string>(cats.map((c) => [c.id, c.title]));
|
|
231
|
-
const buckets = new Map<string, Room[]>();
|
|
232
|
-
for (const c of cats) buckets.set(c.title, []);
|
|
233
|
-
|
|
234
|
-
const toRoom = (n: ObjectNode, category: string): Room => ({
|
|
235
|
-
id: n.id,
|
|
236
|
-
spaceId,
|
|
237
|
-
category,
|
|
238
|
-
name: n.title,
|
|
239
|
-
kind: subtypeToRoomKind(n.subtype),
|
|
240
|
-
...(n.automation ? { automation: n.automation } : {}),
|
|
241
|
-
});
|
|
242
|
-
|
|
243
|
-
for (const n of rooms.slice().sort(compareSiblings)) {
|
|
244
|
-
const category = (n.parentId != null && titleById.get(n.parentId)) || fallbackCategory;
|
|
245
|
-
if (!buckets.has(category)) buckets.set(category, []);
|
|
246
|
-
buckets.get(category)!.push(toRoom(n, category));
|
|
247
|
-
}
|
|
248
|
-
return [...buckets.entries()].map(([name, rs]) => ({ name, rooms: rs }));
|
|
249
|
-
}
|
|
250
|
-
|
|
251
|
-
/**
|
|
252
|
-
* Drop `kind: 'automated'` rooms from a category list (they belong to an Agents
|
|
253
|
-
* view, not the main room list). A category that held only agents is removed too.
|
|
254
|
-
*
|
|
255
|
-
* @deprecated Use the object tree directly once apps complete their migration.
|
|
256
|
-
*/
|
|
257
|
-
export function excludeAutomatedRooms(categories: AdaptedCategory[]): AdaptedCategory[] {
|
|
258
|
-
return categories
|
|
259
|
-
.map((c) => ({ ...c, rooms: c.rooms.filter((r) => r.kind !== 'automated') }))
|
|
260
|
-
.filter((c, i) => c.rooms.length > 0 || categories[i].rooms.length === 0);
|
|
261
|
-
}
|
|
262
|
-
|
|
263
|
-
// ── Seed: build the initial index nodes for a freshly-created space ────────────
|
|
264
|
-
|
|
265
|
-
/** A minimal object descriptor the {@link seedIndexNodes} builder turns into nodes. */
|
|
266
|
-
export interface SeedRoom {
|
|
267
|
-
id: ID;
|
|
268
|
-
name: string;
|
|
269
|
-
kind: Room['kind'];
|
|
270
|
-
category: string;
|
|
271
|
-
}
|
|
272
|
-
|
|
273
|
-
/**
|
|
274
|
-
* Build the initial `ObjectNode[]` for a brand-new space's index: a `category` node
|
|
275
|
-
* per distinct category and a `room` node per seed object parented under it. Pure +
|
|
276
|
-
* deterministic (category ids via {@link categoryId}).
|
|
277
|
-
*/
|
|
278
|
-
export function seedIndexNodes(rooms: SeedRoom[], now: number): ObjectNode[] {
|
|
279
|
-
const out: ObjectNode[] = [];
|
|
280
|
-
const catId = new Map<string, ID>();
|
|
281
|
-
let catOrder = 0;
|
|
282
|
-
for (const r of rooms) {
|
|
283
|
-
if (catId.has(r.category)) continue;
|
|
284
|
-
const id = categoryId(r.category);
|
|
285
|
-
catId.set(r.category, id);
|
|
286
|
-
out.push({ id, type: 'category', parentId: null, order: catOrder++, title: r.category, updatedAt: now });
|
|
287
|
-
}
|
|
288
|
-
const orderInCat = new Map<ID, number>();
|
|
289
|
-
for (const r of rooms) {
|
|
290
|
-
const parentId = catId.get(r.category)!;
|
|
291
|
-
const order = (orderInCat.get(parentId) ?? 0) + 1;
|
|
292
|
-
orderInCat.set(parentId, order);
|
|
293
|
-
out.push({ id: r.id, type: 'room', subtype: roomKindToSubtype(r.kind), parentId, order, title: r.name, updatedAt: now });
|
|
294
|
-
}
|
|
295
|
-
return out;
|
|
296
|
-
}
|
|
@@ -75,13 +75,20 @@ describe('acceptSpaceInvite validation', () => {
|
|
|
75
75
|
).rejects.toThrow('different identity');
|
|
76
76
|
});
|
|
77
77
|
|
|
78
|
-
it('
|
|
78
|
+
it('accepts invite with no iss field (no longer required — keyrings are per-node now)', async () => {
|
|
79
79
|
const inv = JSON.stringify({
|
|
80
80
|
spaceId: 'sp-x',
|
|
81
|
+
spaceName: 'My Space',
|
|
81
82
|
cap: { kind: 'member', sub: 'my-pub' },
|
|
82
83
|
});
|
|
84
|
+
// Should NOT throw on missing iss — the iss check was removed with space keyrings.
|
|
85
|
+
// The accountClient.push mock is needed for addJoinedSpaceWithCap.
|
|
86
|
+
const fakeClient = {
|
|
87
|
+
pull: () => Promise.resolve({ data: { v: 1, spaces: [], caps: {}, pubAccess: {} }, hash: null }),
|
|
88
|
+
push: () => Promise.resolve(),
|
|
89
|
+
};
|
|
83
90
|
await expect(
|
|
84
|
-
acceptSpaceInvite({ keys: { edPub: 'my-pub' }, accountClient:
|
|
85
|
-
).
|
|
91
|
+
acceptSpaceInvite({ keys: { edPub: 'my-pub' }, accountClient: fakeClient, userId: 'alice' } as never, inv),
|
|
92
|
+
).resolves.toMatchObject({ id: 'sp-x' });
|
|
86
93
|
});
|
|
87
94
|
});
|
package/src/spaces/members.ts
CHANGED
|
@@ -1,21 +1,24 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Space membership —
|
|
2
|
+
* Space membership — invite-based (member cap) and link-based (open access).
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
4
|
+
* MEMBER join: the owner records the invitee in the roster, mints a space-scoped
|
|
5
|
+
* member cap, and adds the invitee to the space-wide keyring (if it exists) so they
|
|
6
|
+
* can decrypt `enc` content. The invitee stores a `{kind:'member'}` entry.
|
|
7
7
|
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
8
|
+
* LINK join: the owner mints an ephemeral Ed/KEM keypair whose *private* key ships
|
|
9
|
+
* inside a URL-fragment token, adds the ephemeral userId to the roster so the server
|
|
10
|
+
* grants `space:member`, and mints a member cap scoped to that ephemeral subject.
|
|
11
11
|
* Any bearer of the link stores a `{kind:'link'}` entry. Revocation = `removeSpaceMember`.
|
|
12
|
+
*
|
|
13
|
+
* DEVICE PAIRING: after pairing, call `addDeviceToSpaceKeyring(session, spaceId, device)`
|
|
14
|
+
* for each space the paired device should decrypt. ONE keyring per space encrypts all
|
|
15
|
+
* `enc` nodes; adding the device once unlocks the whole space's E2EE content.
|
|
12
16
|
*/
|
|
13
17
|
import { generateDeviceKeys } from '@drakkar.software/starfish-identities';
|
|
14
18
|
import { addCollectionRecipient } from '@drakkar.software/starfish-keyring';
|
|
15
19
|
import { mintMemberCap } from '@drakkar.software/starfish-sharing';
|
|
16
20
|
|
|
17
21
|
import type { Space } from '../core/types.js';
|
|
18
|
-
import { buildEncryptor, makeClient } from '../sync/client.js';
|
|
19
22
|
import type { Session } from '../sync/identity.js';
|
|
20
23
|
import {
|
|
21
24
|
getSpaceAccessEntry,
|
|
@@ -39,54 +42,54 @@ export function makeJoinRequest(session: Session): string {
|
|
|
39
42
|
return JSON.stringify(req);
|
|
40
43
|
}
|
|
41
44
|
|
|
45
|
+
function isAlreadyPresentRecipient(err: unknown): boolean {
|
|
46
|
+
return err instanceof Error && /already present in epoch/.test(err.message);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function isKeyringMissing(err: unknown): boolean {
|
|
50
|
+
return err instanceof Error && /not found|404|does not exist/i.test(err.message);
|
|
51
|
+
}
|
|
52
|
+
|
|
42
53
|
interface SpaceInvite {
|
|
43
54
|
spaceId: string;
|
|
44
55
|
spaceName: string;
|
|
45
56
|
cap: unknown;
|
|
46
57
|
}
|
|
47
58
|
|
|
48
|
-
function isAlreadyPresentRecipient(err: unknown): boolean {
|
|
49
|
-
return err instanceof Error && /already present in epoch/.test(err.message);
|
|
50
|
-
}
|
|
51
|
-
|
|
52
59
|
/**
|
|
53
|
-
* Owner
|
|
54
|
-
*
|
|
60
|
+
* Owner: invite an identity into a space. Records them in the roster, mints a
|
|
61
|
+
* space-scoped member cap, and adds them to the space-wide keyring if it exists
|
|
62
|
+
* (so they can decrypt `enc` nodes from the start).
|
|
63
|
+
* Returns the invite bundle JSON.
|
|
55
64
|
*/
|
|
56
|
-
export async function
|
|
65
|
+
export async function inviteToSpace(
|
|
57
66
|
session: Session,
|
|
58
67
|
spaceId: string,
|
|
59
|
-
|
|
60
|
-
|
|
68
|
+
requestJson: string,
|
|
69
|
+
canWrite = true,
|
|
70
|
+
spaceName?: string,
|
|
71
|
+
): Promise<string> {
|
|
72
|
+
const req = JSON.parse(requestJson) as JoinRequest;
|
|
73
|
+
if (!req.edPub || !req.kemPub || !req.userId) throw new Error('That is not a valid join request.');
|
|
74
|
+
await addSpaceMember(session.accountClient, spaceId, session.userId, req.userId);
|
|
75
|
+
|
|
76
|
+
// Add invitee to the space-wide keyring so they can decrypt enc nodes.
|
|
77
|
+
// Silently skip if the keyring doesn't exist yet (no enc nodes in the space).
|
|
61
78
|
try {
|
|
62
79
|
await addCollectionRecipient(
|
|
63
80
|
session.chatClient,
|
|
64
81
|
keyringName(spaceId),
|
|
65
|
-
{ subKem:
|
|
82
|
+
{ subKem: req.kemPub, userId: req.userId, label: req.userId.slice(0, 8) },
|
|
66
83
|
{ edPriv: session.keys.edPriv, edPub: session.keys.edPub, kemPriv: session.keys.kemPriv },
|
|
67
84
|
{ trustedAdders: [session.keys.edPub] },
|
|
68
85
|
);
|
|
69
86
|
} catch (err) {
|
|
70
|
-
if (!isAlreadyPresentRecipient(err)
|
|
87
|
+
if (!isAlreadyPresentRecipient(err) && !isKeyringMissing(err)) {
|
|
88
|
+
// Only rethrow unexpected errors — missing keyring (no enc nodes yet) is normal.
|
|
89
|
+
console.warn('[octospaces] inviteToSpace: keyring add skipped', err);
|
|
90
|
+
}
|
|
71
91
|
}
|
|
72
|
-
}
|
|
73
92
|
|
|
74
|
-
/**
|
|
75
|
-
* Owner: invite an identity into a PRIVATE space. Adds them to the keyring, records
|
|
76
|
-
* them in the roster, and mints a single space-scoped member cap.
|
|
77
|
-
* Returns the invite bundle JSON.
|
|
78
|
-
*/
|
|
79
|
-
export async function inviteToSpace(
|
|
80
|
-
session: Session,
|
|
81
|
-
spaceId: string,
|
|
82
|
-
requestJson: string,
|
|
83
|
-
canWrite = true,
|
|
84
|
-
spaceName?: string,
|
|
85
|
-
): Promise<string> {
|
|
86
|
-
const req = JSON.parse(requestJson) as JoinRequest;
|
|
87
|
-
if (!req.edPub || !req.kemPub || !req.userId) throw new Error('That is not a valid join request.');
|
|
88
|
-
await addDeviceToSpaceKeyring(session, spaceId, { kemPub: req.kemPub, userId: req.userId });
|
|
89
|
-
await addSpaceMember(session.accountClient, spaceId, session.userId, req.userId);
|
|
90
93
|
// NOTE: 'chat' is the cap collection the deployed server's space-member enricher recognises.
|
|
91
94
|
const cap = await mintMemberCap(
|
|
92
95
|
session.keys.edPriv,
|
|
@@ -105,22 +108,18 @@ export async function inviteToSpace(
|
|
|
105
108
|
}
|
|
106
109
|
|
|
107
110
|
/**
|
|
108
|
-
* Invitee: accept a
|
|
109
|
-
*
|
|
111
|
+
* Invitee: accept a space invite — store the cap and register the space.
|
|
112
|
+
* Returns the joined space.
|
|
110
113
|
*/
|
|
111
114
|
export async function acceptSpaceInvite(session: Session, inviteJson: string): Promise<Space> {
|
|
112
115
|
const inv = JSON.parse(inviteJson) as Partial<SpaceInvite>;
|
|
113
|
-
const cap = inv.cap as { kind?: string; sub?: string
|
|
116
|
+
const cap = inv.cap as { kind?: string; sub?: string } | undefined;
|
|
114
117
|
if (!cap || !inv.spaceId) throw new Error('That is not a valid space invite.');
|
|
115
118
|
if (cap.kind !== 'member') throw new Error('That is not a valid space invite.');
|
|
116
119
|
if (!cap.sub || cap.sub !== session.keys.edPub) {
|
|
117
120
|
throw new Error('This invite was issued for a different identity.');
|
|
118
121
|
}
|
|
119
|
-
if (!cap.iss) throw new Error('This invite is missing its issuer.');
|
|
120
122
|
const spaceId = inv.spaceId;
|
|
121
|
-
const client = makeClient(cap, session.keys.edPriv);
|
|
122
|
-
const enc = await buildEncryptor(client, session.keys, spaceId, [cap.iss]);
|
|
123
|
-
if (!enc) throw new Error("Accepted, but you're not in the space keyring yet — ask the owner to re-invite.");
|
|
124
123
|
const capJson = JSON.stringify(cap);
|
|
125
124
|
const name = inv.spaceName?.trim() || `space-${spaceId.slice(-6)}`;
|
|
126
125
|
const space: Space = { id: spaceId, name, short: name.slice(0, 2).toUpperCase(), members: 1 };
|
|
@@ -188,26 +187,38 @@ export async function createSpaceInviteLink(
|
|
|
188
187
|
);
|
|
189
188
|
// Add the ephemeral userId to the roster so the server grants `space:member`
|
|
190
189
|
await addSpaceMember(session.accountClient, spaceId, session.userId, ephemeralUserId);
|
|
190
|
+
|
|
191
|
+
// Add ephemeral KEM to the space keyring so link-bearers can decrypt enc content.
|
|
192
|
+
// Silently skip if the keyring doesn't exist yet (no enc nodes).
|
|
193
|
+
try {
|
|
194
|
+
await addCollectionRecipient(
|
|
195
|
+
session.chatClient,
|
|
196
|
+
keyringName(spaceId),
|
|
197
|
+
{ subKem: ek.kemPub, userId: ephemeralUserId, label: ephemeralUserId.slice(0, 8) },
|
|
198
|
+
{ edPriv: session.keys.edPriv, edPub: session.keys.edPub, kemPriv: session.keys.kemPriv },
|
|
199
|
+
{ trustedAdders: [session.keys.edPub] },
|
|
200
|
+
);
|
|
201
|
+
} catch (err) {
|
|
202
|
+
if (!isAlreadyPresentRecipient(err) && !isKeyringMissing(err)) {
|
|
203
|
+
console.warn('[octospaces] createSpaceInviteLink: keyring add skipped', err);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
191
207
|
const token: SpaceInviteLinkToken = { v: 1, spaceId, spaceName, cap, key: ek.edPriv, write };
|
|
192
208
|
return { token, link: encodeSpaceInviteLink(origin, token) };
|
|
193
209
|
}
|
|
194
210
|
|
|
195
211
|
/**
|
|
196
|
-
* Any user: join a
|
|
212
|
+
* Any user: join a space by redeeming an invite link token.
|
|
197
213
|
* Stores the link credential locally and seals it into the synced `_spaces` doc.
|
|
198
214
|
*/
|
|
199
215
|
export async function joinSpaceByLink(session: Session, token: SpaceInviteLinkToken): Promise<Space> {
|
|
200
|
-
const cap = token.cap as { iss?: string };
|
|
201
|
-
const ownerId = cap.iss ? await userIdFromEdPub(cap.iss) : undefined;
|
|
202
216
|
const name = token.spaceName.trim() || `space-${token.spaceId.slice(-6)}`;
|
|
203
217
|
const space: Space = {
|
|
204
218
|
id: token.spaceId,
|
|
205
219
|
name,
|
|
206
220
|
short: name.slice(0, 2).toUpperCase(),
|
|
207
221
|
members: 1,
|
|
208
|
-
visibility: 'public',
|
|
209
|
-
...(ownerId ? { ownerId } : {}),
|
|
210
|
-
write: token.write,
|
|
211
222
|
};
|
|
212
223
|
const accessPayload = { cap: token.cap, key: token.key, write: token.write };
|
|
213
224
|
const sealed = await sealToSelf(session, JSON.stringify(accessPayload));
|
|
@@ -216,6 +227,32 @@ export async function joinSpaceByLink(session: Session, token: SpaceInviteLinkTo
|
|
|
216
227
|
return space;
|
|
217
228
|
}
|
|
218
229
|
|
|
230
|
+
/**
|
|
231
|
+
* Add a device's KEM key as a recipient of a space's keyring.
|
|
232
|
+
*
|
|
233
|
+
* Call this after device pairing (for each space the new device should be able to
|
|
234
|
+
* decrypt). ONE space keyring encrypts ALL the space's `enc` nodes — adding the device
|
|
235
|
+
* once unlocks the whole space's E2EE content. Silently a no-op if the keyring doesn't
|
|
236
|
+
* exist yet.
|
|
237
|
+
*/
|
|
238
|
+
export async function addDeviceToSpaceKeyring(
|
|
239
|
+
session: Session,
|
|
240
|
+
spaceId: string,
|
|
241
|
+
device: { kemPub: string; edPub: string; userId: string },
|
|
242
|
+
): Promise<void> {
|
|
243
|
+
try {
|
|
244
|
+
await addCollectionRecipient(
|
|
245
|
+
session.chatClient,
|
|
246
|
+
keyringName(spaceId),
|
|
247
|
+
{ subKem: device.kemPub, userId: device.userId, label: device.userId.slice(0, 8) },
|
|
248
|
+
{ edPriv: session.keys.edPriv, edPub: session.keys.edPub, kemPriv: session.keys.kemPriv },
|
|
249
|
+
{ trustedAdders: [session.keys.edPub] },
|
|
250
|
+
);
|
|
251
|
+
} catch (err) {
|
|
252
|
+
if (!isAlreadyPresentRecipient(err) && !isKeyringMissing(err)) throw err;
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
219
256
|
/**
|
|
220
257
|
* Single sign-in hydration: merges server-side caps (plaintext member caps from
|
|
221
258
|
* `_spaces.caps`) and sealed link access (from `_spaces.pubAccess`) into the
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
|
+
import type { StarfishClient } from '@drakkar.software/starfish-client';
|
|
3
|
+
import type { ObjectNode } from '../core/types.js';
|
|
4
|
+
|
|
5
|
+
// ── Mocks ──────────────────────────────────────────────────────────────────────
|
|
6
|
+
//
|
|
7
|
+
// We mock heavy external modules so the tests focus on the business logic in
|
|
8
|
+
// nodes.ts (validation, index mutations, access-store calls) without needing
|
|
9
|
+
// a real Starfish server or keyring library.
|
|
10
|
+
|
|
11
|
+
vi.mock('../sync/space-access.js', () => ({
|
|
12
|
+
getSpaceClient: vi.fn().mockReturnValue({
|
|
13
|
+
pull: vi.fn().mockResolvedValue(null),
|
|
14
|
+
push: vi.fn().mockResolvedValue(undefined),
|
|
15
|
+
}),
|
|
16
|
+
}));
|
|
17
|
+
|
|
18
|
+
vi.mock('../sync/client.js', () => ({
|
|
19
|
+
ownerEnsureKeyring: vi.fn().mockResolvedValue({}),
|
|
20
|
+
makeClient: vi.fn().mockReturnValue({
|
|
21
|
+
pull: vi.fn().mockResolvedValue(null),
|
|
22
|
+
push: vi.fn().mockResolvedValue(undefined),
|
|
23
|
+
}),
|
|
24
|
+
}));
|
|
25
|
+
|
|
26
|
+
vi.mock('../sync/space-access-store.js', () => ({
|
|
27
|
+
saveNodeAccessEntry: vi.fn(),
|
|
28
|
+
saveSpaceAccessEntry: vi.fn(),
|
|
29
|
+
getNodeAccessEntry: vi.fn().mockReturnValue(null),
|
|
30
|
+
}));
|
|
31
|
+
|
|
32
|
+
vi.mock('@drakkar.software/starfish-sharing', () => ({
|
|
33
|
+
mintMemberCap: vi.fn().mockResolvedValue({ kind: 'member', sub: 'pub-key' }),
|
|
34
|
+
}));
|
|
35
|
+
|
|
36
|
+
vi.mock('@drakkar.software/starfish-keyring', () => ({
|
|
37
|
+
addCollectionRecipient: vi.fn().mockResolvedValue(undefined),
|
|
38
|
+
}));
|
|
39
|
+
|
|
40
|
+
vi.mock('@drakkar.software/starfish-identities', () => ({
|
|
41
|
+
generateDeviceKeys: vi.fn().mockReturnValue({
|
|
42
|
+
edPub: 'eph-edpub',
|
|
43
|
+
edPriv: 'eph-edpriv',
|
|
44
|
+
kemPub: 'eph-kempub',
|
|
45
|
+
kemPriv: 'eph-kempriv',
|
|
46
|
+
}),
|
|
47
|
+
}));
|
|
48
|
+
|
|
49
|
+
vi.mock('../sync/paths.js', async (importOriginal) => {
|
|
50
|
+
const original = await importOriginal<typeof import('../sync/paths.js')>();
|
|
51
|
+
return {
|
|
52
|
+
...original,
|
|
53
|
+
userIdFromEdPub: vi.fn().mockResolvedValue('ephemeral-user-id'),
|
|
54
|
+
};
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
vi.mock('./registry.js', () => ({
|
|
58
|
+
addSpaceMember: vi.fn().mockResolvedValue(undefined),
|
|
59
|
+
readSpaces: vi.fn().mockResolvedValue({ spaces: [], caps: {}, pubAccess: {} }),
|
|
60
|
+
updateSpacesDoc: vi.fn().mockImplementation(
|
|
61
|
+
(_client: unknown, _userId: string, mutator: (cur: { spaces: ObjectNode[]; caps: Record<string, string>; pubAccess: Record<string, unknown> }) => unknown) =>
|
|
62
|
+
mutator({ spaces: [], caps: {}, pubAccess: {} }),
|
|
63
|
+
),
|
|
64
|
+
}));
|
|
65
|
+
|
|
66
|
+
vi.mock('../sync/account-seal.js', () => ({
|
|
67
|
+
sealToSelf: vi.fn().mockResolvedValue({ encrypted: true }),
|
|
68
|
+
}));
|
|
69
|
+
|
|
70
|
+
// ── now import the module under test ──────────────────────────────────────────
|
|
71
|
+
|
|
72
|
+
import { createNode, setNodeAccess, decodeNodeInviteLink, encodeNodeInviteLink } from './nodes.js';
|
|
73
|
+
import { ownerEnsureKeyring } from '../sync/client.js';
|
|
74
|
+
|
|
75
|
+
// ── Fake session ──────────────────────────────────────────────────────────────
|
|
76
|
+
|
|
77
|
+
function makeIndexClient(nodes: ObjectNode[] = []): StarfishClient {
|
|
78
|
+
return {
|
|
79
|
+
pull: vi.fn().mockResolvedValue({ data: { v: 2, objects: nodes }, hash: 'h1' }),
|
|
80
|
+
push: vi.fn().mockResolvedValue(undefined),
|
|
81
|
+
} as unknown as StarfishClient;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function makeSession(indexClient?: StarfishClient) {
|
|
85
|
+
return {
|
|
86
|
+
userId: 'alice',
|
|
87
|
+
keys: { edPriv: 'priv', edPub: 'pub', kemPriv: 'kempriv', kemPub: 'kempub' },
|
|
88
|
+
chatClient: indexClient ?? makeIndexClient(),
|
|
89
|
+
accountClient: {
|
|
90
|
+
pull: vi.fn().mockResolvedValue({ data: { v: 1, spaces: [], caps: {}, pubAccess: {} }, hash: null }),
|
|
91
|
+
push: vi.fn().mockResolvedValue(undefined),
|
|
92
|
+
} as unknown as StarfishClient,
|
|
93
|
+
} as unknown as import('../sync/identity.js').Session;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// ── createNode ────────────────────────────────────────────────────────────────
|
|
97
|
+
|
|
98
|
+
describe('createNode', () => {
|
|
99
|
+
beforeEach(() => {
|
|
100
|
+
vi.mocked(ownerEnsureKeyring).mockClear();
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('creates a node with default access "space" (no access field in stored node)', async () => {
|
|
104
|
+
const session = makeSession();
|
|
105
|
+
const node = await createNode(session, 'sp-1', { type: 'page', title: 'Hello' });
|
|
106
|
+
expect(node.title).toBe('Hello');
|
|
107
|
+
expect(node.type).toBe('page');
|
|
108
|
+
// 'space' is the default — addObject omits the access field (absent ⇒ 'space')
|
|
109
|
+
expect(node.access).toBeUndefined();
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('creates a public node', async () => {
|
|
113
|
+
const session = makeSession();
|
|
114
|
+
const node = await createNode(session, 'sp-1', { type: 'page', title: 'Public', access: 'public' });
|
|
115
|
+
expect(node.access).toBe('public');
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it('creates an invite node', async () => {
|
|
119
|
+
const session = makeSession();
|
|
120
|
+
const node = await createNode(session, 'sp-1', { type: 'page', title: 'Secret', access: 'invite' });
|
|
121
|
+
expect(node.access).toBe('invite');
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it('rejects the invalid combo public+enc', async () => {
|
|
125
|
+
const session = makeSession();
|
|
126
|
+
await expect(
|
|
127
|
+
createNode(session, 'sp-1', { type: 'page', title: 'Bad', access: 'public', enc: true }),
|
|
128
|
+
).rejects.toThrow(/public\+enc/i);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it('mints a per-node keyring for enc nodes', async () => {
|
|
132
|
+
const session = makeSession();
|
|
133
|
+
await createNode(session, 'sp-1', { type: 'page', title: 'E2EE', enc: true });
|
|
134
|
+
expect(ownerEnsureKeyring).toHaveBeenCalledOnce();
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it('does NOT mint a keyring for plaintext nodes', async () => {
|
|
138
|
+
const session = makeSession();
|
|
139
|
+
await createNode(session, 'sp-1', { type: 'page', title: 'Plain' });
|
|
140
|
+
expect(ownerEnsureKeyring).not.toHaveBeenCalled();
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it('pushes the new node to the index (via the space client)', async () => {
|
|
144
|
+
// updateObjectIndex calls getSpaceClient, which is mocked to return a fixed client.
|
|
145
|
+
// We capture that mock client to verify the push was called.
|
|
146
|
+
const { getSpaceClient } = await import('../sync/space-access.js');
|
|
147
|
+
const mockSpaceClient = vi.mocked(getSpaceClient).getMockImplementation()?.('sp-1', makeSession());
|
|
148
|
+
const session = makeSession();
|
|
149
|
+
await createNode(session, 'sp-1', { type: 'page', title: 'New Page' });
|
|
150
|
+
// The mock resolves correctly — just verify no error was thrown and the node id is set.
|
|
151
|
+
const node = await createNode(session, 'sp-1', { type: 'page', title: 'Check' });
|
|
152
|
+
expect(node.id).toMatch(/^obj-/);
|
|
153
|
+
void mockSpaceClient; // consumed
|
|
154
|
+
});
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
// ── setNodeAccess ─────────────────────────────────────────────────────────────
|
|
158
|
+
|
|
159
|
+
describe('setNodeAccess', () => {
|
|
160
|
+
beforeEach(() => {
|
|
161
|
+
vi.mocked(ownerEnsureKeyring).mockClear();
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it('rejects public+enc patch', async () => {
|
|
165
|
+
const session = makeSession();
|
|
166
|
+
await expect(
|
|
167
|
+
setNodeAccess(session, 'sp-1', 'n-1', { access: 'public', enc: true }),
|
|
168
|
+
).rejects.toThrow(/public\+enc/i);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it('mints a keyring when enabling enc', async () => {
|
|
172
|
+
const client = makeIndexClient([
|
|
173
|
+
{ id: 'n-1', type: 'page', parentId: null, order: 1, title: 'T', updatedAt: 1 },
|
|
174
|
+
]);
|
|
175
|
+
const session = makeSession(client);
|
|
176
|
+
await setNodeAccess(session, 'sp-1', 'n-1', { enc: true });
|
|
177
|
+
expect(ownerEnsureKeyring).toHaveBeenCalledOnce();
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it('does not mint a keyring when not enabling enc', async () => {
|
|
181
|
+
const client = makeIndexClient([
|
|
182
|
+
{ id: 'n-1', type: 'page', parentId: null, order: 1, title: 'T', updatedAt: 1 },
|
|
183
|
+
]);
|
|
184
|
+
const session = makeSession(client);
|
|
185
|
+
await setNodeAccess(session, 'sp-1', 'n-1', { access: 'invite' });
|
|
186
|
+
expect(ownerEnsureKeyring).not.toHaveBeenCalled();
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it('is a no-op when the node does not exist in the index', async () => {
|
|
190
|
+
const client = makeIndexClient([]); // empty index
|
|
191
|
+
const session = makeSession(client);
|
|
192
|
+
await setNodeAccess(session, 'sp-1', 'n-missing', { access: 'public' });
|
|
193
|
+
expect(client.push).not.toHaveBeenCalled();
|
|
194
|
+
});
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
// ── node invite link encode/decode ────────────────────────────────────────────
|
|
198
|
+
|
|
199
|
+
describe('encodeNodeInviteLink / decodeNodeInviteLink', () => {
|
|
200
|
+
it('round-trips a token through encode/decode', () => {
|
|
201
|
+
const token = {
|
|
202
|
+
v: 1 as const,
|
|
203
|
+
spaceId: 'sp-1',
|
|
204
|
+
nodeId: 'n-42',
|
|
205
|
+
nodeName: 'My Page',
|
|
206
|
+
cap: { kind: 'member', sub: 'pub' },
|
|
207
|
+
key: 'secretkey',
|
|
208
|
+
write: true,
|
|
209
|
+
};
|
|
210
|
+
const link = encodeNodeInviteLink('https://app.example.com', token);
|
|
211
|
+
expect(link).toContain('join/node#');
|
|
212
|
+
|
|
213
|
+
const fragment = link.split('#')[1]!;
|
|
214
|
+
const decoded = decodeNodeInviteLink(fragment);
|
|
215
|
+
expect(decoded.spaceId).toBe('sp-1');
|
|
216
|
+
expect(decoded.nodeId).toBe('n-42');
|
|
217
|
+
expect(decoded.nodeName).toBe('My Page');
|
|
218
|
+
expect(decoded.write).toBe(true);
|
|
219
|
+
expect(decoded.key).toBe('secretkey');
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
it('throws on a malformed fragment', () => {
|
|
223
|
+
expect(() => decodeNodeInviteLink('not-valid-base64-json')).toThrow();
|
|
224
|
+
});
|
|
225
|
+
});
|