@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,296 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unified Object model — pure logic over the space object index.
|
|
3
|
+
*
|
|
4
|
+
* A space's contents (rooms, categories, docs, projects, tasks, …) are
|
|
5
|
+
* {@link ObjectNode}s in one union-merged index doc at
|
|
6
|
+
* `spaces/{spaceId}/objects/_index`. THIS module is the pure, testable core:
|
|
7
|
+
* the tree builder + merge-artifact guards, breadcrumbs, ordering, and the node
|
|
8
|
+
* reducers a `store.set` applies.
|
|
9
|
+
*
|
|
10
|
+
* Because the index is union-merged (per-node last-write-wins keyed on `updatedAt`),
|
|
11
|
+
* the tree is eventually consistent — two devices can concurrently produce a cycle
|
|
12
|
+
* or an orphan. The builder below is the single place those are repaired so every
|
|
13
|
+
* consumer renders a well-formed tree.
|
|
14
|
+
*
|
|
15
|
+
* **Transitional bridges** (`objectsToRoomCategories`, `roomKindToSubtype`, …) project
|
|
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.
|
|
19
|
+
*/
|
|
20
|
+
import type { ID, ObjectNode, ObjectType, Room, RoomSubtype } from '../core/types.js';
|
|
21
|
+
import { randomId, roomSlug } from '../core/ids.js';
|
|
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()}`;
|
|
30
|
+
|
|
31
|
+
/** A node plus its resolved children — the shape a tree view renders. */
|
|
32
|
+
export interface ObjectTreeNode extends ObjectNode {
|
|
33
|
+
depth: number;
|
|
34
|
+
children: ObjectTreeNode[];
|
|
35
|
+
}
|
|
36
|
+
|
|
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
|
+
function compareSiblings(a: ObjectNode, b: ObjectNode): number {
|
|
57
|
+
if (a.order !== b.order) return a.order - b.order;
|
|
58
|
+
return a.id < b.id ? -1 : a.id > b.id ? 1 : 0;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** The order value for a new node appended after `siblings`. */
|
|
62
|
+
export function nextOrder(siblings: ObjectNode[]): number {
|
|
63
|
+
let max = 0;
|
|
64
|
+
for (const s of siblings) if (s.order > max) max = s.order;
|
|
65
|
+
return max + 1;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Build the render tree from a flat node list, repairing merge artifacts:
|
|
70
|
+
* - **archived** nodes (and their subtrees) are dropped.
|
|
71
|
+
* - **orphans** — a `parentId` that is missing or archived — reparent to root.
|
|
72
|
+
* - **cycles** — a node reachable from itself via `parentId` — reparent to root.
|
|
73
|
+
* - **siblings** sort by {@link compareSiblings} for cross-device determinism.
|
|
74
|
+
*/
|
|
75
|
+
export function buildTree(nodes: ObjectNode[], includeArchived = false): ObjectTreeNode[] {
|
|
76
|
+
const live = includeArchived ? nodes : nodes.filter((n) => !n.archived);
|
|
77
|
+
const byId = new Map<ID, ObjectNode>(live.map((n) => [n.id, n]));
|
|
78
|
+
|
|
79
|
+
const effectiveParent = (n: ObjectNode): ID | null => {
|
|
80
|
+
if (n.parentId == null) return null;
|
|
81
|
+
if (!byId.has(n.parentId)) return null;
|
|
82
|
+
const seen = new Set<ID>([n.id]);
|
|
83
|
+
let cur: ID | null = n.parentId;
|
|
84
|
+
while (cur != null) {
|
|
85
|
+
if (seen.has(cur)) return null;
|
|
86
|
+
seen.add(cur);
|
|
87
|
+
const parent = byId.get(cur);
|
|
88
|
+
if (!parent) return null;
|
|
89
|
+
cur = parent.parentId;
|
|
90
|
+
}
|
|
91
|
+
return n.parentId;
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
const childrenOf = new Map<ID | null, ObjectNode[]>();
|
|
95
|
+
for (const n of live) {
|
|
96
|
+
const p = effectiveParent(n);
|
|
97
|
+
const bucket = childrenOf.get(p) ?? [];
|
|
98
|
+
bucket.push(n);
|
|
99
|
+
childrenOf.set(p, bucket);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function attach(parent: ID | null, depth: number): ObjectTreeNode[] {
|
|
103
|
+
return (childrenOf.get(parent) ?? [])
|
|
104
|
+
.slice()
|
|
105
|
+
.sort(compareSiblings)
|
|
106
|
+
.map((n): ObjectTreeNode => ({ ...n, depth, children: attach(n.id, depth + 1) }));
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return attach(null, 0);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** The root→node trail (inclusive) for breadcrumbs. Returns `[]` if unknown. */
|
|
113
|
+
export function breadcrumbs(nodes: ObjectNode[], id: ID): ObjectNode[] {
|
|
114
|
+
const byId = new Map<ID, ObjectNode>(nodes.map((n) => [n.id, n]));
|
|
115
|
+
const trail: ObjectNode[] = [];
|
|
116
|
+
const seen = new Set<ID>();
|
|
117
|
+
let cur: ID | null = id;
|
|
118
|
+
while (cur != null && byId.has(cur) && !seen.has(cur)) {
|
|
119
|
+
seen.add(cur);
|
|
120
|
+
const node: ObjectNode = byId.get(cur)!;
|
|
121
|
+
trail.unshift(node);
|
|
122
|
+
cur = node.parentId;
|
|
123
|
+
}
|
|
124
|
+
return trail;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** The root→parent trail (EXCLUSIVE of the node itself). */
|
|
128
|
+
export function ancestors(nodes: ObjectNode[], id: ID): ObjectNode[] {
|
|
129
|
+
return breadcrumbs(nodes, id).slice(0, -1);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** The ids of a node and its whole subtree (for cascade-archive). */
|
|
133
|
+
export function subtreeIds(nodes: ObjectNode[], rootId: ID): Set<ID> {
|
|
134
|
+
const childrenOf = new Map<ID | null, ID[]>();
|
|
135
|
+
for (const n of nodes) {
|
|
136
|
+
const bucket = childrenOf.get(n.parentId) ?? [];
|
|
137
|
+
bucket.push(n.id);
|
|
138
|
+
childrenOf.set(n.parentId, bucket);
|
|
139
|
+
}
|
|
140
|
+
const out = new Set<ID>();
|
|
141
|
+
const walk = (id: ID) => {
|
|
142
|
+
if (out.has(id)) return;
|
|
143
|
+
out.add(id);
|
|
144
|
+
for (const child of childrenOf.get(id) ?? []) walk(child);
|
|
145
|
+
};
|
|
146
|
+
walk(rootId);
|
|
147
|
+
return out;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// ── Node reducers (pure: ObjectNode[] → ObjectNode[]) ─────────────────────────
|
|
151
|
+
|
|
152
|
+
export interface NewObjectInput {
|
|
153
|
+
type: ObjectType;
|
|
154
|
+
subtype?: RoomSubtype;
|
|
155
|
+
parentId?: ID | null;
|
|
156
|
+
title: string;
|
|
157
|
+
emoji?: string;
|
|
158
|
+
automation?: import('../core/types.js').AutomationMeta;
|
|
159
|
+
/** Provide to reuse an id (e.g. a room id derived elsewhere); else minted. */
|
|
160
|
+
id?: ID;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** Append a new node under `parentId` at the end of its sibling order. */
|
|
164
|
+
export function addObject(nodes: ObjectNode[], input: NewObjectInput, now: number): { nodes: ObjectNode[]; node: ObjectNode } {
|
|
165
|
+
const parentId = input.parentId ?? null;
|
|
166
|
+
const siblings = nodes.filter((n) => n.parentId === parentId);
|
|
167
|
+
const node: ObjectNode = {
|
|
168
|
+
id: input.id ?? `obj-${randomId()}`,
|
|
169
|
+
type: input.type,
|
|
170
|
+
...(input.subtype ? { subtype: input.subtype } : {}),
|
|
171
|
+
parentId,
|
|
172
|
+
order: nextOrder(siblings),
|
|
173
|
+
title: input.title,
|
|
174
|
+
...(input.emoji ? { emoji: input.emoji } : {}),
|
|
175
|
+
updatedAt: now,
|
|
176
|
+
...(input.automation ? { automation: input.automation } : {}),
|
|
177
|
+
};
|
|
178
|
+
return { nodes: [...nodes, node], node };
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** Patch a node's mutable metadata (title/emoji/automation), bumping `updatedAt`. */
|
|
182
|
+
export function patchObject(nodes: ObjectNode[], id: ID, patch: Partial<Pick<ObjectNode, 'title' | 'emoji' | 'automation'>>, now: number): ObjectNode[] {
|
|
183
|
+
return nodes.map((n) => (n.id === id ? { ...n, ...patch, updatedAt: now } : n));
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** Reparent a node (move in the tree). Rejects making a node its own descendant. */
|
|
187
|
+
export function reparentObject(nodes: ObjectNode[], id: ID, parentId: ID | null, now: number): ObjectNode[] {
|
|
188
|
+
if (id === parentId) return nodes;
|
|
189
|
+
if (parentId != null && subtreeIds(nodes, id).has(parentId)) return nodes;
|
|
190
|
+
const siblings = nodes.filter((n) => n.parentId === parentId && n.id !== id);
|
|
191
|
+
return nodes.map((n) => (n.id === id ? { ...n, parentId, order: nextOrder(siblings), updatedAt: now } : n));
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** Set explicit sibling order (drag-reorder). */
|
|
195
|
+
export function reorderObjects(nodes: ObjectNode[], orderById: Record<ID, number>, now: number): ObjectNode[] {
|
|
196
|
+
return nodes.map((n) => (n.id in orderById ? { ...n, order: orderById[n.id]!, updatedAt: now } : n));
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** Cascade-archive a node and its whole subtree (soft delete). */
|
|
200
|
+
export function archiveObject(nodes: ObjectNode[], id: ID, now: number): ObjectNode[] {
|
|
201
|
+
const ids = subtreeIds(nodes, id);
|
|
202
|
+
return nodes.map((n) => (ids.has(n.id) ? { ...n, archived: true, updatedAt: now } : n));
|
|
203
|
+
}
|
|
204
|
+
|
|
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
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/** Native KV adapter — backed by `@react-native-async-storage/async-storage`. */
|
|
2
|
+
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
|
3
|
+
// @ts-ignore — optional peer dep; the native bundle is only loaded on RN targets.
|
|
4
|
+
import AsyncStorage from '@react-native-async-storage/async-storage';
|
|
5
|
+
|
|
6
|
+
import { configureKv } from '../core/adapters.js';
|
|
7
|
+
|
|
8
|
+
export function kvGet(key: string): Promise<string | null> {
|
|
9
|
+
return (AsyncStorage as { getItem(k: string): Promise<string | null> }).getItem(key).catch(() => null);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function kvSet(key: string, value: string): Promise<void> {
|
|
13
|
+
return (AsyncStorage as { setItem(k: string, v: string): Promise<void> }).setItem(key, value).catch(() => {});
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function kvRemove(key: string): Promise<void> {
|
|
17
|
+
return (AsyncStorage as { removeItem(k: string): Promise<void> }).removeItem(key).catch(() => {});
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Call once at app boot (native). Wires `AsyncStorage` into the SDK. */
|
|
21
|
+
export function configureNativeKv(): void {
|
|
22
|
+
configureKv({ get: kvGet, set: kvSet, remove: kvRemove });
|
|
23
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/** Web KV adapter — backed by `localStorage`. */
|
|
2
|
+
import { configureKv } from '../core/adapters.js';
|
|
3
|
+
|
|
4
|
+
export function kvGet(key: string): Promise<string | null> {
|
|
5
|
+
try {
|
|
6
|
+
return Promise.resolve(localStorage.getItem(key));
|
|
7
|
+
} catch {
|
|
8
|
+
return Promise.resolve(null);
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function kvSet(key: string, value: string): Promise<void> {
|
|
13
|
+
try {
|
|
14
|
+
localStorage.setItem(key, value);
|
|
15
|
+
} catch { /* quota exceeded / private mode */ }
|
|
16
|
+
return Promise.resolve();
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function kvRemove(key: string): Promise<void> {
|
|
20
|
+
try {
|
|
21
|
+
localStorage.removeItem(key);
|
|
22
|
+
} catch { /* noop */ }
|
|
23
|
+
return Promise.resolve();
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Call once at app boot (web). Wires `localStorage` into the SDK. */
|
|
27
|
+
export function configureWebKv(): void {
|
|
28
|
+
configureKv({ get: kvGet, set: kvSet, remove: kvRemove });
|
|
29
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Native crypto setup. Installs react-native-quick-crypto to patch
|
|
3
|
+
* `globalThis.crypto` and `globalThis.Buffer` before any SDK call.
|
|
4
|
+
* Requires a custom dev build (not Expo Go) + New Architecture.
|
|
5
|
+
*/
|
|
6
|
+
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
|
7
|
+
// @ts-ignore — optional peer dep; only present in native builds
|
|
8
|
+
import { install } from 'react-native-quick-crypto';
|
|
9
|
+
import { configurePlatform } from '@drakkar.software/starfish-protocol';
|
|
10
|
+
import { starfishBase64 } from '../sync/base64.js';
|
|
11
|
+
|
|
12
|
+
install();
|
|
13
|
+
|
|
14
|
+
export function configureStarfishPlatform(): void {
|
|
15
|
+
configurePlatform({ base64: starfishBase64 });
|
|
16
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Web (+ Node) crypto setup. WebCrypto lives on globalThis, so no patching needed;
|
|
3
|
+
* only the chunked base64 provider is registered. Call before any SDK call.
|
|
4
|
+
*/
|
|
5
|
+
import { configurePlatform } from '@drakkar.software/starfish-protocol';
|
|
6
|
+
import { starfishBase64 } from '../sync/base64.js';
|
|
7
|
+
|
|
8
|
+
export function configureStarfishPlatform(): void {
|
|
9
|
+
configurePlatform({ base64: starfishBase64 });
|
|
10
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
decodeSpaceInviteLink,
|
|
4
|
+
encodeSpaceInviteLink,
|
|
5
|
+
acceptSpaceInvite,
|
|
6
|
+
} from './members.js';
|
|
7
|
+
import { toBase64Url } from '../sync/base64url.js';
|
|
8
|
+
import type { SpaceInviteLinkToken } from './members.js';
|
|
9
|
+
|
|
10
|
+
const baseToken: SpaceInviteLinkToken = {
|
|
11
|
+
v: 1,
|
|
12
|
+
spaceId: 'sp-abc123',
|
|
13
|
+
spaceName: 'My Space',
|
|
14
|
+
cap: { kind: 'member', iss: 'deadbeef', sub: 'cafecafe', scope: {} },
|
|
15
|
+
key: 'a1b2c3d4',
|
|
16
|
+
write: true,
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
describe('encodeSpaceInviteLink / decodeSpaceInviteLink', () => {
|
|
20
|
+
it('round-trips a token through encode → decode', () => {
|
|
21
|
+
const link = encodeSpaceInviteLink('https://app.example.com', baseToken);
|
|
22
|
+
expect(link).toContain('#');
|
|
23
|
+
const decoded = decodeSpaceInviteLink(link.split('#')[1]!);
|
|
24
|
+
expect(decoded.spaceId).toBe(baseToken.spaceId);
|
|
25
|
+
expect(decoded.spaceName).toBe(baseToken.spaceName);
|
|
26
|
+
expect(decoded.key).toBe(baseToken.key);
|
|
27
|
+
expect(decoded.write).toBe(true);
|
|
28
|
+
expect(decoded.v).toBe(1);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('decodeSpaceInviteLink accepts a # prefixed fragment', () => {
|
|
32
|
+
const link = encodeSpaceInviteLink('https://app.example.com', baseToken);
|
|
33
|
+
const fragment = '#' + link.split('#')[1]!;
|
|
34
|
+
const decoded = decodeSpaceInviteLink(fragment);
|
|
35
|
+
expect(decoded.spaceId).toBe(baseToken.spaceId);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('write is false when token has write:false', () => {
|
|
39
|
+
const noWrite: SpaceInviteLinkToken = { ...baseToken, write: false };
|
|
40
|
+
const link = encodeSpaceInviteLink('https://app.example.com', noWrite);
|
|
41
|
+
const decoded = decodeSpaceInviteLink(link.split('#')[1]!);
|
|
42
|
+
expect(decoded.write).toBe(false);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('strips trailing slash from origin', () => {
|
|
46
|
+
const link = encodeSpaceInviteLink('https://app.example.com/', baseToken);
|
|
47
|
+
expect(link.startsWith('https://app.example.com/join#')).toBe(true);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('throws on malformed fragment', () => {
|
|
51
|
+
expect(() => decodeSpaceInviteLink('not-base64url!!!')).toThrow();
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('throws when required fields are missing', () => {
|
|
55
|
+
const bad = toBase64Url(JSON.stringify({ v: 1, spaceName: 'x' }));
|
|
56
|
+
expect(() => decodeSpaceInviteLink(bad)).toThrow();
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
describe('acceptSpaceInvite validation', () => {
|
|
61
|
+
it('rejects invite with wrong cap.kind', async () => {
|
|
62
|
+
const inv = JSON.stringify({ spaceId: 'sp-x', cap: { kind: 'device', sub: 'abc', iss: 'def' } });
|
|
63
|
+
await expect(
|
|
64
|
+
acceptSpaceInvite({ keys: { edPub: 'abc' }, accountClient: {} } as never, inv),
|
|
65
|
+
).rejects.toThrow('not a valid space invite');
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('rejects invite with mismatched sub', async () => {
|
|
69
|
+
const inv = JSON.stringify({
|
|
70
|
+
spaceId: 'sp-x',
|
|
71
|
+
cap: { kind: 'member', sub: 'different-pub', iss: 'owner-pub' },
|
|
72
|
+
});
|
|
73
|
+
await expect(
|
|
74
|
+
acceptSpaceInvite({ keys: { edPub: 'my-pub' }, accountClient: {} } as never, inv),
|
|
75
|
+
).rejects.toThrow('different identity');
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('rejects invite missing iss', async () => {
|
|
79
|
+
const inv = JSON.stringify({
|
|
80
|
+
spaceId: 'sp-x',
|
|
81
|
+
cap: { kind: 'member', sub: 'my-pub' },
|
|
82
|
+
});
|
|
83
|
+
await expect(
|
|
84
|
+
acceptSpaceInvite({ keys: { edPub: 'my-pub' }, accountClient: {} } as never, inv),
|
|
85
|
+
).rejects.toThrow('missing its issuer');
|
|
86
|
+
});
|
|
87
|
+
});
|