@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,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A chunked base64 provider for the Starfish platform.
|
|
3
|
+
*
|
|
4
|
+
* The SDK's default web encoder is `btoa(String.fromCharCode(...data))`, which
|
|
5
|
+
* spreads the entire byte array into one call — a multi-megabyte attachment
|
|
6
|
+
* overflows the argument/stack limit and throws "Maximum call stack size exceeded".
|
|
7
|
+
* This provider walks the bytes in fixed windows instead, so it scales to large blobs.
|
|
8
|
+
*
|
|
9
|
+
* Prefers the platform's own `btoa`/`atob` (web) and falls back to a pure
|
|
10
|
+
* implementation where they're absent (Hermes/native).
|
|
11
|
+
*/
|
|
12
|
+
import type { Base64Provider } from '@drakkar.software/starfish-protocol';
|
|
13
|
+
|
|
14
|
+
const CHUNK = 0x6000; // 24 576 bytes — multiple of 3, well under V8's apply limit
|
|
15
|
+
|
|
16
|
+
const ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
|
|
17
|
+
|
|
18
|
+
const REVERSE = (() => {
|
|
19
|
+
const table = new Int16Array(128).fill(-1);
|
|
20
|
+
for (let i = 0; i < ALPHABET.length; i++) table[ALPHABET.charCodeAt(i)] = i;
|
|
21
|
+
return table;
|
|
22
|
+
})();
|
|
23
|
+
|
|
24
|
+
const nativeCodec =
|
|
25
|
+
typeof globalThis !== 'undefined' &&
|
|
26
|
+
typeof globalThis.btoa === 'function' &&
|
|
27
|
+
typeof globalThis.atob === 'function';
|
|
28
|
+
|
|
29
|
+
function encodeViaBtoa(data: Uint8Array): string {
|
|
30
|
+
let binary = '';
|
|
31
|
+
for (let i = 0; i < data.length; i += CHUNK) {
|
|
32
|
+
binary += String.fromCharCode.apply(null, data.subarray(i, i + CHUNK) as unknown as number[]);
|
|
33
|
+
}
|
|
34
|
+
return globalThis.btoa(binary);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function decodeViaAtob(encoded: string): Uint8Array {
|
|
38
|
+
const binary = globalThis.atob(encoded);
|
|
39
|
+
const out = new Uint8Array(binary.length);
|
|
40
|
+
for (let i = 0; i < binary.length; i++) out[i] = binary.charCodeAt(i);
|
|
41
|
+
return out;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function encodePure(data: Uint8Array): string {
|
|
45
|
+
const len = data.length;
|
|
46
|
+
const full = len - (len % 3);
|
|
47
|
+
const parts: string[] = [];
|
|
48
|
+
for (let start = 0; start < full; start += CHUNK) {
|
|
49
|
+
const stop = Math.min(start + CHUNK, full);
|
|
50
|
+
let s = '';
|
|
51
|
+
for (let i = start; i < stop; i += 3) {
|
|
52
|
+
const n = (data[i] << 16) | (data[i + 1] << 8) | data[i + 2];
|
|
53
|
+
s += ALPHABET[(n >> 18) & 63] + ALPHABET[(n >> 12) & 63] + ALPHABET[(n >> 6) & 63] + ALPHABET[n & 63];
|
|
54
|
+
}
|
|
55
|
+
parts.push(s);
|
|
56
|
+
}
|
|
57
|
+
if (len - full === 1) {
|
|
58
|
+
const n = data[full] << 16;
|
|
59
|
+
parts.push(ALPHABET[(n >> 18) & 63] + ALPHABET[(n >> 12) & 63] + '==');
|
|
60
|
+
} else if (len - full === 2) {
|
|
61
|
+
const n = (data[full] << 16) | (data[full + 1] << 8);
|
|
62
|
+
parts.push(ALPHABET[(n >> 18) & 63] + ALPHABET[(n >> 12) & 63] + ALPHABET[(n >> 6) & 63] + '=');
|
|
63
|
+
}
|
|
64
|
+
return parts.join('');
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function decodePure(encoded: string): Uint8Array {
|
|
68
|
+
let validLen = encoded.length;
|
|
69
|
+
while (validLen > 0 && encoded.charCodeAt(validLen - 1) === 61) validLen--;
|
|
70
|
+
const out = new Uint8Array((validLen * 3) >> 2);
|
|
71
|
+
let o = 0, buf = 0, bits = 0;
|
|
72
|
+
for (let i = 0; i < validLen; i++) {
|
|
73
|
+
const code = encoded.charCodeAt(i);
|
|
74
|
+
const v = code < 128 ? REVERSE[code] : -1;
|
|
75
|
+
if (v < 0) continue;
|
|
76
|
+
buf = (buf << 6) | v;
|
|
77
|
+
bits += 6;
|
|
78
|
+
if (bits >= 8) {
|
|
79
|
+
bits -= 8;
|
|
80
|
+
out[o++] = (buf >> bits) & 0xff;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return o === out.length ? out : out.subarray(0, o);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Spread-free, chunked base64 — a drop-in for the SDK's default provider. */
|
|
87
|
+
export const starfishBase64: Base64Provider = nativeCodec
|
|
88
|
+
? { encode: encodeViaBtoa, decode: decodeViaAtob }
|
|
89
|
+
: { encode: encodePure, decode: decodePure };
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* base64url for link fragments (UTF-8 safe, web + native) — the encoding both
|
|
3
|
+
* invitation-link kinds ride in a URL `#fragment`. No padding, `+/` → `-_`.
|
|
4
|
+
*/
|
|
5
|
+
export function toBase64Url(json: string): string {
|
|
6
|
+
const bytes = new TextEncoder().encode(json);
|
|
7
|
+
let bin = '';
|
|
8
|
+
for (const b of bytes) bin += String.fromCharCode(b);
|
|
9
|
+
const b64 = typeof btoa === 'function' ? btoa(bin) : Buffer.from(json, 'utf-8').toString('base64');
|
|
10
|
+
return b64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function fromBase64Url(b64url: string): string {
|
|
14
|
+
const b64 = b64url.replace(/-/g, '+').replace(/_/g, '/');
|
|
15
|
+
if (typeof atob === 'function') {
|
|
16
|
+
const bin = atob(b64);
|
|
17
|
+
const bytes = new Uint8Array(bin.length);
|
|
18
|
+
for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
|
|
19
|
+
return new TextDecoder().decode(bytes);
|
|
20
|
+
}
|
|
21
|
+
return Buffer.from(b64, 'base64').toString('utf-8');
|
|
22
|
+
}
|
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Starfish client construction + space keyring/encryptor helpers.
|
|
3
|
+
*/
|
|
4
|
+
import { StarfishClient } from '@drakkar.software/starfish-client';
|
|
5
|
+
import type { BatchPullEntry, Encryptor, StarfishCapProvider } from '@drakkar.software/starfish-client';
|
|
6
|
+
import { createKeyring, createKeyringEncryptor } from '@drakkar.software/starfish-keyring';
|
|
7
|
+
import type { Keyring } from '@drakkar.software/starfish-keyring';
|
|
8
|
+
import { signRequest, stableStringify } from '@drakkar.software/starfish-protocol';
|
|
9
|
+
import type { SignableMethod } from '@drakkar.software/starfish-protocol';
|
|
10
|
+
|
|
11
|
+
import { getSyncBase, getSyncNamespace, getSyncPrefix, getOnServerReachable } from '../core/config.js';
|
|
12
|
+
import { fetchWithTimeout } from './fetch-timeout.js';
|
|
13
|
+
import { pullCache, PULL_CACHE_MAX_AGE_MS } from './pull-cache.js';
|
|
14
|
+
import { cacheProfile, loadCachedProfile } from './profile-cache.js';
|
|
15
|
+
import { keyringPull, keyringPush, profilePull, profilePush } from './paths.js';
|
|
16
|
+
import { SpaceAccessError } from '../core/space-access-error.js';
|
|
17
|
+
|
|
18
|
+
export interface DeviceKeys {
|
|
19
|
+
edPriv: string;
|
|
20
|
+
edPub: string;
|
|
21
|
+
kemPriv: string;
|
|
22
|
+
kemPub: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function capProviderFor(cap: unknown, devEdPrivHex: string): StarfishCapProvider {
|
|
26
|
+
return {
|
|
27
|
+
async getCap() {
|
|
28
|
+
return { cap: cap as never, devEdPrivHex };
|
|
29
|
+
},
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Build a Starfish client. `namespaceOverride` overrides the configured namespace,
|
|
35
|
+
* enabling the shared-spaces feature (a separate namespace for cross-app registry ops).
|
|
36
|
+
*/
|
|
37
|
+
export function makeClient(cap: unknown, devEdPrivHex: string, namespaceOverride?: string): StarfishClient {
|
|
38
|
+
return new StarfishClient({
|
|
39
|
+
baseUrl: getSyncBase(),
|
|
40
|
+
namespace: namespaceOverride ?? getSyncNamespace(),
|
|
41
|
+
capProvider: capProviderFor(cap, devEdPrivHex),
|
|
42
|
+
fetch: fetchWithTimeout(),
|
|
43
|
+
cache: pullCache(),
|
|
44
|
+
cacheMaxAgeMs: PULL_CACHE_MAX_AGE_MS,
|
|
45
|
+
cacheFallbackStatuses: [429, 500, 502, 503, 504],
|
|
46
|
+
onRevalidated: () => getOnServerReachable()?.(),
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Open a SPACE's decryptor, throwing a descriptive error per failure mode
|
|
52
|
+
* (unreachable server / no keyring yet / not a recipient).
|
|
53
|
+
*
|
|
54
|
+
* A `SpaceAccessError` is a hard access denial; any other thrown error is a
|
|
55
|
+
* transient offline state.
|
|
56
|
+
*/
|
|
57
|
+
export async function openEncryptor(
|
|
58
|
+
client: StarfishClient,
|
|
59
|
+
keys: DeviceKeys,
|
|
60
|
+
spaceId: string,
|
|
61
|
+
trustedAdders: string[],
|
|
62
|
+
): Promise<Encryptor> {
|
|
63
|
+
const res = await client.pull(keyringPull(spaceId)).catch(() => {
|
|
64
|
+
throw new Error('Could not reach the server to fetch space keys.');
|
|
65
|
+
});
|
|
66
|
+
const keyring = res?.data as unknown as Keyring | undefined;
|
|
67
|
+
if (!keyring || !keyring.epochs) {
|
|
68
|
+
throw new SpaceAccessError('This space has no keyring yet — ask the owner to open it first.');
|
|
69
|
+
}
|
|
70
|
+
try {
|
|
71
|
+
const enc = await createKeyringEncryptor(
|
|
72
|
+
keyring,
|
|
73
|
+
{ kemPubHex: keys.kemPub, kemPrivHex: keys.kemPriv },
|
|
74
|
+
{ trustedAdders },
|
|
75
|
+
);
|
|
76
|
+
return enc as unknown as Encryptor;
|
|
77
|
+
} catch {
|
|
78
|
+
throw new SpaceAccessError("You're not a recipient of this space's keyring yet — ask the owner to re-invite.");
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Soft variant of {@link openEncryptor}: returns null instead of throwing. */
|
|
83
|
+
export async function buildEncryptor(
|
|
84
|
+
client: StarfishClient,
|
|
85
|
+
keys: DeviceKeys,
|
|
86
|
+
spaceId: string,
|
|
87
|
+
trustedAdders: string[],
|
|
88
|
+
): Promise<Encryptor | null> {
|
|
89
|
+
try {
|
|
90
|
+
return await openEncryptor(client, keys, spaceId, trustedAdders);
|
|
91
|
+
} catch {
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Owner-side: create the SPACE keyring if missing, return an encryptor.
|
|
98
|
+
*/
|
|
99
|
+
export async function ownerEnsureKeyring(
|
|
100
|
+
client: StarfishClient,
|
|
101
|
+
keys: DeviceKeys,
|
|
102
|
+
spaceId: string,
|
|
103
|
+
trustedAdders: string[] = [keys.edPub],
|
|
104
|
+
): Promise<Encryptor> {
|
|
105
|
+
const krRes = await client.pull(keyringPull(spaceId)).catch(() => null);
|
|
106
|
+
let keyring = krRes?.data as unknown as Keyring | undefined;
|
|
107
|
+
if (!keyring || !keyring.epochs) {
|
|
108
|
+
const created = await createKeyring({ edPrivHex: keys.edPriv, edPubHex: keys.edPub }, [
|
|
109
|
+
{ subKemHex: keys.kemPub },
|
|
110
|
+
]);
|
|
111
|
+
keyring = created.keyring;
|
|
112
|
+
await client.push(keyringPush(spaceId), keyring as unknown as Record<string, unknown>, krRes?.hash ?? null);
|
|
113
|
+
}
|
|
114
|
+
const enc = await createKeyringEncryptor(
|
|
115
|
+
keyring,
|
|
116
|
+
{ kemPubHex: keys.kemPub, kemPrivHex: keys.kemPriv },
|
|
117
|
+
{ trustedAdders },
|
|
118
|
+
);
|
|
119
|
+
return enc as unknown as Encryptor;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** A user's public profile: display pseudo + optional inline avatar + public identity keys. */
|
|
123
|
+
export interface PublicProfile {
|
|
124
|
+
pseudo: string | null;
|
|
125
|
+
avatar: string | null;
|
|
126
|
+
edPub: string | null;
|
|
127
|
+
kemPub: string | null;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Read any user's public profile. */
|
|
131
|
+
export async function readProfile(userId: string): Promise<PublicProfile> {
|
|
132
|
+
try {
|
|
133
|
+
const r = await fetchWithTimeout()(`${getSyncBase()}${getSyncPrefix()}${profilePull(userId)}`);
|
|
134
|
+
if (!r.ok) return { pseudo: null, avatar: null, edPub: null, kemPub: null };
|
|
135
|
+
const body = await r.json();
|
|
136
|
+
const data = body?.data as { pseudo?: unknown; avatar?: unknown; edPub?: unknown; kemPub?: unknown } | undefined;
|
|
137
|
+
const profile: PublicProfile = {
|
|
138
|
+
pseudo: typeof data?.pseudo === 'string' ? data.pseudo : null,
|
|
139
|
+
avatar: typeof data?.avatar === 'string' ? data.avatar : null,
|
|
140
|
+
edPub: typeof data?.edPub === 'string' ? data.edPub : null,
|
|
141
|
+
kemPub: typeof data?.kemPub === 'string' ? data.kemPub : null,
|
|
142
|
+
};
|
|
143
|
+
cacheProfile(userId, profile);
|
|
144
|
+
return profile;
|
|
145
|
+
} catch {
|
|
146
|
+
return (await loadCachedProfile(userId)) ?? { pseudo: null, avatar: null, edPub: null, kemPub: null };
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** Read any user's public profile pseudo. */
|
|
151
|
+
export async function readPseudo(userId: string): Promise<string | null> {
|
|
152
|
+
return (await readProfile(userId)).pseudo;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
let profileBatchClient: StarfishClient | undefined;
|
|
156
|
+
function getProfileBatchClient(): StarfishClient {
|
|
157
|
+
if (!profileBatchClient) {
|
|
158
|
+
profileBatchClient = new StarfishClient({ baseUrl: getSyncBase(), namespace: getSyncNamespace(), fetch: fetchWithTimeout() });
|
|
159
|
+
}
|
|
160
|
+
return profileBatchClient;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const PROFILE_BATCH_CHUNK = 24;
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Read MANY users' public profiles in one /batch/pull round-trip per chunk.
|
|
167
|
+
*/
|
|
168
|
+
export async function readProfiles(ids: string[]): Promise<Map<string, PublicProfile>> {
|
|
169
|
+
const out = new Map<string, PublicProfile>();
|
|
170
|
+
const client = getProfileBatchClient();
|
|
171
|
+
for (let i = 0; i < ids.length; i += PROFILE_BATCH_CHUNK) {
|
|
172
|
+
const chunk = ids.slice(i, i + PROFILE_BATCH_CHUNK);
|
|
173
|
+
let entries: BatchPullEntry[];
|
|
174
|
+
try {
|
|
175
|
+
entries = await client.batchPullMany('profile', chunk.map((id) => ({ identity: id })));
|
|
176
|
+
} catch {
|
|
177
|
+
for (const id of chunk) {
|
|
178
|
+
const cached = await loadCachedProfile(id);
|
|
179
|
+
if (cached) out.set(id, cached);
|
|
180
|
+
}
|
|
181
|
+
continue;
|
|
182
|
+
}
|
|
183
|
+
chunk.forEach((id, j) => {
|
|
184
|
+
const entry = entries[j];
|
|
185
|
+
if (!entry || entry.error) return;
|
|
186
|
+
const data = (entry.data ?? null) as { pseudo?: unknown; avatar?: unknown; edPub?: unknown; kemPub?: unknown } | null;
|
|
187
|
+
const profile: PublicProfile = {
|
|
188
|
+
pseudo: typeof data?.pseudo === 'string' ? data.pseudo : null,
|
|
189
|
+
avatar: typeof data?.avatar === 'string' ? data.avatar : null,
|
|
190
|
+
edPub: typeof data?.edPub === 'string' ? data.edPub : null,
|
|
191
|
+
kemPub: typeof data?.kemPub === 'string' ? data.kemPub : null,
|
|
192
|
+
};
|
|
193
|
+
cacheProfile(id, profile);
|
|
194
|
+
out.set(id, profile);
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
return out;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Merge a patch into the caller's own profile doc.
|
|
202
|
+
*/
|
|
203
|
+
export async function writeProfile(
|
|
204
|
+
client: StarfishClient,
|
|
205
|
+
userId: string,
|
|
206
|
+
patch: { pseudo?: string; avatar?: string | null; edPub?: string; kemPub?: string },
|
|
207
|
+
): Promise<void> {
|
|
208
|
+
const current = await client.pull(profilePull(userId)).catch(() => null);
|
|
209
|
+
const base = (current?.data as Record<string, unknown> | undefined) ?? {};
|
|
210
|
+
const next: Record<string, unknown> = { ...base, ...patch, v: 1 };
|
|
211
|
+
if (next.avatar == null) delete next.avatar;
|
|
212
|
+
await client.push(profilePush(userId), next, current?.hash ?? null);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** Write the caller's own profile pseudo. */
|
|
216
|
+
export async function writePseudo(client: StarfishClient, userId: string, pseudo: string): Promise<void> {
|
|
217
|
+
await writeProfile(client, userId, { pseudo });
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Publish this identity's PUBLIC keys in its profile so a peer can start an E2EE DM.
|
|
222
|
+
* One-time + idempotent. ROOT-DEVICE ONLY — `profile` is `device:root`-write.
|
|
223
|
+
*/
|
|
224
|
+
export async function ensureProfileKeys(
|
|
225
|
+
client: StarfishClient,
|
|
226
|
+
userId: string,
|
|
227
|
+
keys: { edPub: string; kemPub: string },
|
|
228
|
+
): Promise<void> {
|
|
229
|
+
let confirmedAbsent = false;
|
|
230
|
+
try {
|
|
231
|
+
const r = await fetchWithTimeout()(`${getSyncBase()}${getSyncPrefix()}${profilePull(userId)}`);
|
|
232
|
+
if (r.status === 404) confirmedAbsent = true;
|
|
233
|
+
else if (r.ok) {
|
|
234
|
+
const body = await r.json();
|
|
235
|
+
const data = body?.data as { edPub?: unknown; kemPub?: unknown } | undefined;
|
|
236
|
+
confirmedAbsent = !(typeof data?.edPub === 'string' && typeof data?.kemPub === 'string');
|
|
237
|
+
} else return;
|
|
238
|
+
} catch {
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
if (!confirmedAbsent) return;
|
|
242
|
+
await writeProfile(client, userId, { edPub: keys.edPub, kemPub: keys.kemPub });
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Build cap-cert auth headers for a raw `fetch` outside the StarfishClient.
|
|
247
|
+
*/
|
|
248
|
+
export async function buildAuthHeaders(
|
|
249
|
+
cap: unknown,
|
|
250
|
+
devEdPrivHex: string,
|
|
251
|
+
method: string,
|
|
252
|
+
pathAndQuery: string,
|
|
253
|
+
): Promise<Record<string, string>> {
|
|
254
|
+
let host = '';
|
|
255
|
+
try {
|
|
256
|
+
host = new URL(getSyncBase()).host;
|
|
257
|
+
} catch { /* relative base */ }
|
|
258
|
+
|
|
259
|
+
const { sig, ts, nonce } = await signRequest(
|
|
260
|
+
{ method: method as SignableMethod, pathAndQuery, host },
|
|
261
|
+
devEdPrivHex,
|
|
262
|
+
);
|
|
263
|
+
|
|
264
|
+
const capJson = stableStringify(cap as Record<string, unknown>);
|
|
265
|
+
const capB64 =
|
|
266
|
+
typeof btoa === 'function'
|
|
267
|
+
? btoa(capJson)
|
|
268
|
+
: Buffer.from(capJson, 'utf-8').toString('base64');
|
|
269
|
+
|
|
270
|
+
return {
|
|
271
|
+
Authorization: `Cap ${capB64}`,
|
|
272
|
+
'X-Starfish-Sig': sig,
|
|
273
|
+
'X-Starfish-Ts': String(ts),
|
|
274
|
+
'X-Starfish-Nonce': nonce,
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
async function readOwnPseudo(userId: string): Promise<{ read: boolean; pseudo: string | null }> {
|
|
279
|
+
try {
|
|
280
|
+
const r = await fetchWithTimeout()(`${getSyncBase()}${getSyncPrefix()}${profilePull(userId)}`);
|
|
281
|
+
if (r.status === 404) return { read: true, pseudo: null };
|
|
282
|
+
if (!r.ok) return { read: false, pseudo: null };
|
|
283
|
+
const body = await r.json();
|
|
284
|
+
const data = body?.data as { pseudo?: unknown } | undefined;
|
|
285
|
+
return { read: true, pseudo: typeof data?.pseudo === 'string' ? data.pseudo : null };
|
|
286
|
+
} catch {
|
|
287
|
+
return { read: false, pseudo: null };
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* Seed the caller's profile pseudo only if none exists yet, returning the
|
|
293
|
+
* authoritative server value.
|
|
294
|
+
*/
|
|
295
|
+
export async function ensurePseudo(client: StarfishClient, userId: string, fallback: string): Promise<string> {
|
|
296
|
+
const { read, pseudo } = await readOwnPseudo(userId);
|
|
297
|
+
if (pseudo && pseudo.trim()) return pseudo;
|
|
298
|
+
if (!read) return fallback;
|
|
299
|
+
await writeProfile(client, userId, { pseudo: fallback });
|
|
300
|
+
return fallback;
|
|
301
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { CONNECT_TIMEOUT_MS, fetchWithTimeout } from './fetch-timeout.js';
|
|
3
|
+
|
|
4
|
+
describe('CONNECT_TIMEOUT_MS', () => {
|
|
5
|
+
it('is 12 seconds', () => {
|
|
6
|
+
expect(CONNECT_TIMEOUT_MS).toBe(12_000);
|
|
7
|
+
});
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
describe('fetchWithTimeout', () => {
|
|
11
|
+
it('returns a fetch function', () => {
|
|
12
|
+
const fn = fetchWithTimeout();
|
|
13
|
+
expect(typeof fn).toBe('function');
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it('returns a fetch function with custom timeout', () => {
|
|
17
|
+
const fn = fetchWithTimeout(5_000);
|
|
18
|
+
expect(typeof fn).toBe('function');
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it('aborts on timeout', async () => {
|
|
22
|
+
// Use a very short timeout and a never-resolving URL to trigger abort.
|
|
23
|
+
const fn = fetchWithTimeout(10);
|
|
24
|
+
await expect(fn('https://httpbin.org/delay/10')).rejects.toThrow();
|
|
25
|
+
}, 2_000);
|
|
26
|
+
});
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A `fetch` wrapper that bounds the CONNECT/TTFB phase only.
|
|
3
|
+
*
|
|
4
|
+
* Aborts a request that hasn't RESPONDED within {@link CONNECT_TIMEOUT_MS}, turning
|
|
5
|
+
* an opaque infinite spinner into a normal rejection the open path can surface as a
|
|
6
|
+
* retriable error. Clears the timer once response headers arrive, so it bounds ONLY
|
|
7
|
+
* the connect phase — body downloads and long-lived streams stay unbounded.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export const CONNECT_TIMEOUT_MS = 12_000; // generous: trips only on a truly stalled socket
|
|
11
|
+
|
|
12
|
+
export function fetchWithTimeout(timeoutMs = CONNECT_TIMEOUT_MS): typeof fetch {
|
|
13
|
+
return (input, init) => {
|
|
14
|
+
const ctrl = new AbortController();
|
|
15
|
+
const timer = setTimeout(() => ctrl.abort(), timeoutMs);
|
|
16
|
+
const caller = init?.signal;
|
|
17
|
+
if (caller) {
|
|
18
|
+
if (caller.aborted) ctrl.abort();
|
|
19
|
+
else caller.addEventListener('abort', () => ctrl.abort(), { once: true });
|
|
20
|
+
}
|
|
21
|
+
return fetch(input as RequestInfo, { ...init, signal: ctrl.signal }).finally(() => clearTimeout(timer));
|
|
22
|
+
};
|
|
23
|
+
}
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Identity & 12-word recovery seed. The seed is a BIP-39 mnemonic used as the
|
|
3
|
+
* passphrase for Starfish's `bootstrapRootIdentity`; the same words deterministically
|
|
4
|
+
* recover the identity.
|
|
5
|
+
*/
|
|
6
|
+
import { generateMnemonic, validateMnemonic } from '@scure/bip39';
|
|
7
|
+
import { wordlist } from '@scure/bip39/wordlists/english.js';
|
|
8
|
+
import { bootstrapRootIdentity, mintDeviceCap } from '@drakkar.software/starfish-identities';
|
|
9
|
+
import type { StarfishClient } from '@drakkar.software/starfish-client';
|
|
10
|
+
import type { CapCert } from '@drakkar.software/starfish-protocol';
|
|
11
|
+
|
|
12
|
+
import { makeClient, ensureProfileKeys, ensurePseudo, type DeviceKeys } from './client.js';
|
|
13
|
+
import { accountScope, ownerScope } from './paths.js';
|
|
14
|
+
import { getSharedSpacesNamespace } from '../core/config.js';
|
|
15
|
+
import type { DerivedIdentity } from '../core/storage-types.js';
|
|
16
|
+
|
|
17
|
+
export interface Session {
|
|
18
|
+
userId: string;
|
|
19
|
+
name: string;
|
|
20
|
+
keys: DeviceKeys;
|
|
21
|
+
chatCap: unknown;
|
|
22
|
+
accountCap: unknown;
|
|
23
|
+
/**
|
|
24
|
+
* The primary Starfish client for space content (keyring, channels, objects).
|
|
25
|
+
* Uses the app's default namespace.
|
|
26
|
+
*/
|
|
27
|
+
chatClient: StarfishClient;
|
|
28
|
+
/**
|
|
29
|
+
* The Starfish client for account-scoped content (profile, _spaces registry).
|
|
30
|
+
* Uses the app's default namespace.
|
|
31
|
+
*/
|
|
32
|
+
accountClient: StarfishClient;
|
|
33
|
+
/**
|
|
34
|
+
* Starfish client for cross-app shared-spaces registry operations.
|
|
35
|
+
* When `sharedSpacesNamespace` is configured, uses that namespace override so
|
|
36
|
+
* the spaces list lives in a separate namespace shared across multiple apps.
|
|
37
|
+
* Falls back to `accountClient` when no shared namespace is configured.
|
|
38
|
+
*/
|
|
39
|
+
spacesRegistryClient: StarfishClient;
|
|
40
|
+
/**
|
|
41
|
+
* Starfish client for cross-app shared-spaces keyring operations.
|
|
42
|
+
* Same namespace logic as `spacesRegistryClient`, scoped to space content.
|
|
43
|
+
* Falls back to `chatClient` when no shared namespace is configured.
|
|
44
|
+
*/
|
|
45
|
+
spacesKeyringClient: StarfishClient;
|
|
46
|
+
fingerprint: string;
|
|
47
|
+
/**
|
|
48
|
+
* The Ed25519 pubkey that signs this identity's OWNED-space keyring entries —
|
|
49
|
+
* the trusted-adder provenance anchor for opening them.
|
|
50
|
+
*/
|
|
51
|
+
ownerEdPub: string;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Trusted-adder allow-list for opening an OWNED space's keyring.
|
|
56
|
+
*/
|
|
57
|
+
export function ownerTrustedAdders(session: Session): string[] {
|
|
58
|
+
return session.ownerEdPub === session.keys.edPub
|
|
59
|
+
? [session.keys.edPub]
|
|
60
|
+
: [session.ownerEdPub, session.keys.edPub];
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Fresh 12-word recovery seed. */
|
|
64
|
+
export function generateSeedWords(): string[] {
|
|
65
|
+
return generateMnemonic(wordlist, 128).split(' ');
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function isValidSeed(words: string[]): boolean {
|
|
69
|
+
return validateMnemonic(words.join(' ').trim(), wordlist);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Human-readable fingerprint derived from the identity's user id. */
|
|
73
|
+
export function fingerprintFromUserId(userId: string): string {
|
|
74
|
+
const h = userId.replace(/[^0-9a-f]/gi, '').toUpperCase();
|
|
75
|
+
return [h.slice(0, 4), h.slice(4, 8), h.slice(8, 12)].filter(Boolean).join(' · ');
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Build a full owner session (caps + clients + pseudo) from an already-derived
|
|
80
|
+
* root identity. No Argon2id — only fast Ed25519 cap-minting plus a profile fetch.
|
|
81
|
+
*/
|
|
82
|
+
export async function buildSession({ userId, keys }: DerivedIdentity, name?: string): Promise<Session> {
|
|
83
|
+
const fallback = name && name.trim() ? name.trim() : `user-${userId.slice(0, 6)}`;
|
|
84
|
+
const sub = { edPubHex: keys.edPub, kemPubHex: keys.kemPub };
|
|
85
|
+
const chatCap = await mintDeviceCap(keys.edPriv, keys.edPub, sub, ownerScope());
|
|
86
|
+
const accountCap = await mintDeviceCap(keys.edPriv, keys.edPub, sub, accountScope(userId));
|
|
87
|
+
const chatClient = makeClient(chatCap, keys.edPriv);
|
|
88
|
+
const accountClient = makeClient(accountCap, keys.edPriv);
|
|
89
|
+
|
|
90
|
+
const sharedNs = getSharedSpacesNamespace();
|
|
91
|
+
const spacesRegistryClient = sharedNs ? makeClient(accountCap, keys.edPriv, sharedNs) : accountClient;
|
|
92
|
+
const spacesKeyringClient = sharedNs ? makeClient(chatCap, keys.edPriv, sharedNs) : chatClient;
|
|
93
|
+
|
|
94
|
+
const displayName = await ensurePseudo(accountClient, userId, fallback).catch(() => fallback);
|
|
95
|
+
void ensureProfileKeys(accountClient, userId, keys).catch(() => {});
|
|
96
|
+
return {
|
|
97
|
+
userId,
|
|
98
|
+
name: displayName,
|
|
99
|
+
keys,
|
|
100
|
+
chatCap,
|
|
101
|
+
accountCap,
|
|
102
|
+
chatClient,
|
|
103
|
+
accountClient,
|
|
104
|
+
spacesRegistryClient,
|
|
105
|
+
spacesKeyringClient,
|
|
106
|
+
fingerprint: fingerprintFromUserId(userId),
|
|
107
|
+
ownerEdPub: keys.edPub,
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** A paired device's credentials: its own keypair + the root-signed cap-cert. */
|
|
112
|
+
export interface LinkedIdentity {
|
|
113
|
+
userId: string;
|
|
114
|
+
keys: DeviceKeys;
|
|
115
|
+
capCert: CapCert;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Build a session for a PAIRED (linked) device. Unlike {@link buildSession}, the
|
|
120
|
+
* device keypair is NOT the root, so it cannot self-mint caps — both clients are
|
|
121
|
+
* driven by the single root-signed `capCert` from the pairing bundle.
|
|
122
|
+
*/
|
|
123
|
+
export async function buildLinkedSession({ userId, keys, capCert }: LinkedIdentity, name?: string): Promise<Session> {
|
|
124
|
+
const fallback = name && name.trim() ? name.trim() : `user-${userId.slice(0, 6)}`;
|
|
125
|
+
const chatClient = makeClient(capCert, keys.edPriv);
|
|
126
|
+
const accountClient = makeClient(capCert, keys.edPriv);
|
|
127
|
+
|
|
128
|
+
const sharedNs = getSharedSpacesNamespace();
|
|
129
|
+
const spacesRegistryClient = sharedNs ? makeClient(capCert, keys.edPriv, sharedNs) : accountClient;
|
|
130
|
+
const spacesKeyringClient = sharedNs ? makeClient(capCert, keys.edPriv, sharedNs) : chatClient;
|
|
131
|
+
|
|
132
|
+
const displayName = await ensurePseudo(accountClient, userId, fallback).catch(() => fallback);
|
|
133
|
+
return {
|
|
134
|
+
userId,
|
|
135
|
+
name: displayName,
|
|
136
|
+
keys,
|
|
137
|
+
chatCap: capCert,
|
|
138
|
+
accountCap: capCert,
|
|
139
|
+
chatClient,
|
|
140
|
+
accountClient,
|
|
141
|
+
spacesRegistryClient,
|
|
142
|
+
spacesKeyringClient,
|
|
143
|
+
fingerprint: fingerprintFromUserId(userId),
|
|
144
|
+
ownerEdPub: capCert.iss,
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Derive a full owner session (identity + caps + clients) from a seed. */
|
|
149
|
+
export async function deriveSession(seedWords: string[], name?: string): Promise<Session> {
|
|
150
|
+
const passphrase = seedWords.join(' ').trim();
|
|
151
|
+
const creds = await bootstrapRootIdentity(passphrase);
|
|
152
|
+
return buildSession({ userId: creds.userId, keys: creds.device as DeviceKeys }, name);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** The cached root identity (userId + keys) carried by a built session. */
|
|
156
|
+
export function rootIdentityOf(s: Session): DerivedIdentity {
|
|
157
|
+
return { userId: s.userId, keys: s.keys };
|
|
158
|
+
}
|