@muhkoo/connect 0.2.0-alpha.2 → 0.4.0-alpha.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/README.md +73 -6
- package/dist/browser/index.js +844 -282
- package/dist/browser/index.js.map +1 -1
- package/dist/connect.d.ts +661 -121
- package/dist/server/index.js +1456 -894
- package/dist/server/index.js.map +1 -1
- package/dist/workers/index.js +138 -4
- package/dist/workers/index.js.map +1 -1
- package/package.json +1 -1
package/dist/server/index.js
CHANGED
|
@@ -2562,7 +2562,7 @@ async function poseidonHash(inputs) {
|
|
|
2562
2562
|
* worker depending on `VITE_WORKER_URL`.
|
|
2563
2563
|
*/
|
|
2564
2564
|
let _snarkjs$1 = null;
|
|
2565
|
-
async function loadSnarkjs$
|
|
2565
|
+
async function loadSnarkjs$1() {
|
|
2566
2566
|
if (_snarkjs$1)
|
|
2567
2567
|
return _snarkjs$1;
|
|
2568
2568
|
_snarkjs$1 = (await import('snarkjs'));
|
|
@@ -2584,7 +2584,7 @@ function defaultCircuitUrls(baseUrl) {
|
|
|
2584
2584
|
};
|
|
2585
2585
|
}
|
|
2586
2586
|
/** BN254 scalar field modulus — the prime the circuit's signals live in. */
|
|
2587
|
-
const FIELD_SIZE$
|
|
2587
|
+
const FIELD_SIZE$1 = 21888242871839275222246405745257275088548364400416034343698204186575808495617n;
|
|
2588
2588
|
/**
|
|
2589
2589
|
* Reduce any hex/decimal/byte input to a decimal-string field element in
|
|
2590
2590
|
* `[0, FIELD_SIZE)`. snarkjs and the `preimagePoK` circuit both want decimal
|
|
@@ -2625,9 +2625,9 @@ function toField(value) {
|
|
|
2625
2625
|
hex += b.toString(16).padStart(2, "0");
|
|
2626
2626
|
v = BigInt("0x" + hex);
|
|
2627
2627
|
}
|
|
2628
|
-
v = v % FIELD_SIZE$
|
|
2628
|
+
v = v % FIELD_SIZE$1;
|
|
2629
2629
|
if (v < 0n)
|
|
2630
|
-
v += FIELD_SIZE$
|
|
2630
|
+
v += FIELD_SIZE$1;
|
|
2631
2631
|
return v.toString();
|
|
2632
2632
|
}
|
|
2633
2633
|
/**
|
|
@@ -2667,7 +2667,7 @@ async function generateAuthProof(args) {
|
|
|
2667
2667
|
salt: saltField,
|
|
2668
2668
|
ecdsaPub: ecdsaPubField,
|
|
2669
2669
|
};
|
|
2670
|
-
const snarkjs = await loadSnarkjs$
|
|
2670
|
+
const snarkjs = await loadSnarkjs$1();
|
|
2671
2671
|
const { proof, publicSignals } = await snarkjs.groth16.fullProve(circuitInput, args.circuits.wasmUrl, args.circuits.zkeyUrl);
|
|
2672
2672
|
return {
|
|
2673
2673
|
proof: proof,
|
|
@@ -2752,6 +2752,7 @@ class HttpClient {
|
|
|
2752
2752
|
baseUrl;
|
|
2753
2753
|
apiKey;
|
|
2754
2754
|
getSessionToken;
|
|
2755
|
+
onUnauthorized;
|
|
2755
2756
|
fetchFn;
|
|
2756
2757
|
constructor(opts) {
|
|
2757
2758
|
if (!opts?.baseUrl)
|
|
@@ -2759,6 +2760,7 @@ class HttpClient {
|
|
|
2759
2760
|
this.baseUrl = opts.baseUrl.replace(/\/+$/, "");
|
|
2760
2761
|
this.apiKey = opts.apiKey ?? null;
|
|
2761
2762
|
this.getSessionToken = opts.getSessionToken ?? (() => null);
|
|
2763
|
+
this.onUnauthorized = opts.onUnauthorized ?? null;
|
|
2762
2764
|
const f = opts.fetch ?? globalThis.fetch;
|
|
2763
2765
|
if (typeof f !== "function") {
|
|
2764
2766
|
throw new Error("HttpClient: `globalThis.fetch` is unavailable; pass an explicit `fetch`.");
|
|
@@ -2788,10 +2790,13 @@ class HttpClient {
|
|
|
2788
2790
|
post(path, body) {
|
|
2789
2791
|
return this.request("POST", path, body);
|
|
2790
2792
|
}
|
|
2793
|
+
patch(path, body) {
|
|
2794
|
+
return this.request("PATCH", path, body);
|
|
2795
|
+
}
|
|
2791
2796
|
del(path, body) {
|
|
2792
2797
|
return this.request("DELETE", path, body);
|
|
2793
2798
|
}
|
|
2794
|
-
async request(method, path, body) {
|
|
2799
|
+
async request(method, path, body, allowRecovery = true) {
|
|
2795
2800
|
const url = path.startsWith("http") ? path : `${this.baseUrl}${path.startsWith("/") ? "" : "/"}${path}`;
|
|
2796
2801
|
const init = { method };
|
|
2797
2802
|
if (body !== undefined) {
|
|
@@ -2799,6 +2804,15 @@ class HttpClient {
|
|
|
2799
2804
|
init.body = JSON.stringify(body);
|
|
2800
2805
|
}
|
|
2801
2806
|
const res = await this.fetch(url, init);
|
|
2807
|
+
// Stale session: try to silently re-auth and replay the request once.
|
|
2808
|
+
// `allowRecovery` is cleared on the retry so a still-401 response (e.g.
|
|
2809
|
+
// recovery minted a token the endpoint still rejects) can't loop.
|
|
2810
|
+
if (res.status === 401 && allowRecovery && this.onUnauthorized && this.getSessionToken()) {
|
|
2811
|
+
const recovered = await this.onUnauthorized();
|
|
2812
|
+
if (recovered) {
|
|
2813
|
+
return this.request(method, path, body, false);
|
|
2814
|
+
}
|
|
2815
|
+
}
|
|
2802
2816
|
let parsed = null;
|
|
2803
2817
|
try {
|
|
2804
2818
|
parsed = await res.json();
|
|
@@ -3028,6 +3042,44 @@ class ZkAuth {
|
|
|
3028
3042
|
*/
|
|
3029
3043
|
async login(username, password, opts = {}) {
|
|
3030
3044
|
const identity = await deriveIdentity(username, password);
|
|
3045
|
+
return this.proveAndStore(username, identity, opts);
|
|
3046
|
+
}
|
|
3047
|
+
/**
|
|
3048
|
+
* Silently re-authenticate the current user without prompting for a
|
|
3049
|
+
* password. Only possible while the client is **unlocked** — i.e. the
|
|
3050
|
+
* derived {@link ZkIdentity} (secret + keypairs) is still in memory from a
|
|
3051
|
+
* prior `login()`/`unlock()`. Re-runs the full challenge→proof→authenticate
|
|
3052
|
+
* dance with that in-memory identity and swaps in the fresh token.
|
|
3053
|
+
*
|
|
3054
|
+
* Returns `true` if a new session was minted, `false` if recovery isn't
|
|
3055
|
+
* possible (no active user, or locked — the identity was never derived or
|
|
3056
|
+
* was lost on reload). A `false` result is the app's cue to send the user
|
|
3057
|
+
* back to the login screen.
|
|
3058
|
+
*
|
|
3059
|
+
* Used automatically by the {@link Client} when a token-gated request comes
|
|
3060
|
+
* back `401` (stale/expired token), so transient session expiry self-heals
|
|
3061
|
+
* instead of surfacing errors mid-session.
|
|
3062
|
+
*/
|
|
3063
|
+
async recover() {
|
|
3064
|
+
const username = this.deps.session.username;
|
|
3065
|
+
const identity = this.deps.session.identity;
|
|
3066
|
+
if (!username || !identity)
|
|
3067
|
+
return false;
|
|
3068
|
+
try {
|
|
3069
|
+
await this.proveAndStore(username, identity, {});
|
|
3070
|
+
return true;
|
|
3071
|
+
}
|
|
3072
|
+
catch {
|
|
3073
|
+
return false;
|
|
3074
|
+
}
|
|
3075
|
+
}
|
|
3076
|
+
/**
|
|
3077
|
+
* Shared sign-in core: prove knowledge of `identity` against a fresh
|
|
3078
|
+
* challenge and persist the resulting session + identity. Used by both
|
|
3079
|
+
* `login()` (identity derived from a password) and `recover()` (identity
|
|
3080
|
+
* already in memory).
|
|
3081
|
+
*/
|
|
3082
|
+
async proveAndStore(username, identity, opts) {
|
|
3031
3083
|
const ecdsaPubHex = await exportPublicKeyHex(identity.ecdsaKeyPair.publicKey);
|
|
3032
3084
|
const challenge = await this.deps.auth.getChallenge(username);
|
|
3033
3085
|
const { proof, publicSignals, commitment } = await generateAuthProof({
|
|
@@ -3184,144 +3236,6 @@ class StorageCipher {
|
|
|
3184
3236
|
}
|
|
3185
3237
|
}
|
|
3186
3238
|
|
|
3187
|
-
/**
|
|
3188
|
-
* `client.storage` — per-user persistent storage backed by the accelerator's
|
|
3189
|
-
* personal space.
|
|
3190
|
-
*
|
|
3191
|
-
* await client.storage.set('todos', id, { title, completed })
|
|
3192
|
-
* const todo = await client.storage.get('todos', id)
|
|
3193
|
-
* const ids = await client.storage.list('todos')
|
|
3194
|
-
* await client.storage.delete('todos', id)
|
|
3195
|
-
* const off = client.storage.on('change', e => …) // realtime, cross-device
|
|
3196
|
-
*
|
|
3197
|
-
* A *collection* is a key namespace within the user's space; the stored key is
|
|
3198
|
-
* `${collection}/${id}`. Operations are authorized by the user's session token
|
|
3199
|
-
* (stamped on every request by {@link HttpClient}) — no per-op Groth16 proof.
|
|
3200
|
-
*
|
|
3201
|
-
* Values are encrypted at rest by default ({@link StorageCipher}, an AES-GCM
|
|
3202
|
-
* key derived from the ZK identity); pass `{ encrypt: false }` to store
|
|
3203
|
-
* plaintext. The realtime feed rides the personal space's own websocket, so no
|
|
3204
|
-
* separate transport is involved.
|
|
3205
|
-
*
|
|
3206
|
-
* `query()` is intentionally deferred — see the SDK overhaul plan.
|
|
3207
|
-
*/
|
|
3208
|
-
class StorageNamespace {
|
|
3209
|
-
deps;
|
|
3210
|
-
cipher = null;
|
|
3211
|
-
cipherIdentity = null;
|
|
3212
|
-
constructor(deps) {
|
|
3213
|
-
this.deps = deps;
|
|
3214
|
-
}
|
|
3215
|
-
/** Store `value` under `collection`/`id` (encrypted at rest by default). */
|
|
3216
|
-
async set(collection, id, value, opts) {
|
|
3217
|
-
const encrypt = opts?.encrypt !== false;
|
|
3218
|
-
const stored = encrypt ? await this.getCipher().encrypt(value) : value;
|
|
3219
|
-
await this.deps.http.post(this.kvPath(collection, id), { value: stored });
|
|
3220
|
-
}
|
|
3221
|
-
/** Fetch the value at `collection`/`id`, or `null` if absent. */
|
|
3222
|
-
async get(collection, id) {
|
|
3223
|
-
const res = await this.deps.http.post(`${this.kvPath(collection, id)}/get`, {});
|
|
3224
|
-
return this.decode(res.value);
|
|
3225
|
-
}
|
|
3226
|
-
/** Delete `collection`/`id`. Returns whether the key existed. */
|
|
3227
|
-
async delete(collection, id) {
|
|
3228
|
-
const res = await this.deps.http.del(this.kvPath(collection, id), {});
|
|
3229
|
-
return Boolean(res.existed);
|
|
3230
|
-
}
|
|
3231
|
-
/** List the ids present in `collection`. */
|
|
3232
|
-
async list(collection) {
|
|
3233
|
-
const res = await this.deps.http.post(this.spacePath("/list"), {});
|
|
3234
|
-
const prefix = `${collection}/`;
|
|
3235
|
-
return (res.keys ?? [])
|
|
3236
|
-
.filter((k) => k.startsWith(prefix))
|
|
3237
|
-
.map((k) => k.slice(prefix.length));
|
|
3238
|
-
}
|
|
3239
|
-
/** Reserved — server-side query is deferred (see the overhaul plan). */
|
|
3240
|
-
async query(_collection, _filter) {
|
|
3241
|
-
throw new Error("client.storage.query is not implemented yet (deferred in the SDK overhaul).");
|
|
3242
|
-
}
|
|
3243
|
-
/**
|
|
3244
|
-
* Subscribe to realtime changes to this user's data across devices. Opens
|
|
3245
|
-
* the personal space's websocket and decrypts each change before handing
|
|
3246
|
-
* it to `handler`. Returns an unsubscribe function.
|
|
3247
|
-
*
|
|
3248
|
-
* Requires a `WebSocket` global (browsers; Node 22+ or a `ws` shim).
|
|
3249
|
-
*/
|
|
3250
|
-
on(event, handler) {
|
|
3251
|
-
if (event !== "change")
|
|
3252
|
-
throw new Error(`client.storage: unknown event "${event}"`);
|
|
3253
|
-
const WS = globalThis.WebSocket;
|
|
3254
|
-
if (typeof WS !== "function") {
|
|
3255
|
-
throw new Error("client.storage.on: no `WebSocket` global available in this runtime.");
|
|
3256
|
-
}
|
|
3257
|
-
const token = this.deps.session.token;
|
|
3258
|
-
if (!token)
|
|
3259
|
-
throw new Error("client.storage.on: not signed in.");
|
|
3260
|
-
const url = `${this.deps.wsBaseUrl}/api/personal/${encodeURIComponent(this.commitment())}/websocket?session=${encodeURIComponent(token)}`;
|
|
3261
|
-
const socket = new WS(url);
|
|
3262
|
-
socket.addEventListener("message", (ev) => {
|
|
3263
|
-
void this.dispatchChange(ev.data, handler);
|
|
3264
|
-
});
|
|
3265
|
-
return () => {
|
|
3266
|
-
try {
|
|
3267
|
-
socket.close();
|
|
3268
|
-
}
|
|
3269
|
-
catch { /* already closed */ }
|
|
3270
|
-
};
|
|
3271
|
-
}
|
|
3272
|
-
// -------------------------------------------------------------------------
|
|
3273
|
-
// Internals
|
|
3274
|
-
// -------------------------------------------------------------------------
|
|
3275
|
-
async dispatchChange(raw, handler) {
|
|
3276
|
-
let frame;
|
|
3277
|
-
try {
|
|
3278
|
-
frame = typeof raw === "string" ? JSON.parse(raw) : raw;
|
|
3279
|
-
}
|
|
3280
|
-
catch {
|
|
3281
|
-
return;
|
|
3282
|
-
}
|
|
3283
|
-
if (!frame || frame._t !== "change" || typeof frame.key !== "string")
|
|
3284
|
-
return;
|
|
3285
|
-
const slash = frame.key.indexOf("/");
|
|
3286
|
-
if (slash < 0)
|
|
3287
|
-
return;
|
|
3288
|
-
const collection = frame.key.slice(0, slash);
|
|
3289
|
-
const id = frame.key.slice(slash + 1);
|
|
3290
|
-
const data = frame.op === "set" ? await this.decode(frame.value) : null;
|
|
3291
|
-
handler({ collection, id, type: frame.op, data });
|
|
3292
|
-
}
|
|
3293
|
-
/** Decode a stored value: decrypt envelopes, pass plaintext through. */
|
|
3294
|
-
async decode(value) {
|
|
3295
|
-
if (value == null)
|
|
3296
|
-
return null;
|
|
3297
|
-
if (StorageCipher.isEnvelope(value))
|
|
3298
|
-
return this.getCipher().decrypt(value);
|
|
3299
|
-
return value;
|
|
3300
|
-
}
|
|
3301
|
-
commitment() {
|
|
3302
|
-
const c = this.deps.session.commitment;
|
|
3303
|
-
if (!c)
|
|
3304
|
-
throw new Error("client.storage: not signed in — call client.auth.zk.login() first.");
|
|
3305
|
-
return c;
|
|
3306
|
-
}
|
|
3307
|
-
spacePath(suffix) {
|
|
3308
|
-
return `/api/personal/${encodeURIComponent(this.commitment())}${suffix}`;
|
|
3309
|
-
}
|
|
3310
|
-
kvPath(collection, id) {
|
|
3311
|
-
const key = `${collection}/${id}`;
|
|
3312
|
-
return this.spacePath(`/kv/${encodeURIComponent(key)}`);
|
|
3313
|
-
}
|
|
3314
|
-
/** The at-rest cipher for the current identity (re-derived if it changes). */
|
|
3315
|
-
getCipher() {
|
|
3316
|
-
const identity = this.deps.session.requireIdentity();
|
|
3317
|
-
if (this.cipher && this.cipherIdentity === identity)
|
|
3318
|
-
return this.cipher;
|
|
3319
|
-
this.cipher = new StorageCipher(identity.secretHex, identity.saltHex);
|
|
3320
|
-
this.cipherIdentity = identity;
|
|
3321
|
-
return this.cipher;
|
|
3322
|
-
}
|
|
3323
|
-
}
|
|
3324
|
-
|
|
3325
3239
|
/**
|
|
3326
3240
|
* Pure WebSocket transport — owns the socket lifecycle and nothing else.
|
|
3327
3241
|
*
|
|
@@ -3337,11 +3251,31 @@ class StorageNamespace {
|
|
|
3337
3251
|
* - Emit lifecycle events (CONNECTED / DISCONNECTED / RECONNECTING / ERROR)
|
|
3338
3252
|
* plus raw inbound MESSAGE events
|
|
3339
3253
|
*/
|
|
3254
|
+
/** Monotonic source of unique transport ids when a caller doesn't supply one. */
|
|
3255
|
+
let __wsInstanceSeq = 0;
|
|
3340
3256
|
class WSTransport extends EventCore {
|
|
3341
|
-
|
|
3342
|
-
|
|
3343
|
-
|
|
3257
|
+
/**
|
|
3258
|
+
* Per-instance event scope. `EventCore` is a process-global static emitter,
|
|
3259
|
+
* so without namespacing every WSTransport would hear every other one's
|
|
3260
|
+
* frames. We prefix each event with this transport's `id` and expose
|
|
3261
|
+
* `on`/`off`/`emit` that scope transparently — callers still use the plain
|
|
3262
|
+
* `EventCoreEvents` enum and only ever see their own connection's events.
|
|
3263
|
+
*/
|
|
3264
|
+
id;
|
|
3265
|
+
emit = (event, data) => {
|
|
3266
|
+
EventCore.emit(this.scopedEvent(event), data);
|
|
3267
|
+
};
|
|
3268
|
+
on = (event, handler) => {
|
|
3269
|
+
EventCore.on(this.scopedEvent(event), handler);
|
|
3270
|
+
};
|
|
3271
|
+
off = (event, handler) => {
|
|
3272
|
+
EventCore.off(this.scopedEvent(event), handler);
|
|
3273
|
+
};
|
|
3274
|
+
scopedEvent(event) {
|
|
3275
|
+
return `${this.id}:${event}`;
|
|
3276
|
+
}
|
|
3344
3277
|
url;
|
|
3278
|
+
urlProvider;
|
|
3345
3279
|
socket = null;
|
|
3346
3280
|
_isConnected = false;
|
|
3347
3281
|
_isConnecting = false;
|
|
@@ -3354,16 +3288,29 @@ class WSTransport extends EventCore {
|
|
|
3354
3288
|
// Outbound queue (raw frame strings — already serialized by caller)
|
|
3355
3289
|
outboundQueue = [];
|
|
3356
3290
|
maxQueueSize;
|
|
3291
|
+
// Heartbeat / liveness state
|
|
3292
|
+
heartbeatInterval;
|
|
3293
|
+
heartbeatTimeout;
|
|
3294
|
+
pingFrame;
|
|
3295
|
+
pongFrame;
|
|
3296
|
+
heartbeatTimer = null;
|
|
3297
|
+
pongTimer = null;
|
|
3357
3298
|
constructor(opts) {
|
|
3358
3299
|
super();
|
|
3359
3300
|
if (!opts || !opts.url) {
|
|
3360
3301
|
throw new Error("WSTransport: `url` is required");
|
|
3361
3302
|
}
|
|
3362
3303
|
this.url = opts.url;
|
|
3304
|
+
this.urlProvider = opts.urlProvider;
|
|
3305
|
+
this.id = opts.id ?? `ws${++__wsInstanceSeq}`;
|
|
3363
3306
|
this.autoReconnect = opts.autoReconnect ?? true;
|
|
3364
3307
|
this.reconnectDelay = opts.reconnectDelay ?? 3000;
|
|
3365
3308
|
this.maxReconnectAttempts = opts.maxReconnectAttempts ?? 5;
|
|
3366
3309
|
this.maxQueueSize = opts.maxQueueSize ?? 100;
|
|
3310
|
+
this.heartbeatInterval = opts.heartbeatInterval ?? 30000;
|
|
3311
|
+
this.heartbeatTimeout = opts.heartbeatTimeout ?? 10000;
|
|
3312
|
+
this.pingFrame = opts.pingFrame ?? "ping";
|
|
3313
|
+
this.pongFrame = opts.pongFrame ?? "pong";
|
|
3367
3314
|
}
|
|
3368
3315
|
/** Open the socket. Resolves when CONNECTED fires. Rejects on first error. */
|
|
3369
3316
|
async connect() {
|
|
@@ -3371,6 +3318,18 @@ class WSTransport extends EventCore {
|
|
|
3371
3318
|
throw new Error("WSTransport: already connected or connecting");
|
|
3372
3319
|
}
|
|
3373
3320
|
this._isConnecting = true;
|
|
3321
|
+
// On a RECONNECT, refresh the URL (the baked-in single-use ticket is spent).
|
|
3322
|
+
// The first connect uses `url` as-is. If the provider throws (e.g. can't
|
|
3323
|
+
// mint a ticket), surface it and let scheduleReconnect retry.
|
|
3324
|
+
if (this.urlProvider && this.reconnectAttempts > 0) {
|
|
3325
|
+
try {
|
|
3326
|
+
this.url = await this.urlProvider();
|
|
3327
|
+
}
|
|
3328
|
+
catch (err) {
|
|
3329
|
+
this._isConnecting = false;
|
|
3330
|
+
throw err;
|
|
3331
|
+
}
|
|
3332
|
+
}
|
|
3374
3333
|
return new Promise((resolve, reject) => {
|
|
3375
3334
|
try {
|
|
3376
3335
|
const socket = new WebSocket(this.url);
|
|
@@ -3381,9 +3340,19 @@ class WSTransport extends EventCore {
|
|
|
3381
3340
|
this.reconnectAttempts = 0;
|
|
3382
3341
|
this.emit(EventCoreEvents.CONNECTED, undefined);
|
|
3383
3342
|
this.flushQueue();
|
|
3343
|
+
this.startHeartbeat();
|
|
3384
3344
|
resolve();
|
|
3385
3345
|
};
|
|
3386
3346
|
socket.onmessage = (event) => {
|
|
3347
|
+
// Heartbeat ack — swallow it (don't surface as a MESSAGE) and clear
|
|
3348
|
+
// the liveness deadline. The layer above never sees keep-alive frames.
|
|
3349
|
+
if (event.data === this.pongFrame) {
|
|
3350
|
+
if (this.pongTimer) {
|
|
3351
|
+
clearTimeout(this.pongTimer);
|
|
3352
|
+
this.pongTimer = null;
|
|
3353
|
+
}
|
|
3354
|
+
return;
|
|
3355
|
+
}
|
|
3387
3356
|
// Raw inbound frame — layer above is responsible for parsing.
|
|
3388
3357
|
this.emit(EventCoreEvents.MESSAGE, event.data);
|
|
3389
3358
|
};
|
|
@@ -3398,6 +3367,7 @@ class WSTransport extends EventCore {
|
|
|
3398
3367
|
const wasConnected = this._isConnected;
|
|
3399
3368
|
this._isConnected = false;
|
|
3400
3369
|
this._isConnecting = false;
|
|
3370
|
+
this.stopHeartbeat();
|
|
3401
3371
|
this.emit(EventCoreEvents.DISCONNECTED, undefined);
|
|
3402
3372
|
if (this.autoReconnect && wasConnected) {
|
|
3403
3373
|
this.scheduleReconnect();
|
|
@@ -3416,6 +3386,7 @@ class WSTransport extends EventCore {
|
|
|
3416
3386
|
clearTimeout(this.reconnectTimeout);
|
|
3417
3387
|
this.reconnectTimeout = null;
|
|
3418
3388
|
}
|
|
3389
|
+
this.stopHeartbeat();
|
|
3419
3390
|
this.autoReconnect = false;
|
|
3420
3391
|
if (this.socket) {
|
|
3421
3392
|
try {
|
|
@@ -3446,6 +3417,78 @@ class WSTransport extends EventCore {
|
|
|
3446
3417
|
queuedFrames() { return this.outboundQueue.length; }
|
|
3447
3418
|
reconnectAttemptCount() { return this.reconnectAttempts; }
|
|
3448
3419
|
// -- internals -------------------------------------------------------------
|
|
3420
|
+
/** Begin the keep-alive loop (no-op when disabled). */
|
|
3421
|
+
startHeartbeat() {
|
|
3422
|
+
if (this.heartbeatInterval <= 0)
|
|
3423
|
+
return;
|
|
3424
|
+
this.stopHeartbeat();
|
|
3425
|
+
this.heartbeatTimer = setInterval(() => this.sendHeartbeat(), this.heartbeatInterval);
|
|
3426
|
+
}
|
|
3427
|
+
/** Stop the keep-alive loop and clear any pending liveness deadline. */
|
|
3428
|
+
stopHeartbeat() {
|
|
3429
|
+
if (this.heartbeatTimer) {
|
|
3430
|
+
clearInterval(this.heartbeatTimer);
|
|
3431
|
+
this.heartbeatTimer = null;
|
|
3432
|
+
}
|
|
3433
|
+
if (this.pongTimer) {
|
|
3434
|
+
clearTimeout(this.pongTimer);
|
|
3435
|
+
this.pongTimer = null;
|
|
3436
|
+
}
|
|
3437
|
+
}
|
|
3438
|
+
/**
|
|
3439
|
+
* Send one ping and arm a liveness deadline. If no `pongFrame` arrives before
|
|
3440
|
+
* `heartbeatTimeout`, the connection is considered dead and we force a
|
|
3441
|
+
* reconnect — this is what catches a silently-dropped TCP connection that
|
|
3442
|
+
* never fired `onclose` (the failure mode that "loses connection with the
|
|
3443
|
+
* Accelerator" on long sessions).
|
|
3444
|
+
*/
|
|
3445
|
+
sendHeartbeat() {
|
|
3446
|
+
if (!this._isConnected || !this.socket)
|
|
3447
|
+
return;
|
|
3448
|
+
try {
|
|
3449
|
+
this.socket.send(this.pingFrame);
|
|
3450
|
+
}
|
|
3451
|
+
catch {
|
|
3452
|
+
this.forceReconnect();
|
|
3453
|
+
return;
|
|
3454
|
+
}
|
|
3455
|
+
// A still-pending deadline means the previous pong never came back.
|
|
3456
|
+
if (this.pongTimer)
|
|
3457
|
+
return;
|
|
3458
|
+
this.pongTimer = setTimeout(() => {
|
|
3459
|
+
this.pongTimer = null;
|
|
3460
|
+
this.forceReconnect();
|
|
3461
|
+
}, this.heartbeatTimeout);
|
|
3462
|
+
}
|
|
3463
|
+
/**
|
|
3464
|
+
* Tear down a connection we believe is dead and kick off reconnect, without
|
|
3465
|
+
* waiting on the OS/browser to eventually fire `onclose` (which a half-open
|
|
3466
|
+
* socket may never do). We detach the handlers first so the normal close path
|
|
3467
|
+
* can't double-fire the reconnect.
|
|
3468
|
+
*/
|
|
3469
|
+
forceReconnect() {
|
|
3470
|
+
this.stopHeartbeat();
|
|
3471
|
+
const sock = this.socket;
|
|
3472
|
+
const wasConnected = this._isConnected;
|
|
3473
|
+
this.socket = null;
|
|
3474
|
+
this._isConnected = false;
|
|
3475
|
+
this._isConnecting = false;
|
|
3476
|
+
if (sock) {
|
|
3477
|
+
sock.onopen = null;
|
|
3478
|
+
sock.onmessage = null;
|
|
3479
|
+
sock.onerror = null;
|
|
3480
|
+
sock.onclose = null;
|
|
3481
|
+
try {
|
|
3482
|
+
sock.close();
|
|
3483
|
+
}
|
|
3484
|
+
catch { /* already gone */ }
|
|
3485
|
+
}
|
|
3486
|
+
this.emit(EventCoreEvents.ERROR, new Error("WSTransport: heartbeat timeout"));
|
|
3487
|
+
this.emit(EventCoreEvents.DISCONNECTED, undefined);
|
|
3488
|
+
if (this.autoReconnect && wasConnected) {
|
|
3489
|
+
this.scheduleReconnect();
|
|
3490
|
+
}
|
|
3491
|
+
}
|
|
3449
3492
|
scheduleReconnect() {
|
|
3450
3493
|
if (this.maxReconnectAttempts > 0 &&
|
|
3451
3494
|
this.reconnectAttempts >= this.maxReconnectAttempts) {
|
|
@@ -3459,7 +3502,12 @@ class WSTransport extends EventCore {
|
|
|
3459
3502
|
await this.connect();
|
|
3460
3503
|
}
|
|
3461
3504
|
catch {
|
|
3462
|
-
//
|
|
3505
|
+
// A failed reconnect never opened the socket, so its `onclose` won't
|
|
3506
|
+
// re-schedule (that path is guarded by `wasConnected`). Re-schedule here
|
|
3507
|
+
// so we keep retrying (with a fresh ticket each time) until we succeed
|
|
3508
|
+
// or hit the attempt cap.
|
|
3509
|
+
if (this.autoReconnect)
|
|
3510
|
+
this.scheduleReconnect();
|
|
3463
3511
|
}
|
|
3464
3512
|
}, this.reconnectDelay);
|
|
3465
3513
|
}
|
|
@@ -3481,473 +3529,160 @@ class WSTransport extends EventCore {
|
|
|
3481
3529
|
}
|
|
3482
3530
|
|
|
3483
3531
|
/**
|
|
3484
|
-
*
|
|
3485
|
-
*
|
|
3486
|
-
* per-pair `DoubleRatchet` instances and owns the protocol bookkeeping
|
|
3487
|
-
* (handshake reciprocation, dedup, role assignment, broadcast fan-out,
|
|
3488
|
-
* recipient filtering) so application code doesn't have to.
|
|
3532
|
+
* `client.kv` — per-user key/value storage backed by the accelerator's personal
|
|
3533
|
+
* space.
|
|
3489
3534
|
*
|
|
3490
|
-
*
|
|
3491
|
-
*
|
|
3492
|
-
*
|
|
3493
|
-
*
|
|
3494
|
-
*
|
|
3495
|
-
* the typed result (plaintext, handshake-done, ignored).
|
|
3535
|
+
* await client.kv.set('todos', id, { title, completed })
|
|
3536
|
+
* const todo = await client.kv.get('todos', id)
|
|
3537
|
+
* const ids = await client.kv.list('todos')
|
|
3538
|
+
* await client.kv.delete('todos', id)
|
|
3539
|
+
* const off = client.kv.on('change', e => …) // realtime, cross-device
|
|
3496
3540
|
*
|
|
3497
|
-
*
|
|
3498
|
-
*
|
|
3499
|
-
*
|
|
3541
|
+
* A *collection* is a key namespace within the user's space; the stored key is
|
|
3542
|
+
* `${collection}/${id}`. Operations are authorized by the user's session token
|
|
3543
|
+
* (stamped on every request by {@link HttpClient}) — no per-op Groth16 proof.
|
|
3544
|
+
*
|
|
3545
|
+
* Values are encrypted at rest by default ({@link StorageCipher}, an AES-GCM
|
|
3546
|
+
* key derived from the ZK identity); pass `{ encrypt: false }` to store
|
|
3547
|
+
* plaintext. The realtime feed rides the personal space's websocket over a
|
|
3548
|
+
* {@link WSTransport} (heartbeat keep-alive + auto-reconnect, event-namespaced
|
|
3549
|
+
* so it doesn't cross wires with chat/space transports).
|
|
3550
|
+
*
|
|
3551
|
+
* For **files** (blobs), use `client.storage` instead — `client.kv` is for
|
|
3552
|
+
* small structured values. `query()` is intentionally deferred.
|
|
3500
3553
|
*/
|
|
3501
|
-
|
|
3502
|
-
|
|
3503
|
-
|
|
3504
|
-
|
|
3505
|
-
|
|
3506
|
-
|
|
3507
|
-
/** One DoubleRatchet per peer pair. */
|
|
3508
|
-
ratchets = new Map();
|
|
3509
|
-
/** Peers we've already sent our keyExchange to in this Session. */
|
|
3510
|
-
sentHandshakeTo = new Set();
|
|
3511
|
-
/** Resolved when own keys are generated and ready to use. */
|
|
3512
|
-
_ready;
|
|
3513
|
-
_resolveReady;
|
|
3514
|
-
_initialized = false;
|
|
3515
|
-
constructor(opts) {
|
|
3516
|
-
if (!opts || !opts.myId) {
|
|
3517
|
-
throw new Error("EncryptedSession: `myId` is required");
|
|
3518
|
-
}
|
|
3519
|
-
this.myId = opts.myId;
|
|
3520
|
-
this.keyStore = KeyStore.getInstance();
|
|
3521
|
-
this._ready = new Promise((resolve) => {
|
|
3522
|
-
this._resolveReady = resolve;
|
|
3523
|
-
});
|
|
3554
|
+
class KvNamespace {
|
|
3555
|
+
deps;
|
|
3556
|
+
cipher = null;
|
|
3557
|
+
cipherIdentity = null;
|
|
3558
|
+
constructor(deps) {
|
|
3559
|
+
this.deps = deps;
|
|
3524
3560
|
}
|
|
3525
|
-
/**
|
|
3526
|
-
|
|
3527
|
-
|
|
3561
|
+
/** Store `value` under `collection`/`id` (encrypted at rest by default). */
|
|
3562
|
+
async set(collection, id, value, opts) {
|
|
3563
|
+
const encrypt = opts?.encrypt !== false;
|
|
3564
|
+
const stored = encrypt ? await this.getCipher().encrypt(value) : value;
|
|
3565
|
+
await this.deps.http.post(this.kvPath(collection, id), { value: stored });
|
|
3528
3566
|
}
|
|
3529
|
-
/**
|
|
3530
|
-
get id
|
|
3531
|
-
|
|
3567
|
+
/** Fetch the value at `collection`/`id`, or `null` if absent. */
|
|
3568
|
+
async get(collection, id) {
|
|
3569
|
+
const res = await this.deps.http.post(`${this.kvPath(collection, id)}/get`, {});
|
|
3570
|
+
return this.decode(res.value);
|
|
3532
3571
|
}
|
|
3533
|
-
/**
|
|
3534
|
-
|
|
3535
|
-
|
|
3536
|
-
|
|
3537
|
-
async initialize() {
|
|
3538
|
-
if (this._initialized)
|
|
3539
|
-
return;
|
|
3540
|
-
if (!this.keyStore.getKeyPair(this.myId)) {
|
|
3541
|
-
await this.keyStore.generateOwnKeyPair(this.myId);
|
|
3542
|
-
}
|
|
3543
|
-
this._initialized = true;
|
|
3544
|
-
this._resolveReady();
|
|
3572
|
+
/** Delete `collection`/`id`. Returns whether the key existed. */
|
|
3573
|
+
async delete(collection, id) {
|
|
3574
|
+
const res = await this.deps.http.del(this.kvPath(collection, id), {});
|
|
3575
|
+
return Boolean(res.existed);
|
|
3545
3576
|
}
|
|
3546
|
-
/**
|
|
3547
|
-
|
|
3548
|
-
|
|
3549
|
-
|
|
3550
|
-
|
|
3551
|
-
|
|
3552
|
-
|
|
3553
|
-
|
|
3554
|
-
|
|
3555
|
-
|
|
3556
|
-
|
|
3557
|
-
}
|
|
3558
|
-
return {
|
|
3559
|
-
keyExchange: {
|
|
3560
|
-
type: "handshake",
|
|
3561
|
-
userId: this.myId,
|
|
3562
|
-
ecdhPublicKey: await exportJwkBase64(ownEcdh.publicKey),
|
|
3563
|
-
ecdsaPublicKey: await exportJwkBase64(ownEcdsa.publicKey),
|
|
3564
|
-
},
|
|
3565
|
-
};
|
|
3577
|
+
/** List the ids present in `collection`. */
|
|
3578
|
+
async list(collection) {
|
|
3579
|
+
const res = await this.deps.http.post(this.spacePath("/list"), {});
|
|
3580
|
+
const prefix = `${collection}/`;
|
|
3581
|
+
return (res.keys ?? [])
|
|
3582
|
+
.filter((k) => k.startsWith(prefix))
|
|
3583
|
+
.map((k) => k.slice(prefix.length));
|
|
3584
|
+
}
|
|
3585
|
+
/** Reserved — server-side query is deferred (see the overhaul plan). */
|
|
3586
|
+
async query(_collection, _filter) {
|
|
3587
|
+
throw new Error("client.kv.query is not implemented yet (deferred in the SDK overhaul).");
|
|
3566
3588
|
}
|
|
3567
3589
|
/**
|
|
3568
|
-
*
|
|
3569
|
-
*
|
|
3570
|
-
*
|
|
3590
|
+
* Subscribe to realtime changes to this user's data across devices. Opens
|
|
3591
|
+
* the personal space's websocket and decrypts each change before handing
|
|
3592
|
+
* it to `handler`. Returns an unsubscribe function.
|
|
3571
3593
|
*
|
|
3572
|
-
*
|
|
3573
|
-
* returned array may be empty — the caller should typically render the
|
|
3574
|
-
* outgoing message locally in that case (since there's no one to send
|
|
3575
|
-
* it to yet).
|
|
3594
|
+
* Requires a `WebSocket` global (browsers; Node 22+ or a `ws` shim).
|
|
3576
3595
|
*/
|
|
3577
|
-
|
|
3578
|
-
|
|
3579
|
-
|
|
3580
|
-
|
|
3581
|
-
|
|
3582
|
-
const sessionId = this._sessionIdFor(peerId);
|
|
3583
|
-
const cipherMessage = await ratchet.encrypt(plaintext,
|
|
3584
|
-
/* newDhKey */ false, this.myId, peerId, sessionId, "specific");
|
|
3585
|
-
frames.push({ cipherMessage });
|
|
3596
|
+
on(event, handler) {
|
|
3597
|
+
if (event !== "change")
|
|
3598
|
+
throw new Error(`client.kv: unknown event "${event}"`);
|
|
3599
|
+
if (typeof globalThis.WebSocket !== "function") {
|
|
3600
|
+
throw new Error("client.kv.on: no `WebSocket` global available in this runtime.");
|
|
3586
3601
|
}
|
|
3587
|
-
|
|
3602
|
+
const token = this.deps.session.token;
|
|
3603
|
+
if (!token)
|
|
3604
|
+
throw new Error("client.kv.on: not signed in.");
|
|
3605
|
+
const commitment = this.commitment();
|
|
3606
|
+
const url = `${this.deps.wsBaseUrl}/api/personal/${encodeURIComponent(commitment)}/websocket?session=${encodeURIComponent(token)}`;
|
|
3607
|
+
// Ride a WSTransport rather than a bare socket: it keeps the (otherwise
|
|
3608
|
+
// idle) personal-space feed warm with heartbeat pings, auto-reconnects
|
|
3609
|
+
// if it drops, and namespaces its events by `id` so this feed never
|
|
3610
|
+
// crosses wires with chat/space transports on the shared EventCore bus.
|
|
3611
|
+
const transport = new WSTransport({ url, id: `personal:${commitment}` });
|
|
3612
|
+
const onMessage = (e) => {
|
|
3613
|
+
void this.dispatchChange(e.detail, handler);
|
|
3614
|
+
};
|
|
3615
|
+
transport.on(EventCoreEvents.MESSAGE, onMessage);
|
|
3616
|
+
// Fire-and-forget: the realtime feed is best-effort, and the transport
|
|
3617
|
+
// queues nothing it needs before CONNECTED (server→client only).
|
|
3618
|
+
void transport.connect();
|
|
3619
|
+
return () => {
|
|
3620
|
+
transport.off(EventCoreEvents.MESSAGE, onMessage);
|
|
3621
|
+
transport.disconnect();
|
|
3622
|
+
};
|
|
3588
3623
|
}
|
|
3589
|
-
|
|
3590
|
-
|
|
3591
|
-
|
|
3592
|
-
|
|
3593
|
-
|
|
3594
|
-
|
|
3595
|
-
|
|
3596
|
-
* - `kind: 'ignored'` — not for us, malformed, duplicate, etc.
|
|
3597
|
-
*
|
|
3598
|
-
* Calling code should always handle `'ignored'` (no UI side effect).
|
|
3599
|
-
*/
|
|
3600
|
-
async receive(frame) {
|
|
3601
|
-
await this._ready;
|
|
3602
|
-
// -- handshake frame -----------------------------------------------------
|
|
3603
|
-
if (isKeyExchangeFrame(frame)) {
|
|
3604
|
-
const kx = frame.keyExchange;
|
|
3605
|
-
const peerId = kx.userId;
|
|
3606
|
-
if (peerId === this.myId) {
|
|
3607
|
-
return { kind: "ignored", reason: "own keyExchange echo" };
|
|
3608
|
-
}
|
|
3609
|
-
const isNew = !this.ratchets.has(peerId);
|
|
3610
|
-
// (Re-)store the peer's pubkeys + (re-)build the ratchet if needed.
|
|
3611
|
-
const peerEcdh = await importJwkBase64(kx.ecdhPublicKey, "ECDH");
|
|
3612
|
-
const peerEcdsa = await importJwkBase64(kx.ecdsaPublicKey, "ECDSA");
|
|
3613
|
-
await this.keyStore.storeRemotePublicKeys(peerId, peerEcdh, peerEcdsa);
|
|
3614
|
-
if (isNew) {
|
|
3615
|
-
const isClient = this.myId < peerId;
|
|
3616
|
-
const ratchet = new DoubleRatchet(this.myId, peerId, "specific", isClient);
|
|
3617
|
-
await ratchet.initializeSession(isClient);
|
|
3618
|
-
this.ratchets.set(peerId, ratchet);
|
|
3619
|
-
}
|
|
3620
|
-
// Reciprocate exactly once per peer per session lifetime.
|
|
3621
|
-
let outbound = null;
|
|
3622
|
-
if (isNew && !this.sentHandshakeTo.has(peerId)) {
|
|
3623
|
-
outbound = await this.getOwnKeyExchange();
|
|
3624
|
-
this.sentHandshakeTo.add(peerId);
|
|
3625
|
-
}
|
|
3626
|
-
return { kind: "handshake", peerId, outbound };
|
|
3624
|
+
// -------------------------------------------------------------------------
|
|
3625
|
+
// Internals
|
|
3626
|
+
// -------------------------------------------------------------------------
|
|
3627
|
+
async dispatchChange(raw, handler) {
|
|
3628
|
+
let frame;
|
|
3629
|
+
try {
|
|
3630
|
+
frame = typeof raw === "string" ? JSON.parse(raw) : raw;
|
|
3627
3631
|
}
|
|
3628
|
-
|
|
3629
|
-
|
|
3630
|
-
const cipherMessage = frame.cipherMessage;
|
|
3631
|
-
const header = cipherMessage?.header;
|
|
3632
|
-
if (!header) {
|
|
3633
|
-
return { kind: "ignored", reason: "cipherMessage has no header" };
|
|
3634
|
-
}
|
|
3635
|
-
if (header.senderId === this.myId) {
|
|
3636
|
-
return { kind: "ignored", reason: "own outbound echo" };
|
|
3637
|
-
}
|
|
3638
|
-
if (header.recipientId && header.recipientId !== this.myId) {
|
|
3639
|
-
return { kind: "ignored", reason: "addressed to a different peer" };
|
|
3640
|
-
}
|
|
3641
|
-
const ratchet = this.ratchets.get(header.senderId);
|
|
3642
|
-
if (!ratchet) {
|
|
3643
|
-
return {
|
|
3644
|
-
kind: "ignored",
|
|
3645
|
-
reason: `no ratchet with sender ${header.senderId} (handshake hasn't completed)`,
|
|
3646
|
-
};
|
|
3647
|
-
}
|
|
3648
|
-
const isClient = this.myId < header.senderId;
|
|
3649
|
-
let text;
|
|
3650
|
-
try {
|
|
3651
|
-
text = await ratchet.decrypt(cipherMessage, isClient);
|
|
3652
|
-
}
|
|
3653
|
-
catch (err) {
|
|
3654
|
-
// DoubleRatchet's wallclock-based replay-protection check throws
|
|
3655
|
-
// "CipherMessage too old, possible replay attack" for any frame
|
|
3656
|
-
// older than 5 minutes. Real replays still fail downstream because
|
|
3657
|
-
// the message key has been consumed; the wallclock check is a
|
|
3658
|
-
// defense-in-depth heuristic, not a security boundary. Treating it
|
|
3659
|
-
// as `ignored` lets the channel survive legacy backlog replays and
|
|
3660
|
-
// CF WS-idle-timeout reconnect cycles without spamming the
|
|
3661
|
-
// channel's ERROR stream.
|
|
3662
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
3663
|
-
if (msg.includes("too old") || msg.includes("replay attack")) {
|
|
3664
|
-
return { kind: "ignored", reason: `stale cipherMessage rejected: ${msg}` };
|
|
3665
|
-
}
|
|
3666
|
-
throw err;
|
|
3667
|
-
}
|
|
3668
|
-
return { kind: "plaintext", from: header.senderId, text, cipherMessage };
|
|
3632
|
+
catch {
|
|
3633
|
+
return;
|
|
3669
3634
|
}
|
|
3670
|
-
|
|
3635
|
+
if (!frame || frame._t !== "change" || typeof frame.key !== "string")
|
|
3636
|
+
return;
|
|
3637
|
+
const slash = frame.key.indexOf("/");
|
|
3638
|
+
if (slash < 0)
|
|
3639
|
+
return;
|
|
3640
|
+
const collection = frame.key.slice(0, slash);
|
|
3641
|
+
const id = frame.key.slice(slash + 1);
|
|
3642
|
+
const data = frame.op === "set" ? await this.decode(frame.value) : null;
|
|
3643
|
+
handler({ collection, id, type: frame.op, data });
|
|
3671
3644
|
}
|
|
3672
|
-
|
|
3673
|
-
|
|
3674
|
-
|
|
3675
|
-
|
|
3645
|
+
/** Decode a stored value: decrypt envelopes, pass plaintext through. */
|
|
3646
|
+
async decode(value) {
|
|
3647
|
+
if (value == null)
|
|
3648
|
+
return null;
|
|
3649
|
+
if (StorageCipher.isEnvelope(value))
|
|
3650
|
+
return this.getCipher().decrypt(value);
|
|
3651
|
+
return value;
|
|
3676
3652
|
}
|
|
3677
|
-
|
|
3678
|
-
|
|
3679
|
-
|
|
3653
|
+
commitment() {
|
|
3654
|
+
const c = this.deps.session.commitment;
|
|
3655
|
+
if (!c)
|
|
3656
|
+
throw new Error("client.kv: not signed in — call client.auth.zk.login() first.");
|
|
3657
|
+
return c;
|
|
3680
3658
|
}
|
|
3681
|
-
|
|
3682
|
-
|
|
3683
|
-
* stored keys remain in `KeyStore` so a fresh handshake can be set up
|
|
3684
|
-
* later without re-fetching them, but the ratchet state is dropped.
|
|
3685
|
-
*/
|
|
3686
|
-
forgetPeer(peerId) {
|
|
3687
|
-
this.ratchets.delete(peerId);
|
|
3688
|
-
this.sentHandshakeTo.delete(peerId);
|
|
3659
|
+
spacePath(suffix) {
|
|
3660
|
+
return `/api/personal/${encodeURIComponent(this.commitment())}${suffix}`;
|
|
3689
3661
|
}
|
|
3690
|
-
|
|
3691
|
-
|
|
3692
|
-
|
|
3693
|
-
|
|
3662
|
+
kvPath(collection, id) {
|
|
3663
|
+
const key = `${collection}/${id}`;
|
|
3664
|
+
return this.spacePath(`/kv/${encodeURIComponent(key)}`);
|
|
3665
|
+
}
|
|
3666
|
+
/** The at-rest cipher for the current identity (re-derived if it changes). */
|
|
3667
|
+
getCipher() {
|
|
3668
|
+
const identity = this.deps.session.requireIdentity();
|
|
3669
|
+
if (this.cipher && this.cipherIdentity === identity)
|
|
3670
|
+
return this.cipher;
|
|
3671
|
+
this.cipher = new StorageCipher(identity.secretHex, identity.saltHex);
|
|
3672
|
+
this.cipherIdentity = identity;
|
|
3673
|
+
return this.cipher;
|
|
3694
3674
|
}
|
|
3695
|
-
}
|
|
3696
|
-
// ---------------------------------------------------------------------------
|
|
3697
|
-
// Helpers — JWK <-> base64 string for key transport
|
|
3698
|
-
// ---------------------------------------------------------------------------
|
|
3699
|
-
async function exportJwkBase64(key) {
|
|
3700
|
-
const jwk = await crypto.subtle.exportKey("jwk", key);
|
|
3701
|
-
return btoa(JSON.stringify(jwk));
|
|
3702
|
-
}
|
|
3703
|
-
async function importJwkBase64(b64, type) {
|
|
3704
|
-
const jwk = JSON.parse(atob(b64));
|
|
3705
|
-
const algorithm = type === "ECDH"
|
|
3706
|
-
? { name: "ECDH", namedCurve: "P-384" }
|
|
3707
|
-
: { name: "ECDSA", namedCurve: "P-384" };
|
|
3708
|
-
const usages = type === "ECDH" ? [] : ["verify"];
|
|
3709
|
-
return await crypto.subtle.importKey("jwk", jwk, algorithm, true, usages);
|
|
3710
|
-
}
|
|
3711
|
-
// ---------------------------------------------------------------------------
|
|
3712
|
-
// Type guards
|
|
3713
|
-
// ---------------------------------------------------------------------------
|
|
3714
|
-
function isKeyExchangeFrame(frame) {
|
|
3715
|
-
const kx = frame.keyExchange;
|
|
3716
|
-
if (!kx || typeof kx !== "object")
|
|
3717
|
-
return false;
|
|
3718
|
-
const k = kx;
|
|
3719
|
-
return (typeof k.userId === "string" &&
|
|
3720
|
-
typeof k.ecdhPublicKey === "string" &&
|
|
3721
|
-
typeof k.ecdsaPublicKey === "string");
|
|
3722
|
-
}
|
|
3723
|
-
function isCipherFrame(frame) {
|
|
3724
|
-
const cm = frame.cipherMessage;
|
|
3725
|
-
if (!cm || typeof cm !== "object")
|
|
3726
|
-
return false;
|
|
3727
|
-
const m = cm;
|
|
3728
|
-
return Boolean(m.header && m.ciphertext && m.nonce);
|
|
3729
3675
|
}
|
|
3730
3676
|
|
|
3731
3677
|
/**
|
|
3732
|
-
*
|
|
3733
|
-
* "room"-style apps (e.g. a chat). Wires together:
|
|
3734
|
-
*
|
|
3735
|
-
* - `WSTransport` for socket lifecycle, reconnect, and offline frame queue
|
|
3736
|
-
* - `EncryptedSession` for per-peer Double Ratchet state, handshake
|
|
3737
|
-
* reciprocation, fan-out encryption, and recipient filtering
|
|
3738
|
-
*
|
|
3739
|
-
* The host app only deals with high-level events and method calls; it never
|
|
3740
|
-
* touches `new WebSocket(...)` or builds cipherMessage frames by hand.
|
|
3741
|
-
*
|
|
3742
|
-
* Wire protocol (kept compatible with the existing chat protocol so this
|
|
3743
|
-
* drops into the deployed Accelerator without server changes):
|
|
3678
|
+
* Wire types for the storage layer.
|
|
3744
3679
|
*
|
|
3745
|
-
*
|
|
3746
|
-
*
|
|
3747
|
-
*
|
|
3748
|
-
*
|
|
3749
|
-
*
|
|
3750
|
-
*
|
|
3751
|
-
* emits `peer_handshake`
|
|
3752
|
-
* `{ cipherMessage: {...} }` — decrypted; emits `message`
|
|
3753
|
-
* anything else — passes through as `raw_frame`
|
|
3754
|
-
* (the app decides what to do)
|
|
3755
|
-
*
|
|
3756
|
-
* **Event scoping.** Unlike `Network`, BroadcastChannel uses a per-instance
|
|
3757
|
-
* `EventTarget`, so two channels (e.g. two rooms in the same app) don't
|
|
3758
|
-
* cross-contaminate each other's events.
|
|
3759
|
-
*
|
|
3760
|
-
* Note: this class shadows the `BroadcastChannel` Web API global (cross-tab
|
|
3761
|
-
* messaging) only when imported by name. They're unrelated.
|
|
3762
|
-
*/
|
|
3763
|
-
// ---------------------------------------------------------------------------
|
|
3764
|
-
// Event names
|
|
3765
|
-
// ---------------------------------------------------------------------------
|
|
3766
|
-
/**
|
|
3767
|
-
* Event names BroadcastChannel emits. Lifecycle events reuse the
|
|
3768
|
-
* `EventCoreEvents` string values (`connected`, `disconnected`, etc.) so
|
|
3769
|
-
* apps can use shared constants if they want. Channel-specific events are
|
|
3770
|
-
* prefixed `channel:` to avoid colliding with other event systems in the
|
|
3771
|
-
* same app.
|
|
3772
|
-
*/
|
|
3773
|
-
const BroadcastChannelEvents = {
|
|
3774
|
-
CONNECTED: EventCoreEvents.CONNECTED,
|
|
3775
|
-
DISCONNECTED: EventCoreEvents.DISCONNECTED,
|
|
3776
|
-
RECONNECTING: EventCoreEvents.RECONNECTING,
|
|
3777
|
-
ERROR: EventCoreEvents.ERROR,
|
|
3778
|
-
/** A peer's ratchet is now ready. `detail.peerId` is the peer's id. */
|
|
3779
|
-
PEER_HANDSHAKE: "channel:peer_handshake",
|
|
3780
|
-
/** Decrypted message. `detail` is `{ from, text, cipherMessage }`. */
|
|
3781
|
-
MESSAGE: "channel:message",
|
|
3782
|
-
/** Inbound frame BroadcastChannel didn't recognize. App handles it. */
|
|
3783
|
-
RAW_FRAME: "channel:raw_frame",
|
|
3784
|
-
};
|
|
3785
|
-
// ---------------------------------------------------------------------------
|
|
3786
|
-
// BroadcastChannel
|
|
3787
|
-
// ---------------------------------------------------------------------------
|
|
3788
|
-
class BroadcastChannel {
|
|
3789
|
-
transport;
|
|
3790
|
-
session;
|
|
3791
|
-
myId;
|
|
3792
|
-
autoAnnounce;
|
|
3793
|
-
announced = false;
|
|
3794
|
-
/** Per-instance EventTarget — events do NOT leak across BroadcastChannels. */
|
|
3795
|
-
events = new EventTarget();
|
|
3796
|
-
constructor(opts) {
|
|
3797
|
-
if (!opts || !opts.myId) {
|
|
3798
|
-
throw new Error("BroadcastChannel: `myId` is required");
|
|
3799
|
-
}
|
|
3800
|
-
this.myId = opts.myId;
|
|
3801
|
-
this.autoAnnounce = opts.autoAnnounce ?? false;
|
|
3802
|
-
this.transport = new WSTransport(opts);
|
|
3803
|
-
this.session = new EncryptedSession({ myId: opts.myId });
|
|
3804
|
-
// Pass-through lifecycle events from the transport. `EventCore.on` is
|
|
3805
|
-
// static so it routes back through the shared singleton; we bridge it
|
|
3806
|
-
// into our instance-scoped event target.
|
|
3807
|
-
this.transport.on(EventCoreEvents.CONNECTED, () => {
|
|
3808
|
-
// Reset the announce flag on every (re)connect. CF Workers WebSockets
|
|
3809
|
-
// hit an idle timeout and force reconnects; without resetting, the
|
|
3810
|
-
// SDK would short-circuit announce() and any peer who joined the
|
|
3811
|
-
// room after our last announce would never see our pubkey.
|
|
3812
|
-
this.announced = false;
|
|
3813
|
-
this.emit(BroadcastChannelEvents.CONNECTED);
|
|
3814
|
-
if (this.autoAnnounce) {
|
|
3815
|
-
void this.announce();
|
|
3816
|
-
}
|
|
3817
|
-
});
|
|
3818
|
-
this.transport.on(EventCoreEvents.DISCONNECTED, () => {
|
|
3819
|
-
this.emit(BroadcastChannelEvents.DISCONNECTED);
|
|
3820
|
-
});
|
|
3821
|
-
this.transport.on(EventCoreEvents.RECONNECTING, (e) => {
|
|
3822
|
-
this.emit(BroadcastChannelEvents.RECONNECTING, e.detail);
|
|
3823
|
-
});
|
|
3824
|
-
this.transport.on(EventCoreEvents.ERROR, (e) => {
|
|
3825
|
-
this.emit(BroadcastChannelEvents.ERROR, e.detail);
|
|
3826
|
-
});
|
|
3827
|
-
// Route inbound raw frames through our framing/decryption logic.
|
|
3828
|
-
this.transport.on(EventCoreEvents.MESSAGE, (e) => {
|
|
3829
|
-
void this.handleInboundFrame(e.detail);
|
|
3830
|
-
});
|
|
3831
|
-
}
|
|
3832
|
-
// -- event API -------------------------------------------------------------
|
|
3833
|
-
/** Subscribe to a channel event. Handler receives a CustomEvent. */
|
|
3834
|
-
on(event, handler) {
|
|
3835
|
-
this.events.addEventListener(event, handler);
|
|
3836
|
-
}
|
|
3837
|
-
/** Unsubscribe. */
|
|
3838
|
-
off(event, handler) {
|
|
3839
|
-
this.events.removeEventListener(event, handler);
|
|
3840
|
-
}
|
|
3841
|
-
emit(event, detail) {
|
|
3842
|
-
this.events.dispatchEvent(new CustomEvent(event, { detail }));
|
|
3843
|
-
}
|
|
3844
|
-
// -- lifecycle -------------------------------------------------------------
|
|
3845
|
-
/** Generate our keys (idempotent) and open the WebSocket. */
|
|
3846
|
-
async connect() {
|
|
3847
|
-
await this.session.initialize();
|
|
3848
|
-
await this.transport.connect();
|
|
3849
|
-
}
|
|
3850
|
-
/** Close the WebSocket and stop reconnecting. */
|
|
3851
|
-
disconnect() {
|
|
3852
|
-
this.transport.disconnect();
|
|
3853
|
-
}
|
|
3854
|
-
/**
|
|
3855
|
-
* Send our `keyExchange` frame to the room. Idempotent — only sends on the
|
|
3856
|
-
* first call. Call this once your higher-level handshake (e.g. sending a
|
|
3857
|
-
* `{name}` identification frame) has completed.
|
|
3858
|
-
*/
|
|
3859
|
-
async announce() {
|
|
3860
|
-
if (this.announced)
|
|
3861
|
-
return;
|
|
3862
|
-
this.announced = true;
|
|
3863
|
-
const frame = await this.session.getOwnKeyExchange();
|
|
3864
|
-
this.transport.send(JSON.stringify(frame));
|
|
3865
|
-
}
|
|
3866
|
-
/**
|
|
3867
|
-
* Encrypt `plaintext` to every peer we have a ratchet with, and send each
|
|
3868
|
-
* cipherMessage as its own WS frame. Returns the number of peers we sent to
|
|
3869
|
-
* (0 if no peers are handshaken yet — in which case the caller should
|
|
3870
|
-
* typically render the message locally for the sender and try again later).
|
|
3871
|
-
*/
|
|
3872
|
-
async send(plaintext) {
|
|
3873
|
-
const frames = await this.session.encrypt(plaintext);
|
|
3874
|
-
for (const frame of frames) {
|
|
3875
|
-
this.transport.send(JSON.stringify(frame));
|
|
3876
|
-
}
|
|
3877
|
-
return frames.length;
|
|
3878
|
-
}
|
|
3879
|
-
/**
|
|
3880
|
-
* Send an arbitrary JSON-serializable frame as-is. The app uses this for
|
|
3881
|
-
* any frame that isn't channel-managed (e.g. a chat's `{name}`, `{file}`,
|
|
3882
|
-
* or unencrypted `{message}`).
|
|
3883
|
-
*/
|
|
3884
|
-
sendRaw(frame) {
|
|
3885
|
-
this.transport.send(JSON.stringify(frame));
|
|
3886
|
-
}
|
|
3887
|
-
// -- introspection ---------------------------------------------------------
|
|
3888
|
-
/** List of peer ids we have an established ratchet for. */
|
|
3889
|
-
peers() {
|
|
3890
|
-
return this.session.peers();
|
|
3891
|
-
}
|
|
3892
|
-
/** Drop a peer's ratchet (e.g. when they leave the room). */
|
|
3893
|
-
forgetPeer(peerId) {
|
|
3894
|
-
this.session.forgetPeer(peerId);
|
|
3895
|
-
}
|
|
3896
|
-
isConnected() {
|
|
3897
|
-
return this.transport.isConnected();
|
|
3898
|
-
}
|
|
3899
|
-
// -- internals -------------------------------------------------------------
|
|
3900
|
-
async handleInboundFrame(raw) {
|
|
3901
|
-
let parsed;
|
|
3902
|
-
try {
|
|
3903
|
-
parsed = JSON.parse(raw);
|
|
3904
|
-
}
|
|
3905
|
-
catch (err) {
|
|
3906
|
-
this.emit(BroadcastChannelEvents.ERROR, err);
|
|
3907
|
-
return;
|
|
3908
|
-
}
|
|
3909
|
-
if (!parsed || typeof parsed !== "object") {
|
|
3910
|
-
this.emit(BroadcastChannelEvents.RAW_FRAME, parsed);
|
|
3911
|
-
return;
|
|
3912
|
-
}
|
|
3913
|
-
const obj = parsed;
|
|
3914
|
-
// Channel-managed: keyExchange & cipherMessage. Anything else is RAW_FRAME.
|
|
3915
|
-
if ("keyExchange" in obj || "cipherMessage" in obj) {
|
|
3916
|
-
try {
|
|
3917
|
-
const result = await this.session.receive(obj);
|
|
3918
|
-
if (result.kind === "handshake") {
|
|
3919
|
-
if (result.outbound) {
|
|
3920
|
-
this.transport.send(JSON.stringify(result.outbound));
|
|
3921
|
-
}
|
|
3922
|
-
this.emit(BroadcastChannelEvents.PEER_HANDSHAKE, { peerId: result.peerId });
|
|
3923
|
-
}
|
|
3924
|
-
else if (result.kind === "plaintext") {
|
|
3925
|
-
this.emit(BroadcastChannelEvents.MESSAGE, {
|
|
3926
|
-
from: result.from,
|
|
3927
|
-
text: result.text,
|
|
3928
|
-
cipherMessage: result.cipherMessage,
|
|
3929
|
-
});
|
|
3930
|
-
}
|
|
3931
|
-
// `ignored` is intentionally swallowed; subscribe to ERROR for bad frames.
|
|
3932
|
-
}
|
|
3933
|
-
catch (err) {
|
|
3934
|
-
this.emit(BroadcastChannelEvents.ERROR, err);
|
|
3935
|
-
}
|
|
3936
|
-
return;
|
|
3937
|
-
}
|
|
3938
|
-
this.emit(BroadcastChannelEvents.RAW_FRAME, obj);
|
|
3939
|
-
}
|
|
3940
|
-
}
|
|
3941
|
-
|
|
3942
|
-
/**
|
|
3943
|
-
* Wire types for the storage layer.
|
|
3944
|
-
*
|
|
3945
|
-
* The `FileManifest` is the single piece of metadata the system needs to
|
|
3946
|
-
* reassemble a file: per-chunk encryption material, RS parameters, and the
|
|
3947
|
-
* SHA-256 content hashes of every shard that belongs to the chunk. With the
|
|
3948
|
-
* manifest in hand, a reader can pull each shard from the open content-
|
|
3949
|
-
* addressed shard store, decode, decrypt, and concatenate to recover the
|
|
3950
|
-
* original bytes.
|
|
3680
|
+
* The `FileManifest` is the single piece of metadata the system needs to
|
|
3681
|
+
* reassemble a file: per-chunk encryption material, RS parameters, and the
|
|
3682
|
+
* SHA-256 content hashes of every shard that belongs to the chunk. With the
|
|
3683
|
+
* manifest in hand, a reader can pull each shard from the open content-
|
|
3684
|
+
* addressed shard store, decode, decrypt, and concatenate to recover the
|
|
3685
|
+
* original bytes.
|
|
3951
3686
|
*
|
|
3952
3687
|
* The manifest itself lives inside a `SharedSpaceDO` (Phase B) which gates
|
|
3953
3688
|
* access by ZK proof + ACL. Shards are stored in the open content-addressed
|
|
@@ -4553,6 +4288,8 @@ class FileStorage {
|
|
|
4553
4288
|
const fileId = input.fileId ?? generateId();
|
|
4554
4289
|
const createdAt = Date.now();
|
|
4555
4290
|
const chunks = [];
|
|
4291
|
+
const totalChunks = Math.max(1, Math.ceil(data.length / this.chunkSize));
|
|
4292
|
+
input.onProgress?.(0, totalChunks);
|
|
4556
4293
|
// Slicing + encrypting + encoding + uploading is done per-chunk so
|
|
4557
4294
|
// the peak memory footprint stays at ~chunkSize even for huge files.
|
|
4558
4295
|
// Within a chunk, the shard uploads are parallelized up to
|
|
@@ -4562,6 +4299,7 @@ class FileStorage {
|
|
|
4562
4299
|
const end = Math.min(start + this.chunkSize, data.length);
|
|
4563
4300
|
const plaintext = data.subarray(start, end);
|
|
4564
4301
|
chunks.push(await this.writeChunk(plaintext, chunkIndex));
|
|
4302
|
+
input.onProgress?.(chunks.length, totalChunks);
|
|
4565
4303
|
// Stop after one iteration if the file is empty — we still emit a
|
|
4566
4304
|
// single zero-length chunk so the manifest has a stable shape.
|
|
4567
4305
|
if (data.length === 0)
|
|
@@ -4733,20 +4471,710 @@ class FileStorage {
|
|
|
4733
4471
|
await tasks[idx]();
|
|
4734
4472
|
}
|
|
4735
4473
|
}
|
|
4736
|
-
const workers = new Array(Math.min(concurrency, tasks.length))
|
|
4737
|
-
.fill(null)
|
|
4738
|
-
.map(() => nextWorker());
|
|
4739
|
-
await Promise.all(workers);
|
|
4474
|
+
const workers = new Array(Math.min(concurrency, tasks.length))
|
|
4475
|
+
.fill(null)
|
|
4476
|
+
.map(() => nextWorker());
|
|
4477
|
+
await Promise.all(workers);
|
|
4478
|
+
}
|
|
4479
|
+
}
|
|
4480
|
+
/** Normalize `data` to a flat `Uint8Array`. */
|
|
4481
|
+
async function toUint8Array(data) {
|
|
4482
|
+
if (data instanceof Uint8Array)
|
|
4483
|
+
return data;
|
|
4484
|
+
// Blob covers File too (File extends Blob in every runtime we target).
|
|
4485
|
+
const ab = await data.arrayBuffer();
|
|
4486
|
+
return new Uint8Array(ab);
|
|
4487
|
+
}
|
|
4488
|
+
|
|
4489
|
+
/**
|
|
4490
|
+
* HTTP client for the gated file-manifest store on a SharedSpace
|
|
4491
|
+
* (`/api/spaces/:spaceId/files`).
|
|
4492
|
+
*
|
|
4493
|
+
* A "file" in a space is its **manifest** (per-chunk keys + the SHA-256 of each
|
|
4494
|
+
* global shard). The shard bytes live in the open `/api/shards` store; the
|
|
4495
|
+
* manifest is the capability. This client only moves manifests in/out of the
|
|
4496
|
+
* space's gated store.
|
|
4497
|
+
*
|
|
4498
|
+
* Authorization is **session-based**, consistent with the rest of the
|
|
4499
|
+
* SharedSpace (keyring, websocket): the SDK's credential-stamping `fetch`
|
|
4500
|
+
* (from {@link HttpClient}) attaches the app key + session token, the platform
|
|
4501
|
+
* validates the session and injects a trusted `X-Muhkoo-User-Context`, and the
|
|
4502
|
+
* DO authorizes on that + channel membership. There is no per-op Groth16 proof.
|
|
4503
|
+
*
|
|
4504
|
+
* Wire protocol (server: `SharedSpaceDO`):
|
|
4505
|
+
* POST /api/spaces/:spaceId/files body: { manifest } → { ok, fileId }
|
|
4506
|
+
* GET /api/spaces/:spaceId/files → { files: FileStat[] }
|
|
4507
|
+
* GET /api/spaces/:spaceId/files/:fileId → { manifest }
|
|
4508
|
+
* DELETE /api/spaces/:spaceId/files/:fileId → { ok, existed }
|
|
4509
|
+
*/
|
|
4510
|
+
class SharedSpaceClient {
|
|
4511
|
+
baseUrl;
|
|
4512
|
+
fetchFn;
|
|
4513
|
+
constructor(opts) {
|
|
4514
|
+
if (!opts?.baseUrl)
|
|
4515
|
+
throw new Error("SharedSpaceClient: `baseUrl` is required");
|
|
4516
|
+
this.baseUrl = opts.baseUrl.replace(/\/+$/, "");
|
|
4517
|
+
const f = opts.fetch ?? globalThis.fetch;
|
|
4518
|
+
if (typeof f !== "function") {
|
|
4519
|
+
throw new Error("SharedSpaceClient: `globalThis.fetch` is unavailable; pass an explicit fetch.");
|
|
4520
|
+
}
|
|
4521
|
+
this.fetchFn = f.bind(globalThis);
|
|
4522
|
+
}
|
|
4523
|
+
/** Write a file manifest into the space. Requires space membership. */
|
|
4524
|
+
async writeFileManifest(spaceId, manifest) {
|
|
4525
|
+
const url = `${this.filesBase(spaceId)}`;
|
|
4526
|
+
const res = await this.fetchFn(url, this.jsonRequest("POST", { manifest }));
|
|
4527
|
+
await parseOrThrow$1(res, `writeFileManifest(${manifest.id})`);
|
|
4528
|
+
}
|
|
4529
|
+
/** Read a file manifest by id. Requires space membership. */
|
|
4530
|
+
async readFileManifest(spaceId, fileId) {
|
|
4531
|
+
const url = `${this.filesBase(spaceId)}/${encodeURIComponent(fileId)}`;
|
|
4532
|
+
const res = await this.fetchFn(url, this.jsonRequest("GET"));
|
|
4533
|
+
const data = await parseOrThrow$1(res, `readFileManifest(${fileId})`);
|
|
4534
|
+
return data.manifest;
|
|
4535
|
+
}
|
|
4536
|
+
/** Delete a file manifest by id. Requires space membership. */
|
|
4537
|
+
async deleteFileManifest(spaceId, fileId) {
|
|
4538
|
+
const url = `${this.filesBase(spaceId)}/${encodeURIComponent(fileId)}`;
|
|
4539
|
+
const res = await this.fetchFn(url, this.jsonRequest("DELETE"));
|
|
4540
|
+
const data = await parseOrThrow$1(res, `deleteFileManifest(${fileId})`);
|
|
4541
|
+
return data.existed;
|
|
4542
|
+
}
|
|
4543
|
+
/** List file summaries in the space. Requires space membership. */
|
|
4544
|
+
async listFiles(spaceId) {
|
|
4545
|
+
const url = `${this.filesBase(spaceId)}`;
|
|
4546
|
+
const res = await this.fetchFn(url, this.jsonRequest("GET"));
|
|
4547
|
+
const data = await parseOrThrow$1(res, "listFiles");
|
|
4548
|
+
return Array.isArray(data.files) ? data.files : [];
|
|
4549
|
+
}
|
|
4550
|
+
// -------------------------------------------------------------------------
|
|
4551
|
+
filesBase(spaceId) {
|
|
4552
|
+
return `${this.baseUrl}/api/spaces/${encodeURIComponent(spaceId)}/files`;
|
|
4553
|
+
}
|
|
4554
|
+
jsonRequest(method, body) {
|
|
4555
|
+
const init = { method };
|
|
4556
|
+
if (body !== undefined) {
|
|
4557
|
+
init.headers = { "Content-Type": "application/json" };
|
|
4558
|
+
init.body = JSON.stringify(body);
|
|
4559
|
+
}
|
|
4560
|
+
return init;
|
|
4561
|
+
}
|
|
4562
|
+
}
|
|
4563
|
+
async function parseOrThrow$1(response, op) {
|
|
4564
|
+
let parsed = null;
|
|
4565
|
+
try {
|
|
4566
|
+
parsed = await response.json();
|
|
4567
|
+
}
|
|
4568
|
+
catch {
|
|
4569
|
+
// non-JSON body
|
|
4570
|
+
}
|
|
4571
|
+
if (!response.ok) {
|
|
4572
|
+
const msg = parsed && typeof parsed === "object" && "error" in parsed
|
|
4573
|
+
? String(parsed.error)
|
|
4574
|
+
: `${response.status} ${response.statusText}`;
|
|
4575
|
+
throw new Error(`SharedSpaceClient.${op}: ${msg}`);
|
|
4576
|
+
}
|
|
4577
|
+
return parsed;
|
|
4578
|
+
}
|
|
4579
|
+
|
|
4580
|
+
/**
|
|
4581
|
+
* `client.storage` — file storage. Hides the `FileStorage` / `ShardClient` /
|
|
4582
|
+
* `SharedSpaceClient` primitives behind the unified client.
|
|
4583
|
+
*
|
|
4584
|
+
* const { stat, manifest } = await client.storage.writeFile({ spaceId, data, metadata })
|
|
4585
|
+
* const { data } = await client.storage.readFile(spaceId, fileId)
|
|
4586
|
+
* const files = await client.storage.listFiles() // my shared files
|
|
4587
|
+
* const files = await client.storage.listFiles({ spaceId }) // a space's files
|
|
4588
|
+
* await client.storage.deleteFile(spaceId, fileId)
|
|
4589
|
+
*
|
|
4590
|
+
* Model:
|
|
4591
|
+
* - **Files are written to a SharedSpace** (never the personal space). A file
|
|
4592
|
+
* is its manifest (per-chunk keys + global shard hashes); the manifest lives
|
|
4593
|
+
* in the space's gated, metered `/files` store, the shard ciphertext in the
|
|
4594
|
+
* global open `/api/shards` store.
|
|
4595
|
+
* - **The manifest is the capability** — anyone holding it reads the file from
|
|
4596
|
+
* the global shards, anywhere ({@link readByManifest}). Share a file by
|
|
4597
|
+
* sharing its manifest.
|
|
4598
|
+
* - **Personal discovery mirror** — on a successful write the SDK also records
|
|
4599
|
+
* the manifest in the user's `client.kv` under a reserved collection, so
|
|
4600
|
+
* {@link listFiles} (no `spaceId`) can show "files I've shared" without
|
|
4601
|
+
* enumerating spaces. The mirror is unmetered and is NOT a write path.
|
|
4602
|
+
*
|
|
4603
|
+
* For small structured values use `client.kv` instead.
|
|
4604
|
+
*/
|
|
4605
|
+
/** Reserved `client.kv` collection holding the personal manifest mirror. */
|
|
4606
|
+
const MIRROR_COLLECTION = "__files__";
|
|
4607
|
+
class StorageNamespace {
|
|
4608
|
+
deps;
|
|
4609
|
+
fileStorage = null;
|
|
4610
|
+
constructor(deps) {
|
|
4611
|
+
this.deps = deps;
|
|
4612
|
+
}
|
|
4613
|
+
/**
|
|
4614
|
+
* Write a file to a SharedSpace: shard ciphertext → the global store, the
|
|
4615
|
+
* manifest → the space's gated store, and a mirror copy → the caller's
|
|
4616
|
+
* personal index. Returns the `stat` and the `manifest` (share the manifest
|
|
4617
|
+
* to let others read the file).
|
|
4618
|
+
*/
|
|
4619
|
+
async writeFile(input) {
|
|
4620
|
+
if (!input?.spaceId) {
|
|
4621
|
+
throw new Error("client.storage.writeFile: `spaceId` is required (files live in a SharedSpace).");
|
|
4622
|
+
}
|
|
4623
|
+
const fs = this.fs();
|
|
4624
|
+
const { manifest, stat } = await fs.writeFileToShards({
|
|
4625
|
+
data: input.data,
|
|
4626
|
+
metadata: input.metadata,
|
|
4627
|
+
});
|
|
4628
|
+
// Authoritative, metered write: the manifest into the space's gated store.
|
|
4629
|
+
await this.space().writeFileManifest(input.spaceId, manifest);
|
|
4630
|
+
// Best-effort personal discovery mirror (unmetered). A failure here
|
|
4631
|
+
// leaves the file intact in the space — log and continue.
|
|
4632
|
+
try {
|
|
4633
|
+
await this.deps.kv.set(MIRROR_COLLECTION, manifest.id, {
|
|
4634
|
+
spaceId: input.spaceId,
|
|
4635
|
+
manifest,
|
|
4636
|
+
});
|
|
4637
|
+
}
|
|
4638
|
+
catch (err) {
|
|
4639
|
+
console.warn("client.storage.writeFile: discovery mirror failed (file still written):", err);
|
|
4640
|
+
}
|
|
4641
|
+
return { stat, manifest };
|
|
4642
|
+
}
|
|
4643
|
+
/** Read a file from its space (resolves the manifest, then the shards). */
|
|
4644
|
+
async readFile(spaceId, fileId) {
|
|
4645
|
+
return this.fs().readFile(spaceId, fileId);
|
|
4646
|
+
}
|
|
4647
|
+
/**
|
|
4648
|
+
* Read a file straight from a manifest — the capability path. No space
|
|
4649
|
+
* membership needed; this is how a shared file is opened.
|
|
4650
|
+
*/
|
|
4651
|
+
async readByManifest(manifest) {
|
|
4652
|
+
return this.fs().readFileFromShards(manifest);
|
|
4653
|
+
}
|
|
4654
|
+
/**
|
|
4655
|
+
* List files. With no argument, returns the caller's own shared files from
|
|
4656
|
+
* the personal mirror; with `{ spaceId }`, lists that space's files.
|
|
4657
|
+
*/
|
|
4658
|
+
async listFiles(opts) {
|
|
4659
|
+
if (opts?.spaceId)
|
|
4660
|
+
return this.space().listFiles(opts.spaceId);
|
|
4661
|
+
const ids = await this.deps.kv.list(MIRROR_COLLECTION);
|
|
4662
|
+
const entries = await Promise.all(ids.map((id) => this.deps.kv.get(MIRROR_COLLECTION, id)));
|
|
4663
|
+
return entries
|
|
4664
|
+
.filter((e) => Boolean(e?.manifest))
|
|
4665
|
+
.map((e) => manifestToStat(e.manifest));
|
|
4666
|
+
}
|
|
4667
|
+
/** Delete a file from its space and remove the personal mirror entry. */
|
|
4668
|
+
async deleteFile(spaceId, fileId) {
|
|
4669
|
+
const existed = await this.fs().deleteFile(spaceId, fileId);
|
|
4670
|
+
try {
|
|
4671
|
+
await this.deps.kv.delete(MIRROR_COLLECTION, fileId);
|
|
4672
|
+
}
|
|
4673
|
+
catch {
|
|
4674
|
+
// mirror cleanup is best-effort
|
|
4675
|
+
}
|
|
4676
|
+
return existed;
|
|
4677
|
+
}
|
|
4678
|
+
/** Read a manifest verbatim (e.g. to share a file). */
|
|
4679
|
+
async getManifest(spaceId, fileId) {
|
|
4680
|
+
return this.fs().readManifest(spaceId, fileId);
|
|
4681
|
+
}
|
|
4682
|
+
// -------------------------------------------------------------------------
|
|
4683
|
+
// Internals — lazily built so an unauthenticated client is cheap to create.
|
|
4684
|
+
// -------------------------------------------------------------------------
|
|
4685
|
+
fs() {
|
|
4686
|
+
if (!this.fileStorage) {
|
|
4687
|
+
this.fileStorage = new FileStorage({
|
|
4688
|
+
shards: this.shards(),
|
|
4689
|
+
space: this.space(),
|
|
4690
|
+
rsWasmModule: this.deps.rsWasmModule,
|
|
4691
|
+
});
|
|
4692
|
+
}
|
|
4693
|
+
return this.fileStorage;
|
|
4694
|
+
}
|
|
4695
|
+
_shards = null;
|
|
4696
|
+
shards() {
|
|
4697
|
+
// Global, content-addressed shard store. The credential-stamping fetch
|
|
4698
|
+
// attaches the app key so PUTs are attributed + quota-gated; GETs are open.
|
|
4699
|
+
if (!this._shards) {
|
|
4700
|
+
this._shards = new ShardClient({
|
|
4701
|
+
baseUrl: this.deps.baseUrl,
|
|
4702
|
+
pathPrefix: "/api/shards",
|
|
4703
|
+
fetch: this.deps.http.fetch,
|
|
4704
|
+
});
|
|
4705
|
+
}
|
|
4706
|
+
return this._shards;
|
|
4707
|
+
}
|
|
4708
|
+
_space = null;
|
|
4709
|
+
space() {
|
|
4710
|
+
if (!this._space) {
|
|
4711
|
+
this._space = new SharedSpaceClient({
|
|
4712
|
+
baseUrl: this.deps.baseUrl,
|
|
4713
|
+
fetch: this.deps.http.fetch,
|
|
4714
|
+
});
|
|
4715
|
+
}
|
|
4716
|
+
return this._space;
|
|
4717
|
+
}
|
|
4718
|
+
}
|
|
4719
|
+
|
|
4720
|
+
/**
|
|
4721
|
+
* An end-to-end-encrypted communication session for one local identity
|
|
4722
|
+
* (`myId`) talking to an arbitrary set of peers. Wraps `KeyStore` +
|
|
4723
|
+
* per-pair `DoubleRatchet` instances and owns the protocol bookkeeping
|
|
4724
|
+
* (handshake reciprocation, dedup, role assignment, broadcast fan-out,
|
|
4725
|
+
* recipient filtering) so application code doesn't have to.
|
|
4726
|
+
*
|
|
4727
|
+
* Transport-agnostic by design: the Session never touches a WebSocket
|
|
4728
|
+
* (or HTTP, or anything else). Application code:
|
|
4729
|
+
* - calls `session.encrypt(text)` and gets back an array of frames it
|
|
4730
|
+
* ships over whatever transport it has;
|
|
4731
|
+
* - calls `session.receive(frame)` with each inbound frame and acts on
|
|
4732
|
+
* the typed result (plaintext, handshake-done, ignored).
|
|
4733
|
+
*
|
|
4734
|
+
* Frames are plain JSON objects with the same shape the existing chat
|
|
4735
|
+
* protocol uses (`{ keyExchange: {...} }` and `{ cipherMessage: {...} }`),
|
|
4736
|
+
* so this can drop into a chat app without changing the wire format.
|
|
4737
|
+
*/
|
|
4738
|
+
// ---------------------------------------------------------------------------
|
|
4739
|
+
// EncryptedSession
|
|
4740
|
+
// ---------------------------------------------------------------------------
|
|
4741
|
+
class EncryptedSession {
|
|
4742
|
+
myId;
|
|
4743
|
+
keyStore;
|
|
4744
|
+
/** One DoubleRatchet per peer pair. */
|
|
4745
|
+
ratchets = new Map();
|
|
4746
|
+
/** Peers we've already sent our keyExchange to in this Session. */
|
|
4747
|
+
sentHandshakeTo = new Set();
|
|
4748
|
+
/** Resolved when own keys are generated and ready to use. */
|
|
4749
|
+
_ready;
|
|
4750
|
+
_resolveReady;
|
|
4751
|
+
_initialized = false;
|
|
4752
|
+
constructor(opts) {
|
|
4753
|
+
if (!opts || !opts.myId) {
|
|
4754
|
+
throw new Error("EncryptedSession: `myId` is required");
|
|
4755
|
+
}
|
|
4756
|
+
this.myId = opts.myId;
|
|
4757
|
+
this.keyStore = KeyStore.getInstance();
|
|
4758
|
+
this._ready = new Promise((resolve) => {
|
|
4759
|
+
this._resolveReady = resolve;
|
|
4760
|
+
});
|
|
4761
|
+
}
|
|
4762
|
+
/** A promise that resolves once `initialize()` has completed. */
|
|
4763
|
+
get ready() {
|
|
4764
|
+
return this._ready;
|
|
4765
|
+
}
|
|
4766
|
+
/** Our local identifier. */
|
|
4767
|
+
get id() {
|
|
4768
|
+
return this.myId;
|
|
4769
|
+
}
|
|
4770
|
+
/**
|
|
4771
|
+
* Generate this client's ECDH + ECDSA keypair (idempotent — safe to call
|
|
4772
|
+
* multiple times across reconnects; only the first call generates).
|
|
4773
|
+
*/
|
|
4774
|
+
async initialize() {
|
|
4775
|
+
if (this._initialized)
|
|
4776
|
+
return;
|
|
4777
|
+
if (!this.keyStore.getKeyPair(this.myId)) {
|
|
4778
|
+
await this.keyStore.generateOwnKeyPair(this.myId);
|
|
4779
|
+
}
|
|
4780
|
+
this._initialized = true;
|
|
4781
|
+
this._resolveReady();
|
|
4782
|
+
}
|
|
4783
|
+
/**
|
|
4784
|
+
* Build the keyExchange frame this client should send to peers when it
|
|
4785
|
+
* comes online. Application is responsible for actually transmitting it
|
|
4786
|
+
* (e.g. ws.send(JSON.stringify(frame))).
|
|
4787
|
+
*/
|
|
4788
|
+
async getOwnKeyExchange() {
|
|
4789
|
+
await this._ready;
|
|
4790
|
+
const ownEcdh = this.keyStore.getKeyPair(this.myId);
|
|
4791
|
+
const ownEcdsa = this.keyStore.getAuthKeyPair(this.myId);
|
|
4792
|
+
if (!ownEcdh || !ownEcdsa) {
|
|
4793
|
+
throw new Error("EncryptedSession.getOwnKeyExchange: keys not initialized");
|
|
4794
|
+
}
|
|
4795
|
+
return {
|
|
4796
|
+
keyExchange: {
|
|
4797
|
+
type: "handshake",
|
|
4798
|
+
userId: this.myId,
|
|
4799
|
+
ecdhPublicKey: await exportJwkBase64(ownEcdh.publicKey),
|
|
4800
|
+
ecdsaPublicKey: await exportJwkBase64(ownEcdsa.publicKey),
|
|
4801
|
+
},
|
|
4802
|
+
};
|
|
4803
|
+
}
|
|
4804
|
+
/**
|
|
4805
|
+
* Encrypt a plaintext to every peer we currently have a ratchet for, and
|
|
4806
|
+
* return one cipherMessage frame per recipient. The application sends
|
|
4807
|
+
* each frame over its transport.
|
|
4808
|
+
*
|
|
4809
|
+
* If there are no peers yet (or all peers are still mid-handshake), the
|
|
4810
|
+
* returned array may be empty — the caller should typically render the
|
|
4811
|
+
* outgoing message locally in that case (since there's no one to send
|
|
4812
|
+
* it to yet).
|
|
4813
|
+
*/
|
|
4814
|
+
async encrypt(plaintext) {
|
|
4815
|
+
await this._ready;
|
|
4816
|
+
const frames = [];
|
|
4817
|
+
for (const peerId of this.ratchets.keys()) {
|
|
4818
|
+
const ratchet = this.ratchets.get(peerId);
|
|
4819
|
+
const sessionId = this._sessionIdFor(peerId);
|
|
4820
|
+
const cipherMessage = await ratchet.encrypt(plaintext,
|
|
4821
|
+
/* newDhKey */ false, this.myId, peerId, sessionId, "specific");
|
|
4822
|
+
frames.push({ cipherMessage });
|
|
4823
|
+
}
|
|
4824
|
+
return frames;
|
|
4825
|
+
}
|
|
4826
|
+
/**
|
|
4827
|
+
* Hand an inbound frame to the session. Returns a typed result the
|
|
4828
|
+
* application acts on.
|
|
4829
|
+
*
|
|
4830
|
+
* - `kind: 'plaintext'` — decrypted; render `result.text`
|
|
4831
|
+
* - `kind: 'handshake'` — a peer's keys are now stored, and if
|
|
4832
|
+
* `result.outbound` is set, send it
|
|
4833
|
+
* - `kind: 'ignored'` — not for us, malformed, duplicate, etc.
|
|
4834
|
+
*
|
|
4835
|
+
* Calling code should always handle `'ignored'` (no UI side effect).
|
|
4836
|
+
*/
|
|
4837
|
+
async receive(frame) {
|
|
4838
|
+
await this._ready;
|
|
4839
|
+
// -- handshake frame -----------------------------------------------------
|
|
4840
|
+
if (isKeyExchangeFrame(frame)) {
|
|
4841
|
+
const kx = frame.keyExchange;
|
|
4842
|
+
const peerId = kx.userId;
|
|
4843
|
+
if (peerId === this.myId) {
|
|
4844
|
+
return { kind: "ignored", reason: "own keyExchange echo" };
|
|
4845
|
+
}
|
|
4846
|
+
const isNew = !this.ratchets.has(peerId);
|
|
4847
|
+
// (Re-)store the peer's pubkeys + (re-)build the ratchet if needed.
|
|
4848
|
+
const peerEcdh = await importJwkBase64(kx.ecdhPublicKey, "ECDH");
|
|
4849
|
+
const peerEcdsa = await importJwkBase64(kx.ecdsaPublicKey, "ECDSA");
|
|
4850
|
+
await this.keyStore.storeRemotePublicKeys(peerId, peerEcdh, peerEcdsa);
|
|
4851
|
+
if (isNew) {
|
|
4852
|
+
const isClient = this.myId < peerId;
|
|
4853
|
+
const ratchet = new DoubleRatchet(this.myId, peerId, "specific", isClient);
|
|
4854
|
+
await ratchet.initializeSession(isClient);
|
|
4855
|
+
this.ratchets.set(peerId, ratchet);
|
|
4856
|
+
}
|
|
4857
|
+
// Reciprocate exactly once per peer per session lifetime.
|
|
4858
|
+
let outbound = null;
|
|
4859
|
+
if (isNew && !this.sentHandshakeTo.has(peerId)) {
|
|
4860
|
+
outbound = await this.getOwnKeyExchange();
|
|
4861
|
+
this.sentHandshakeTo.add(peerId);
|
|
4862
|
+
}
|
|
4863
|
+
return { kind: "handshake", peerId, outbound };
|
|
4864
|
+
}
|
|
4865
|
+
// -- ciphertext frame ----------------------------------------------------
|
|
4866
|
+
if (isCipherFrame(frame)) {
|
|
4867
|
+
const cipherMessage = frame.cipherMessage;
|
|
4868
|
+
const header = cipherMessage?.header;
|
|
4869
|
+
if (!header) {
|
|
4870
|
+
return { kind: "ignored", reason: "cipherMessage has no header" };
|
|
4871
|
+
}
|
|
4872
|
+
if (header.senderId === this.myId) {
|
|
4873
|
+
return { kind: "ignored", reason: "own outbound echo" };
|
|
4874
|
+
}
|
|
4875
|
+
if (header.recipientId && header.recipientId !== this.myId) {
|
|
4876
|
+
return { kind: "ignored", reason: "addressed to a different peer" };
|
|
4877
|
+
}
|
|
4878
|
+
const ratchet = this.ratchets.get(header.senderId);
|
|
4879
|
+
if (!ratchet) {
|
|
4880
|
+
return {
|
|
4881
|
+
kind: "ignored",
|
|
4882
|
+
reason: `no ratchet with sender ${header.senderId} (handshake hasn't completed)`,
|
|
4883
|
+
};
|
|
4884
|
+
}
|
|
4885
|
+
const isClient = this.myId < header.senderId;
|
|
4886
|
+
let text;
|
|
4887
|
+
try {
|
|
4888
|
+
text = await ratchet.decrypt(cipherMessage, isClient);
|
|
4889
|
+
}
|
|
4890
|
+
catch (err) {
|
|
4891
|
+
// DoubleRatchet's wallclock-based replay-protection check throws
|
|
4892
|
+
// "CipherMessage too old, possible replay attack" for any frame
|
|
4893
|
+
// older than 5 minutes. Real replays still fail downstream because
|
|
4894
|
+
// the message key has been consumed; the wallclock check is a
|
|
4895
|
+
// defense-in-depth heuristic, not a security boundary. Treating it
|
|
4896
|
+
// as `ignored` lets the channel survive legacy backlog replays and
|
|
4897
|
+
// CF WS-idle-timeout reconnect cycles without spamming the
|
|
4898
|
+
// channel's ERROR stream.
|
|
4899
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
4900
|
+
if (msg.includes("too old") || msg.includes("replay attack")) {
|
|
4901
|
+
return { kind: "ignored", reason: `stale cipherMessage rejected: ${msg}` };
|
|
4902
|
+
}
|
|
4903
|
+
throw err;
|
|
4904
|
+
}
|
|
4905
|
+
return { kind: "plaintext", from: header.senderId, text, cipherMessage };
|
|
4906
|
+
}
|
|
4907
|
+
return { kind: "ignored", reason: "unknown frame type" };
|
|
4908
|
+
}
|
|
4909
|
+
// -- introspection ---------------------------------------------------------
|
|
4910
|
+
/** List of peer ids we have an established ratchet for. */
|
|
4911
|
+
peers() {
|
|
4912
|
+
return Array.from(this.ratchets.keys());
|
|
4913
|
+
}
|
|
4914
|
+
/** Has the handshake completed for this peer? */
|
|
4915
|
+
hasRatchetFor(peerId) {
|
|
4916
|
+
return this.ratchets.has(peerId);
|
|
4917
|
+
}
|
|
4918
|
+
/**
|
|
4919
|
+
* Tear down a peer's ratchet (e.g. when they leave the room). The peer's
|
|
4920
|
+
* stored keys remain in `KeyStore` so a fresh handshake can be set up
|
|
4921
|
+
* later without re-fetching them, but the ratchet state is dropped.
|
|
4922
|
+
*/
|
|
4923
|
+
forgetPeer(peerId) {
|
|
4924
|
+
this.ratchets.delete(peerId);
|
|
4925
|
+
this.sentHandshakeTo.delete(peerId);
|
|
4926
|
+
}
|
|
4927
|
+
// -- internals -------------------------------------------------------------
|
|
4928
|
+
/** Deterministic per-pair session id — same string on both sides. */
|
|
4929
|
+
_sessionIdFor(peerId) {
|
|
4930
|
+
return [this.myId, peerId].sort().join(":");
|
|
4931
|
+
}
|
|
4932
|
+
}
|
|
4933
|
+
// ---------------------------------------------------------------------------
|
|
4934
|
+
// Helpers — JWK <-> base64 string for key transport
|
|
4935
|
+
// ---------------------------------------------------------------------------
|
|
4936
|
+
async function exportJwkBase64(key) {
|
|
4937
|
+
const jwk = await crypto.subtle.exportKey("jwk", key);
|
|
4938
|
+
return btoa(JSON.stringify(jwk));
|
|
4939
|
+
}
|
|
4940
|
+
async function importJwkBase64(b64, type) {
|
|
4941
|
+
const jwk = JSON.parse(atob(b64));
|
|
4942
|
+
const algorithm = type === "ECDH"
|
|
4943
|
+
? { name: "ECDH", namedCurve: "P-384" }
|
|
4944
|
+
: { name: "ECDSA", namedCurve: "P-384" };
|
|
4945
|
+
const usages = type === "ECDH" ? [] : ["verify"];
|
|
4946
|
+
return await crypto.subtle.importKey("jwk", jwk, algorithm, true, usages);
|
|
4947
|
+
}
|
|
4948
|
+
// ---------------------------------------------------------------------------
|
|
4949
|
+
// Type guards
|
|
4950
|
+
// ---------------------------------------------------------------------------
|
|
4951
|
+
function isKeyExchangeFrame(frame) {
|
|
4952
|
+
const kx = frame.keyExchange;
|
|
4953
|
+
if (!kx || typeof kx !== "object")
|
|
4954
|
+
return false;
|
|
4955
|
+
const k = kx;
|
|
4956
|
+
return (typeof k.userId === "string" &&
|
|
4957
|
+
typeof k.ecdhPublicKey === "string" &&
|
|
4958
|
+
typeof k.ecdsaPublicKey === "string");
|
|
4959
|
+
}
|
|
4960
|
+
function isCipherFrame(frame) {
|
|
4961
|
+
const cm = frame.cipherMessage;
|
|
4962
|
+
if (!cm || typeof cm !== "object")
|
|
4963
|
+
return false;
|
|
4964
|
+
const m = cm;
|
|
4965
|
+
return Boolean(m.header && m.ciphertext && m.nonce);
|
|
4966
|
+
}
|
|
4967
|
+
|
|
4968
|
+
/**
|
|
4969
|
+
* BroadcastChannel — a turnkey end-to-end encrypted channel for multi-peer
|
|
4970
|
+
* "room"-style apps (e.g. a chat). Wires together:
|
|
4971
|
+
*
|
|
4972
|
+
* - `WSTransport` for socket lifecycle, reconnect, and offline frame queue
|
|
4973
|
+
* - `EncryptedSession` for per-peer Double Ratchet state, handshake
|
|
4974
|
+
* reciprocation, fan-out encryption, and recipient filtering
|
|
4975
|
+
*
|
|
4976
|
+
* The host app only deals with high-level events and method calls; it never
|
|
4977
|
+
* touches `new WebSocket(...)` or builds cipherMessage frames by hand.
|
|
4978
|
+
*
|
|
4979
|
+
* Wire protocol (kept compatible with the existing chat protocol so this
|
|
4980
|
+
* drops into the deployed Accelerator without server changes):
|
|
4981
|
+
*
|
|
4982
|
+
* outbound: `{ keyExchange: {...} }` — produced by us on `announce()`
|
|
4983
|
+
* `{ cipherMessage: {...} }` — produced by `send(text)`,
|
|
4984
|
+
* one frame per peer
|
|
4985
|
+
* arbitrary JSON — sent via `sendRaw(obj)`
|
|
4986
|
+
*
|
|
4987
|
+
* inbound: `{ keyExchange: {...} }` — routed into EncryptedSession;
|
|
4988
|
+
* emits `peer_handshake`
|
|
4989
|
+
* `{ cipherMessage: {...} }` — decrypted; emits `message`
|
|
4990
|
+
* anything else — passes through as `raw_frame`
|
|
4991
|
+
* (the app decides what to do)
|
|
4992
|
+
*
|
|
4993
|
+
* **Event scoping.** Unlike `Network`, BroadcastChannel uses a per-instance
|
|
4994
|
+
* `EventTarget`, so two channels (e.g. two rooms in the same app) don't
|
|
4995
|
+
* cross-contaminate each other's events.
|
|
4996
|
+
*
|
|
4997
|
+
* Note: this class shadows the `BroadcastChannel` Web API global (cross-tab
|
|
4998
|
+
* messaging) only when imported by name. They're unrelated.
|
|
4999
|
+
*/
|
|
5000
|
+
// ---------------------------------------------------------------------------
|
|
5001
|
+
// Event names
|
|
5002
|
+
// ---------------------------------------------------------------------------
|
|
5003
|
+
/**
|
|
5004
|
+
* Event names BroadcastChannel emits. Lifecycle events reuse the
|
|
5005
|
+
* `EventCoreEvents` string values (`connected`, `disconnected`, etc.) so
|
|
5006
|
+
* apps can use shared constants if they want. Channel-specific events are
|
|
5007
|
+
* prefixed `channel:` to avoid colliding with other event systems in the
|
|
5008
|
+
* same app.
|
|
5009
|
+
*/
|
|
5010
|
+
const BroadcastChannelEvents = {
|
|
5011
|
+
CONNECTED: EventCoreEvents.CONNECTED,
|
|
5012
|
+
DISCONNECTED: EventCoreEvents.DISCONNECTED,
|
|
5013
|
+
RECONNECTING: EventCoreEvents.RECONNECTING,
|
|
5014
|
+
ERROR: EventCoreEvents.ERROR,
|
|
5015
|
+
/** A peer's ratchet is now ready. `detail.peerId` is the peer's id. */
|
|
5016
|
+
PEER_HANDSHAKE: "channel:peer_handshake",
|
|
5017
|
+
/** Decrypted message. `detail` is `{ from, text, cipherMessage }`. */
|
|
5018
|
+
MESSAGE: "channel:message",
|
|
5019
|
+
/** Inbound frame BroadcastChannel didn't recognize. App handles it. */
|
|
5020
|
+
RAW_FRAME: "channel:raw_frame",
|
|
5021
|
+
};
|
|
5022
|
+
// ---------------------------------------------------------------------------
|
|
5023
|
+
// BroadcastChannel
|
|
5024
|
+
// ---------------------------------------------------------------------------
|
|
5025
|
+
class BroadcastChannel {
|
|
5026
|
+
transport;
|
|
5027
|
+
session;
|
|
5028
|
+
myId;
|
|
5029
|
+
autoAnnounce;
|
|
5030
|
+
announced = false;
|
|
5031
|
+
/** Per-instance EventTarget — events do NOT leak across BroadcastChannels. */
|
|
5032
|
+
events = new EventTarget();
|
|
5033
|
+
constructor(opts) {
|
|
5034
|
+
if (!opts || !opts.myId) {
|
|
5035
|
+
throw new Error("BroadcastChannel: `myId` is required");
|
|
5036
|
+
}
|
|
5037
|
+
this.myId = opts.myId;
|
|
5038
|
+
this.autoAnnounce = opts.autoAnnounce ?? false;
|
|
5039
|
+
this.transport = new WSTransport(opts);
|
|
5040
|
+
this.session = new EncryptedSession({ myId: opts.myId });
|
|
5041
|
+
// Pass-through lifecycle events from the transport. `EventCore.on` is
|
|
5042
|
+
// static so it routes back through the shared singleton; we bridge it
|
|
5043
|
+
// into our instance-scoped event target.
|
|
5044
|
+
this.transport.on(EventCoreEvents.CONNECTED, () => {
|
|
5045
|
+
// Reset the announce flag on every (re)connect. CF Workers WebSockets
|
|
5046
|
+
// hit an idle timeout and force reconnects; without resetting, the
|
|
5047
|
+
// SDK would short-circuit announce() and any peer who joined the
|
|
5048
|
+
// room after our last announce would never see our pubkey.
|
|
5049
|
+
this.announced = false;
|
|
5050
|
+
this.emit(BroadcastChannelEvents.CONNECTED);
|
|
5051
|
+
if (this.autoAnnounce) {
|
|
5052
|
+
void this.announce();
|
|
5053
|
+
}
|
|
5054
|
+
});
|
|
5055
|
+
this.transport.on(EventCoreEvents.DISCONNECTED, () => {
|
|
5056
|
+
this.emit(BroadcastChannelEvents.DISCONNECTED);
|
|
5057
|
+
});
|
|
5058
|
+
this.transport.on(EventCoreEvents.RECONNECTING, (e) => {
|
|
5059
|
+
this.emit(BroadcastChannelEvents.RECONNECTING, e.detail);
|
|
5060
|
+
});
|
|
5061
|
+
this.transport.on(EventCoreEvents.ERROR, (e) => {
|
|
5062
|
+
this.emit(BroadcastChannelEvents.ERROR, e.detail);
|
|
5063
|
+
});
|
|
5064
|
+
// Route inbound raw frames through our framing/decryption logic.
|
|
5065
|
+
this.transport.on(EventCoreEvents.MESSAGE, (e) => {
|
|
5066
|
+
void this.handleInboundFrame(e.detail);
|
|
5067
|
+
});
|
|
5068
|
+
}
|
|
5069
|
+
// -- event API -------------------------------------------------------------
|
|
5070
|
+
/** Subscribe to a channel event. Handler receives a CustomEvent. */
|
|
5071
|
+
on(event, handler) {
|
|
5072
|
+
this.events.addEventListener(event, handler);
|
|
5073
|
+
}
|
|
5074
|
+
/** Unsubscribe. */
|
|
5075
|
+
off(event, handler) {
|
|
5076
|
+
this.events.removeEventListener(event, handler);
|
|
5077
|
+
}
|
|
5078
|
+
emit(event, detail) {
|
|
5079
|
+
this.events.dispatchEvent(new CustomEvent(event, { detail }));
|
|
5080
|
+
}
|
|
5081
|
+
// -- lifecycle -------------------------------------------------------------
|
|
5082
|
+
/** Generate our keys (idempotent) and open the WebSocket. */
|
|
5083
|
+
async connect() {
|
|
5084
|
+
await this.session.initialize();
|
|
5085
|
+
await this.transport.connect();
|
|
5086
|
+
}
|
|
5087
|
+
/** Close the WebSocket and stop reconnecting. */
|
|
5088
|
+
disconnect() {
|
|
5089
|
+
this.transport.disconnect();
|
|
5090
|
+
}
|
|
5091
|
+
/**
|
|
5092
|
+
* Send our `keyExchange` frame to the room. Idempotent — only sends on the
|
|
5093
|
+
* first call. Call this once your higher-level handshake (e.g. sending a
|
|
5094
|
+
* `{name}` identification frame) has completed.
|
|
5095
|
+
*/
|
|
5096
|
+
async announce() {
|
|
5097
|
+
if (this.announced)
|
|
5098
|
+
return;
|
|
5099
|
+
this.announced = true;
|
|
5100
|
+
const frame = await this.session.getOwnKeyExchange();
|
|
5101
|
+
this.transport.send(JSON.stringify(frame));
|
|
5102
|
+
}
|
|
5103
|
+
/**
|
|
5104
|
+
* Encrypt `plaintext` to every peer we have a ratchet with, and send each
|
|
5105
|
+
* cipherMessage as its own WS frame. Returns the number of peers we sent to
|
|
5106
|
+
* (0 if no peers are handshaken yet — in which case the caller should
|
|
5107
|
+
* typically render the message locally for the sender and try again later).
|
|
5108
|
+
*/
|
|
5109
|
+
async send(plaintext) {
|
|
5110
|
+
const frames = await this.session.encrypt(plaintext);
|
|
5111
|
+
for (const frame of frames) {
|
|
5112
|
+
this.transport.send(JSON.stringify(frame));
|
|
5113
|
+
}
|
|
5114
|
+
return frames.length;
|
|
5115
|
+
}
|
|
5116
|
+
/**
|
|
5117
|
+
* Send an arbitrary JSON-serializable frame as-is. The app uses this for
|
|
5118
|
+
* any frame that isn't channel-managed (e.g. a chat's `{name}`, `{file}`,
|
|
5119
|
+
* or unencrypted `{message}`).
|
|
5120
|
+
*/
|
|
5121
|
+
sendRaw(frame) {
|
|
5122
|
+
this.transport.send(JSON.stringify(frame));
|
|
5123
|
+
}
|
|
5124
|
+
// -- introspection ---------------------------------------------------------
|
|
5125
|
+
/** List of peer ids we have an established ratchet for. */
|
|
5126
|
+
peers() {
|
|
5127
|
+
return this.session.peers();
|
|
5128
|
+
}
|
|
5129
|
+
/** Drop a peer's ratchet (e.g. when they leave the room). */
|
|
5130
|
+
forgetPeer(peerId) {
|
|
5131
|
+
this.session.forgetPeer(peerId);
|
|
5132
|
+
}
|
|
5133
|
+
isConnected() {
|
|
5134
|
+
return this.transport.isConnected();
|
|
5135
|
+
}
|
|
5136
|
+
// -- internals -------------------------------------------------------------
|
|
5137
|
+
async handleInboundFrame(raw) {
|
|
5138
|
+
let parsed;
|
|
5139
|
+
try {
|
|
5140
|
+
parsed = JSON.parse(raw);
|
|
5141
|
+
}
|
|
5142
|
+
catch (err) {
|
|
5143
|
+
this.emit(BroadcastChannelEvents.ERROR, err);
|
|
5144
|
+
return;
|
|
5145
|
+
}
|
|
5146
|
+
if (!parsed || typeof parsed !== "object") {
|
|
5147
|
+
this.emit(BroadcastChannelEvents.RAW_FRAME, parsed);
|
|
5148
|
+
return;
|
|
5149
|
+
}
|
|
5150
|
+
const obj = parsed;
|
|
5151
|
+
// Channel-managed: keyExchange & cipherMessage. Anything else is RAW_FRAME.
|
|
5152
|
+
if ("keyExchange" in obj || "cipherMessage" in obj) {
|
|
5153
|
+
try {
|
|
5154
|
+
const result = await this.session.receive(obj);
|
|
5155
|
+
if (result.kind === "handshake") {
|
|
5156
|
+
if (result.outbound) {
|
|
5157
|
+
this.transport.send(JSON.stringify(result.outbound));
|
|
5158
|
+
}
|
|
5159
|
+
this.emit(BroadcastChannelEvents.PEER_HANDSHAKE, { peerId: result.peerId });
|
|
5160
|
+
}
|
|
5161
|
+
else if (result.kind === "plaintext") {
|
|
5162
|
+
this.emit(BroadcastChannelEvents.MESSAGE, {
|
|
5163
|
+
from: result.from,
|
|
5164
|
+
text: result.text,
|
|
5165
|
+
cipherMessage: result.cipherMessage,
|
|
5166
|
+
});
|
|
5167
|
+
}
|
|
5168
|
+
// `ignored` is intentionally swallowed; subscribe to ERROR for bad frames.
|
|
5169
|
+
}
|
|
5170
|
+
catch (err) {
|
|
5171
|
+
this.emit(BroadcastChannelEvents.ERROR, err);
|
|
5172
|
+
}
|
|
5173
|
+
return;
|
|
5174
|
+
}
|
|
5175
|
+
this.emit(BroadcastChannelEvents.RAW_FRAME, obj);
|
|
4740
5176
|
}
|
|
4741
5177
|
}
|
|
4742
|
-
/** Normalize `data` to a flat `Uint8Array`. */
|
|
4743
|
-
async function toUint8Array(data) {
|
|
4744
|
-
if (data instanceof Uint8Array)
|
|
4745
|
-
return data;
|
|
4746
|
-
// Blob covers File too (File extends Blob in every runtime we target).
|
|
4747
|
-
const ab = await data.arrayBuffer();
|
|
4748
|
-
return new Uint8Array(ab);
|
|
4749
|
-
}
|
|
4750
5178
|
|
|
4751
5179
|
/**
|
|
4752
5180
|
* SpaceCipher — pure crypto for the fan-out group-encryption layer.
|
|
@@ -4987,12 +5415,27 @@ class Space {
|
|
|
4987
5415
|
/** Open the space websocket (resolves the ticket + identity lazily). */
|
|
4988
5416
|
async connect() {
|
|
4989
5417
|
if (!this.channel) {
|
|
4990
|
-
|
|
4991
|
-
|
|
4992
|
-
|
|
5418
|
+
// The WS upgrade ticket is single-use + short-TTL, so a reconnect
|
|
5419
|
+
// must mint a FRESH one — otherwise the socket drops (idle timeout,
|
|
5420
|
+
// network blip) and never comes back. `buildUrl` is handed to the
|
|
5421
|
+
// transport as a `urlProvider` it calls before each reconnect.
|
|
5422
|
+
const buildUrl = async () => {
|
|
5423
|
+
const ticket = await this.deps.fetchTicket();
|
|
5424
|
+
return (`${this.deps.wsBaseUrl}/api/spaces/${encodeURIComponent(this.name)}/websocket` +
|
|
5425
|
+
(ticket ? `?ticket=${encodeURIComponent(ticket)}` : ""));
|
|
5426
|
+
};
|
|
5427
|
+
const url = await buildUrl();
|
|
4993
5428
|
this.channel = this.deps.createChannel
|
|
4994
5429
|
? this.deps.createChannel(url, this.deps.myId())
|
|
4995
|
-
: new BroadcastChannel({
|
|
5430
|
+
: new BroadcastChannel({
|
|
5431
|
+
url,
|
|
5432
|
+
myId: this.deps.myId(),
|
|
5433
|
+
autoAnnounce: false,
|
|
5434
|
+
urlProvider: buildUrl,
|
|
5435
|
+
// Keep recovering across network flaps (each attempt mints a
|
|
5436
|
+
// fresh ticket); teardown calls disconnect() which stops it.
|
|
5437
|
+
maxReconnectAttempts: 0,
|
|
5438
|
+
});
|
|
4996
5439
|
for (const [event, handler] of this.pending)
|
|
4997
5440
|
this.channel.on(event, handler);
|
|
4998
5441
|
this.pending.length = 0;
|
|
@@ -5056,23 +5499,7 @@ class Space {
|
|
|
5056
5499
|
* persisted `spaceMessage`. Requires a keyring.
|
|
5057
5500
|
*/
|
|
5058
5501
|
async sendMessage(payload, opts = {}) {
|
|
5059
|
-
|
|
5060
|
-
const message = new Message(payload);
|
|
5061
|
-
const headers = await cipher.seal(message.serialize());
|
|
5062
|
-
const source = this.deps.myId();
|
|
5063
|
-
const subject = opts.channel ?? "default";
|
|
5064
|
-
// Sign the message with our identity ECDSA key so receivers can verify
|
|
5065
|
-
// authorship end-to-end (independent of the relay's `source` stamp).
|
|
5066
|
-
const authPriv = KeyStore.getInstance().getAuthKeyPair(source)?.privateKey;
|
|
5067
|
-
if (authPriv) {
|
|
5068
|
-
const canonical = canonicalMessage({
|
|
5069
|
-
source, target: this.name, subject,
|
|
5070
|
-
epoch: Number(headers.epoch), iv: String(headers.iv), ciphertext: String(headers.ciphertext),
|
|
5071
|
-
});
|
|
5072
|
-
headers.sig = await signSpaceMessage(canonical, authPriv);
|
|
5073
|
-
}
|
|
5074
|
-
const packet = new Packet({ subject, source, target: this.name, headers });
|
|
5075
|
-
this.sendRaw({ spaceMessage: packet.serialize() });
|
|
5502
|
+
this.sendRaw({ spaceMessage: await this.sealMessage(payload, opts.channel, opts.contentType) });
|
|
5076
5503
|
}
|
|
5077
5504
|
/** Subscribe to decrypted fan-out messages. Returns an unsubscribe fn. */
|
|
5078
5505
|
onMessage(handler) {
|
|
@@ -5080,6 +5507,56 @@ class Space {
|
|
|
5080
5507
|
this.events.addEventListener("message", listener);
|
|
5081
5508
|
return () => this.events.removeEventListener("message", listener);
|
|
5082
5509
|
}
|
|
5510
|
+
/**
|
|
5511
|
+
* Edit a persisted message IN PLACE, addressed by its server
|
|
5512
|
+
* {@link SpaceMessageEvent.handle}. Re-seals `payload` and asks the server
|
|
5513
|
+
* to replace the stored entry; the server authorizes against the original
|
|
5514
|
+
* author. Receivers get an {@link onMessageEdited} event carrying the new
|
|
5515
|
+
* content at the same handle. This is a generic persisted-item edit — the
|
|
5516
|
+
* server has no notion of what the payload means.
|
|
5517
|
+
*/
|
|
5518
|
+
async editMessage(handle, payload, opts = {}) {
|
|
5519
|
+
const spaceMessage = await this.sealMessage(payload, opts.channel, opts.contentType);
|
|
5520
|
+
this.sendRaw({ editSpaceMessage: { ts: handle, spaceMessage } });
|
|
5521
|
+
}
|
|
5522
|
+
/**
|
|
5523
|
+
* Hard-delete a persisted message by its server {@link SpaceMessageEvent.handle}
|
|
5524
|
+
* — the ciphertext is removed from storage. Server authorizes against the
|
|
5525
|
+
* original author. Receivers get an {@link onMessageDeleted} event.
|
|
5526
|
+
*/
|
|
5527
|
+
async deleteMessage(handle) {
|
|
5528
|
+
this.sendRaw({ deleteSpaceMessage: { ts: handle } });
|
|
5529
|
+
}
|
|
5530
|
+
/** Subscribe to in-place edits of persisted messages (new content at the same handle). */
|
|
5531
|
+
onMessageEdited(handler) {
|
|
5532
|
+
const listener = (e) => handler(e.detail);
|
|
5533
|
+
this.events.addEventListener("message-edited", listener);
|
|
5534
|
+
return () => this.events.removeEventListener("message-edited", listener);
|
|
5535
|
+
}
|
|
5536
|
+
/** Subscribe to deletions of persisted messages. */
|
|
5537
|
+
onMessageDeleted(handler) {
|
|
5538
|
+
const listener = (e) => handler(e.detail);
|
|
5539
|
+
this.events.addEventListener("message-deleted", listener);
|
|
5540
|
+
return () => this.events.removeEventListener("message-deleted", listener);
|
|
5541
|
+
}
|
|
5542
|
+
/**
|
|
5543
|
+
* Broadcast an ephemeral, never-persisted signal to the room over the
|
|
5544
|
+
* generic `pub` relay. `subject` is an app-defined routing key; `data` is
|
|
5545
|
+
* opaque. The transport stays domain-agnostic — app concepts like typing
|
|
5546
|
+
* indicators, presence, or live cursors are built on top of this. No-op if
|
|
5547
|
+
* the space isn't connected (ephemeral signals aren't worth queuing).
|
|
5548
|
+
*/
|
|
5549
|
+
sendEphemeral(subject, data) {
|
|
5550
|
+
if (!this.isConnected())
|
|
5551
|
+
return;
|
|
5552
|
+
this.sendRaw({ pub: { subject, data } });
|
|
5553
|
+
}
|
|
5554
|
+
/** Subscribe to inbound ephemeral signals. `from` is the authenticated sender. */
|
|
5555
|
+
onEphemeral(handler) {
|
|
5556
|
+
const listener = (e) => handler(e.detail);
|
|
5557
|
+
this.events.addEventListener("ephemeral", listener);
|
|
5558
|
+
return () => this.events.removeEventListener("ephemeral", listener);
|
|
5559
|
+
}
|
|
5083
5560
|
/** Subscribe to inbound join requests (for a key-holder to admit). */
|
|
5084
5561
|
onJoinRequest(handler) {
|
|
5085
5562
|
const listener = (e) => handler(e.detail);
|
|
@@ -5162,16 +5639,20 @@ class Space {
|
|
|
5162
5639
|
return this.requireKeyring().rotate(roster);
|
|
5163
5640
|
}
|
|
5164
5641
|
// -- room-scoped files (no websocket needed) -------------------------------
|
|
5165
|
-
async putFile(file, metadata) {
|
|
5166
|
-
return this.fileStorage().writeFileToShards({ data: file, metadata });
|
|
5642
|
+
async putFile(file, metadata, opts = {}) {
|
|
5643
|
+
return this.fileStorage().writeFileToShards({ data: file, metadata, onProgress: opts.onProgress });
|
|
5167
5644
|
}
|
|
5168
5645
|
async getFile(manifest) {
|
|
5169
5646
|
return this.fileStorage().readFileFromShards(manifest);
|
|
5170
5647
|
}
|
|
5171
5648
|
fileStorage() {
|
|
5649
|
+
// Global, content-addressed shard store — same store `client.storage`
|
|
5650
|
+
// uses, so a file shared into a channel resolves from anywhere by
|
|
5651
|
+
// manifest. (Shards are encrypted ciphertext; the manifest is the
|
|
5652
|
+
// capability.)
|
|
5172
5653
|
const shards = new ShardClient({
|
|
5173
5654
|
baseUrl: this.deps.httpBaseUrl,
|
|
5174
|
-
pathPrefix:
|
|
5655
|
+
pathPrefix: "/api/shards",
|
|
5175
5656
|
fetch: this.deps.fetch,
|
|
5176
5657
|
});
|
|
5177
5658
|
return new FileStorage({ shards });
|
|
@@ -5191,11 +5672,39 @@ class Space {
|
|
|
5191
5672
|
if (!f || typeof f !== "object")
|
|
5192
5673
|
return;
|
|
5193
5674
|
if (typeof f.spaceMessage === "string" && this.cipher) {
|
|
5194
|
-
const decoded = await this.decodeFrame(f.spaceMessage, this.cipher, /*raw*/ true);
|
|
5675
|
+
const decoded = await this.decodeFrame(f.spaceMessage, this.cipher, /*raw*/ true, Number(f.timestamp ?? 0));
|
|
5195
5676
|
if (decoded)
|
|
5196
5677
|
this.emitEvent("message", decoded);
|
|
5197
5678
|
return;
|
|
5198
5679
|
}
|
|
5680
|
+
if (f.editSpaceMessage && typeof f.editSpaceMessage === "object" && this.cipher) {
|
|
5681
|
+
// Server-authoritative in-place edit: new sealed content at the same
|
|
5682
|
+
// handle. Decode and surface it; the app replaces by `handle`.
|
|
5683
|
+
const e = f.editSpaceMessage;
|
|
5684
|
+
if (typeof e.spaceMessage === "string") {
|
|
5685
|
+
const decoded = await this.decodeFrame(e.spaceMessage, this.cipher, /*raw*/ true, Number(e.ts ?? 0));
|
|
5686
|
+
if (decoded)
|
|
5687
|
+
this.emitEvent("message-edited", decoded);
|
|
5688
|
+
}
|
|
5689
|
+
return;
|
|
5690
|
+
}
|
|
5691
|
+
if (f.deleteSpaceMessage && typeof f.deleteSpaceMessage === "object") {
|
|
5692
|
+
const d = f.deleteSpaceMessage;
|
|
5693
|
+
this.emitEvent("message-deleted", { handle: Number(d.ts ?? 0) });
|
|
5694
|
+
return;
|
|
5695
|
+
}
|
|
5696
|
+
if (f.pub && typeof f.pub === "object") {
|
|
5697
|
+
// Generic ephemeral relay frame. The server stamps the authenticated
|
|
5698
|
+
// sender as `name`; `pub` carries the app's {subject, data}. We
|
|
5699
|
+
// surface the authenticated `name` as `from` (not any client-set
|
|
5700
|
+
// value inside `pub`), so app signals can't spoof their sender.
|
|
5701
|
+
const p = f.pub;
|
|
5702
|
+
const from = typeof f.name === "string" ? f.name : "";
|
|
5703
|
+
if (from && typeof p.subject === "string") {
|
|
5704
|
+
this.emitEvent("ephemeral", { from, subject: p.subject, data: p.data });
|
|
5705
|
+
}
|
|
5706
|
+
return;
|
|
5707
|
+
}
|
|
5199
5708
|
if (f.joinRequest && typeof f.joinRequest === "object") {
|
|
5200
5709
|
const req = f.joinRequest;
|
|
5201
5710
|
this.emitEvent("join-request", req);
|
|
@@ -5212,19 +5721,46 @@ class Space {
|
|
|
5212
5721
|
void this.deps.keyring.pullKeys().catch(() => { });
|
|
5213
5722
|
}
|
|
5214
5723
|
}
|
|
5724
|
+
/**
|
|
5725
|
+
* Seal `payload` once with the current group key + sign it, returning the
|
|
5726
|
+
* serialized `Packet` string. Shared by send (a new message) and edit
|
|
5727
|
+
* (replacement content for an existing handle). Requires a keyring.
|
|
5728
|
+
*/
|
|
5729
|
+
async sealMessage(payload, channel, contentType) {
|
|
5730
|
+
const cipher = this.requireCipher(contentType);
|
|
5731
|
+
const message = new Message(payload);
|
|
5732
|
+
const headers = await cipher.seal(message.serialize());
|
|
5733
|
+
const source = this.deps.myId();
|
|
5734
|
+
const subject = channel ?? "default";
|
|
5735
|
+
// Sign with our identity ECDSA key so receivers can verify authorship
|
|
5736
|
+
// end-to-end (independent of the relay's `source` stamp).
|
|
5737
|
+
const authPriv = KeyStore.getInstance().getAuthKeyPair(source)?.privateKey;
|
|
5738
|
+
if (authPriv) {
|
|
5739
|
+
const canonical = canonicalMessage({
|
|
5740
|
+
source, target: this.name, subject,
|
|
5741
|
+
epoch: Number(headers.epoch), iv: String(headers.iv), ciphertext: String(headers.ciphertext),
|
|
5742
|
+
});
|
|
5743
|
+
headers.sig = await signSpaceMessage(canonical, authPriv);
|
|
5744
|
+
}
|
|
5745
|
+
return new Packet({ subject, source, target: this.name, headers }).serialize();
|
|
5746
|
+
}
|
|
5215
5747
|
/**
|
|
5216
5748
|
* Decode one persisted/broadcast frame into a SpaceMessageEvent.
|
|
5217
5749
|
* `isRawFrame` true means `input` is the already-extracted `spaceMessage`
|
|
5218
|
-
* packet string
|
|
5750
|
+
* packet string (and `serverTs` carries the message's storage handle from
|
|
5751
|
+
* the enclosing frame); false means `input` is the full
|
|
5752
|
+
* `{spaceMessage, name, timestamp}` frame and the handle is read from it.
|
|
5219
5753
|
*/
|
|
5220
|
-
async decodeFrame(input, cipher, isRawFrame = false) {
|
|
5754
|
+
async decodeFrame(input, cipher, isRawFrame = false, serverTs = 0) {
|
|
5221
5755
|
try {
|
|
5222
5756
|
let packetJson = input;
|
|
5757
|
+
let handle = serverTs;
|
|
5223
5758
|
if (!isRawFrame) {
|
|
5224
5759
|
const outer = JSON.parse(input);
|
|
5225
5760
|
if (typeof outer.spaceMessage !== "string")
|
|
5226
5761
|
return null;
|
|
5227
5762
|
packetJson = outer.spaceMessage;
|
|
5763
|
+
handle = Number(outer.timestamp ?? 0);
|
|
5228
5764
|
}
|
|
5229
5765
|
const packet = Packet.deserialize(packetJson);
|
|
5230
5766
|
if (!cipher.handles(packet.headers))
|
|
@@ -5245,6 +5781,7 @@ class Space {
|
|
|
5245
5781
|
channel: packet.subject,
|
|
5246
5782
|
epoch: Number(packet.headers?.epoch ?? 0),
|
|
5247
5783
|
contentType: packet.headers?.contentType,
|
|
5784
|
+
handle,
|
|
5248
5785
|
message,
|
|
5249
5786
|
};
|
|
5250
5787
|
}
|
|
@@ -5260,6 +5797,12 @@ class Space {
|
|
|
5260
5797
|
* nothing to verify against → reject.
|
|
5261
5798
|
*/
|
|
5262
5799
|
async verifySender(packet) {
|
|
5800
|
+
// Trust our OWN messages — we sent them, so there's nothing to prove
|
|
5801
|
+
// against the roster. This also keeps a returning member (who loaded a
|
|
5802
|
+
// cached key and never re-published a join-request) from dropping the
|
|
5803
|
+
// echo of their own messages when their `ecdsaPub` isn't in the roster.
|
|
5804
|
+
if (packet.source === this.deps.myId())
|
|
5805
|
+
return true;
|
|
5263
5806
|
const keyring = this.deps.keyring;
|
|
5264
5807
|
const sig = packet.headers?.sig;
|
|
5265
5808
|
if (!keyring || typeof sig !== "string")
|
|
@@ -5756,6 +6299,34 @@ class KeyringClient {
|
|
|
5756
6299
|
async invite(username) {
|
|
5757
6300
|
await this.post("/keyring/invite", { username });
|
|
5758
6301
|
}
|
|
6302
|
+
// -- Shareable invite links ----------------------------------------------
|
|
6303
|
+
// A capability token tied to this space. Redeeming it allowlists the holder
|
|
6304
|
+
// so the keeper admits them — the link IS the invitation. Mint/list/revoke
|
|
6305
|
+
// require the caller to be the creator or an existing member.
|
|
6306
|
+
async createInviteLink(opts = {}) {
|
|
6307
|
+
return this.post("/keyring/link", opts);
|
|
6308
|
+
}
|
|
6309
|
+
async listInviteLinks() {
|
|
6310
|
+
const body = await this.get("/keyring/links");
|
|
6311
|
+
return body.links ?? [];
|
|
6312
|
+
}
|
|
6313
|
+
/** Set a member's role ("viewer" | "editor"). Owner-only on the server. */
|
|
6314
|
+
async setMemberRole(username, role) {
|
|
6315
|
+
await this.post("/keyring/role", { username, role });
|
|
6316
|
+
}
|
|
6317
|
+
/** Roster with roles (owner/members only). */
|
|
6318
|
+
async members() {
|
|
6319
|
+
const body = await this.get("/keyring/members");
|
|
6320
|
+
return body.members ?? [];
|
|
6321
|
+
}
|
|
6322
|
+
async revokeInviteLink(token) {
|
|
6323
|
+
await this.post("/keyring/link-revoke", { token });
|
|
6324
|
+
}
|
|
6325
|
+
/** Redeem a link: the server allowlists this member and asks the keeper to
|
|
6326
|
+
* wrap the group key for their identity. Caller must be authenticated. */
|
|
6327
|
+
async redeemInvite(req) {
|
|
6328
|
+
await this.post("/keyring/redeem", req);
|
|
6329
|
+
}
|
|
5759
6330
|
async fetchMetadata() {
|
|
5760
6331
|
try {
|
|
5761
6332
|
return await this.get("/metadata");
|
|
@@ -5893,6 +6464,56 @@ class SpaceNamespace {
|
|
|
5893
6464
|
return this.joinSpace(spaceId, opts);
|
|
5894
6465
|
}
|
|
5895
6466
|
// -------------------------------------------------------------------------
|
|
6467
|
+
// Invite links — shareable, keeper-gated invitations to a space. The token
|
|
6468
|
+
// is a capability: anyone signed in who redeems it is admitted by the
|
|
6469
|
+
// keeper. Mint/list/revoke require the caller to be the space's creator or
|
|
6470
|
+
// an existing member.
|
|
6471
|
+
// -------------------------------------------------------------------------
|
|
6472
|
+
/** Mint a shareable invite link for a space. `opts.expiresInSec` / `maxUses`
|
|
6473
|
+
* bound it (0/undefined = unlimited); `opts.role` ("viewer"|"editor") is the
|
|
6474
|
+
* access granted to redeemers (default viewer). Returns the link. */
|
|
6475
|
+
async createInviteLink(spaceId, opts = {}) {
|
|
6476
|
+
return this.keyringFor(spaceId).createInviteLink(opts);
|
|
6477
|
+
}
|
|
6478
|
+
/** Set a member's role on a space ("viewer" | "editor"). Owner-only. */
|
|
6479
|
+
async setMemberRole(spaceId, username, role) {
|
|
6480
|
+
await this.keyringFor(spaceId).setMemberRole(username, role);
|
|
6481
|
+
}
|
|
6482
|
+
/** Roster with roles (owner/members only). */
|
|
6483
|
+
async members(spaceId) {
|
|
6484
|
+
return this.keyringFor(spaceId).members();
|
|
6485
|
+
}
|
|
6486
|
+
/** List a space's active invite links (creator/members only). */
|
|
6487
|
+
async listInviteLinks(spaceId) {
|
|
6488
|
+
return this.keyringFor(spaceId).listInviteLinks();
|
|
6489
|
+
}
|
|
6490
|
+
/** Revoke an invite link by token. */
|
|
6491
|
+
async revokeInviteLink(spaceId, token) {
|
|
6492
|
+
await this.keyringFor(spaceId).revokeInviteLink(token);
|
|
6493
|
+
}
|
|
6494
|
+
/**
|
|
6495
|
+
* Join a space using an invite link: redeem the token (the keeper admits
|
|
6496
|
+
* this member), then connect and resolve once the group key arrives.
|
|
6497
|
+
* Returns the open {@link Space}.
|
|
6498
|
+
*/
|
|
6499
|
+
async joinByInvite(spaceId, token, opts = {}) {
|
|
6500
|
+
const memberId = this.myId();
|
|
6501
|
+
const { ecdhPub, ecdsaPub } = await this.ensureIdentity(memberId);
|
|
6502
|
+
await this.keyringFor(spaceId).redeemInvite({ token, identityEcdhPub: ecdhPub, identityEcdsaPub: ecdsaPub });
|
|
6503
|
+
return this.joinSpace(spaceId, opts);
|
|
6504
|
+
}
|
|
6505
|
+
/** The current member roster of a space (member id → identity public keys). */
|
|
6506
|
+
async roster(spaceId) {
|
|
6507
|
+
return this.keyringFor(spaceId).fetchRoster();
|
|
6508
|
+
}
|
|
6509
|
+
keyringFor(spaceId) {
|
|
6510
|
+
return new KeyringClient({
|
|
6511
|
+
spaceId,
|
|
6512
|
+
httpBaseUrl: this.deps.http.baseUrl,
|
|
6513
|
+
fetch: this.deps.http.fetch,
|
|
6514
|
+
});
|
|
6515
|
+
}
|
|
6516
|
+
// -------------------------------------------------------------------------
|
|
5896
6517
|
// Internals
|
|
5897
6518
|
// -------------------------------------------------------------------------
|
|
5898
6519
|
async build(spaceId, historyPolicy) {
|
|
@@ -5959,6 +6580,133 @@ class SpaceNamespace {
|
|
|
5959
6580
|
}
|
|
5960
6581
|
}
|
|
5961
6582
|
|
|
6583
|
+
/**
|
|
6584
|
+
* `client.agents` — manage an app's **Programmable Agents** (server-side
|
|
6585
|
+
* "virtual users" backed by Workers AI).
|
|
6586
|
+
*
|
|
6587
|
+
* const { config } = await client.agents.create(appId, {
|
|
6588
|
+
* handle: '@assistant', displayName: 'Assistant',
|
|
6589
|
+
* systemPrompt: 'You are a helpful teammate in this Space.',
|
|
6590
|
+
* triggers: [{ type: 'mention' }],
|
|
6591
|
+
* });
|
|
6592
|
+
* await client.agents.enable(appId, config.agentId, spaceId); // per-Space opt-in
|
|
6593
|
+
*
|
|
6594
|
+
* These are MANAGEMENT calls — the agent itself runs on the accelerator. Only an
|
|
6595
|
+
* app's owner or an editor of its shared Space may create/update/enable an agent
|
|
6596
|
+
* (viewers can read). Authorization rides the user session token; non-owner
|
|
6597
|
+
* editors pass their management Space via `opts.space`.
|
|
6598
|
+
*
|
|
6599
|
+
* v1 is "prompts + persona": a system prompt, a model, named prompt `skills`,
|
|
6600
|
+
* and `triggers` that decide when the agent speaks. No tools, no code.
|
|
6601
|
+
*/
|
|
6602
|
+
class AgentsNamespace {
|
|
6603
|
+
deps;
|
|
6604
|
+
constructor(deps) {
|
|
6605
|
+
this.deps = deps;
|
|
6606
|
+
}
|
|
6607
|
+
/** List the app's agents. */
|
|
6608
|
+
async list(appId, opts = {}) {
|
|
6609
|
+
const res = await this.deps.http.get(this.path(appId, "", opts));
|
|
6610
|
+
return res.agents ?? [];
|
|
6611
|
+
}
|
|
6612
|
+
/** Read one agent (config + identity keys). */
|
|
6613
|
+
async get(appId, agentId, opts = {}) {
|
|
6614
|
+
return this.deps.http.get(this.path(appId, `/${encodeURIComponent(agentId)}`, opts));
|
|
6615
|
+
}
|
|
6616
|
+
/** Create an agent (owner/editor only). */
|
|
6617
|
+
async create(appId, input, opts = {}) {
|
|
6618
|
+
return this.deps.http.post(this.path(appId, "", {}), { ...input, space: opts.space });
|
|
6619
|
+
}
|
|
6620
|
+
/** Update an agent's editor-settable fields (owner/editor only). */
|
|
6621
|
+
async update(appId, agentId, patch, opts = {}) {
|
|
6622
|
+
return this.deps.http.patch(this.path(appId, `/${encodeURIComponent(agentId)}`, {}), { ...patch, space: opts.space });
|
|
6623
|
+
}
|
|
6624
|
+
/** Delete an agent (owner/editor only). */
|
|
6625
|
+
async delete(appId, agentId, opts = {}) {
|
|
6626
|
+
return this.deps.http.del(this.path(appId, `/${encodeURIComponent(agentId)}`, {}), { space: opts.space });
|
|
6627
|
+
}
|
|
6628
|
+
/** Enable the agent on a Space (per-Space opt-in). */
|
|
6629
|
+
async enable(appId, agentId, spaceId, opts = {}) {
|
|
6630
|
+
return this.deps.http.post(this.path(appId, `/${encodeURIComponent(agentId)}/enable`, {}), { targetSpaceId: spaceId, space: opts.space });
|
|
6631
|
+
}
|
|
6632
|
+
/** Disable the agent on a Space. */
|
|
6633
|
+
async disable(appId, agentId, spaceId, opts = {}) {
|
|
6634
|
+
return this.deps.http.post(this.path(appId, `/${encodeURIComponent(agentId)}/disable`, {}), { targetSpaceId: spaceId, space: opts.space });
|
|
6635
|
+
}
|
|
6636
|
+
path(appId, suffix, opts) {
|
|
6637
|
+
const q = opts.space ? `?space=${encodeURIComponent(opts.space)}` : "";
|
|
6638
|
+
return `/api/apps/${encodeURIComponent(appId)}/agents${suffix}${q}`;
|
|
6639
|
+
}
|
|
6640
|
+
}
|
|
6641
|
+
|
|
6642
|
+
/**
|
|
6643
|
+
* `client.functions` — manage an app's **serverless functions** (developer-
|
|
6644
|
+
* authored code that runs on the accelerator).
|
|
6645
|
+
*
|
|
6646
|
+
* const { config } = await client.functions.deploy(appId, {
|
|
6647
|
+
* name: 'hello', displayName: 'Hello',
|
|
6648
|
+
* code: `export default { fetch: () => new Response('hi') }`,
|
|
6649
|
+
* triggers: { http: { enabled: true } },
|
|
6650
|
+
* });
|
|
6651
|
+
* // HTTP-reachable at hello--<slug>.fns.muhkoo.dev
|
|
6652
|
+
* await client.functions.enable(appId, config.functionId, spaceId); // Space-bound
|
|
6653
|
+
*
|
|
6654
|
+
* These are MANAGEMENT calls — the function itself runs on the accelerator
|
|
6655
|
+
* (uploaded just-in-time, decrypted only at invocation). Only an app's owner or
|
|
6656
|
+
* an editor of its shared Space may deploy/update/enable (viewers read).
|
|
6657
|
+
* Authorization rides the user session token; non-owner editors pass their
|
|
6658
|
+
* management Space via `opts.space`.
|
|
6659
|
+
*
|
|
6660
|
+
* v1 is "compute-only": a function is an untrusted single-module ES worker
|
|
6661
|
+
* (`export default { fetch }`) with NO platform bindings — it reaches the
|
|
6662
|
+
* platform as an external client (the end-user session is passed through). Two
|
|
6663
|
+
* triggers: `http` (its own subdomain) and `space` (invoked on Space messages,
|
|
6664
|
+
* like a Programmable Agent).
|
|
6665
|
+
*/
|
|
6666
|
+
class FunctionsNamespace {
|
|
6667
|
+
deps;
|
|
6668
|
+
constructor(deps) {
|
|
6669
|
+
this.deps = deps;
|
|
6670
|
+
}
|
|
6671
|
+
/** List the app's functions. */
|
|
6672
|
+
async list(appId, opts = {}) {
|
|
6673
|
+
const res = await this.deps.http.get(this.path(appId, "", opts));
|
|
6674
|
+
return res.functions ?? [];
|
|
6675
|
+
}
|
|
6676
|
+
/** Read one function's config. */
|
|
6677
|
+
async get(appId, functionId, opts = {}) {
|
|
6678
|
+
return this.deps.http.get(this.path(appId, `/${encodeURIComponent(functionId)}`, opts));
|
|
6679
|
+
}
|
|
6680
|
+
/** Read a function's decrypted source (owner/editor; for the editor UI). */
|
|
6681
|
+
async code(appId, functionId, opts = {}) {
|
|
6682
|
+
return this.deps.http.get(this.path(appId, `/${encodeURIComponent(functionId)}/code`, opts));
|
|
6683
|
+
}
|
|
6684
|
+
/** Deploy (create) a function (owner/editor only). */
|
|
6685
|
+
async deploy(appId, input, opts = {}) {
|
|
6686
|
+
return this.deps.http.post(this.path(appId, "", {}), { ...input, space: opts.space });
|
|
6687
|
+
}
|
|
6688
|
+
/** Update a function's editor-settable fields and/or redeploy its `code`. */
|
|
6689
|
+
async update(appId, functionId, patch, opts = {}) {
|
|
6690
|
+
return this.deps.http.patch(this.path(appId, `/${encodeURIComponent(functionId)}`, {}), { ...patch, space: opts.space });
|
|
6691
|
+
}
|
|
6692
|
+
/** Delete a function (owner/editor only). */
|
|
6693
|
+
async delete(appId, functionId, opts = {}) {
|
|
6694
|
+
return this.deps.http.del(this.path(appId, `/${encodeURIComponent(functionId)}`, {}), { space: opts.space });
|
|
6695
|
+
}
|
|
6696
|
+
/** Enable the function on a Space (per-Space opt-in for the `space` trigger). */
|
|
6697
|
+
async enable(appId, functionId, spaceId, opts = {}) {
|
|
6698
|
+
return this.deps.http.post(this.path(appId, `/${encodeURIComponent(functionId)}/enable`, {}), { targetSpaceId: spaceId, space: opts.space });
|
|
6699
|
+
}
|
|
6700
|
+
/** Disable the function on a Space. */
|
|
6701
|
+
async disable(appId, functionId, spaceId, opts = {}) {
|
|
6702
|
+
return this.deps.http.post(this.path(appId, `/${encodeURIComponent(functionId)}/disable`, {}), { targetSpaceId: spaceId, space: opts.space });
|
|
6703
|
+
}
|
|
6704
|
+
path(appId, suffix, opts) {
|
|
6705
|
+
const q = opts.space ? `?space=${encodeURIComponent(opts.space)}` : "";
|
|
6706
|
+
return `/api/apps/${encodeURIComponent(appId)}/functions${suffix}${q}`;
|
|
6707
|
+
}
|
|
6708
|
+
}
|
|
6709
|
+
|
|
5962
6710
|
/**
|
|
5963
6711
|
* `Client` — the single entry point to the Muhkoo SDK.
|
|
5964
6712
|
*
|
|
@@ -5995,14 +6743,24 @@ class Client {
|
|
|
5995
6743
|
baseUrl;
|
|
5996
6744
|
/** Authentication — `client.auth.zk.login(...)`, etc. */
|
|
5997
6745
|
auth;
|
|
5998
|
-
/** Per-user
|
|
6746
|
+
/** Per-user key/value storage — `client.kv.set(...)`, etc. */
|
|
6747
|
+
kv;
|
|
6748
|
+
/** File storage — `client.storage.writeFile(...)`, etc. */
|
|
5999
6749
|
storage;
|
|
6000
6750
|
/** Realtime messaging — `client.message.subscribe(...)`, etc. */
|
|
6001
6751
|
message;
|
|
6002
6752
|
/** Fan-out group spaces — `client.space.createSpace(...)`, etc. */
|
|
6003
6753
|
space;
|
|
6754
|
+
/** Programmable Agents — `client.agents.create(appId, …)`, etc. */
|
|
6755
|
+
agents;
|
|
6756
|
+
/** Serverless functions — `client.functions.deploy(appId, …)`, etc. */
|
|
6757
|
+
functions;
|
|
6004
6758
|
session;
|
|
6005
6759
|
http;
|
|
6760
|
+
/** Subscribers notified when a session expires and can't be recovered. */
|
|
6761
|
+
sessionExpiredHandlers = new Set();
|
|
6762
|
+
/** Shared in-flight recovery so a burst of 401s triggers one re-auth. */
|
|
6763
|
+
recoverInFlight = null;
|
|
6006
6764
|
constructor(options = {}) {
|
|
6007
6765
|
if (options.logLevel && typeof globalThis.appLogger?.setLevel === "function") {
|
|
6008
6766
|
globalThis.appLogger.setLevel(options.logLevel);
|
|
@@ -6014,19 +6772,24 @@ class Client {
|
|
|
6014
6772
|
apiKey: options.apiKey,
|
|
6015
6773
|
getSessionToken: () => this.session.token,
|
|
6016
6774
|
fetch: options.fetch,
|
|
6775
|
+
// Self-heal stale sessions: on a 401, try a silent re-auth (only
|
|
6776
|
+
// works while unlocked). If it can't, `recoverSession` fires the
|
|
6777
|
+
// session-expired event so the app can redirect to login.
|
|
6778
|
+
onUnauthorized: () => this.recoverSession(),
|
|
6017
6779
|
});
|
|
6018
6780
|
const circuits = options.circuits ?? defaultCircuitUrls(this.baseUrl);
|
|
6019
6781
|
const authClient = new AuthClient({ baseUrl: this.baseUrl, fetch: this.http.fetch });
|
|
6020
6782
|
const wsBaseUrl = toWsBase(this.baseUrl);
|
|
6021
6783
|
this.auth = new AuthNamespace({ auth: authClient, circuits, session: this.session });
|
|
6022
|
-
this.
|
|
6784
|
+
this.kv = new KvNamespace({ http: this.http, session: this.session, wsBaseUrl });
|
|
6785
|
+
this.storage = new StorageNamespace({ http: this.http, baseUrl: this.baseUrl, kv: this.kv });
|
|
6023
6786
|
this.message = new MessageNamespace({ http: this.http, session: this.session, wsBaseUrl });
|
|
6024
6787
|
// Group keys are cached in the user's PersonalSpace (encrypted at rest
|
|
6025
|
-
// by
|
|
6026
|
-
//
|
|
6788
|
+
// by client.kv), so a returning member hydrates without a fresh keyring
|
|
6789
|
+
// round-trip. The server only ever sees the ciphertext.
|
|
6027
6790
|
const spaceKeyCache = {
|
|
6028
|
-
loadKeys: (spaceId) => this.
|
|
6029
|
-
saveKeys: (spaceId, keys) => this.
|
|
6791
|
+
loadKeys: (spaceId) => this.kv.get("space-keys", spaceId),
|
|
6792
|
+
saveKeys: (spaceId, keys) => this.kv.set("space-keys", spaceId, keys),
|
|
6030
6793
|
};
|
|
6031
6794
|
this.space = new SpaceNamespace({
|
|
6032
6795
|
http: this.http,
|
|
@@ -6034,6 +6797,8 @@ class Client {
|
|
|
6034
6797
|
wsBaseUrl,
|
|
6035
6798
|
cache: spaceKeyCache,
|
|
6036
6799
|
});
|
|
6800
|
+
this.agents = new AgentsNamespace({ http: this.http });
|
|
6801
|
+
this.functions = new FunctionsNamespace({ http: this.http });
|
|
6037
6802
|
}
|
|
6038
6803
|
/** The currently signed-in user, or `null`. */
|
|
6039
6804
|
get user() {
|
|
@@ -6043,6 +6808,48 @@ class Client {
|
|
|
6043
6808
|
get isAuthenticated() {
|
|
6044
6809
|
return this.session.isAuthenticated;
|
|
6045
6810
|
}
|
|
6811
|
+
/**
|
|
6812
|
+
* Subscribe to session expiry that couldn't be silently recovered (the
|
|
6813
|
+
* user's identity wasn't in memory to re-prove — typically after a reload
|
|
6814
|
+
* where only the token was persisted, and that token has since gone stale).
|
|
6815
|
+
* This is the signal to send the user back to the login screen.
|
|
6816
|
+
*
|
|
6817
|
+
* Returns an unsubscribe function.
|
|
6818
|
+
*
|
|
6819
|
+
* const off = client.onSessionExpired(() => router.push('/login'));
|
|
6820
|
+
*/
|
|
6821
|
+
onSessionExpired(handler) {
|
|
6822
|
+
this.sessionExpiredHandlers.add(handler);
|
|
6823
|
+
return () => this.sessionExpiredHandlers.delete(handler);
|
|
6824
|
+
}
|
|
6825
|
+
/**
|
|
6826
|
+
* Attempt a silent re-auth of the current user. Concurrent callers share a
|
|
6827
|
+
* single in-flight attempt (a stale token usually 401s several requests at
|
|
6828
|
+
* once — we re-auth once, not once per request). On failure the
|
|
6829
|
+
* session-expired event fires so the app can react. Exposed so apps can
|
|
6830
|
+
* also drive recovery proactively (e.g. on `visibilitychange`).
|
|
6831
|
+
*/
|
|
6832
|
+
recoverSession() {
|
|
6833
|
+
if (this.recoverInFlight)
|
|
6834
|
+
return this.recoverInFlight;
|
|
6835
|
+
this.recoverInFlight = this.auth.zk
|
|
6836
|
+
.recover()
|
|
6837
|
+
.then((ok) => {
|
|
6838
|
+
if (!ok) {
|
|
6839
|
+
for (const handler of this.sessionExpiredHandlers) {
|
|
6840
|
+
try {
|
|
6841
|
+
handler();
|
|
6842
|
+
}
|
|
6843
|
+
catch { /* a bad listener mustn't break recovery */ }
|
|
6844
|
+
}
|
|
6845
|
+
}
|
|
6846
|
+
return ok;
|
|
6847
|
+
})
|
|
6848
|
+
.finally(() => {
|
|
6849
|
+
this.recoverInFlight = null;
|
|
6850
|
+
});
|
|
6851
|
+
return this.recoverInFlight;
|
|
6852
|
+
}
|
|
6046
6853
|
}
|
|
6047
6854
|
|
|
6048
6855
|
const appLogger$1 = new Logger("connect", 'ERROR');
|
|
@@ -6351,7 +7158,7 @@ function alloc(u32, size) {
|
|
|
6351
7158
|
* a convenient way to passphrase-encrypt a payload before putting it.
|
|
6352
7159
|
*/
|
|
6353
7160
|
let _snarkjs = null;
|
|
6354
|
-
async function loadSnarkjs
|
|
7161
|
+
async function loadSnarkjs() {
|
|
6355
7162
|
if (_snarkjs)
|
|
6356
7163
|
return _snarkjs;
|
|
6357
7164
|
_snarkjs = (await import('snarkjs'));
|
|
@@ -6365,21 +7172,21 @@ async function loadSnarkjs$1() {
|
|
|
6365
7172
|
* Must stay in sync with the `nonce` public-signal width used by both
|
|
6366
7173
|
* accelerator's `verifyZkAuthProof` and the `preimagePoK` circuit.
|
|
6367
7174
|
*/
|
|
6368
|
-
const FIELD_SIZE
|
|
7175
|
+
const FIELD_SIZE = 21888242871839275222246405745257275088548364400416034343698204186575808495617n;
|
|
6369
7176
|
/**
|
|
6370
7177
|
* Reduce a hex nonce into a BN254 field-element decimal string, matching what
|
|
6371
7178
|
* the Circom circuit expects as the `nonce` public signal.
|
|
6372
7179
|
*/
|
|
6373
|
-
function nonceHexToFieldString
|
|
7180
|
+
function nonceHexToFieldString(nonceHex) {
|
|
6374
7181
|
const hex = nonceHex.startsWith("0x") ? nonceHex : "0x" + nonceHex;
|
|
6375
|
-
return (BigInt(hex) % FIELD_SIZE
|
|
7182
|
+
return (BigInt(hex) % FIELD_SIZE).toString();
|
|
6376
7183
|
}
|
|
6377
7184
|
/**
|
|
6378
7185
|
* Read the response body as JSON, falling back to text for the error message
|
|
6379
7186
|
* when the body isn't JSON. Centralized so every method handles failures the
|
|
6380
7187
|
* same way.
|
|
6381
7188
|
*/
|
|
6382
|
-
async function parseOrThrow
|
|
7189
|
+
async function parseOrThrow(response, label) {
|
|
6383
7190
|
if (!response.ok) {
|
|
6384
7191
|
let detail = "";
|
|
6385
7192
|
try {
|
|
@@ -6435,7 +7242,7 @@ class PersonalSpaceClient {
|
|
|
6435
7242
|
const url = `${this.baseUrl}/api/personal/${encodeURIComponent(this.commitment)}/kv/${encodeURIComponent(key)}`;
|
|
6436
7243
|
const body = { challengeId, proof, publicSignals, value };
|
|
6437
7244
|
const response = await this.fetch(url, "POST", body);
|
|
6438
|
-
await parseOrThrow
|
|
7245
|
+
await parseOrThrow(response, `put(${key})`);
|
|
6439
7246
|
}
|
|
6440
7247
|
/** Fetch the value stored under `key`. Returns `null` if absent. */
|
|
6441
7248
|
async get(key) {
|
|
@@ -6443,7 +7250,7 @@ class PersonalSpaceClient {
|
|
|
6443
7250
|
const url = `${this.baseUrl}/api/personal/${encodeURIComponent(this.commitment)}/kv/${encodeURIComponent(key)}/get`;
|
|
6444
7251
|
const body = { challengeId, proof, publicSignals };
|
|
6445
7252
|
const response = await this.fetch(url, "POST", body);
|
|
6446
|
-
const data = await parseOrThrow
|
|
7253
|
+
const data = await parseOrThrow(response, `get(${key})`);
|
|
6447
7254
|
return data.value ?? null;
|
|
6448
7255
|
}
|
|
6449
7256
|
/** Remove `key` from the user's space. Returns whether the key existed. */
|
|
@@ -6452,7 +7259,7 @@ class PersonalSpaceClient {
|
|
|
6452
7259
|
const url = `${this.baseUrl}/api/personal/${encodeURIComponent(this.commitment)}/kv/${encodeURIComponent(key)}`;
|
|
6453
7260
|
const body = { challengeId, proof, publicSignals };
|
|
6454
7261
|
const response = await this.fetch(url, "DELETE", body);
|
|
6455
|
-
const data = await parseOrThrow
|
|
7262
|
+
const data = await parseOrThrow(response, `delete(${key})`);
|
|
6456
7263
|
return data.existed;
|
|
6457
7264
|
}
|
|
6458
7265
|
/** List every key currently stored in the user's space. */
|
|
@@ -6461,7 +7268,7 @@ class PersonalSpaceClient {
|
|
|
6461
7268
|
const url = `${this.baseUrl}/api/personal/${encodeURIComponent(this.commitment)}/list`;
|
|
6462
7269
|
const body = { challengeId, proof, publicSignals };
|
|
6463
7270
|
const response = await this.fetch(url, "POST", body);
|
|
6464
|
-
const data = await parseOrThrow
|
|
7271
|
+
const data = await parseOrThrow(response, "list()");
|
|
6465
7272
|
return Array.isArray(data.keys) ? data.keys : [];
|
|
6466
7273
|
}
|
|
6467
7274
|
// -------------------------------------------------------------------------
|
|
@@ -6474,7 +7281,7 @@ class PersonalSpaceClient {
|
|
|
6474
7281
|
*/
|
|
6475
7282
|
async proveFreshChallenge() {
|
|
6476
7283
|
const challenge = await this.requestChallenge();
|
|
6477
|
-
const nonceField = nonceHexToFieldString
|
|
7284
|
+
const nonceField = nonceHexToFieldString(challenge.nonce);
|
|
6478
7285
|
// Inputs to the `preimagePoK` circuit. Public signals are
|
|
6479
7286
|
// `[commitment, nonce, ecdsaPubHash]`; the rest are private witnesses.
|
|
6480
7287
|
const input = {
|
|
@@ -6485,7 +7292,7 @@ class PersonalSpaceClient {
|
|
|
6485
7292
|
salt: this.salt,
|
|
6486
7293
|
ecdsaPub: this.ecdsaPub,
|
|
6487
7294
|
};
|
|
6488
|
-
const snarkjs = await loadSnarkjs
|
|
7295
|
+
const snarkjs = await loadSnarkjs();
|
|
6489
7296
|
const { proof, publicSignals } = await snarkjs.groth16.fullProve(input, this.wasmUrl, this.zkeyUrl);
|
|
6490
7297
|
return {
|
|
6491
7298
|
challengeId: challenge.challengeId,
|
|
@@ -6497,7 +7304,7 @@ class PersonalSpaceClient {
|
|
|
6497
7304
|
async requestChallenge() {
|
|
6498
7305
|
const url = `${this.baseUrl}/api/personal/${encodeURIComponent(this.commitment)}/challenge`;
|
|
6499
7306
|
const response = await this.fetch(url, "POST", {});
|
|
6500
|
-
return await parseOrThrow
|
|
7307
|
+
return await parseOrThrow(response, "challenge");
|
|
6501
7308
|
}
|
|
6502
7309
|
/**
|
|
6503
7310
|
* Thin wrapper over `globalThis.fetch` so all methods share a single set of
|
|
@@ -6518,251 +7325,6 @@ class PersonalSpaceClient {
|
|
|
6518
7325
|
}
|
|
6519
7326
|
}
|
|
6520
7327
|
|
|
6521
|
-
/**
|
|
6522
|
-
* HTTP client for the multi-user `SharedSpaceDO` (Phase B).
|
|
6523
|
-
*
|
|
6524
|
-
* Mirrors the proven shape of `PersonalSpaceClient` — every gated call:
|
|
6525
|
-
* 1. POSTs `/challenge` to get a fresh nonce keyed to the caller's commitment
|
|
6526
|
-
* 2. Generates a Groth16 proof for the `preimagePoK` circuit with the nonce
|
|
6527
|
-
* reduced into a BN254 field element as the public signal
|
|
6528
|
-
* 3. POSTs the operation with `{ challengeId, proof, publicSignals, ... }`
|
|
6529
|
-
*
|
|
6530
|
-
* What's different from `PersonalSpaceClient`:
|
|
6531
|
-
* - Addressed by space ID, not user commitment. Multiple users can be
|
|
6532
|
-
* members of the same space.
|
|
6533
|
-
* - The space carries a participants list + ACL (shared types from
|
|
6534
|
-
* `src/types/permissions.ts`). The server checks that the proving
|
|
6535
|
-
* commitment is a participant with the requested permission before each
|
|
6536
|
-
* mutation.
|
|
6537
|
-
* - Stores file manifests (not generic KV blobs). The shape is constrained
|
|
6538
|
-
* by `FileManifest` from `../types`.
|
|
6539
|
-
*
|
|
6540
|
-
* Wire protocol (this client implements the client side; the server side is
|
|
6541
|
-
* the next slice of work in accelerator):
|
|
6542
|
-
*
|
|
6543
|
-
* POST /api/spaces create a new space (gated by caller proof)
|
|
6544
|
-
* body: { challengeId, proof, publicSignals, initialAcl? }
|
|
6545
|
-
* returns: { spaceId }
|
|
6546
|
-
*
|
|
6547
|
-
* POST /api/spaces/:spaceId/challenge issue a nonce for the caller
|
|
6548
|
-
* body: { commitment }
|
|
6549
|
-
* returns: { challengeId, nonce }
|
|
6550
|
-
*
|
|
6551
|
-
* POST /api/spaces/:spaceId/files write a file manifest
|
|
6552
|
-
* body: { challengeId, proof, publicSignals, manifest }
|
|
6553
|
-
* returns: { ok: true, fileId }
|
|
6554
|
-
*
|
|
6555
|
-
* POST /api/spaces/:spaceId/files/:fileId/get read a file manifest
|
|
6556
|
-
* body: { challengeId, proof, publicSignals }
|
|
6557
|
-
* returns: { manifest }
|
|
6558
|
-
*
|
|
6559
|
-
* DELETE /api/spaces/:spaceId/files/:fileId delete a file manifest
|
|
6560
|
-
* body: { challengeId, proof, publicSignals }
|
|
6561
|
-
* returns: { ok: true, existed: boolean }
|
|
6562
|
-
*
|
|
6563
|
-
* POST /api/spaces/:spaceId/files/list list manifests in space
|
|
6564
|
-
* body: { challengeId, proof, publicSignals }
|
|
6565
|
-
* returns: { files: FileStat[] }
|
|
6566
|
-
*
|
|
6567
|
-
* POST /api/spaces/:spaceId/participants add a participant
|
|
6568
|
-
* body: { challengeId, proof, publicSignals, participant: Participant }
|
|
6569
|
-
* returns: { ok: true }
|
|
6570
|
-
*
|
|
6571
|
-
* DELETE /api/spaces/:spaceId/participants/:commitment remove a participant
|
|
6572
|
-
* body: { challengeId, proof, publicSignals }
|
|
6573
|
-
* returns: { ok: true, existed: boolean }
|
|
6574
|
-
*
|
|
6575
|
-
* POST /api/spaces/:spaceId/acl set / replace an ACL entry
|
|
6576
|
-
* body: { challengeId, proof, publicSignals, entry: ACLEntry }
|
|
6577
|
-
* returns: { ok: true }
|
|
6578
|
-
*/
|
|
6579
|
-
let snarkjsModule = null;
|
|
6580
|
-
async function loadSnarkjs() {
|
|
6581
|
-
if (snarkjsModule)
|
|
6582
|
-
return snarkjsModule;
|
|
6583
|
-
snarkjsModule = (await import('snarkjs'));
|
|
6584
|
-
return snarkjsModule;
|
|
6585
|
-
}
|
|
6586
|
-
// BN254 scalar field modulus — same value used everywhere else in the ZK path.
|
|
6587
|
-
const FIELD_SIZE = 21888242871839275222246405745257275088548364400416034343698204186575808495617n;
|
|
6588
|
-
class SharedSpaceClient {
|
|
6589
|
-
baseUrl;
|
|
6590
|
-
commitment;
|
|
6591
|
-
secret;
|
|
6592
|
-
salt;
|
|
6593
|
-
ecdsaPub;
|
|
6594
|
-
ecdsaPubHash;
|
|
6595
|
-
wasmUrl;
|
|
6596
|
-
zkeyUrl;
|
|
6597
|
-
fetchFn;
|
|
6598
|
-
constructor(opts) {
|
|
6599
|
-
if (!opts?.baseUrl)
|
|
6600
|
-
throw new Error("SharedSpaceClient: `baseUrl` is required");
|
|
6601
|
-
if (!opts.commitment)
|
|
6602
|
-
throw new Error("SharedSpaceClient: `commitment` is required");
|
|
6603
|
-
if (!opts.secret)
|
|
6604
|
-
throw new Error("SharedSpaceClient: `secret` is required");
|
|
6605
|
-
if (!opts.salt)
|
|
6606
|
-
throw new Error("SharedSpaceClient: `salt` is required");
|
|
6607
|
-
if (!opts.ecdsaPub)
|
|
6608
|
-
throw new Error("SharedSpaceClient: `ecdsaPub` is required");
|
|
6609
|
-
if (!opts.ecdsaPubHash)
|
|
6610
|
-
throw new Error("SharedSpaceClient: `ecdsaPubHash` is required");
|
|
6611
|
-
if (!opts.circuits?.wasmUrl || !opts.circuits?.zkeyUrl) {
|
|
6612
|
-
throw new Error("SharedSpaceClient: `circuits.wasmUrl` and `circuits.zkeyUrl` are required");
|
|
6613
|
-
}
|
|
6614
|
-
this.baseUrl = opts.baseUrl.replace(/\/+$/, "");
|
|
6615
|
-
this.commitment = opts.commitment;
|
|
6616
|
-
this.secret = opts.secret;
|
|
6617
|
-
this.salt = opts.salt;
|
|
6618
|
-
this.ecdsaPub = opts.ecdsaPub;
|
|
6619
|
-
this.ecdsaPubHash = opts.ecdsaPubHash;
|
|
6620
|
-
this.wasmUrl = opts.circuits.wasmUrl;
|
|
6621
|
-
this.zkeyUrl = opts.circuits.zkeyUrl;
|
|
6622
|
-
const f = opts.fetch ?? globalThis.fetch;
|
|
6623
|
-
if (typeof f !== "function") {
|
|
6624
|
-
throw new Error("SharedSpaceClient: `globalThis.fetch` is unavailable; pass an explicit fetch.");
|
|
6625
|
-
}
|
|
6626
|
-
this.fetchFn = f.bind(globalThis);
|
|
6627
|
-
}
|
|
6628
|
-
/**
|
|
6629
|
-
* Create a brand new space. The caller becomes the owner; `initialAcl`
|
|
6630
|
-
* adds any additional ACL entries up front (e.g. inviting a co-owner at
|
|
6631
|
-
* creation time).
|
|
6632
|
-
*/
|
|
6633
|
-
async createSpace(initialAcl) {
|
|
6634
|
-
// Creating a space still requires the caller to prove they hold the
|
|
6635
|
-
// commitment they're claiming to own — that's what the proof
|
|
6636
|
-
// accomplishes here. The server issues a challenge against a global
|
|
6637
|
-
// "newspace" pseudo-id for the bootstrap step.
|
|
6638
|
-
const proof = await this.proveFreshChallenge("__new__");
|
|
6639
|
-
const body = { ...proof };
|
|
6640
|
-
if (initialAcl)
|
|
6641
|
-
body.initialAcl = initialAcl;
|
|
6642
|
-
const url = `${this.baseUrl}/api/spaces`;
|
|
6643
|
-
const response = await this.fetchFn(url, this.jsonRequest("POST", body));
|
|
6644
|
-
const data = await parseOrThrow(response, "createSpace");
|
|
6645
|
-
return { spaceId: data.spaceId };
|
|
6646
|
-
}
|
|
6647
|
-
/** Write a file manifest into the space. Requires `write` permission. */
|
|
6648
|
-
async writeFileManifest(spaceId, manifest) {
|
|
6649
|
-
const proof = await this.proveFreshChallenge(spaceId);
|
|
6650
|
-
const body = { ...proof, manifest };
|
|
6651
|
-
const url = `${this.baseUrl}/api/spaces/${encodeURIComponent(spaceId)}/files`;
|
|
6652
|
-
const response = await this.fetchFn(url, this.jsonRequest("POST", body));
|
|
6653
|
-
await parseOrThrow(response, `writeFileManifest(${manifest.id})`);
|
|
6654
|
-
}
|
|
6655
|
-
/** Read a file manifest by id. Requires `read` permission. */
|
|
6656
|
-
async readFileManifest(spaceId, fileId) {
|
|
6657
|
-
const proof = await this.proveFreshChallenge(spaceId);
|
|
6658
|
-
const body = { ...proof };
|
|
6659
|
-
const url = `${this.baseUrl}/api/spaces/${encodeURIComponent(spaceId)}/files/${encodeURIComponent(fileId)}/get`;
|
|
6660
|
-
const response = await this.fetchFn(url, this.jsonRequest("POST", body));
|
|
6661
|
-
const data = await parseOrThrow(response, `readFileManifest(${fileId})`);
|
|
6662
|
-
return data.manifest;
|
|
6663
|
-
}
|
|
6664
|
-
/** Delete a file manifest by id. Requires `delete` permission. */
|
|
6665
|
-
async deleteFileManifest(spaceId, fileId) {
|
|
6666
|
-
const proof = await this.proveFreshChallenge(spaceId);
|
|
6667
|
-
const body = { ...proof };
|
|
6668
|
-
const url = `${this.baseUrl}/api/spaces/${encodeURIComponent(spaceId)}/files/${encodeURIComponent(fileId)}`;
|
|
6669
|
-
const response = await this.fetchFn(url, this.jsonRequest("DELETE", body));
|
|
6670
|
-
const data = await parseOrThrow(response, `deleteFileManifest(${fileId})`);
|
|
6671
|
-
return data.existed;
|
|
6672
|
-
}
|
|
6673
|
-
/** List file summaries in the space. Requires `read` permission. */
|
|
6674
|
-
async listFiles(spaceId) {
|
|
6675
|
-
const proof = await this.proveFreshChallenge(spaceId);
|
|
6676
|
-
const body = { ...proof };
|
|
6677
|
-
const url = `${this.baseUrl}/api/spaces/${encodeURIComponent(spaceId)}/files/list`;
|
|
6678
|
-
const response = await this.fetchFn(url, this.jsonRequest("POST", body));
|
|
6679
|
-
const data = await parseOrThrow(response, "listFiles");
|
|
6680
|
-
return Array.isArray(data.files) ? data.files : [];
|
|
6681
|
-
}
|
|
6682
|
-
/** Add a participant. Requires `invite` or `admin`. */
|
|
6683
|
-
async addParticipant(spaceId, participant) {
|
|
6684
|
-
const proof = await this.proveFreshChallenge(spaceId);
|
|
6685
|
-
const body = { ...proof, participant };
|
|
6686
|
-
const url = `${this.baseUrl}/api/spaces/${encodeURIComponent(spaceId)}/participants`;
|
|
6687
|
-
const response = await this.fetchFn(url, this.jsonRequest("POST", body));
|
|
6688
|
-
await parseOrThrow(response, "addParticipant");
|
|
6689
|
-
}
|
|
6690
|
-
/** Remove a participant by their public-key identity. Requires `admin`. */
|
|
6691
|
-
async removeParticipant(spaceId, participantCommitment) {
|
|
6692
|
-
const proof = await this.proveFreshChallenge(spaceId);
|
|
6693
|
-
const body = { ...proof };
|
|
6694
|
-
const url = `${this.baseUrl}/api/spaces/${encodeURIComponent(spaceId)}/participants/${encodeURIComponent(participantCommitment)}`;
|
|
6695
|
-
const response = await this.fetchFn(url, this.jsonRequest("DELETE", body));
|
|
6696
|
-
const data = await parseOrThrow(response, "removeParticipant");
|
|
6697
|
-
return data.existed;
|
|
6698
|
-
}
|
|
6699
|
-
/** Set / replace an ACL entry. Requires `admin`. */
|
|
6700
|
-
async setACLEntry(spaceId, entry) {
|
|
6701
|
-
const proof = await this.proveFreshChallenge(spaceId);
|
|
6702
|
-
const body = { ...proof, entry };
|
|
6703
|
-
const url = `${this.baseUrl}/api/spaces/${encodeURIComponent(spaceId)}/acl`;
|
|
6704
|
-
const response = await this.fetchFn(url, this.jsonRequest("POST", body));
|
|
6705
|
-
await parseOrThrow(response, "setACLEntry");
|
|
6706
|
-
}
|
|
6707
|
-
// -------------------------------------------------------------------------
|
|
6708
|
-
// Internals
|
|
6709
|
-
// -------------------------------------------------------------------------
|
|
6710
|
-
/** Request a challenge for `spaceId` and generate the matching proof. */
|
|
6711
|
-
async proveFreshChallenge(spaceId) {
|
|
6712
|
-
const challenge = await this.requestChallenge(spaceId);
|
|
6713
|
-
const nonceField = nonceHexToFieldString(challenge.nonce);
|
|
6714
|
-
const input = {
|
|
6715
|
-
commitment: this.commitment,
|
|
6716
|
-
nonce: nonceField,
|
|
6717
|
-
ecdsaPubHash: this.ecdsaPubHash,
|
|
6718
|
-
secret: this.secret,
|
|
6719
|
-
salt: this.salt,
|
|
6720
|
-
ecdsaPub: this.ecdsaPub,
|
|
6721
|
-
};
|
|
6722
|
-
const snarkjs = await loadSnarkjs();
|
|
6723
|
-
const { proof, publicSignals } = await snarkjs.groth16.fullProve(input, this.wasmUrl, this.zkeyUrl);
|
|
6724
|
-
return {
|
|
6725
|
-
challengeId: challenge.challengeId,
|
|
6726
|
-
proof: proof,
|
|
6727
|
-
publicSignals: publicSignals,
|
|
6728
|
-
};
|
|
6729
|
-
}
|
|
6730
|
-
/**
|
|
6731
|
-
* Hit `/challenge`. The body includes the caller's commitment so the
|
|
6732
|
-
* server can scope the issued nonce to this principal.
|
|
6733
|
-
*/
|
|
6734
|
-
async requestChallenge(spaceId) {
|
|
6735
|
-
const url = `${this.baseUrl}/api/spaces/${encodeURIComponent(spaceId)}/challenge`;
|
|
6736
|
-
const response = await this.fetchFn(url, this.jsonRequest("POST", { commitment: this.commitment }));
|
|
6737
|
-
return await parseOrThrow(response, "challenge");
|
|
6738
|
-
}
|
|
6739
|
-
jsonRequest(method, body) {
|
|
6740
|
-
return {
|
|
6741
|
-
method,
|
|
6742
|
-
headers: { "content-type": "application/json" },
|
|
6743
|
-
body: JSON.stringify(body ?? {}),
|
|
6744
|
-
};
|
|
6745
|
-
}
|
|
6746
|
-
}
|
|
6747
|
-
function nonceHexToFieldString(nonceHex) {
|
|
6748
|
-
const hex = nonceHex.startsWith("0x") ? nonceHex : "0x" + nonceHex;
|
|
6749
|
-
return (BigInt(hex) % FIELD_SIZE).toString();
|
|
6750
|
-
}
|
|
6751
|
-
async function parseOrThrow(response, label) {
|
|
6752
|
-
if (!response.ok) {
|
|
6753
|
-
let detail = "";
|
|
6754
|
-
try {
|
|
6755
|
-
const text = await response.text();
|
|
6756
|
-
detail = text ? `: ${text}` : "";
|
|
6757
|
-
}
|
|
6758
|
-
catch {
|
|
6759
|
-
// ignore — we still throw with the status
|
|
6760
|
-
}
|
|
6761
|
-
throw new Error(`SharedSpaceClient ${label} failed (${response.status})${detail}`);
|
|
6762
|
-
}
|
|
6763
|
-
return (await response.json());
|
|
6764
|
-
}
|
|
6765
|
-
|
|
6766
7328
|
/**
|
|
6767
7329
|
* PacketCipher — the strategy `Network` uses to seal/open a packet's payload.
|
|
6768
7330
|
*
|
|
@@ -6826,5 +7388,5 @@ var bundledLoader = /*#__PURE__*/Object.freeze({
|
|
|
6826
7388
|
default: loadRsWasm
|
|
6827
7389
|
});
|
|
6828
7390
|
|
|
6829
|
-
export { AES_GCM_IV_BYTES, AES_GCM_KEY_BYTES, AES_GCM_TAG_BYTES, AuthClient, AuthNamespace, AuthPublicInput, Authenticator, BroadcastChannel, BroadcastChannelEvents, ChannelExistsError, ChannelNotFoundError, ChunkCipher, Client, DEFAULT_BASE_URL, DoubleRatchet, DoubleRatchetCipher, DoubleRatchetManager, EncryptedSession, EventCore, EventCoreEvents, Field, Field as FieldElement, FileStorage, HashKnowledge, HttpClient, HttpError, KEEPER_MEMBER_ID, KeyStore, KeyringClient, LocalStorageSessionStore, Logger, MemorySessionStore, Message, Message as MessageDefault, MessageNamespace, PBKDF2_DEFAULT_ITERATIONS, PREIMAGE_POK_VERIFICATION_KEY, Packet, PersonalSpaceClient, Poseidon, PreimagePoK, Ready, ReedSolomonCodec, Retry, Space as Room, SerializeMessage, SessionState, ShardClient, SharedSpaceClient, Space, SpaceKeyring, SpaceNamespace, SpacePacketCipher, StorageNamespace, WSTransport, ZkAuth, _accountId, _formatBytes, _messageId, _objectId, _packetId, _socketId, _userId, appLogger$1 as appLogger, assertType, base58Decode, base58Encode, buildCommitment, compilePrograms, concatBytes, decodeFromHex, decorators, decryptAesGcm, defaultCircuitUrls, defaultSessionStore, deriveAesKeyFromPassphrase, deriveBitsHkdf, deriveIdentity, deserialize, encodeToHex, encryptAesGcm, exportPublicKeyBase64, exportPublicKeyHex, fromBase64, fromBase64Url, fromHex, generateAuthProof, generateChecksum, generateId, getIPAddress, getId, getSubtle, initBn128Wasm, initRsWasm, initializeCircuits, isNumber, log, manifestToStat, poseidonHash, quickVerify, randomBytes, serialize, shardHash, signMessage, toBase64, toBase64Url, toField, toHex, unwrapWithPassphrase, utf8Decode, utf8Encode, verify, verifyChecksum, verifyGroth16, verifyHashKnowledge, verifyPreimagePoK, wrapWithPassphrase };
|
|
7391
|
+
export { AES_GCM_IV_BYTES, AES_GCM_KEY_BYTES, AES_GCM_TAG_BYTES, AgentsNamespace, AuthClient, AuthNamespace, AuthPublicInput, Authenticator, BroadcastChannel, BroadcastChannelEvents, ChannelExistsError, ChannelNotFoundError, ChunkCipher, Client, DEFAULT_BASE_URL, DoubleRatchet, DoubleRatchetCipher, DoubleRatchetManager, EncryptedSession, EventCore, EventCoreEvents, Field, Field as FieldElement, FileStorage, FunctionsNamespace, HashKnowledge, HttpClient, HttpError, KEEPER_MEMBER_ID, KeyStore, KeyringClient, KvNamespace, LocalStorageSessionStore, Logger, MemorySessionStore, Message, Message as MessageDefault, MessageNamespace, PBKDF2_DEFAULT_ITERATIONS, PREIMAGE_POK_VERIFICATION_KEY, Packet, PersonalSpaceClient, Poseidon, PreimagePoK, Ready, ReedSolomonCodec, Retry, Space as Room, SerializeMessage, SessionState, ShardClient, SharedSpaceClient, Space, SpaceKeyring, SpaceNamespace, SpacePacketCipher, StorageNamespace, WSTransport, ZkAuth, _accountId, _formatBytes, _messageId, _objectId, _packetId, _socketId, _userId, appLogger$1 as appLogger, assertType, base58Decode, base58Encode, buildCommitment, compilePrograms, concatBytes, decodeFromHex, decorators, decryptAesGcm, defaultCircuitUrls, defaultSessionStore, deriveAesKeyFromPassphrase, deriveBitsHkdf, deriveIdentity, deserialize, encodeToHex, encryptAesGcm, exportPublicKeyBase64, exportPublicKeyHex, fromBase64, fromBase64Url, fromHex, generateAuthProof, generateChecksum, generateId, getIPAddress, getId, getSubtle, initBn128Wasm, initRsWasm, initializeCircuits, isNumber, log, manifestToStat, poseidonHash, quickVerify, randomBytes, serialize, shardHash, signMessage, toBase64, toBase64Url, toField, toHex, unwrapWithPassphrase, utf8Decode, utf8Encode, verify, verifyChecksum, verifyGroth16, verifyHashKnowledge, verifyPreimagePoK, wrapWithPassphrase };
|
|
6830
7392
|
//# sourceMappingURL=index.js.map
|