@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.
- package/dist/index.d.ts +972 -0
- package/dist/index.js +1656 -0
- package/dist/index.js.map +1 -0
- package/dist/platform/index.d.ts +9 -0
- package/dist/platform/index.js +111 -0
- package/dist/platform/index.js.map +1 -0
- package/dist/platform/index.native.d.ts +9 -0
- package/dist/platform/index.native.js +106 -0
- package/dist/platform/index.native.js.map +1 -0
- package/package.json +50 -0
- package/src/core/adapters.ts +34 -0
- package/src/core/config.ts +87 -0
- package/src/core/ids.test.ts +45 -0
- package/src/core/ids.ts +29 -0
- package/src/core/space-access-error.ts +13 -0
- package/src/core/storage-types.ts +71 -0
- package/src/core/types.ts +162 -0
- package/src/index.ts +221 -0
- package/src/objects/objects.test.ts +288 -0
- package/src/objects/objects.ts +296 -0
- package/src/platform/index.native.ts +3 -0
- package/src/platform/index.ts +3 -0
- package/src/platform/kv.native.ts +23 -0
- package/src/platform/kv.ts +29 -0
- package/src/platform/platform.native.ts +16 -0
- package/src/platform/platform.ts +10 -0
- package/src/spaces/members.test.ts +87 -0
- package/src/spaces/members.ts +271 -0
- package/src/spaces/object-index.test.ts +105 -0
- package/src/spaces/object-index.ts +160 -0
- package/src/spaces/registry.test.ts +111 -0
- package/src/spaces/registry.ts +466 -0
- package/src/sync/account-seal.test.ts +70 -0
- package/src/sync/account-seal.ts +80 -0
- package/src/sync/base64.ts +89 -0
- package/src/sync/base64url.ts +22 -0
- package/src/sync/client.ts +301 -0
- package/src/sync/fetch-timeout.test.ts +26 -0
- package/src/sync/fetch-timeout.ts +23 -0
- package/src/sync/identity.ts +158 -0
- package/src/sync/pairing.ts +103 -0
- package/src/sync/paths.test.ts +135 -0
- package/src/sync/paths.ts +177 -0
- package/src/sync/profile-cache.ts +34 -0
- package/src/sync/pull-cache.test.ts +55 -0
- package/src/sync/pull-cache.ts +33 -0
- package/src/sync/space-access-store.test.ts +129 -0
- package/src/sync/space-access-store.ts +117 -0
- package/src/sync/space-access.ts +136 -0
- package/tsconfig.json +17 -0
- package/tsup.config.ts +40 -0
- package/vitest.config.ts +12 -0
|
@@ -0,0 +1,466 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Space + room registries (plaintext metadata docs). A user's spaces live at
|
|
3
|
+
* `user/<userId>/_spaces`; each space's ACCESS RECORD (owner/members + shared
|
|
4
|
+
* name/image) at `spaces/<spaceId>/_rooms`. The room/category LIST no longer lives
|
|
5
|
+
* here — it moved to the encrypted unified object index (`objects/_index`, see
|
|
6
|
+
* `object-index.ts`); `_rooms` is now just the owner-only access record.
|
|
7
|
+
*/
|
|
8
|
+
import { ConflictError, StarfishHttpError } from '@drakkar.software/starfish-client';
|
|
9
|
+
import type { StarfishClient } from '@drakkar.software/starfish-client';
|
|
10
|
+
|
|
11
|
+
import type { ArchivedDms, CapMap, DmMap, MutePrefs, PubAccessMap, ReadPrefs, Room, Space, SpaceVisibility } from '../core/types.js';
|
|
12
|
+
import type { SealedBlob } from '../sync/account-seal.js';
|
|
13
|
+
import { randomId } from '../core/ids.js';
|
|
14
|
+
import type { Session } from '../sync/identity.js';
|
|
15
|
+
import { DEFAULT_CATEGORY } from '../objects/objects.js';
|
|
16
|
+
import { seedSpaceObjectIndex } from './object-index.js';
|
|
17
|
+
import { roomsRegistryPull, roomsRegistryPush, spacesPull, spacesPush } from '../sync/paths.js';
|
|
18
|
+
|
|
19
|
+
// Re-export so existing `import { DEFAULT_CATEGORY } from './registry'` consumers keep working.
|
|
20
|
+
export { DEFAULT_CATEGORY };
|
|
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. */
|
|
24
|
+
export interface SpaceMeta {
|
|
25
|
+
name?: string | null;
|
|
26
|
+
image?: string | null;
|
|
27
|
+
visibility?: SpaceVisibility;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** A resolved name/image update fanned out so the SpacesProvider adopts a
|
|
31
|
+
* freshly-reconciled value without waiting for its next navigation refresh. */
|
|
32
|
+
export interface SpaceMetaUpdate {
|
|
33
|
+
name: string;
|
|
34
|
+
short: string;
|
|
35
|
+
image?: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const spaceMetaListeners = new Set<(spaceId: string, meta: SpaceMetaUpdate) => void>();
|
|
39
|
+
|
|
40
|
+
export function onSpaceMeta(fn: (spaceId: string, meta: SpaceMetaUpdate) => void): () => void {
|
|
41
|
+
spaceMetaListeners.add(fn);
|
|
42
|
+
return () => { spaceMetaListeners.delete(fn); };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function broadcastSpaceMeta(spaceId: string, meta: SpaceMetaUpdate): void {
|
|
46
|
+
for (const fn of spaceMetaListeners) fn(spaceId, meta);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
interface SpacesDoc {
|
|
50
|
+
spaces: Space[];
|
|
51
|
+
caps: CapMap;
|
|
52
|
+
mutes: MutePrefs;
|
|
53
|
+
reads: ReadPrefs;
|
|
54
|
+
pubAccess: PubAccessMap;
|
|
55
|
+
dms: DmMap;
|
|
56
|
+
quickReactions: string[];
|
|
57
|
+
archivedDms: ArchivedDms;
|
|
58
|
+
hash: string | null;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function coerceDms(raw: unknown): DmMap {
|
|
62
|
+
const src = raw && typeof raw === 'object' ? (raw as Record<string, unknown>) : {};
|
|
63
|
+
const out: DmMap = {};
|
|
64
|
+
for (const [k, v] of Object.entries(src)) if (typeof v === 'string') out[k] = v;
|
|
65
|
+
return out;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function coerceMutes(raw: unknown): MutePrefs {
|
|
69
|
+
const r = (raw && typeof raw === 'object' ? raw : {}) as { rooms?: unknown; spaces?: unknown };
|
|
70
|
+
const pick = (v: unknown): Record<string, true | number> =>
|
|
71
|
+
v && typeof v === 'object' ? (v as Record<string, true | number>) : {};
|
|
72
|
+
return { rooms: pick(r.rooms), spaces: pick(r.spaces) };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function coerceReads(raw: unknown): ReadPrefs {
|
|
76
|
+
const r = (raw && typeof raw === 'object' ? raw : {}) as { rooms?: unknown };
|
|
77
|
+
const src = r.rooms && typeof r.rooms === 'object' ? (r.rooms as Record<string, unknown>) : {};
|
|
78
|
+
const rooms: Record<string, number> = {};
|
|
79
|
+
for (const [id, v] of Object.entries(src)) if (typeof v === 'number' && Number.isFinite(v)) rooms[id] = v;
|
|
80
|
+
return { rooms };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function coerceQuickReactions(raw: unknown): string[] {
|
|
84
|
+
return Array.isArray(raw) ? raw.filter((v): v is string => typeof v === 'string') : [];
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function coerceArchivedDms(raw: unknown): ArchivedDms {
|
|
88
|
+
const src = raw && typeof raw === 'object' ? (raw as Record<string, unknown>) : {};
|
|
89
|
+
const out: ArchivedDms = {};
|
|
90
|
+
for (const [k, v] of Object.entries(src)) if (v === true) out[k] = true;
|
|
91
|
+
return out;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async function pullSpacesDoc(client: StarfishClient, userId: string): Promise<SpacesDoc> {
|
|
95
|
+
const res = await client.pull(spacesPull(userId)).catch((err: unknown) => {
|
|
96
|
+
if (err instanceof StarfishHttpError && err.status === 404) return null;
|
|
97
|
+
throw err;
|
|
98
|
+
});
|
|
99
|
+
const data = res?.data as
|
|
100
|
+
| {
|
|
101
|
+
spaces?: Space[];
|
|
102
|
+
caps?: CapMap;
|
|
103
|
+
mutes?: unknown;
|
|
104
|
+
reads?: unknown;
|
|
105
|
+
pubAccess?: PubAccessMap;
|
|
106
|
+
dms?: unknown;
|
|
107
|
+
quickReactions?: unknown;
|
|
108
|
+
archivedDms?: unknown;
|
|
109
|
+
}
|
|
110
|
+
| undefined;
|
|
111
|
+
return {
|
|
112
|
+
spaces: Array.isArray(data?.spaces) ? data!.spaces! : [],
|
|
113
|
+
caps: data?.caps && typeof data.caps === 'object' ? data.caps : {},
|
|
114
|
+
mutes: coerceMutes(data?.mutes),
|
|
115
|
+
reads: coerceReads(data?.reads),
|
|
116
|
+
pubAccess: data?.pubAccess && typeof data.pubAccess === 'object' ? data.pubAccess : {},
|
|
117
|
+
dms: coerceDms(data?.dms),
|
|
118
|
+
quickReactions: coerceQuickReactions(data?.quickReactions),
|
|
119
|
+
archivedDms: coerceArchivedDms(data?.archivedDms),
|
|
120
|
+
hash: res?.hash ?? null,
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export async function readSpaces(client: StarfishClient, userId: string): Promise<SpacesDoc> {
|
|
125
|
+
try {
|
|
126
|
+
return await pullSpacesDoc(client, userId);
|
|
127
|
+
} catch (err) {
|
|
128
|
+
console.error('[readSpaces] failed to pull spaces registry', err);
|
|
129
|
+
return {
|
|
130
|
+
spaces: [],
|
|
131
|
+
caps: {},
|
|
132
|
+
mutes: coerceMutes(undefined),
|
|
133
|
+
reads: coerceReads(undefined),
|
|
134
|
+
pubAccess: {},
|
|
135
|
+
dms: {},
|
|
136
|
+
quickReactions: [],
|
|
137
|
+
archivedDms: {},
|
|
138
|
+
hash: null,
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export async function updateSpacesDoc(
|
|
144
|
+
client: StarfishClient,
|
|
145
|
+
userId: string,
|
|
146
|
+
mutator: (cur: { spaces: Space[]; caps: CapMap; pubAccess: PubAccessMap }) => { spaces: Space[]; caps: CapMap; pubAccess: PubAccessMap },
|
|
147
|
+
): Promise<void> {
|
|
148
|
+
const MAX_ATTEMPTS = 3;
|
|
149
|
+
for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) {
|
|
150
|
+
const { spaces, caps, mutes, reads, pubAccess, dms, quickReactions, archivedDms, hash } = await pullSpacesDoc(client, userId);
|
|
151
|
+
const cur = { spaces, caps, pubAccess };
|
|
152
|
+
const next = mutator(cur);
|
|
153
|
+
if (next === cur) return;
|
|
154
|
+
try {
|
|
155
|
+
await client.push(
|
|
156
|
+
spacesPush(userId),
|
|
157
|
+
{ v: 1, spaces: next.spaces, caps: next.caps, mutes, reads, pubAccess: next.pubAccess, dms, quickReactions, archivedDms },
|
|
158
|
+
hash,
|
|
159
|
+
);
|
|
160
|
+
return;
|
|
161
|
+
} catch (err) {
|
|
162
|
+
if (err instanceof ConflictError && attempt < MAX_ATTEMPTS - 1) continue;
|
|
163
|
+
throw err;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export async function updateMutesDoc(
|
|
169
|
+
client: StarfishClient,
|
|
170
|
+
userId: string,
|
|
171
|
+
mutator: (cur: MutePrefs) => MutePrefs | null,
|
|
172
|
+
): Promise<void> {
|
|
173
|
+
const MAX_ATTEMPTS = 3;
|
|
174
|
+
for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) {
|
|
175
|
+
const { spaces, caps, mutes, reads, pubAccess, dms, quickReactions, archivedDms, hash } = await pullSpacesDoc(client, userId);
|
|
176
|
+
const next = mutator(mutes);
|
|
177
|
+
if (!next) return;
|
|
178
|
+
try {
|
|
179
|
+
await client.push(spacesPush(userId), { v: 1, spaces, caps, mutes: next, reads, pubAccess, dms, quickReactions, archivedDms }, hash);
|
|
180
|
+
return;
|
|
181
|
+
} catch (err) {
|
|
182
|
+
if (err instanceof ConflictError && attempt < MAX_ATTEMPTS - 1) continue;
|
|
183
|
+
throw err;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
export async function updateReadsDoc(
|
|
189
|
+
client: StarfishClient,
|
|
190
|
+
userId: string,
|
|
191
|
+
mutator: (cur: ReadPrefs) => ReadPrefs | null,
|
|
192
|
+
): Promise<void> {
|
|
193
|
+
const MAX_ATTEMPTS = 3;
|
|
194
|
+
for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) {
|
|
195
|
+
const { spaces, caps, mutes, reads, pubAccess, dms, quickReactions, archivedDms, hash } = await pullSpacesDoc(client, userId);
|
|
196
|
+
const next = mutator(reads);
|
|
197
|
+
if (!next) return;
|
|
198
|
+
try {
|
|
199
|
+
await client.push(spacesPush(userId), { v: 1, spaces, caps, mutes, reads: next, pubAccess, dms, quickReactions, archivedDms }, hash);
|
|
200
|
+
return;
|
|
201
|
+
} catch (err) {
|
|
202
|
+
if (err instanceof ConflictError && attempt < MAX_ATTEMPTS - 1) continue;
|
|
203
|
+
throw err;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
export async function updateDmsDoc(
|
|
209
|
+
client: StarfishClient,
|
|
210
|
+
userId: string,
|
|
211
|
+
mutator: (cur: DmMap) => DmMap | null,
|
|
212
|
+
): Promise<void> {
|
|
213
|
+
const MAX_ATTEMPTS = 3;
|
|
214
|
+
for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) {
|
|
215
|
+
const { spaces, caps, mutes, reads, pubAccess, dms, quickReactions, archivedDms, hash } = await pullSpacesDoc(client, userId);
|
|
216
|
+
const next = mutator(dms);
|
|
217
|
+
if (!next) return;
|
|
218
|
+
try {
|
|
219
|
+
await client.push(spacesPush(userId), { v: 1, spaces, caps, mutes, reads, pubAccess, dms: next, quickReactions, archivedDms }, hash);
|
|
220
|
+
return;
|
|
221
|
+
} catch (err) {
|
|
222
|
+
if (err instanceof ConflictError && attempt < MAX_ATTEMPTS - 1) continue;
|
|
223
|
+
throw err;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
export async function updateQuickReactionsDoc(
|
|
229
|
+
client: StarfishClient,
|
|
230
|
+
userId: string,
|
|
231
|
+
mutator: (cur: string[]) => string[] | null,
|
|
232
|
+
): Promise<void> {
|
|
233
|
+
const MAX_ATTEMPTS = 3;
|
|
234
|
+
for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) {
|
|
235
|
+
const { spaces, caps, mutes, reads, pubAccess, dms, quickReactions, archivedDms, hash } = await pullSpacesDoc(client, userId);
|
|
236
|
+
const next = mutator(quickReactions);
|
|
237
|
+
if (!next) return;
|
|
238
|
+
try {
|
|
239
|
+
await client.push(spacesPush(userId), { v: 1, spaces, caps, mutes, reads, pubAccess, dms, quickReactions: next, archivedDms }, hash);
|
|
240
|
+
return;
|
|
241
|
+
} catch (err) {
|
|
242
|
+
if (err instanceof ConflictError && attempt < MAX_ATTEMPTS - 1) continue;
|
|
243
|
+
throw err;
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
export async function updateArchivedDmsDoc(
|
|
249
|
+
client: StarfishClient,
|
|
250
|
+
userId: string,
|
|
251
|
+
mutator: (cur: ArchivedDms) => ArchivedDms | null,
|
|
252
|
+
): Promise<void> {
|
|
253
|
+
const MAX_ATTEMPTS = 3;
|
|
254
|
+
for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) {
|
|
255
|
+
const { spaces, caps, mutes, reads, pubAccess, dms, quickReactions, archivedDms, hash } = await pullSpacesDoc(client, userId);
|
|
256
|
+
const next = mutator(archivedDms);
|
|
257
|
+
if (!next) return;
|
|
258
|
+
try {
|
|
259
|
+
await client.push(spacesPush(userId), { v: 1, spaces, caps, mutes, reads, pubAccess, dms, quickReactions, archivedDms: next }, hash);
|
|
260
|
+
return;
|
|
261
|
+
} catch (err) {
|
|
262
|
+
if (err instanceof ConflictError && attempt < MAX_ATTEMPTS - 1) continue;
|
|
263
|
+
throw err;
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
export async function setDmMapping(
|
|
269
|
+
client: StarfishClient,
|
|
270
|
+
userId: string,
|
|
271
|
+
peerUserId: string,
|
|
272
|
+
spaceId: string,
|
|
273
|
+
): Promise<void> {
|
|
274
|
+
await updateDmsDoc(client, userId, (cur) => (cur[peerUserId] === spaceId ? null : { ...cur, [peerUserId]: spaceId }));
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
export async function writeSpaces(
|
|
278
|
+
client: StarfishClient,
|
|
279
|
+
userId: string,
|
|
280
|
+
spaces: Space[],
|
|
281
|
+
_hash: string | null,
|
|
282
|
+
): Promise<void> {
|
|
283
|
+
await updateSpacesDoc(client, userId, (cur) => ({ spaces, caps: cur.caps, pubAccess: cur.pubAccess }));
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
export async function reorderSpaces(client: StarfishClient, userId: string, order: string[]): Promise<void> {
|
|
287
|
+
await updateSpacesDoc(client, userId, (cur) => {
|
|
288
|
+
const byId = new Map(cur.spaces.map((s) => [s.id, s]));
|
|
289
|
+
const next: Space[] = [];
|
|
290
|
+
for (const id of order) {
|
|
291
|
+
const s = byId.get(id);
|
|
292
|
+
if (s) { next.push(s); byId.delete(id); }
|
|
293
|
+
}
|
|
294
|
+
for (const s of cur.spaces) if (byId.has(s.id)) next.push(s);
|
|
295
|
+
const unchanged = next.length === cur.spaces.length && next.every((s, i) => s === cur.spaces[i]);
|
|
296
|
+
if (unchanged) return cur;
|
|
297
|
+
return { spaces: next, caps: cur.caps, pubAccess: cur.pubAccess };
|
|
298
|
+
});
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function newSpaceId(): string {
|
|
302
|
+
return `sp-${randomId()}`;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
export function normalizeCategories(rooms: Room[], stored: unknown): string[] {
|
|
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(
|
|
316
|
+
client: StarfishClient,
|
|
317
|
+
spaceId: string,
|
|
318
|
+
): Promise<{ owner: string | null; members: string[]; visibility: SpaceVisibility | null; name: string | null; image: string | null; hash: string | null }> {
|
|
319
|
+
const res = await client.pull(roomsRegistryPull(spaceId)).catch((err: unknown) => {
|
|
320
|
+
if (err instanceof StarfishHttpError && err.status === 404) return null;
|
|
321
|
+
throw err;
|
|
322
|
+
});
|
|
323
|
+
const data = res?.data as { owner?: string; members?: unknown[]; visibility?: string; name?: string; image?: string } | undefined;
|
|
324
|
+
return {
|
|
325
|
+
owner: typeof data?.owner === 'string' ? data.owner : null,
|
|
326
|
+
members: Array.isArray(data?.members) ? data!.members!.filter((m): m is string => typeof m === 'string') : [],
|
|
327
|
+
visibility: data?.visibility === 'public' ? 'public' : null,
|
|
328
|
+
name: typeof data?.name === 'string' ? data.name : null,
|
|
329
|
+
image: typeof data?.image === 'string' ? data.image : null,
|
|
330
|
+
hash: res?.hash ?? null,
|
|
331
|
+
};
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
export async function writeRooms(
|
|
335
|
+
client: StarfishClient,
|
|
336
|
+
spaceId: string,
|
|
337
|
+
owner: string,
|
|
338
|
+
members: string[],
|
|
339
|
+
hash: string | null,
|
|
340
|
+
meta?: SpaceMeta,
|
|
341
|
+
): Promise<void> {
|
|
342
|
+
const name = meta?.name?.trim() || undefined;
|
|
343
|
+
const image = meta?.image || undefined;
|
|
344
|
+
const visibility = meta?.visibility === 'public' ? 'public' : undefined;
|
|
345
|
+
await client.push(
|
|
346
|
+
roomsRegistryPush(spaceId),
|
|
347
|
+
{ v: 1, owner, members, ...(visibility ? { visibility } : {}), ...(name ? { name } : {}), ...(image ? { image } : {}) },
|
|
348
|
+
hash,
|
|
349
|
+
);
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
export async function addSpaceMember(
|
|
353
|
+
client: StarfishClient,
|
|
354
|
+
spaceId: string,
|
|
355
|
+
ownerUserId: string,
|
|
356
|
+
memberUserId: string,
|
|
357
|
+
): Promise<void> {
|
|
358
|
+
const { owner, members, visibility, name, image, hash } = await readRooms(client, spaceId);
|
|
359
|
+
if (memberUserId === (owner ?? ownerUserId) || members.includes(memberUserId)) return;
|
|
360
|
+
await writeRooms(client, spaceId, owner ?? ownerUserId, [...members, memberUserId], hash, { name, image, visibility: visibility ?? undefined });
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/** Remove a member from the space roster (used for link revocation). */
|
|
364
|
+
export async function removeSpaceMember(
|
|
365
|
+
client: StarfishClient,
|
|
366
|
+
spaceId: string,
|
|
367
|
+
memberUserId: string,
|
|
368
|
+
): Promise<void> {
|
|
369
|
+
const { owner, members, visibility, name, image, hash } = await readRooms(client, spaceId);
|
|
370
|
+
if (!members.includes(memberUserId)) return;
|
|
371
|
+
await writeRooms(client, spaceId, owner ?? memberUserId, members.filter((m) => m !== memberUserId), hash, { name, image, visibility: visibility ?? undefined });
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
export async function addJoinedSpace(client: StarfishClient, userId: string, space: Space): Promise<void> {
|
|
375
|
+
await updateSpacesDoc(client, userId, (cur) =>
|
|
376
|
+
cur.spaces.some((s) => s.id === space.id)
|
|
377
|
+
? cur
|
|
378
|
+
: { spaces: [...cur.spaces, space], caps: cur.caps, pubAccess: cur.pubAccess },
|
|
379
|
+
);
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
export async function addJoinedSpaceWithCap(
|
|
383
|
+
client: StarfishClient,
|
|
384
|
+
userId: string,
|
|
385
|
+
space: Space,
|
|
386
|
+
capJson: string,
|
|
387
|
+
): Promise<void> {
|
|
388
|
+
await updateSpacesDoc(client, userId, (cur) => ({
|
|
389
|
+
spaces: cur.spaces.some((s) => s.id === space.id) ? cur.spaces : [...cur.spaces, space],
|
|
390
|
+
caps: { ...cur.caps, [space.id]: capJson },
|
|
391
|
+
pubAccess: cur.pubAccess,
|
|
392
|
+
}));
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
export async function addJoinedSpaceWithLinkAccess(
|
|
396
|
+
client: StarfishClient,
|
|
397
|
+
userId: string,
|
|
398
|
+
space: Space,
|
|
399
|
+
sealed: SealedBlob,
|
|
400
|
+
): Promise<void> {
|
|
401
|
+
await updateSpacesDoc(client, userId, (cur) => ({
|
|
402
|
+
spaces: cur.spaces.some((s) => s.id === space.id) ? cur.spaces : [...cur.spaces, space],
|
|
403
|
+
caps: cur.caps,
|
|
404
|
+
pubAccess: { ...cur.pubAccess, [space.id]: sealed },
|
|
405
|
+
}));
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
/**
|
|
409
|
+
* Create a new space owned by the identity. Seeds ONE generic `general` object node
|
|
410
|
+
* into the object index (encrypted for private, plaintext for public).
|
|
411
|
+
*
|
|
412
|
+
* `opts.visibility` defaults to `'private'`.
|
|
413
|
+
*/
|
|
414
|
+
export async function createSpace(
|
|
415
|
+
session: Session,
|
|
416
|
+
name: string,
|
|
417
|
+
opts?: { visibility?: SpaceVisibility },
|
|
418
|
+
): Promise<Space> {
|
|
419
|
+
const { accountClient, userId } = session;
|
|
420
|
+
const { spaces, hash } = await readSpaces(accountClient, userId);
|
|
421
|
+
const trimmed = name.trim() || 'New Space';
|
|
422
|
+
const visibility = opts?.visibility ?? 'private';
|
|
423
|
+
const id = newSpaceId();
|
|
424
|
+
const space: Space = {
|
|
425
|
+
id,
|
|
426
|
+
name: trimmed,
|
|
427
|
+
short: trimmed.slice(0, 2).toUpperCase(),
|
|
428
|
+
members: 1,
|
|
429
|
+
...(visibility === 'public' ? { visibility: 'public', ownerId: userId, write: true } : {}),
|
|
430
|
+
};
|
|
431
|
+
await writeRooms(accountClient, id, userId, [], null, { name: trimmed, visibility: visibility === 'public' ? 'public' : undefined });
|
|
432
|
+
await seedSpaceObjectIndex(session, id, [{ id: `${id}-general`, name: 'general', kind: 'channel', category: DEFAULT_CATEGORY }], { visibility });
|
|
433
|
+
await writeSpaces(accountClient, userId, [...spaces, space], hash);
|
|
434
|
+
return space;
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
export class CategoryError extends Error {}
|
|
438
|
+
|
|
439
|
+
export async function reconcileSpaceMeta(
|
|
440
|
+
client: StarfishClient,
|
|
441
|
+
userId: string,
|
|
442
|
+
spaceId: string,
|
|
443
|
+
shared: SpaceMeta,
|
|
444
|
+
knownSpaces?: Space[],
|
|
445
|
+
): Promise<void> {
|
|
446
|
+
const sharedName = typeof shared.name === 'string' && shared.name.trim() ? shared.name : null;
|
|
447
|
+
const sharedImage = typeof shared.image === 'string' && shared.image ? shared.image : null;
|
|
448
|
+
if (sharedName === null && sharedImage === null) return;
|
|
449
|
+
const known = knownSpaces?.find((s) => s.id === spaceId);
|
|
450
|
+
if (known) {
|
|
451
|
+
const name = sharedName ?? known.name;
|
|
452
|
+
const short = name.slice(0, 2).toUpperCase();
|
|
453
|
+
const image = sharedImage ?? known.image;
|
|
454
|
+
if (name === known.name && short === known.short && (image ?? null) === (known.image ?? null)) return;
|
|
455
|
+
}
|
|
456
|
+
const { spaces, hash } = await readSpaces(client, userId);
|
|
457
|
+
const cur = spaces.find((s) => s.id === spaceId);
|
|
458
|
+
if (!cur) return;
|
|
459
|
+
const name = sharedName ?? cur.name;
|
|
460
|
+
const image = sharedImage ?? cur.image;
|
|
461
|
+
const short = name.slice(0, 2).toUpperCase();
|
|
462
|
+
if (name === cur.name && short === cur.short && (image ?? null) === (cur.image ?? null)) return;
|
|
463
|
+
const next = spaces.map((s) => (s.id === spaceId ? { ...s, name, short, image } : s));
|
|
464
|
+
await writeSpaces(client, userId, next, hash);
|
|
465
|
+
broadcastSpaceMeta(spaceId, { name, short, image });
|
|
466
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { sealToRecipient, unsealFromRecipient, sealToSelf, unsealFromSelf } from './account-seal.js';
|
|
3
|
+
import type { SealedBlob } from './account-seal.js';
|
|
4
|
+
|
|
5
|
+
// Minimal stubs — use WebCrypto + @noble/curves which are already deps.
|
|
6
|
+
import { ed25519, x25519 } from '@noble/curves/ed25519.js';
|
|
7
|
+
|
|
8
|
+
function randomBytes(n: number): Uint8Array {
|
|
9
|
+
const b = new Uint8Array(n);
|
|
10
|
+
crypto.getRandomValues(b);
|
|
11
|
+
return b;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function toHex(b: Uint8Array): string {
|
|
15
|
+
return Buffer.from(b).toString('hex');
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function makeKeyPair() {
|
|
19
|
+
const edPrivBytes = randomBytes(32);
|
|
20
|
+
const edPriv = toHex(edPrivBytes);
|
|
21
|
+
const edPub = toHex(ed25519.getPublicKey(edPrivBytes));
|
|
22
|
+
const kemPrivBytes = randomBytes(32);
|
|
23
|
+
const kemPriv = toHex(kemPrivBytes);
|
|
24
|
+
const kemPub = toHex(x25519.getPublicKey(kemPrivBytes));
|
|
25
|
+
return { edPriv, edPub, kemPriv, kemPub };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function makeSession(keys: ReturnType<typeof makeKeyPair>) {
|
|
29
|
+
return { userId: 'test-user', keys } as Parameters<typeof sealToSelf>[0];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
describe('account-seal', () => {
|
|
33
|
+
it('sealToSelf returns a SealedBlob with entry and ct', async () => {
|
|
34
|
+
const keys = makeKeyPair();
|
|
35
|
+
const session = makeSession(keys);
|
|
36
|
+
const sealed: SealedBlob = await sealToSelf(session, 'hello');
|
|
37
|
+
expect(sealed).toHaveProperty('entry');
|
|
38
|
+
expect(sealed).toHaveProperty('ct');
|
|
39
|
+
expect(typeof sealed.ct).toBe('string');
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('sealToSelf / unsealFromSelf round-trips a string payload', async () => {
|
|
43
|
+
const keys = makeKeyPair();
|
|
44
|
+
const session = makeSession(keys);
|
|
45
|
+
const payload = JSON.stringify({ test: true, value: 42 });
|
|
46
|
+
const sealed = await sealToSelf(session, payload);
|
|
47
|
+
const recovered = await unsealFromSelf(session, sealed);
|
|
48
|
+
expect(recovered).toBe(payload);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('sealToRecipient returns a SealedBlob', async () => {
|
|
52
|
+
const sender = makeKeyPair();
|
|
53
|
+
const senderSession = makeSession(sender);
|
|
54
|
+
const recipient = makeKeyPair();
|
|
55
|
+
const sealed = await sealToRecipient(senderSession, recipient.kemPub, 'secret');
|
|
56
|
+
expect(sealed).toHaveProperty('entry');
|
|
57
|
+
expect(sealed).toHaveProperty('ct');
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('unsealFromRecipient decrypts what sealToRecipient sealed', async () => {
|
|
61
|
+
const sender = makeKeyPair();
|
|
62
|
+
const senderSession = makeSession(sender);
|
|
63
|
+
const recipient = makeKeyPair();
|
|
64
|
+
const recipientSession = makeSession(recipient);
|
|
65
|
+
const message = 'hello from sender';
|
|
66
|
+
const sealed = await sealToRecipient(senderSession, recipient.kemPub, message);
|
|
67
|
+
const decrypted = await unsealFromRecipient(recipientSession, sealed);
|
|
68
|
+
expect(decrypted).toBe(message);
|
|
69
|
+
});
|
|
70
|
+
});
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Seal a small secret to an X25519 KEM key so it can ride in a plaintext synced
|
|
3
|
+
* doc without exposing it to the server.
|
|
4
|
+
*
|
|
5
|
+
* - {@link sealToSelf}/{@link unsealFromSelf} — sealed to THIS account's own key
|
|
6
|
+
* (public-space join credentials, which embed a bearer secret). Recovered on
|
|
7
|
+
* any device with the same seed.
|
|
8
|
+
* - {@link sealToRecipient}/{@link unsealFromRecipient} — sealed to ANOTHER user's
|
|
9
|
+
* published KEM key (DM-invite delivery).
|
|
10
|
+
*/
|
|
11
|
+
import {
|
|
12
|
+
bytesToHex,
|
|
13
|
+
hexToBytes,
|
|
14
|
+
unwrapFromEntry,
|
|
15
|
+
verifyEntrySignature,
|
|
16
|
+
wrapForRecipient,
|
|
17
|
+
} from '@drakkar.software/starfish-keyring';
|
|
18
|
+
import type { WrappedKeyEntry } from '@drakkar.software/starfish-keyring';
|
|
19
|
+
|
|
20
|
+
import type { Session } from './identity.js';
|
|
21
|
+
|
|
22
|
+
/** A payload sealed to a KEM key: the wrapped CEK + hex(iv ‖ AES-GCM ct). */
|
|
23
|
+
export interface SealedBlob {
|
|
24
|
+
entry: WrappedKeyEntry;
|
|
25
|
+
ct: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const SELF_EPOCH = 0;
|
|
29
|
+
|
|
30
|
+
const subtle = () => globalThis.crypto.subtle;
|
|
31
|
+
|
|
32
|
+
async function seal(session: Session, recipientKemPub: string, plaintext: string): Promise<SealedBlob> {
|
|
33
|
+
const cek = globalThis.crypto.getRandomValues(new Uint8Array(32));
|
|
34
|
+
const entry = await wrapForRecipient(cek, recipientKemPub, {
|
|
35
|
+
adderEdPrivHex: session.keys.edPriv,
|
|
36
|
+
adderEdPubHex: session.keys.edPub,
|
|
37
|
+
addedAt: Math.floor(Date.now() / 1000),
|
|
38
|
+
epoch: SELF_EPOCH,
|
|
39
|
+
});
|
|
40
|
+
const iv = globalThis.crypto.getRandomValues(new Uint8Array(12));
|
|
41
|
+
const key = await subtle().importKey('raw', cek, { name: 'AES-GCM' }, false, ['encrypt']);
|
|
42
|
+
const ctBuf = await subtle().encrypt({ name: 'AES-GCM', iv }, key, new TextEncoder().encode(plaintext));
|
|
43
|
+
const packed = new Uint8Array(iv.length + ctBuf.byteLength);
|
|
44
|
+
packed.set(iv, 0);
|
|
45
|
+
packed.set(new Uint8Array(ctBuf), iv.length);
|
|
46
|
+
return { entry, ct: bytesToHex(packed) };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async function open(session: Session, blob: SealedBlob): Promise<string> {
|
|
50
|
+
const cek = await unwrapFromEntry(blob.entry, session.keys.kemPriv);
|
|
51
|
+
const packed = hexToBytes(blob.ct);
|
|
52
|
+
const iv = new Uint8Array(packed.subarray(0, 12));
|
|
53
|
+
const ctBytes = new Uint8Array(packed.subarray(12));
|
|
54
|
+
const key = await subtle().importKey('raw', new Uint8Array(cek), { name: 'AES-GCM' }, false, ['decrypt']);
|
|
55
|
+
const out = await subtle().decrypt({ name: 'AES-GCM', iv }, key, ctBytes);
|
|
56
|
+
return new TextDecoder().decode(out);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Seal `plaintext` so only this account (its seed) can open it. */
|
|
60
|
+
export function sealToSelf(session: Session, plaintext: string): Promise<SealedBlob> {
|
|
61
|
+
return seal(session, session.keys.kemPub, plaintext);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Open a {@link SealedBlob} sealed by {@link sealToSelf} for this account. */
|
|
65
|
+
export async function unsealFromSelf(session: Session, blob: SealedBlob): Promise<string> {
|
|
66
|
+
if (blob.entry.addedBy !== session.keys.edPub) throw new Error('sealed blob not self-signed');
|
|
67
|
+
if (!(await verifyEntrySignature(blob.entry, SELF_EPOCH))) throw new Error('sealed blob signature invalid');
|
|
68
|
+
return open(session, blob);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Seal `plaintext` to ANOTHER user's published KEM key, signed by this session. */
|
|
72
|
+
export function sealToRecipient(session: Session, recipientKemPub: string, plaintext: string): Promise<SealedBlob> {
|
|
73
|
+
return seal(session, recipientKemPub, plaintext);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Open a {@link SealedBlob} sealed to THIS account by some (arbitrary) sender. */
|
|
77
|
+
export async function unsealFromRecipient(session: Session, blob: SealedBlob): Promise<string> {
|
|
78
|
+
if (!(await verifyEntrySignature(blob.entry, SELF_EPOCH))) throw new Error('sealed blob signature invalid');
|
|
79
|
+
return open(session, blob);
|
|
80
|
+
}
|