@muhkoo/connect 0.2.0-alpha.1 → 0.3.0-alpha.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +26 -18
- package/dist/browser/index.js +719 -277
- package/dist/browser/index.js.map +1 -1
- package/dist/connect.d.ts +377 -131
- package/dist/server/index.js +1904 -1462
- package/dist/server/index.js.map +1 -1
- package/dist/workers/index.js +118 -3
- package/dist/workers/index.js.map +1 -1
- package/package.json +1 -1
package/dist/browser/index.js
CHANGED
|
@@ -2383,10 +2383,29 @@ function isCipherFrame(frame) {
|
|
|
2383
2383
|
* - Emit lifecycle events (CONNECTED / DISCONNECTED / RECONNECTING / ERROR)
|
|
2384
2384
|
* plus raw inbound MESSAGE events
|
|
2385
2385
|
*/
|
|
2386
|
+
/** Monotonic source of unique transport ids when a caller doesn't supply one. */
|
|
2387
|
+
let __wsInstanceSeq = 0;
|
|
2386
2388
|
class WSTransport extends EventCore {
|
|
2387
|
-
|
|
2388
|
-
|
|
2389
|
-
|
|
2389
|
+
/**
|
|
2390
|
+
* Per-instance event scope. `EventCore` is a process-global static emitter,
|
|
2391
|
+
* so without namespacing every WSTransport would hear every other one's
|
|
2392
|
+
* frames. We prefix each event with this transport's `id` and expose
|
|
2393
|
+
* `on`/`off`/`emit` that scope transparently — callers still use the plain
|
|
2394
|
+
* `EventCoreEvents` enum and only ever see their own connection's events.
|
|
2395
|
+
*/
|
|
2396
|
+
id;
|
|
2397
|
+
emit = (event, data) => {
|
|
2398
|
+
EventCore.emit(this.scopedEvent(event), data);
|
|
2399
|
+
};
|
|
2400
|
+
on = (event, handler) => {
|
|
2401
|
+
EventCore.on(this.scopedEvent(event), handler);
|
|
2402
|
+
};
|
|
2403
|
+
off = (event, handler) => {
|
|
2404
|
+
EventCore.off(this.scopedEvent(event), handler);
|
|
2405
|
+
};
|
|
2406
|
+
scopedEvent(event) {
|
|
2407
|
+
return `${this.id}:${event}`;
|
|
2408
|
+
}
|
|
2390
2409
|
url;
|
|
2391
2410
|
socket = null;
|
|
2392
2411
|
_isConnected = false;
|
|
@@ -2400,16 +2419,28 @@ class WSTransport extends EventCore {
|
|
|
2400
2419
|
// Outbound queue (raw frame strings — already serialized by caller)
|
|
2401
2420
|
outboundQueue = [];
|
|
2402
2421
|
maxQueueSize;
|
|
2422
|
+
// Heartbeat / liveness state
|
|
2423
|
+
heartbeatInterval;
|
|
2424
|
+
heartbeatTimeout;
|
|
2425
|
+
pingFrame;
|
|
2426
|
+
pongFrame;
|
|
2427
|
+
heartbeatTimer = null;
|
|
2428
|
+
pongTimer = null;
|
|
2403
2429
|
constructor(opts) {
|
|
2404
2430
|
super();
|
|
2405
2431
|
if (!opts || !opts.url) {
|
|
2406
2432
|
throw new Error("WSTransport: `url` is required");
|
|
2407
2433
|
}
|
|
2408
2434
|
this.url = opts.url;
|
|
2435
|
+
this.id = opts.id ?? `ws${++__wsInstanceSeq}`;
|
|
2409
2436
|
this.autoReconnect = opts.autoReconnect ?? true;
|
|
2410
2437
|
this.reconnectDelay = opts.reconnectDelay ?? 3000;
|
|
2411
2438
|
this.maxReconnectAttempts = opts.maxReconnectAttempts ?? 5;
|
|
2412
2439
|
this.maxQueueSize = opts.maxQueueSize ?? 100;
|
|
2440
|
+
this.heartbeatInterval = opts.heartbeatInterval ?? 30000;
|
|
2441
|
+
this.heartbeatTimeout = opts.heartbeatTimeout ?? 10000;
|
|
2442
|
+
this.pingFrame = opts.pingFrame ?? "ping";
|
|
2443
|
+
this.pongFrame = opts.pongFrame ?? "pong";
|
|
2413
2444
|
}
|
|
2414
2445
|
/** Open the socket. Resolves when CONNECTED fires. Rejects on first error. */
|
|
2415
2446
|
async connect() {
|
|
@@ -2427,9 +2458,19 @@ class WSTransport extends EventCore {
|
|
|
2427
2458
|
this.reconnectAttempts = 0;
|
|
2428
2459
|
this.emit(EventCoreEvents.CONNECTED, undefined);
|
|
2429
2460
|
this.flushQueue();
|
|
2461
|
+
this.startHeartbeat();
|
|
2430
2462
|
resolve();
|
|
2431
2463
|
};
|
|
2432
2464
|
socket.onmessage = (event) => {
|
|
2465
|
+
// Heartbeat ack — swallow it (don't surface as a MESSAGE) and clear
|
|
2466
|
+
// the liveness deadline. The layer above never sees keep-alive frames.
|
|
2467
|
+
if (event.data === this.pongFrame) {
|
|
2468
|
+
if (this.pongTimer) {
|
|
2469
|
+
clearTimeout(this.pongTimer);
|
|
2470
|
+
this.pongTimer = null;
|
|
2471
|
+
}
|
|
2472
|
+
return;
|
|
2473
|
+
}
|
|
2433
2474
|
// Raw inbound frame — layer above is responsible for parsing.
|
|
2434
2475
|
this.emit(EventCoreEvents.MESSAGE, event.data);
|
|
2435
2476
|
};
|
|
@@ -2444,6 +2485,7 @@ class WSTransport extends EventCore {
|
|
|
2444
2485
|
const wasConnected = this._isConnected;
|
|
2445
2486
|
this._isConnected = false;
|
|
2446
2487
|
this._isConnecting = false;
|
|
2488
|
+
this.stopHeartbeat();
|
|
2447
2489
|
this.emit(EventCoreEvents.DISCONNECTED, undefined);
|
|
2448
2490
|
if (this.autoReconnect && wasConnected) {
|
|
2449
2491
|
this.scheduleReconnect();
|
|
@@ -2462,6 +2504,7 @@ class WSTransport extends EventCore {
|
|
|
2462
2504
|
clearTimeout(this.reconnectTimeout);
|
|
2463
2505
|
this.reconnectTimeout = null;
|
|
2464
2506
|
}
|
|
2507
|
+
this.stopHeartbeat();
|
|
2465
2508
|
this.autoReconnect = false;
|
|
2466
2509
|
if (this.socket) {
|
|
2467
2510
|
try {
|
|
@@ -2492,6 +2535,78 @@ class WSTransport extends EventCore {
|
|
|
2492
2535
|
queuedFrames() { return this.outboundQueue.length; }
|
|
2493
2536
|
reconnectAttemptCount() { return this.reconnectAttempts; }
|
|
2494
2537
|
// -- internals -------------------------------------------------------------
|
|
2538
|
+
/** Begin the keep-alive loop (no-op when disabled). */
|
|
2539
|
+
startHeartbeat() {
|
|
2540
|
+
if (this.heartbeatInterval <= 0)
|
|
2541
|
+
return;
|
|
2542
|
+
this.stopHeartbeat();
|
|
2543
|
+
this.heartbeatTimer = setInterval(() => this.sendHeartbeat(), this.heartbeatInterval);
|
|
2544
|
+
}
|
|
2545
|
+
/** Stop the keep-alive loop and clear any pending liveness deadline. */
|
|
2546
|
+
stopHeartbeat() {
|
|
2547
|
+
if (this.heartbeatTimer) {
|
|
2548
|
+
clearInterval(this.heartbeatTimer);
|
|
2549
|
+
this.heartbeatTimer = null;
|
|
2550
|
+
}
|
|
2551
|
+
if (this.pongTimer) {
|
|
2552
|
+
clearTimeout(this.pongTimer);
|
|
2553
|
+
this.pongTimer = null;
|
|
2554
|
+
}
|
|
2555
|
+
}
|
|
2556
|
+
/**
|
|
2557
|
+
* Send one ping and arm a liveness deadline. If no `pongFrame` arrives before
|
|
2558
|
+
* `heartbeatTimeout`, the connection is considered dead and we force a
|
|
2559
|
+
* reconnect — this is what catches a silently-dropped TCP connection that
|
|
2560
|
+
* never fired `onclose` (the failure mode that "loses connection with the
|
|
2561
|
+
* Accelerator" on long sessions).
|
|
2562
|
+
*/
|
|
2563
|
+
sendHeartbeat() {
|
|
2564
|
+
if (!this._isConnected || !this.socket)
|
|
2565
|
+
return;
|
|
2566
|
+
try {
|
|
2567
|
+
this.socket.send(this.pingFrame);
|
|
2568
|
+
}
|
|
2569
|
+
catch {
|
|
2570
|
+
this.forceReconnect();
|
|
2571
|
+
return;
|
|
2572
|
+
}
|
|
2573
|
+
// A still-pending deadline means the previous pong never came back.
|
|
2574
|
+
if (this.pongTimer)
|
|
2575
|
+
return;
|
|
2576
|
+
this.pongTimer = setTimeout(() => {
|
|
2577
|
+
this.pongTimer = null;
|
|
2578
|
+
this.forceReconnect();
|
|
2579
|
+
}, this.heartbeatTimeout);
|
|
2580
|
+
}
|
|
2581
|
+
/**
|
|
2582
|
+
* Tear down a connection we believe is dead and kick off reconnect, without
|
|
2583
|
+
* waiting on the OS/browser to eventually fire `onclose` (which a half-open
|
|
2584
|
+
* socket may never do). We detach the handlers first so the normal close path
|
|
2585
|
+
* can't double-fire the reconnect.
|
|
2586
|
+
*/
|
|
2587
|
+
forceReconnect() {
|
|
2588
|
+
this.stopHeartbeat();
|
|
2589
|
+
const sock = this.socket;
|
|
2590
|
+
const wasConnected = this._isConnected;
|
|
2591
|
+
this.socket = null;
|
|
2592
|
+
this._isConnected = false;
|
|
2593
|
+
this._isConnecting = false;
|
|
2594
|
+
if (sock) {
|
|
2595
|
+
sock.onopen = null;
|
|
2596
|
+
sock.onmessage = null;
|
|
2597
|
+
sock.onerror = null;
|
|
2598
|
+
sock.onclose = null;
|
|
2599
|
+
try {
|
|
2600
|
+
sock.close();
|
|
2601
|
+
}
|
|
2602
|
+
catch { /* already gone */ }
|
|
2603
|
+
}
|
|
2604
|
+
this.emit(EventCoreEvents.ERROR, new Error("WSTransport: heartbeat timeout"));
|
|
2605
|
+
this.emit(EventCoreEvents.DISCONNECTED, undefined);
|
|
2606
|
+
if (this.autoReconnect && wasConnected) {
|
|
2607
|
+
this.scheduleReconnect();
|
|
2608
|
+
}
|
|
2609
|
+
}
|
|
2495
2610
|
scheduleReconnect() {
|
|
2496
2611
|
if (this.maxReconnectAttempts > 0 &&
|
|
2497
2612
|
this.reconnectAttempts >= this.maxReconnectAttempts) {
|
|
@@ -3040,7 +3155,7 @@ function alloc(u32, size) {
|
|
|
3040
3155
|
* a convenient way to passphrase-encrypt a payload before putting it.
|
|
3041
3156
|
*/
|
|
3042
3157
|
let _snarkjs$1 = null;
|
|
3043
|
-
async function loadSnarkjs$
|
|
3158
|
+
async function loadSnarkjs$1() {
|
|
3044
3159
|
if (_snarkjs$1)
|
|
3045
3160
|
return _snarkjs$1;
|
|
3046
3161
|
_snarkjs$1 = (await import('snarkjs'));
|
|
@@ -3054,14 +3169,14 @@ async function loadSnarkjs$2() {
|
|
|
3054
3169
|
* Must stay in sync with the `nonce` public-signal width used by both
|
|
3055
3170
|
* accelerator's `verifyZkAuthProof` and the `preimagePoK` circuit.
|
|
3056
3171
|
*/
|
|
3057
|
-
const FIELD_SIZE$
|
|
3172
|
+
const FIELD_SIZE$1 = 21888242871839275222246405745257275088548364400416034343698204186575808495617n;
|
|
3058
3173
|
/**
|
|
3059
3174
|
* Reduce a hex nonce into a BN254 field-element decimal string, matching what
|
|
3060
3175
|
* the Circom circuit expects as the `nonce` public signal.
|
|
3061
3176
|
*/
|
|
3062
|
-
function nonceHexToFieldString
|
|
3177
|
+
function nonceHexToFieldString(nonceHex) {
|
|
3063
3178
|
const hex = nonceHex.startsWith("0x") ? nonceHex : "0x" + nonceHex;
|
|
3064
|
-
return (BigInt(hex) % FIELD_SIZE$
|
|
3179
|
+
return (BigInt(hex) % FIELD_SIZE$1).toString();
|
|
3065
3180
|
}
|
|
3066
3181
|
/**
|
|
3067
3182
|
* Read the response body as JSON, falling back to text for the error message
|
|
@@ -3163,7 +3278,7 @@ class PersonalSpaceClient {
|
|
|
3163
3278
|
*/
|
|
3164
3279
|
async proveFreshChallenge() {
|
|
3165
3280
|
const challenge = await this.requestChallenge();
|
|
3166
|
-
const nonceField = nonceHexToFieldString
|
|
3281
|
+
const nonceField = nonceHexToFieldString(challenge.nonce);
|
|
3167
3282
|
// Inputs to the `preimagePoK` circuit. Public signals are
|
|
3168
3283
|
// `[commitment, nonce, ecdsaPubHash]`; the rest are private witnesses.
|
|
3169
3284
|
const input = {
|
|
@@ -3174,7 +3289,7 @@ class PersonalSpaceClient {
|
|
|
3174
3289
|
salt: this.salt,
|
|
3175
3290
|
ecdsaPub: this.ecdsaPub,
|
|
3176
3291
|
};
|
|
3177
|
-
const snarkjs = await loadSnarkjs$
|
|
3292
|
+
const snarkjs = await loadSnarkjs$1();
|
|
3178
3293
|
const { proof, publicSignals } = await snarkjs.groth16.fullProve(input, this.wasmUrl, this.zkeyUrl);
|
|
3179
3294
|
return {
|
|
3180
3295
|
challengeId: challenge.challengeId,
|
|
@@ -4017,248 +4132,94 @@ async function toUint8Array(data) {
|
|
|
4017
4132
|
}
|
|
4018
4133
|
|
|
4019
4134
|
/**
|
|
4020
|
-
* HTTP client for the
|
|
4021
|
-
*
|
|
4022
|
-
*
|
|
4023
|
-
*
|
|
4024
|
-
*
|
|
4025
|
-
*
|
|
4026
|
-
*
|
|
4027
|
-
*
|
|
4028
|
-
*
|
|
4029
|
-
*
|
|
4030
|
-
*
|
|
4031
|
-
*
|
|
4032
|
-
*
|
|
4033
|
-
*
|
|
4034
|
-
*
|
|
4035
|
-
*
|
|
4036
|
-
*
|
|
4037
|
-
*
|
|
4038
|
-
*
|
|
4039
|
-
* the next slice of work in accelerator):
|
|
4040
|
-
*
|
|
4041
|
-
* POST /api/spaces create a new space (gated by caller proof)
|
|
4042
|
-
* body: { challengeId, proof, publicSignals, initialAcl? }
|
|
4043
|
-
* returns: { spaceId }
|
|
4044
|
-
*
|
|
4045
|
-
* POST /api/spaces/:spaceId/challenge issue a nonce for the caller
|
|
4046
|
-
* body: { commitment }
|
|
4047
|
-
* returns: { challengeId, nonce }
|
|
4048
|
-
*
|
|
4049
|
-
* POST /api/spaces/:spaceId/files write a file manifest
|
|
4050
|
-
* body: { challengeId, proof, publicSignals, manifest }
|
|
4051
|
-
* returns: { ok: true, fileId }
|
|
4052
|
-
*
|
|
4053
|
-
* POST /api/spaces/:spaceId/files/:fileId/get read a file manifest
|
|
4054
|
-
* body: { challengeId, proof, publicSignals }
|
|
4055
|
-
* returns: { manifest }
|
|
4056
|
-
*
|
|
4057
|
-
* DELETE /api/spaces/:spaceId/files/:fileId delete a file manifest
|
|
4058
|
-
* body: { challengeId, proof, publicSignals }
|
|
4059
|
-
* returns: { ok: true, existed: boolean }
|
|
4060
|
-
*
|
|
4061
|
-
* POST /api/spaces/:spaceId/files/list list manifests in space
|
|
4062
|
-
* body: { challengeId, proof, publicSignals }
|
|
4063
|
-
* returns: { files: FileStat[] }
|
|
4064
|
-
*
|
|
4065
|
-
* POST /api/spaces/:spaceId/participants add a participant
|
|
4066
|
-
* body: { challengeId, proof, publicSignals, participant: Participant }
|
|
4067
|
-
* returns: { ok: true }
|
|
4068
|
-
*
|
|
4069
|
-
* DELETE /api/spaces/:spaceId/participants/:commitment remove a participant
|
|
4070
|
-
* body: { challengeId, proof, publicSignals }
|
|
4071
|
-
* returns: { ok: true, existed: boolean }
|
|
4072
|
-
*
|
|
4073
|
-
* POST /api/spaces/:spaceId/acl set / replace an ACL entry
|
|
4074
|
-
* body: { challengeId, proof, publicSignals, entry: ACLEntry }
|
|
4075
|
-
* returns: { ok: true }
|
|
4135
|
+
* HTTP client for the gated file-manifest store on a SharedSpace
|
|
4136
|
+
* (`/api/spaces/:spaceId/files`).
|
|
4137
|
+
*
|
|
4138
|
+
* A "file" in a space is its **manifest** (per-chunk keys + the SHA-256 of each
|
|
4139
|
+
* global shard). The shard bytes live in the open `/api/shards` store; the
|
|
4140
|
+
* manifest is the capability. This client only moves manifests in/out of the
|
|
4141
|
+
* space's gated store.
|
|
4142
|
+
*
|
|
4143
|
+
* Authorization is **session-based**, consistent with the rest of the
|
|
4144
|
+
* SharedSpace (keyring, websocket): the SDK's credential-stamping `fetch`
|
|
4145
|
+
* (from {@link HttpClient}) attaches the app key + session token, the platform
|
|
4146
|
+
* validates the session and injects a trusted `X-Muhkoo-User-Context`, and the
|
|
4147
|
+
* DO authorizes on that + channel membership. There is no per-op Groth16 proof.
|
|
4148
|
+
*
|
|
4149
|
+
* Wire protocol (server: `SharedSpaceDO`):
|
|
4150
|
+
* POST /api/spaces/:spaceId/files body: { manifest } → { ok, fileId }
|
|
4151
|
+
* GET /api/spaces/:spaceId/files → { files: FileStat[] }
|
|
4152
|
+
* GET /api/spaces/:spaceId/files/:fileId → { manifest }
|
|
4153
|
+
* DELETE /api/spaces/:spaceId/files/:fileId → { ok, existed }
|
|
4076
4154
|
*/
|
|
4077
|
-
let snarkjsModule = null;
|
|
4078
|
-
async function loadSnarkjs$1() {
|
|
4079
|
-
if (snarkjsModule)
|
|
4080
|
-
return snarkjsModule;
|
|
4081
|
-
snarkjsModule = (await import('snarkjs'));
|
|
4082
|
-
return snarkjsModule;
|
|
4083
|
-
}
|
|
4084
|
-
// BN254 scalar field modulus — same value used everywhere else in the ZK path.
|
|
4085
|
-
const FIELD_SIZE$1 = 21888242871839275222246405745257275088548364400416034343698204186575808495617n;
|
|
4086
4155
|
class SharedSpaceClient {
|
|
4087
4156
|
baseUrl;
|
|
4088
|
-
commitment;
|
|
4089
|
-
secret;
|
|
4090
|
-
salt;
|
|
4091
|
-
ecdsaPub;
|
|
4092
|
-
ecdsaPubHash;
|
|
4093
|
-
wasmUrl;
|
|
4094
|
-
zkeyUrl;
|
|
4095
4157
|
fetchFn;
|
|
4096
4158
|
constructor(opts) {
|
|
4097
4159
|
if (!opts?.baseUrl)
|
|
4098
4160
|
throw new Error("SharedSpaceClient: `baseUrl` is required");
|
|
4099
|
-
if (!opts.commitment)
|
|
4100
|
-
throw new Error("SharedSpaceClient: `commitment` is required");
|
|
4101
|
-
if (!opts.secret)
|
|
4102
|
-
throw new Error("SharedSpaceClient: `secret` is required");
|
|
4103
|
-
if (!opts.salt)
|
|
4104
|
-
throw new Error("SharedSpaceClient: `salt` is required");
|
|
4105
|
-
if (!opts.ecdsaPub)
|
|
4106
|
-
throw new Error("SharedSpaceClient: `ecdsaPub` is required");
|
|
4107
|
-
if (!opts.ecdsaPubHash)
|
|
4108
|
-
throw new Error("SharedSpaceClient: `ecdsaPubHash` is required");
|
|
4109
|
-
if (!opts.circuits?.wasmUrl || !opts.circuits?.zkeyUrl) {
|
|
4110
|
-
throw new Error("SharedSpaceClient: `circuits.wasmUrl` and `circuits.zkeyUrl` are required");
|
|
4111
|
-
}
|
|
4112
4161
|
this.baseUrl = opts.baseUrl.replace(/\/+$/, "");
|
|
4113
|
-
this.commitment = opts.commitment;
|
|
4114
|
-
this.secret = opts.secret;
|
|
4115
|
-
this.salt = opts.salt;
|
|
4116
|
-
this.ecdsaPub = opts.ecdsaPub;
|
|
4117
|
-
this.ecdsaPubHash = opts.ecdsaPubHash;
|
|
4118
|
-
this.wasmUrl = opts.circuits.wasmUrl;
|
|
4119
|
-
this.zkeyUrl = opts.circuits.zkeyUrl;
|
|
4120
4162
|
const f = opts.fetch ?? globalThis.fetch;
|
|
4121
4163
|
if (typeof f !== "function") {
|
|
4122
4164
|
throw new Error("SharedSpaceClient: `globalThis.fetch` is unavailable; pass an explicit fetch.");
|
|
4123
4165
|
}
|
|
4124
4166
|
this.fetchFn = f.bind(globalThis);
|
|
4125
4167
|
}
|
|
4126
|
-
/**
|
|
4127
|
-
* Create a brand new space. The caller becomes the owner; `initialAcl`
|
|
4128
|
-
* adds any additional ACL entries up front (e.g. inviting a co-owner at
|
|
4129
|
-
* creation time).
|
|
4130
|
-
*/
|
|
4131
|
-
async createSpace(initialAcl) {
|
|
4132
|
-
// Creating a space still requires the caller to prove they hold the
|
|
4133
|
-
// commitment they're claiming to own — that's what the proof
|
|
4134
|
-
// accomplishes here. The server issues a challenge against a global
|
|
4135
|
-
// "newspace" pseudo-id for the bootstrap step.
|
|
4136
|
-
const proof = await this.proveFreshChallenge("__new__");
|
|
4137
|
-
const body = { ...proof };
|
|
4138
|
-
if (initialAcl)
|
|
4139
|
-
body.initialAcl = initialAcl;
|
|
4140
|
-
const url = `${this.baseUrl}/api/spaces`;
|
|
4141
|
-
const response = await this.fetchFn(url, this.jsonRequest("POST", body));
|
|
4142
|
-
const data = await parseOrThrow(response, "createSpace");
|
|
4143
|
-
return { spaceId: data.spaceId };
|
|
4144
|
-
}
|
|
4145
|
-
/** Write a file manifest into the space. Requires `write` permission. */
|
|
4168
|
+
/** Write a file manifest into the space. Requires space membership. */
|
|
4146
4169
|
async writeFileManifest(spaceId, manifest) {
|
|
4147
|
-
const
|
|
4148
|
-
const
|
|
4149
|
-
|
|
4150
|
-
const response = await this.fetchFn(url, this.jsonRequest("POST", body));
|
|
4151
|
-
await parseOrThrow(response, `writeFileManifest(${manifest.id})`);
|
|
4170
|
+
const url = `${this.filesBase(spaceId)}`;
|
|
4171
|
+
const res = await this.fetchFn(url, this.jsonRequest("POST", { manifest }));
|
|
4172
|
+
await parseOrThrow(res, `writeFileManifest(${manifest.id})`);
|
|
4152
4173
|
}
|
|
4153
|
-
/** Read a file manifest by id. Requires
|
|
4174
|
+
/** Read a file manifest by id. Requires space membership. */
|
|
4154
4175
|
async readFileManifest(spaceId, fileId) {
|
|
4155
|
-
const
|
|
4156
|
-
const
|
|
4157
|
-
const
|
|
4158
|
-
const response = await this.fetchFn(url, this.jsonRequest("POST", body));
|
|
4159
|
-
const data = await parseOrThrow(response, `readFileManifest(${fileId})`);
|
|
4176
|
+
const url = `${this.filesBase(spaceId)}/${encodeURIComponent(fileId)}`;
|
|
4177
|
+
const res = await this.fetchFn(url, this.jsonRequest("GET"));
|
|
4178
|
+
const data = await parseOrThrow(res, `readFileManifest(${fileId})`);
|
|
4160
4179
|
return data.manifest;
|
|
4161
4180
|
}
|
|
4162
|
-
/** Delete a file manifest by id. Requires
|
|
4181
|
+
/** Delete a file manifest by id. Requires space membership. */
|
|
4163
4182
|
async deleteFileManifest(spaceId, fileId) {
|
|
4164
|
-
const
|
|
4165
|
-
const
|
|
4166
|
-
const
|
|
4167
|
-
const response = await this.fetchFn(url, this.jsonRequest("DELETE", body));
|
|
4168
|
-
const data = await parseOrThrow(response, `deleteFileManifest(${fileId})`);
|
|
4183
|
+
const url = `${this.filesBase(spaceId)}/${encodeURIComponent(fileId)}`;
|
|
4184
|
+
const res = await this.fetchFn(url, this.jsonRequest("DELETE"));
|
|
4185
|
+
const data = await parseOrThrow(res, `deleteFileManifest(${fileId})`);
|
|
4169
4186
|
return data.existed;
|
|
4170
4187
|
}
|
|
4171
|
-
/** List file summaries in the space. Requires
|
|
4188
|
+
/** List file summaries in the space. Requires space membership. */
|
|
4172
4189
|
async listFiles(spaceId) {
|
|
4173
|
-
const
|
|
4174
|
-
const
|
|
4175
|
-
const
|
|
4176
|
-
const response = await this.fetchFn(url, this.jsonRequest("POST", body));
|
|
4177
|
-
const data = await parseOrThrow(response, "listFiles");
|
|
4190
|
+
const url = `${this.filesBase(spaceId)}`;
|
|
4191
|
+
const res = await this.fetchFn(url, this.jsonRequest("GET"));
|
|
4192
|
+
const data = await parseOrThrow(res, "listFiles");
|
|
4178
4193
|
return Array.isArray(data.files) ? data.files : [];
|
|
4179
4194
|
}
|
|
4180
|
-
/** Add a participant. Requires `invite` or `admin`. */
|
|
4181
|
-
async addParticipant(spaceId, participant) {
|
|
4182
|
-
const proof = await this.proveFreshChallenge(spaceId);
|
|
4183
|
-
const body = { ...proof, participant };
|
|
4184
|
-
const url = `${this.baseUrl}/api/spaces/${encodeURIComponent(spaceId)}/participants`;
|
|
4185
|
-
const response = await this.fetchFn(url, this.jsonRequest("POST", body));
|
|
4186
|
-
await parseOrThrow(response, "addParticipant");
|
|
4187
|
-
}
|
|
4188
|
-
/** Remove a participant by their public-key identity. Requires `admin`. */
|
|
4189
|
-
async removeParticipant(spaceId, participantCommitment) {
|
|
4190
|
-
const proof = await this.proveFreshChallenge(spaceId);
|
|
4191
|
-
const body = { ...proof };
|
|
4192
|
-
const url = `${this.baseUrl}/api/spaces/${encodeURIComponent(spaceId)}/participants/${encodeURIComponent(participantCommitment)}`;
|
|
4193
|
-
const response = await this.fetchFn(url, this.jsonRequest("DELETE", body));
|
|
4194
|
-
const data = await parseOrThrow(response, "removeParticipant");
|
|
4195
|
-
return data.existed;
|
|
4196
|
-
}
|
|
4197
|
-
/** Set / replace an ACL entry. Requires `admin`. */
|
|
4198
|
-
async setACLEntry(spaceId, entry) {
|
|
4199
|
-
const proof = await this.proveFreshChallenge(spaceId);
|
|
4200
|
-
const body = { ...proof, entry };
|
|
4201
|
-
const url = `${this.baseUrl}/api/spaces/${encodeURIComponent(spaceId)}/acl`;
|
|
4202
|
-
const response = await this.fetchFn(url, this.jsonRequest("POST", body));
|
|
4203
|
-
await parseOrThrow(response, "setACLEntry");
|
|
4204
|
-
}
|
|
4205
|
-
// -------------------------------------------------------------------------
|
|
4206
|
-
// Internals
|
|
4207
4195
|
// -------------------------------------------------------------------------
|
|
4208
|
-
|
|
4209
|
-
|
|
4210
|
-
const challenge = await this.requestChallenge(spaceId);
|
|
4211
|
-
const nonceField = nonceHexToFieldString(challenge.nonce);
|
|
4212
|
-
const input = {
|
|
4213
|
-
commitment: this.commitment,
|
|
4214
|
-
nonce: nonceField,
|
|
4215
|
-
ecdsaPubHash: this.ecdsaPubHash,
|
|
4216
|
-
secret: this.secret,
|
|
4217
|
-
salt: this.salt,
|
|
4218
|
-
ecdsaPub: this.ecdsaPub,
|
|
4219
|
-
};
|
|
4220
|
-
const snarkjs = await loadSnarkjs$1();
|
|
4221
|
-
const { proof, publicSignals } = await snarkjs.groth16.fullProve(input, this.wasmUrl, this.zkeyUrl);
|
|
4222
|
-
return {
|
|
4223
|
-
challengeId: challenge.challengeId,
|
|
4224
|
-
proof: proof,
|
|
4225
|
-
publicSignals: publicSignals,
|
|
4226
|
-
};
|
|
4227
|
-
}
|
|
4228
|
-
/**
|
|
4229
|
-
* Hit `/challenge`. The body includes the caller's commitment so the
|
|
4230
|
-
* server can scope the issued nonce to this principal.
|
|
4231
|
-
*/
|
|
4232
|
-
async requestChallenge(spaceId) {
|
|
4233
|
-
const url = `${this.baseUrl}/api/spaces/${encodeURIComponent(spaceId)}/challenge`;
|
|
4234
|
-
const response = await this.fetchFn(url, this.jsonRequest("POST", { commitment: this.commitment }));
|
|
4235
|
-
return await parseOrThrow(response, "challenge");
|
|
4196
|
+
filesBase(spaceId) {
|
|
4197
|
+
return `${this.baseUrl}/api/spaces/${encodeURIComponent(spaceId)}/files`;
|
|
4236
4198
|
}
|
|
4237
4199
|
jsonRequest(method, body) {
|
|
4238
|
-
|
|
4239
|
-
|
|
4240
|
-
headers
|
|
4241
|
-
body
|
|
4242
|
-
}
|
|
4200
|
+
const init = { method };
|
|
4201
|
+
if (body !== undefined) {
|
|
4202
|
+
init.headers = { "Content-Type": "application/json" };
|
|
4203
|
+
init.body = JSON.stringify(body);
|
|
4204
|
+
}
|
|
4205
|
+
return init;
|
|
4243
4206
|
}
|
|
4244
4207
|
}
|
|
4245
|
-
function
|
|
4246
|
-
|
|
4247
|
-
|
|
4248
|
-
|
|
4249
|
-
|
|
4208
|
+
async function parseOrThrow(response, op) {
|
|
4209
|
+
let parsed = null;
|
|
4210
|
+
try {
|
|
4211
|
+
parsed = await response.json();
|
|
4212
|
+
}
|
|
4213
|
+
catch {
|
|
4214
|
+
// non-JSON body
|
|
4215
|
+
}
|
|
4250
4216
|
if (!response.ok) {
|
|
4251
|
-
|
|
4252
|
-
|
|
4253
|
-
|
|
4254
|
-
|
|
4255
|
-
}
|
|
4256
|
-
catch {
|
|
4257
|
-
// ignore — we still throw with the status
|
|
4258
|
-
}
|
|
4259
|
-
throw new Error(`SharedSpaceClient ${label} failed (${response.status})${detail}`);
|
|
4217
|
+
const msg = parsed && typeof parsed === "object" && "error" in parsed
|
|
4218
|
+
? String(parsed.error)
|
|
4219
|
+
: `${response.status} ${response.statusText}`;
|
|
4220
|
+
throw new Error(`SharedSpaceClient.${op}: ${msg}`);
|
|
4260
4221
|
}
|
|
4261
|
-
return
|
|
4222
|
+
return parsed;
|
|
4262
4223
|
}
|
|
4263
4224
|
|
|
4264
4225
|
/**
|
|
@@ -4692,6 +4653,7 @@ class HttpClient {
|
|
|
4692
4653
|
baseUrl;
|
|
4693
4654
|
apiKey;
|
|
4694
4655
|
getSessionToken;
|
|
4656
|
+
onUnauthorized;
|
|
4695
4657
|
fetchFn;
|
|
4696
4658
|
constructor(opts) {
|
|
4697
4659
|
if (!opts?.baseUrl)
|
|
@@ -4699,6 +4661,7 @@ class HttpClient {
|
|
|
4699
4661
|
this.baseUrl = opts.baseUrl.replace(/\/+$/, "");
|
|
4700
4662
|
this.apiKey = opts.apiKey ?? null;
|
|
4701
4663
|
this.getSessionToken = opts.getSessionToken ?? (() => null);
|
|
4664
|
+
this.onUnauthorized = opts.onUnauthorized ?? null;
|
|
4702
4665
|
const f = opts.fetch ?? globalThis.fetch;
|
|
4703
4666
|
if (typeof f !== "function") {
|
|
4704
4667
|
throw new Error("HttpClient: `globalThis.fetch` is unavailable; pass an explicit `fetch`.");
|
|
@@ -4731,7 +4694,7 @@ class HttpClient {
|
|
|
4731
4694
|
del(path, body) {
|
|
4732
4695
|
return this.request("DELETE", path, body);
|
|
4733
4696
|
}
|
|
4734
|
-
async request(method, path, body) {
|
|
4697
|
+
async request(method, path, body, allowRecovery = true) {
|
|
4735
4698
|
const url = path.startsWith("http") ? path : `${this.baseUrl}${path.startsWith("/") ? "" : "/"}${path}`;
|
|
4736
4699
|
const init = { method };
|
|
4737
4700
|
if (body !== undefined) {
|
|
@@ -4739,6 +4702,15 @@ class HttpClient {
|
|
|
4739
4702
|
init.body = JSON.stringify(body);
|
|
4740
4703
|
}
|
|
4741
4704
|
const res = await this.fetch(url, init);
|
|
4705
|
+
// Stale session: try to silently re-auth and replay the request once.
|
|
4706
|
+
// `allowRecovery` is cleared on the retry so a still-401 response (e.g.
|
|
4707
|
+
// recovery minted a token the endpoint still rejects) can't loop.
|
|
4708
|
+
if (res.status === 401 && allowRecovery && this.onUnauthorized && this.getSessionToken()) {
|
|
4709
|
+
const recovered = await this.onUnauthorized();
|
|
4710
|
+
if (recovered) {
|
|
4711
|
+
return this.request(method, path, body, false);
|
|
4712
|
+
}
|
|
4713
|
+
}
|
|
4742
4714
|
let parsed = null;
|
|
4743
4715
|
try {
|
|
4744
4716
|
parsed = await res.json();
|
|
@@ -4968,6 +4940,44 @@ class ZkAuth {
|
|
|
4968
4940
|
*/
|
|
4969
4941
|
async login(username, password, opts = {}) {
|
|
4970
4942
|
const identity = await deriveIdentity(username, password);
|
|
4943
|
+
return this.proveAndStore(username, identity, opts);
|
|
4944
|
+
}
|
|
4945
|
+
/**
|
|
4946
|
+
* Silently re-authenticate the current user without prompting for a
|
|
4947
|
+
* password. Only possible while the client is **unlocked** — i.e. the
|
|
4948
|
+
* derived {@link ZkIdentity} (secret + keypairs) is still in memory from a
|
|
4949
|
+
* prior `login()`/`unlock()`. Re-runs the full challenge→proof→authenticate
|
|
4950
|
+
* dance with that in-memory identity and swaps in the fresh token.
|
|
4951
|
+
*
|
|
4952
|
+
* Returns `true` if a new session was minted, `false` if recovery isn't
|
|
4953
|
+
* possible (no active user, or locked — the identity was never derived or
|
|
4954
|
+
* was lost on reload). A `false` result is the app's cue to send the user
|
|
4955
|
+
* back to the login screen.
|
|
4956
|
+
*
|
|
4957
|
+
* Used automatically by the {@link Client} when a token-gated request comes
|
|
4958
|
+
* back `401` (stale/expired token), so transient session expiry self-heals
|
|
4959
|
+
* instead of surfacing errors mid-session.
|
|
4960
|
+
*/
|
|
4961
|
+
async recover() {
|
|
4962
|
+
const username = this.deps.session.username;
|
|
4963
|
+
const identity = this.deps.session.identity;
|
|
4964
|
+
if (!username || !identity)
|
|
4965
|
+
return false;
|
|
4966
|
+
try {
|
|
4967
|
+
await this.proveAndStore(username, identity, {});
|
|
4968
|
+
return true;
|
|
4969
|
+
}
|
|
4970
|
+
catch {
|
|
4971
|
+
return false;
|
|
4972
|
+
}
|
|
4973
|
+
}
|
|
4974
|
+
/**
|
|
4975
|
+
* Shared sign-in core: prove knowledge of `identity` against a fresh
|
|
4976
|
+
* challenge and persist the resulting session + identity. Used by both
|
|
4977
|
+
* `login()` (identity derived from a password) and `recover()` (identity
|
|
4978
|
+
* already in memory).
|
|
4979
|
+
*/
|
|
4980
|
+
async proveAndStore(username, identity, opts) {
|
|
4971
4981
|
const ecdsaPubHex = await exportPublicKeyHex(identity.ecdsaKeyPair.publicKey);
|
|
4972
4982
|
const challenge = await this.deps.auth.getChallenge(username);
|
|
4973
4983
|
const { proof, publicSignals, commitment } = await generateAuthProof({
|
|
@@ -5125,14 +5135,14 @@ class StorageCipher {
|
|
|
5125
5135
|
}
|
|
5126
5136
|
|
|
5127
5137
|
/**
|
|
5128
|
-
* `client.
|
|
5129
|
-
*
|
|
5138
|
+
* `client.kv` — per-user key/value storage backed by the accelerator's personal
|
|
5139
|
+
* space.
|
|
5130
5140
|
*
|
|
5131
|
-
* await client.
|
|
5132
|
-
* const todo = await client.
|
|
5133
|
-
* const ids = await client.
|
|
5134
|
-
* await client.
|
|
5135
|
-
* const off = client.
|
|
5141
|
+
* await client.kv.set('todos', id, { title, completed })
|
|
5142
|
+
* const todo = await client.kv.get('todos', id)
|
|
5143
|
+
* const ids = await client.kv.list('todos')
|
|
5144
|
+
* await client.kv.delete('todos', id)
|
|
5145
|
+
* const off = client.kv.on('change', e => …) // realtime, cross-device
|
|
5136
5146
|
*
|
|
5137
5147
|
* A *collection* is a key namespace within the user's space; the stored key is
|
|
5138
5148
|
* `${collection}/${id}`. Operations are authorized by the user's session token
|
|
@@ -5140,12 +5150,14 @@ class StorageCipher {
|
|
|
5140
5150
|
*
|
|
5141
5151
|
* Values are encrypted at rest by default ({@link StorageCipher}, an AES-GCM
|
|
5142
5152
|
* key derived from the ZK identity); pass `{ encrypt: false }` to store
|
|
5143
|
-
* plaintext. The realtime feed rides the personal space's
|
|
5144
|
-
*
|
|
5153
|
+
* plaintext. The realtime feed rides the personal space's websocket over a
|
|
5154
|
+
* {@link WSTransport} (heartbeat keep-alive + auto-reconnect, event-namespaced
|
|
5155
|
+
* so it doesn't cross wires with chat/space transports).
|
|
5145
5156
|
*
|
|
5146
|
-
*
|
|
5157
|
+
* For **files** (blobs), use `client.storage` instead — `client.kv` is for
|
|
5158
|
+
* small structured values. `query()` is intentionally deferred.
|
|
5147
5159
|
*/
|
|
5148
|
-
class
|
|
5160
|
+
class KvNamespace {
|
|
5149
5161
|
deps;
|
|
5150
5162
|
cipher = null;
|
|
5151
5163
|
cipherIdentity = null;
|
|
@@ -5178,7 +5190,7 @@ class StorageNamespace {
|
|
|
5178
5190
|
}
|
|
5179
5191
|
/** Reserved — server-side query is deferred (see the overhaul plan). */
|
|
5180
5192
|
async query(_collection, _filter) {
|
|
5181
|
-
throw new Error("client.
|
|
5193
|
+
throw new Error("client.kv.query is not implemented yet (deferred in the SDK overhaul).");
|
|
5182
5194
|
}
|
|
5183
5195
|
/**
|
|
5184
5196
|
* Subscribe to realtime changes to this user's data across devices. Opens
|
|
@@ -5189,24 +5201,30 @@ class StorageNamespace {
|
|
|
5189
5201
|
*/
|
|
5190
5202
|
on(event, handler) {
|
|
5191
5203
|
if (event !== "change")
|
|
5192
|
-
throw new Error(`client.
|
|
5193
|
-
|
|
5194
|
-
|
|
5195
|
-
throw new Error("client.storage.on: no `WebSocket` global available in this runtime.");
|
|
5204
|
+
throw new Error(`client.kv: unknown event "${event}"`);
|
|
5205
|
+
if (typeof globalThis.WebSocket !== "function") {
|
|
5206
|
+
throw new Error("client.kv.on: no `WebSocket` global available in this runtime.");
|
|
5196
5207
|
}
|
|
5197
5208
|
const token = this.deps.session.token;
|
|
5198
5209
|
if (!token)
|
|
5199
|
-
throw new Error("client.
|
|
5200
|
-
const
|
|
5201
|
-
const
|
|
5202
|
-
socket
|
|
5203
|
-
|
|
5204
|
-
|
|
5210
|
+
throw new Error("client.kv.on: not signed in.");
|
|
5211
|
+
const commitment = this.commitment();
|
|
5212
|
+
const url = `${this.deps.wsBaseUrl}/api/personal/${encodeURIComponent(commitment)}/websocket?session=${encodeURIComponent(token)}`;
|
|
5213
|
+
// Ride a WSTransport rather than a bare socket: it keeps the (otherwise
|
|
5214
|
+
// idle) personal-space feed warm with heartbeat pings, auto-reconnects
|
|
5215
|
+
// if it drops, and namespaces its events by `id` so this feed never
|
|
5216
|
+
// crosses wires with chat/space transports on the shared EventCore bus.
|
|
5217
|
+
const transport = new WSTransport({ url, id: `personal:${commitment}` });
|
|
5218
|
+
const onMessage = (e) => {
|
|
5219
|
+
void this.dispatchChange(e.detail, handler);
|
|
5220
|
+
};
|
|
5221
|
+
transport.on(EventCoreEvents.MESSAGE, onMessage);
|
|
5222
|
+
// Fire-and-forget: the realtime feed is best-effort, and the transport
|
|
5223
|
+
// queues nothing it needs before CONNECTED (server→client only).
|
|
5224
|
+
void transport.connect();
|
|
5205
5225
|
return () => {
|
|
5206
|
-
|
|
5207
|
-
|
|
5208
|
-
}
|
|
5209
|
-
catch { /* already closed */ }
|
|
5226
|
+
transport.off(EventCoreEvents.MESSAGE, onMessage);
|
|
5227
|
+
transport.disconnect();
|
|
5210
5228
|
};
|
|
5211
5229
|
}
|
|
5212
5230
|
// -------------------------------------------------------------------------
|
|
@@ -5241,7 +5259,7 @@ class StorageNamespace {
|
|
|
5241
5259
|
commitment() {
|
|
5242
5260
|
const c = this.deps.session.commitment;
|
|
5243
5261
|
if (!c)
|
|
5244
|
-
throw new Error("client.
|
|
5262
|
+
throw new Error("client.kv: not signed in — call client.auth.zk.login() first.");
|
|
5245
5263
|
return c;
|
|
5246
5264
|
}
|
|
5247
5265
|
spacePath(suffix) {
|
|
@@ -5262,6 +5280,146 @@ class StorageNamespace {
|
|
|
5262
5280
|
}
|
|
5263
5281
|
}
|
|
5264
5282
|
|
|
5283
|
+
/**
|
|
5284
|
+
* `client.storage` — file storage. Hides the `FileStorage` / `ShardClient` /
|
|
5285
|
+
* `SharedSpaceClient` primitives behind the unified client.
|
|
5286
|
+
*
|
|
5287
|
+
* const { stat, manifest } = await client.storage.writeFile({ spaceId, data, metadata })
|
|
5288
|
+
* const { data } = await client.storage.readFile(spaceId, fileId)
|
|
5289
|
+
* const files = await client.storage.listFiles() // my shared files
|
|
5290
|
+
* const files = await client.storage.listFiles({ spaceId }) // a space's files
|
|
5291
|
+
* await client.storage.deleteFile(spaceId, fileId)
|
|
5292
|
+
*
|
|
5293
|
+
* Model:
|
|
5294
|
+
* - **Files are written to a SharedSpace** (never the personal space). A file
|
|
5295
|
+
* is its manifest (per-chunk keys + global shard hashes); the manifest lives
|
|
5296
|
+
* in the space's gated, metered `/files` store, the shard ciphertext in the
|
|
5297
|
+
* global open `/api/shards` store.
|
|
5298
|
+
* - **The manifest is the capability** — anyone holding it reads the file from
|
|
5299
|
+
* the global shards, anywhere ({@link readByManifest}). Share a file by
|
|
5300
|
+
* sharing its manifest.
|
|
5301
|
+
* - **Personal discovery mirror** — on a successful write the SDK also records
|
|
5302
|
+
* the manifest in the user's `client.kv` under a reserved collection, so
|
|
5303
|
+
* {@link listFiles} (no `spaceId`) can show "files I've shared" without
|
|
5304
|
+
* enumerating spaces. The mirror is unmetered and is NOT a write path.
|
|
5305
|
+
*
|
|
5306
|
+
* For small structured values use `client.kv` instead.
|
|
5307
|
+
*/
|
|
5308
|
+
/** Reserved `client.kv` collection holding the personal manifest mirror. */
|
|
5309
|
+
const MIRROR_COLLECTION = "__files__";
|
|
5310
|
+
class StorageNamespace {
|
|
5311
|
+
deps;
|
|
5312
|
+
fileStorage = null;
|
|
5313
|
+
constructor(deps) {
|
|
5314
|
+
this.deps = deps;
|
|
5315
|
+
}
|
|
5316
|
+
/**
|
|
5317
|
+
* Write a file to a SharedSpace: shard ciphertext → the global store, the
|
|
5318
|
+
* manifest → the space's gated store, and a mirror copy → the caller's
|
|
5319
|
+
* personal index. Returns the `stat` and the `manifest` (share the manifest
|
|
5320
|
+
* to let others read the file).
|
|
5321
|
+
*/
|
|
5322
|
+
async writeFile(input) {
|
|
5323
|
+
if (!input?.spaceId) {
|
|
5324
|
+
throw new Error("client.storage.writeFile: `spaceId` is required (files live in a SharedSpace).");
|
|
5325
|
+
}
|
|
5326
|
+
const fs = this.fs();
|
|
5327
|
+
const { manifest, stat } = await fs.writeFileToShards({
|
|
5328
|
+
data: input.data,
|
|
5329
|
+
metadata: input.metadata,
|
|
5330
|
+
});
|
|
5331
|
+
// Authoritative, metered write: the manifest into the space's gated store.
|
|
5332
|
+
await this.space().writeFileManifest(input.spaceId, manifest);
|
|
5333
|
+
// Best-effort personal discovery mirror (unmetered). A failure here
|
|
5334
|
+
// leaves the file intact in the space — log and continue.
|
|
5335
|
+
try {
|
|
5336
|
+
await this.deps.kv.set(MIRROR_COLLECTION, manifest.id, {
|
|
5337
|
+
spaceId: input.spaceId,
|
|
5338
|
+
manifest,
|
|
5339
|
+
});
|
|
5340
|
+
}
|
|
5341
|
+
catch (err) {
|
|
5342
|
+
console.warn("client.storage.writeFile: discovery mirror failed (file still written):", err);
|
|
5343
|
+
}
|
|
5344
|
+
return { stat, manifest };
|
|
5345
|
+
}
|
|
5346
|
+
/** Read a file from its space (resolves the manifest, then the shards). */
|
|
5347
|
+
async readFile(spaceId, fileId) {
|
|
5348
|
+
return this.fs().readFile(spaceId, fileId);
|
|
5349
|
+
}
|
|
5350
|
+
/**
|
|
5351
|
+
* Read a file straight from a manifest — the capability path. No space
|
|
5352
|
+
* membership needed; this is how a shared file is opened.
|
|
5353
|
+
*/
|
|
5354
|
+
async readByManifest(manifest) {
|
|
5355
|
+
return this.fs().readFileFromShards(manifest);
|
|
5356
|
+
}
|
|
5357
|
+
/**
|
|
5358
|
+
* List files. With no argument, returns the caller's own shared files from
|
|
5359
|
+
* the personal mirror; with `{ spaceId }`, lists that space's files.
|
|
5360
|
+
*/
|
|
5361
|
+
async listFiles(opts) {
|
|
5362
|
+
if (opts?.spaceId)
|
|
5363
|
+
return this.space().listFiles(opts.spaceId);
|
|
5364
|
+
const ids = await this.deps.kv.list(MIRROR_COLLECTION);
|
|
5365
|
+
const entries = await Promise.all(ids.map((id) => this.deps.kv.get(MIRROR_COLLECTION, id)));
|
|
5366
|
+
return entries
|
|
5367
|
+
.filter((e) => Boolean(e?.manifest))
|
|
5368
|
+
.map((e) => manifestToStat(e.manifest));
|
|
5369
|
+
}
|
|
5370
|
+
/** Delete a file from its space and remove the personal mirror entry. */
|
|
5371
|
+
async deleteFile(spaceId, fileId) {
|
|
5372
|
+
const existed = await this.fs().deleteFile(spaceId, fileId);
|
|
5373
|
+
try {
|
|
5374
|
+
await this.deps.kv.delete(MIRROR_COLLECTION, fileId);
|
|
5375
|
+
}
|
|
5376
|
+
catch {
|
|
5377
|
+
// mirror cleanup is best-effort
|
|
5378
|
+
}
|
|
5379
|
+
return existed;
|
|
5380
|
+
}
|
|
5381
|
+
/** Read a manifest verbatim (e.g. to share a file). */
|
|
5382
|
+
async getManifest(spaceId, fileId) {
|
|
5383
|
+
return this.fs().readManifest(spaceId, fileId);
|
|
5384
|
+
}
|
|
5385
|
+
// -------------------------------------------------------------------------
|
|
5386
|
+
// Internals — lazily built so an unauthenticated client is cheap to create.
|
|
5387
|
+
// -------------------------------------------------------------------------
|
|
5388
|
+
fs() {
|
|
5389
|
+
if (!this.fileStorage) {
|
|
5390
|
+
this.fileStorage = new FileStorage({
|
|
5391
|
+
shards: this.shards(),
|
|
5392
|
+
space: this.space(),
|
|
5393
|
+
rsWasmModule: this.deps.rsWasmModule,
|
|
5394
|
+
});
|
|
5395
|
+
}
|
|
5396
|
+
return this.fileStorage;
|
|
5397
|
+
}
|
|
5398
|
+
_shards = null;
|
|
5399
|
+
shards() {
|
|
5400
|
+
// Global, content-addressed shard store. The credential-stamping fetch
|
|
5401
|
+
// attaches the app key so PUTs are attributed + quota-gated; GETs are open.
|
|
5402
|
+
if (!this._shards) {
|
|
5403
|
+
this._shards = new ShardClient({
|
|
5404
|
+
baseUrl: this.deps.baseUrl,
|
|
5405
|
+
pathPrefix: "/api/shards",
|
|
5406
|
+
fetch: this.deps.http.fetch,
|
|
5407
|
+
});
|
|
5408
|
+
}
|
|
5409
|
+
return this._shards;
|
|
5410
|
+
}
|
|
5411
|
+
_space = null;
|
|
5412
|
+
space() {
|
|
5413
|
+
if (!this._space) {
|
|
5414
|
+
this._space = new SharedSpaceClient({
|
|
5415
|
+
baseUrl: this.deps.baseUrl,
|
|
5416
|
+
fetch: this.deps.http.fetch,
|
|
5417
|
+
});
|
|
5418
|
+
}
|
|
5419
|
+
return this._space;
|
|
5420
|
+
}
|
|
5421
|
+
}
|
|
5422
|
+
|
|
5265
5423
|
/**
|
|
5266
5424
|
* SpaceCipher — pure crypto for the fan-out group-encryption layer.
|
|
5267
5425
|
*
|
|
@@ -5305,6 +5463,46 @@ async function importEcdhPublicKey(encoded) {
|
|
|
5305
5463
|
}
|
|
5306
5464
|
/** The space's id is just its encoded ECDH public key. */
|
|
5307
5465
|
const encodeSpaceId = exportEcdhPublicKey;
|
|
5466
|
+
// ---------------------------------------------------------------------------
|
|
5467
|
+
// Sender authentication (ECDSA P-384, matching the Double Ratchet path).
|
|
5468
|
+
// A fan-out message is signed by its sender's identity ECDSA key so receivers
|
|
5469
|
+
// can verify authorship end-to-end — independent of the relay, which only
|
|
5470
|
+
// stamps `source` server-side. SHA-256 hash to match `DoubleRatchet`.
|
|
5471
|
+
// ---------------------------------------------------------------------------
|
|
5472
|
+
const ECDSA_PARAMS = { name: "ECDSA", namedCurve: "P-384" };
|
|
5473
|
+
const ECDSA_SIGN = { name: "ECDSA", hash: "SHA-256" };
|
|
5474
|
+
/** Encode an ECDSA public `CryptoKey` as a base64url JWK string. */
|
|
5475
|
+
async function exportEcdsaPublicKey(key) {
|
|
5476
|
+
const jwk = await getSubtle().exportKey("jwk", key);
|
|
5477
|
+
return toBase64Url(utf8Encode(JSON.stringify(jwk)));
|
|
5478
|
+
}
|
|
5479
|
+
/** Decode a base64url JWK string back into an ECDSA verify-only public key. */
|
|
5480
|
+
async function importEcdsaPublicKey(encoded) {
|
|
5481
|
+
const jwk = JSON.parse(utf8Decode(fromBase64Url(encoded)));
|
|
5482
|
+
return getSubtle().importKey("jwk", jwk, ECDSA_PARAMS, true, ["verify"]);
|
|
5483
|
+
}
|
|
5484
|
+
/**
|
|
5485
|
+
* Canonical bytes a sender signs (and a receiver re-derives). Binds the claimed
|
|
5486
|
+
* sender + routing + the sealed ciphertext, so a relay can't relabel `source`
|
|
5487
|
+
* and a member can't reuse another's ciphertext under a new envelope.
|
|
5488
|
+
*/
|
|
5489
|
+
function canonicalMessage(fields) {
|
|
5490
|
+
return [fields.source, fields.target, fields.subject, fields.epoch, fields.iv, fields.ciphertext].join("\n");
|
|
5491
|
+
}
|
|
5492
|
+
/** Sign the canonical bytes with the sender's ECDSA identity private key. */
|
|
5493
|
+
async function signSpaceMessage(canonical, ecdsaPriv) {
|
|
5494
|
+
const sig = await getSubtle().sign(ECDSA_SIGN, ecdsaPriv, utf8Encode(canonical));
|
|
5495
|
+
return toBase64(new Uint8Array(sig));
|
|
5496
|
+
}
|
|
5497
|
+
/** Verify a signature over the canonical bytes against the sender's ECDSA key. */
|
|
5498
|
+
async function verifySpaceMessage(canonical, sigB64, ecdsaPub) {
|
|
5499
|
+
try {
|
|
5500
|
+
return await getSubtle().verify(ECDSA_SIGN, ecdsaPub, fromBase64(sigB64), utf8Encode(canonical));
|
|
5501
|
+
}
|
|
5502
|
+
catch {
|
|
5503
|
+
return false;
|
|
5504
|
+
}
|
|
5505
|
+
}
|
|
5308
5506
|
/** Generate a fresh space identity keypair (P-384 ECDH). */
|
|
5309
5507
|
async function generateSpaceIdentity() {
|
|
5310
5508
|
const kp = await getSubtle().generateKey(ECDH_PARAMS, true, ["deriveBits"]);
|
|
@@ -5530,16 +5728,7 @@ class Space {
|
|
|
5530
5728
|
* persisted `spaceMessage`. Requires a keyring.
|
|
5531
5729
|
*/
|
|
5532
5730
|
async sendMessage(payload, opts = {}) {
|
|
5533
|
-
|
|
5534
|
-
const message = new Message(payload);
|
|
5535
|
-
const headers = await cipher.seal(message.serialize());
|
|
5536
|
-
const packet = new Packet({
|
|
5537
|
-
subject: opts.channel ?? "default",
|
|
5538
|
-
source: this.deps.myId(),
|
|
5539
|
-
target: this.name,
|
|
5540
|
-
headers,
|
|
5541
|
-
});
|
|
5542
|
-
this.sendRaw({ spaceMessage: packet.serialize() });
|
|
5731
|
+
this.sendRaw({ spaceMessage: await this.sealMessage(payload, opts.channel, opts.contentType) });
|
|
5543
5732
|
}
|
|
5544
5733
|
/** Subscribe to decrypted fan-out messages. Returns an unsubscribe fn. */
|
|
5545
5734
|
onMessage(handler) {
|
|
@@ -5547,6 +5736,56 @@ class Space {
|
|
|
5547
5736
|
this.events.addEventListener("message", listener);
|
|
5548
5737
|
return () => this.events.removeEventListener("message", listener);
|
|
5549
5738
|
}
|
|
5739
|
+
/**
|
|
5740
|
+
* Edit a persisted message IN PLACE, addressed by its server
|
|
5741
|
+
* {@link SpaceMessageEvent.handle}. Re-seals `payload` and asks the server
|
|
5742
|
+
* to replace the stored entry; the server authorizes against the original
|
|
5743
|
+
* author. Receivers get an {@link onMessageEdited} event carrying the new
|
|
5744
|
+
* content at the same handle. This is a generic persisted-item edit — the
|
|
5745
|
+
* server has no notion of what the payload means.
|
|
5746
|
+
*/
|
|
5747
|
+
async editMessage(handle, payload, opts = {}) {
|
|
5748
|
+
const spaceMessage = await this.sealMessage(payload, opts.channel, opts.contentType);
|
|
5749
|
+
this.sendRaw({ editSpaceMessage: { ts: handle, spaceMessage } });
|
|
5750
|
+
}
|
|
5751
|
+
/**
|
|
5752
|
+
* Hard-delete a persisted message by its server {@link SpaceMessageEvent.handle}
|
|
5753
|
+
* — the ciphertext is removed from storage. Server authorizes against the
|
|
5754
|
+
* original author. Receivers get an {@link onMessageDeleted} event.
|
|
5755
|
+
*/
|
|
5756
|
+
async deleteMessage(handle) {
|
|
5757
|
+
this.sendRaw({ deleteSpaceMessage: { ts: handle } });
|
|
5758
|
+
}
|
|
5759
|
+
/** Subscribe to in-place edits of persisted messages (new content at the same handle). */
|
|
5760
|
+
onMessageEdited(handler) {
|
|
5761
|
+
const listener = (e) => handler(e.detail);
|
|
5762
|
+
this.events.addEventListener("message-edited", listener);
|
|
5763
|
+
return () => this.events.removeEventListener("message-edited", listener);
|
|
5764
|
+
}
|
|
5765
|
+
/** Subscribe to deletions of persisted messages. */
|
|
5766
|
+
onMessageDeleted(handler) {
|
|
5767
|
+
const listener = (e) => handler(e.detail);
|
|
5768
|
+
this.events.addEventListener("message-deleted", listener);
|
|
5769
|
+
return () => this.events.removeEventListener("message-deleted", listener);
|
|
5770
|
+
}
|
|
5771
|
+
/**
|
|
5772
|
+
* Broadcast an ephemeral, never-persisted signal to the room over the
|
|
5773
|
+
* generic `pub` relay. `subject` is an app-defined routing key; `data` is
|
|
5774
|
+
* opaque. The transport stays domain-agnostic — app concepts like typing
|
|
5775
|
+
* indicators, presence, or live cursors are built on top of this. No-op if
|
|
5776
|
+
* the space isn't connected (ephemeral signals aren't worth queuing).
|
|
5777
|
+
*/
|
|
5778
|
+
sendEphemeral(subject, data) {
|
|
5779
|
+
if (!this.isConnected())
|
|
5780
|
+
return;
|
|
5781
|
+
this.sendRaw({ pub: { subject, data } });
|
|
5782
|
+
}
|
|
5783
|
+
/** Subscribe to inbound ephemeral signals. `from` is the authenticated sender. */
|
|
5784
|
+
onEphemeral(handler) {
|
|
5785
|
+
const listener = (e) => handler(e.detail);
|
|
5786
|
+
this.events.addEventListener("ephemeral", listener);
|
|
5787
|
+
return () => this.events.removeEventListener("ephemeral", listener);
|
|
5788
|
+
}
|
|
5550
5789
|
/** Subscribe to inbound join requests (for a key-holder to admit). */
|
|
5551
5790
|
onJoinRequest(handler) {
|
|
5552
5791
|
const listener = (e) => handler(e.detail);
|
|
@@ -5570,6 +5809,9 @@ class Space {
|
|
|
5570
5809
|
if (!res.ok)
|
|
5571
5810
|
throw new Error(`Space.history: ${res.status} ${res.statusText}`);
|
|
5572
5811
|
const body = (await res.json());
|
|
5812
|
+
// Warm the member directory once so historical senders' signatures verify.
|
|
5813
|
+
// (refreshDirectory swallows its own transport errors.)
|
|
5814
|
+
await this.deps.keyring?.refreshDirectory();
|
|
5573
5815
|
const messages = [];
|
|
5574
5816
|
for (const raw of body.messages ?? []) {
|
|
5575
5817
|
const decoded = await this.decodeFrame(raw, cipher);
|
|
@@ -5605,6 +5847,9 @@ class Space {
|
|
|
5605
5847
|
const keyring = this.requireKeyring();
|
|
5606
5848
|
await keyring.bootstrapNew();
|
|
5607
5849
|
await this.connect();
|
|
5850
|
+
// Publish our identity in the member directory so peers can verify our
|
|
5851
|
+
// message signatures (the creator never "joins", so register here).
|
|
5852
|
+
await keyring.requestKey().catch(() => { });
|
|
5608
5853
|
// Admit anyone who requested a key before we were listening.
|
|
5609
5854
|
if (this.deps.autoAdmit !== false) {
|
|
5610
5855
|
await keyring.admitPending().catch(() => { });
|
|
@@ -5614,6 +5859,10 @@ class Space {
|
|
|
5614
5859
|
async admit(memberId, identityEcdhPub) {
|
|
5615
5860
|
await this.requireKeyring().admit(memberId, identityEcdhPub);
|
|
5616
5861
|
}
|
|
5862
|
+
/** Invite a user to this (private) channel's membership allowlist. */
|
|
5863
|
+
async invite(username) {
|
|
5864
|
+
await this.requireKeyring().invite(username);
|
|
5865
|
+
}
|
|
5617
5866
|
/** Rotate to a fresh epoch and re-wrap to the roster (rotate spaces). */
|
|
5618
5867
|
async rotate(roster) {
|
|
5619
5868
|
return this.requireKeyring().rotate(roster);
|
|
@@ -5626,9 +5875,13 @@ class Space {
|
|
|
5626
5875
|
return this.fileStorage().readFileFromShards(manifest);
|
|
5627
5876
|
}
|
|
5628
5877
|
fileStorage() {
|
|
5878
|
+
// Global, content-addressed shard store — same store `client.storage`
|
|
5879
|
+
// uses, so a file shared into a channel resolves from anywhere by
|
|
5880
|
+
// manifest. (Shards are encrypted ciphertext; the manifest is the
|
|
5881
|
+
// capability.)
|
|
5629
5882
|
const shards = new ShardClient({
|
|
5630
5883
|
baseUrl: this.deps.httpBaseUrl,
|
|
5631
|
-
pathPrefix:
|
|
5884
|
+
pathPrefix: "/api/shards",
|
|
5632
5885
|
fetch: this.deps.fetch,
|
|
5633
5886
|
});
|
|
5634
5887
|
return new FileStorage({ shards });
|
|
@@ -5648,15 +5901,45 @@ class Space {
|
|
|
5648
5901
|
if (!f || typeof f !== "object")
|
|
5649
5902
|
return;
|
|
5650
5903
|
if (typeof f.spaceMessage === "string" && this.cipher) {
|
|
5651
|
-
const decoded = await this.decodeFrame(f.spaceMessage, this.cipher, /*raw*/ true);
|
|
5904
|
+
const decoded = await this.decodeFrame(f.spaceMessage, this.cipher, /*raw*/ true, Number(f.timestamp ?? 0));
|
|
5652
5905
|
if (decoded)
|
|
5653
5906
|
this.emitEvent("message", decoded);
|
|
5654
5907
|
return;
|
|
5655
5908
|
}
|
|
5909
|
+
if (f.editSpaceMessage && typeof f.editSpaceMessage === "object" && this.cipher) {
|
|
5910
|
+
// Server-authoritative in-place edit: new sealed content at the same
|
|
5911
|
+
// handle. Decode and surface it; the app replaces by `handle`.
|
|
5912
|
+
const e = f.editSpaceMessage;
|
|
5913
|
+
if (typeof e.spaceMessage === "string") {
|
|
5914
|
+
const decoded = await this.decodeFrame(e.spaceMessage, this.cipher, /*raw*/ true, Number(e.ts ?? 0));
|
|
5915
|
+
if (decoded)
|
|
5916
|
+
this.emitEvent("message-edited", decoded);
|
|
5917
|
+
}
|
|
5918
|
+
return;
|
|
5919
|
+
}
|
|
5920
|
+
if (f.deleteSpaceMessage && typeof f.deleteSpaceMessage === "object") {
|
|
5921
|
+
const d = f.deleteSpaceMessage;
|
|
5922
|
+
this.emitEvent("message-deleted", { handle: Number(d.ts ?? 0) });
|
|
5923
|
+
return;
|
|
5924
|
+
}
|
|
5925
|
+
if (f.pub && typeof f.pub === "object") {
|
|
5926
|
+
// Generic ephemeral relay frame. The server stamps the authenticated
|
|
5927
|
+
// sender as `name`; `pub` carries the app's {subject, data}. We
|
|
5928
|
+
// surface the authenticated `name` as `from` (not any client-set
|
|
5929
|
+
// value inside `pub`), so app signals can't spoof their sender.
|
|
5930
|
+
const p = f.pub;
|
|
5931
|
+
const from = typeof f.name === "string" ? f.name : "";
|
|
5932
|
+
if (from && typeof p.subject === "string") {
|
|
5933
|
+
this.emitEvent("ephemeral", { from, subject: p.subject, data: p.data });
|
|
5934
|
+
}
|
|
5935
|
+
return;
|
|
5936
|
+
}
|
|
5656
5937
|
if (f.joinRequest && typeof f.joinRequest === "object") {
|
|
5657
5938
|
const req = f.joinRequest;
|
|
5658
5939
|
this.emitEvent("join-request", req);
|
|
5659
|
-
//
|
|
5940
|
+
// A new member appeared — refresh the directory so their future
|
|
5941
|
+
// messages verify, and (if we hold the key) auto-admit them.
|
|
5942
|
+
void this.deps.keyring?.refreshDirectory();
|
|
5660
5943
|
if (this.deps.autoAdmit !== false && this.deps.keyring?.hasAnyKey() && req.memberId !== this.deps.myId()) {
|
|
5661
5944
|
void this.deps.keyring.admit(req.memberId, req.identityEcdhPub).catch(() => { });
|
|
5662
5945
|
}
|
|
@@ -5667,23 +5950,57 @@ class Space {
|
|
|
5667
5950
|
void this.deps.keyring.pullKeys().catch(() => { });
|
|
5668
5951
|
}
|
|
5669
5952
|
}
|
|
5953
|
+
/**
|
|
5954
|
+
* Seal `payload` once with the current group key + sign it, returning the
|
|
5955
|
+
* serialized `Packet` string. Shared by send (a new message) and edit
|
|
5956
|
+
* (replacement content for an existing handle). Requires a keyring.
|
|
5957
|
+
*/
|
|
5958
|
+
async sealMessage(payload, channel, contentType) {
|
|
5959
|
+
const cipher = this.requireCipher(contentType);
|
|
5960
|
+
const message = new Message(payload);
|
|
5961
|
+
const headers = await cipher.seal(message.serialize());
|
|
5962
|
+
const source = this.deps.myId();
|
|
5963
|
+
const subject = channel ?? "default";
|
|
5964
|
+
// Sign with our identity ECDSA key so receivers can verify authorship
|
|
5965
|
+
// end-to-end (independent of the relay's `source` stamp).
|
|
5966
|
+
const authPriv = KeyStore.getInstance().getAuthKeyPair(source)?.privateKey;
|
|
5967
|
+
if (authPriv) {
|
|
5968
|
+
const canonical = canonicalMessage({
|
|
5969
|
+
source, target: this.name, subject,
|
|
5970
|
+
epoch: Number(headers.epoch), iv: String(headers.iv), ciphertext: String(headers.ciphertext),
|
|
5971
|
+
});
|
|
5972
|
+
headers.sig = await signSpaceMessage(canonical, authPriv);
|
|
5973
|
+
}
|
|
5974
|
+
return new Packet({ subject, source, target: this.name, headers }).serialize();
|
|
5975
|
+
}
|
|
5670
5976
|
/**
|
|
5671
5977
|
* Decode one persisted/broadcast frame into a SpaceMessageEvent.
|
|
5672
5978
|
* `isRawFrame` true means `input` is the already-extracted `spaceMessage`
|
|
5673
|
-
* packet string
|
|
5979
|
+
* packet string (and `serverTs` carries the message's storage handle from
|
|
5980
|
+
* the enclosing frame); false means `input` is the full
|
|
5981
|
+
* `{spaceMessage, name, timestamp}` frame and the handle is read from it.
|
|
5674
5982
|
*/
|
|
5675
|
-
async decodeFrame(input, cipher, isRawFrame = false) {
|
|
5983
|
+
async decodeFrame(input, cipher, isRawFrame = false, serverTs = 0) {
|
|
5676
5984
|
try {
|
|
5677
5985
|
let packetJson = input;
|
|
5986
|
+
let handle = serverTs;
|
|
5678
5987
|
if (!isRawFrame) {
|
|
5679
5988
|
const outer = JSON.parse(input);
|
|
5680
5989
|
if (typeof outer.spaceMessage !== "string")
|
|
5681
5990
|
return null;
|
|
5682
5991
|
packetJson = outer.spaceMessage;
|
|
5992
|
+
handle = Number(outer.timestamp ?? 0);
|
|
5683
5993
|
}
|
|
5684
5994
|
const packet = Packet.deserialize(packetJson);
|
|
5685
5995
|
if (!cipher.handles(packet.headers))
|
|
5686
5996
|
return null;
|
|
5997
|
+
// Verify sender authenticity end-to-end: the message must be signed
|
|
5998
|
+
// by `source`'s identity ECDSA key (looked up in the member
|
|
5999
|
+
// directory). Drop anything unsigned, signed by an unknown member,
|
|
6000
|
+
// or whose signature doesn't match — this defeats both member
|
|
6001
|
+
// impersonation and a relay rewriting `source`.
|
|
6002
|
+
if (!(await this.verifySender(packet)))
|
|
6003
|
+
return null;
|
|
5687
6004
|
const serialized = await cipher.open(packet.headers);
|
|
5688
6005
|
if (serialized === null)
|
|
5689
6006
|
return null; // epoch key we don't hold
|
|
@@ -5693,6 +6010,7 @@ class Space {
|
|
|
5693
6010
|
channel: packet.subject,
|
|
5694
6011
|
epoch: Number(packet.headers?.epoch ?? 0),
|
|
5695
6012
|
contentType: packet.headers?.contentType,
|
|
6013
|
+
handle,
|
|
5696
6014
|
message,
|
|
5697
6015
|
};
|
|
5698
6016
|
}
|
|
@@ -5701,6 +6019,34 @@ class Space {
|
|
|
5701
6019
|
return null;
|
|
5702
6020
|
}
|
|
5703
6021
|
}
|
|
6022
|
+
/**
|
|
6023
|
+
* True if `packet` carries a valid signature by `source`'s identity key.
|
|
6024
|
+
* On a directory cache miss, refresh once (a new member may have just
|
|
6025
|
+
* joined) before giving up. Without a keyring (legacy handle) there's
|
|
6026
|
+
* nothing to verify against → reject.
|
|
6027
|
+
*/
|
|
6028
|
+
async verifySender(packet) {
|
|
6029
|
+
const keyring = this.deps.keyring;
|
|
6030
|
+
const sig = packet.headers?.sig;
|
|
6031
|
+
if (!keyring || typeof sig !== "string")
|
|
6032
|
+
return false;
|
|
6033
|
+
let key = keyring.ecdsaKeyFor(packet.source);
|
|
6034
|
+
if (!key) {
|
|
6035
|
+
await keyring.refreshDirectory();
|
|
6036
|
+
key = keyring.ecdsaKeyFor(packet.source);
|
|
6037
|
+
if (!key)
|
|
6038
|
+
return false;
|
|
6039
|
+
}
|
|
6040
|
+
const canonical = canonicalMessage({
|
|
6041
|
+
source: packet.source,
|
|
6042
|
+
target: packet.target,
|
|
6043
|
+
subject: packet.subject,
|
|
6044
|
+
epoch: Number(packet.headers?.epoch ?? 0),
|
|
6045
|
+
iv: String(packet.headers?.iv ?? ""),
|
|
6046
|
+
ciphertext: String(packet.headers?.ciphertext ?? ""),
|
|
6047
|
+
});
|
|
6048
|
+
return verifySpaceMessage(canonical, sig, key);
|
|
6049
|
+
}
|
|
5704
6050
|
emitEvent(type, detail) {
|
|
5705
6051
|
this.events.dispatchEvent(new CustomEvent(type, { detail }));
|
|
5706
6052
|
}
|
|
@@ -5930,10 +6276,37 @@ class SpaceKeyring {
|
|
|
5930
6276
|
keys = new Map();
|
|
5931
6277
|
epoch = 0;
|
|
5932
6278
|
policy;
|
|
6279
|
+
/** Cached member directory: memberId → imported ECDSA verify key. */
|
|
6280
|
+
memberEcdsa = new Map();
|
|
5933
6281
|
constructor(deps) {
|
|
5934
6282
|
this.deps = deps;
|
|
5935
6283
|
this.policy = deps.historyPolicy ?? "static";
|
|
5936
6284
|
}
|
|
6285
|
+
// -- member directory (for verifying sender signatures) -------------------
|
|
6286
|
+
/** The cached ECDSA verify key for a member, if known. */
|
|
6287
|
+
ecdsaKeyFor(memberId) {
|
|
6288
|
+
return this.memberEcdsa.get(memberId);
|
|
6289
|
+
}
|
|
6290
|
+
/** Refresh the member directory (memberId → ECDSA key) from the roster. */
|
|
6291
|
+
async refreshDirectory() {
|
|
6292
|
+
let roster;
|
|
6293
|
+
try {
|
|
6294
|
+
roster = await this.deps.transport.fetchRoster();
|
|
6295
|
+
}
|
|
6296
|
+
catch {
|
|
6297
|
+
return;
|
|
6298
|
+
}
|
|
6299
|
+
for (const m of roster) {
|
|
6300
|
+
if (!m.identityEcdsaPub || this.memberEcdsa.has(m.memberId))
|
|
6301
|
+
continue;
|
|
6302
|
+
try {
|
|
6303
|
+
this.memberEcdsa.set(m.memberId, await importEcdsaPublicKey(m.identityEcdsaPub));
|
|
6304
|
+
}
|
|
6305
|
+
catch {
|
|
6306
|
+
/* skip malformed entries */
|
|
6307
|
+
}
|
|
6308
|
+
}
|
|
6309
|
+
}
|
|
5937
6310
|
// -- EpochKeyProvider ------------------------------------------------------
|
|
5938
6311
|
currentEpoch() {
|
|
5939
6312
|
return this.epoch;
|
|
@@ -5987,11 +6360,16 @@ class SpaceKeyring {
|
|
|
5987
6360
|
await this.persist();
|
|
5988
6361
|
}
|
|
5989
6362
|
// -- newcomer: request + pull ---------------------------------------------
|
|
5990
|
-
/**
|
|
6363
|
+
/**
|
|
6364
|
+
* Post a join request so an online key-holder can wrap a key for us. Also
|
|
6365
|
+
* publishes our identity keys in the member directory (so others can verify
|
|
6366
|
+
* our message signatures, and the keeper can wrap to us).
|
|
6367
|
+
*/
|
|
5991
6368
|
async requestKey(desiredEpoch) {
|
|
5992
6369
|
await this.deps.transport.postJoinRequest({
|
|
5993
6370
|
memberId: this.deps.memberId,
|
|
5994
6371
|
identityEcdhPub: this.deps.identityEcdhPub,
|
|
6372
|
+
identityEcdsaPub: this.deps.identityEcdsaPub,
|
|
5995
6373
|
desiredEpoch,
|
|
5996
6374
|
});
|
|
5997
6375
|
}
|
|
@@ -6077,6 +6455,10 @@ class SpaceKeyring {
|
|
|
6077
6455
|
await this.persist();
|
|
6078
6456
|
return nextEpoch;
|
|
6079
6457
|
}
|
|
6458
|
+
/** Add `username` to a private channel's membership allowlist (member-only). */
|
|
6459
|
+
async invite(username) {
|
|
6460
|
+
await this.deps.transport.invite(username);
|
|
6461
|
+
}
|
|
6080
6462
|
}
|
|
6081
6463
|
|
|
6082
6464
|
/**
|
|
@@ -6137,6 +6519,9 @@ class KeyringClient {
|
|
|
6137
6519
|
async rotate(nextEpoch) {
|
|
6138
6520
|
return this.post("/keyring/rotate", { nextEpoch });
|
|
6139
6521
|
}
|
|
6522
|
+
async invite(username) {
|
|
6523
|
+
await this.post("/keyring/invite", { username });
|
|
6524
|
+
}
|
|
6140
6525
|
async fetchMetadata() {
|
|
6141
6526
|
try {
|
|
6142
6527
|
return await this.get("/metadata");
|
|
@@ -6193,7 +6578,9 @@ class SpaceNamespace {
|
|
|
6193
6578
|
const historyPolicy = opts.historyPolicy ?? "static";
|
|
6194
6579
|
const identity = await generateSpaceIdentity();
|
|
6195
6580
|
// Register the space (server records pubkey + policy; the DO is lazy).
|
|
6196
|
-
|
|
6581
|
+
// For a private space the server also records the creator (this user) as
|
|
6582
|
+
// its first member — so the metadata POST must carry the user session.
|
|
6583
|
+
await this.deps.http.post(`/api/spaces/${encodeURIComponent(identity.id)}/metadata`, { spacePubKey: identity.id, historyPolicy, visibility: opts.private ? "private" : "public" });
|
|
6197
6584
|
const space = await this.build(identity.id, historyPolicy);
|
|
6198
6585
|
await space.create();
|
|
6199
6586
|
return space;
|
|
@@ -6241,7 +6628,7 @@ class SpaceNamespace {
|
|
|
6241
6628
|
const space = await this.createSpace(opts);
|
|
6242
6629
|
// Register name → id first so a name clash doesn't leave an admitted keeper.
|
|
6243
6630
|
try {
|
|
6244
|
-
await this.deps.http.post("/api/app/channels", { name, spaceId: space.id });
|
|
6631
|
+
await this.deps.http.post("/api/app/channels", { name, spaceId: space.id, visibility: opts.private ? "private" : "public" });
|
|
6245
6632
|
}
|
|
6246
6633
|
catch (err) {
|
|
6247
6634
|
if (err instanceof HttpError && err.status === 409) {
|
|
@@ -6276,7 +6663,7 @@ class SpaceNamespace {
|
|
|
6276
6663
|
// -------------------------------------------------------------------------
|
|
6277
6664
|
async build(spaceId, historyPolicy) {
|
|
6278
6665
|
const memberId = this.myId();
|
|
6279
|
-
const
|
|
6666
|
+
const { ecdhPub, ecdsaPub } = await this.ensureIdentity(memberId);
|
|
6280
6667
|
const transport = new KeyringClient({
|
|
6281
6668
|
spaceId,
|
|
6282
6669
|
httpBaseUrl: this.deps.http.baseUrl,
|
|
@@ -6285,7 +6672,8 @@ class SpaceNamespace {
|
|
|
6285
6672
|
const keyring = new SpaceKeyring({
|
|
6286
6673
|
spaceId,
|
|
6287
6674
|
memberId,
|
|
6288
|
-
identityEcdhPub,
|
|
6675
|
+
identityEcdhPub: ecdhPub,
|
|
6676
|
+
identityEcdsaPub: ecdsaPub,
|
|
6289
6677
|
ownPrivateKey: () => KeyStore.getInstance().getKeyPair(memberId)?.privateKey ?? null,
|
|
6290
6678
|
transport,
|
|
6291
6679
|
cache: this.deps.cache,
|
|
@@ -6302,14 +6690,15 @@ class SpaceNamespace {
|
|
|
6302
6690
|
historyPolicy,
|
|
6303
6691
|
});
|
|
6304
6692
|
}
|
|
6305
|
-
/** Ensure the member has an identity
|
|
6693
|
+
/** Ensure the member has an identity keypair; return its public ECDH + ECDSA keys. */
|
|
6306
6694
|
async ensureIdentity(memberId) {
|
|
6307
6695
|
const store = KeyStore.getInstance();
|
|
6308
6696
|
if (!store.getKeyPair(memberId)) {
|
|
6309
6697
|
await store.generateOwnKeyPair(memberId);
|
|
6310
6698
|
}
|
|
6311
|
-
const
|
|
6312
|
-
|
|
6699
|
+
const ecdhPub = await exportEcdhPublicKey(store.getKeyPair(memberId).publicKey);
|
|
6700
|
+
const ecdsaPub = await exportEcdsaPublicKey(store.getAuthKeyPair(memberId).publicKey);
|
|
6701
|
+
return { ecdhPub, ecdsaPub };
|
|
6313
6702
|
}
|
|
6314
6703
|
async fetchPolicy(spaceId) {
|
|
6315
6704
|
const meta = await new KeyringClient({
|
|
@@ -6372,7 +6761,9 @@ class Client {
|
|
|
6372
6761
|
baseUrl;
|
|
6373
6762
|
/** Authentication — `client.auth.zk.login(...)`, etc. */
|
|
6374
6763
|
auth;
|
|
6375
|
-
/** Per-user
|
|
6764
|
+
/** Per-user key/value storage — `client.kv.set(...)`, etc. */
|
|
6765
|
+
kv;
|
|
6766
|
+
/** File storage — `client.storage.writeFile(...)`, etc. */
|
|
6376
6767
|
storage;
|
|
6377
6768
|
/** Realtime messaging — `client.message.subscribe(...)`, etc. */
|
|
6378
6769
|
message;
|
|
@@ -6380,6 +6771,10 @@ class Client {
|
|
|
6380
6771
|
space;
|
|
6381
6772
|
session;
|
|
6382
6773
|
http;
|
|
6774
|
+
/** Subscribers notified when a session expires and can't be recovered. */
|
|
6775
|
+
sessionExpiredHandlers = new Set();
|
|
6776
|
+
/** Shared in-flight recovery so a burst of 401s triggers one re-auth. */
|
|
6777
|
+
recoverInFlight = null;
|
|
6383
6778
|
constructor(options = {}) {
|
|
6384
6779
|
if (options.logLevel && typeof globalThis.appLogger?.setLevel === "function") {
|
|
6385
6780
|
globalThis.appLogger.setLevel(options.logLevel);
|
|
@@ -6391,19 +6786,24 @@ class Client {
|
|
|
6391
6786
|
apiKey: options.apiKey,
|
|
6392
6787
|
getSessionToken: () => this.session.token,
|
|
6393
6788
|
fetch: options.fetch,
|
|
6789
|
+
// Self-heal stale sessions: on a 401, try a silent re-auth (only
|
|
6790
|
+
// works while unlocked). If it can't, `recoverSession` fires the
|
|
6791
|
+
// session-expired event so the app can redirect to login.
|
|
6792
|
+
onUnauthorized: () => this.recoverSession(),
|
|
6394
6793
|
});
|
|
6395
6794
|
const circuits = options.circuits ?? defaultCircuitUrls(this.baseUrl);
|
|
6396
6795
|
const authClient = new AuthClient({ baseUrl: this.baseUrl, fetch: this.http.fetch });
|
|
6397
6796
|
const wsBaseUrl = toWsBase(this.baseUrl);
|
|
6398
6797
|
this.auth = new AuthNamespace({ auth: authClient, circuits, session: this.session });
|
|
6399
|
-
this.
|
|
6798
|
+
this.kv = new KvNamespace({ http: this.http, session: this.session, wsBaseUrl });
|
|
6799
|
+
this.storage = new StorageNamespace({ http: this.http, baseUrl: this.baseUrl, kv: this.kv });
|
|
6400
6800
|
this.message = new MessageNamespace({ http: this.http, session: this.session, wsBaseUrl });
|
|
6401
6801
|
// Group keys are cached in the user's PersonalSpace (encrypted at rest
|
|
6402
|
-
// by
|
|
6403
|
-
//
|
|
6802
|
+
// by client.kv), so a returning member hydrates without a fresh keyring
|
|
6803
|
+
// round-trip. The server only ever sees the ciphertext.
|
|
6404
6804
|
const spaceKeyCache = {
|
|
6405
|
-
loadKeys: (spaceId) => this.
|
|
6406
|
-
saveKeys: (spaceId, keys) => this.
|
|
6805
|
+
loadKeys: (spaceId) => this.kv.get("space-keys", spaceId),
|
|
6806
|
+
saveKeys: (spaceId, keys) => this.kv.set("space-keys", spaceId, keys),
|
|
6407
6807
|
};
|
|
6408
6808
|
this.space = new SpaceNamespace({
|
|
6409
6809
|
http: this.http,
|
|
@@ -6420,6 +6820,48 @@ class Client {
|
|
|
6420
6820
|
get isAuthenticated() {
|
|
6421
6821
|
return this.session.isAuthenticated;
|
|
6422
6822
|
}
|
|
6823
|
+
/**
|
|
6824
|
+
* Subscribe to session expiry that couldn't be silently recovered (the
|
|
6825
|
+
* user's identity wasn't in memory to re-prove — typically after a reload
|
|
6826
|
+
* where only the token was persisted, and that token has since gone stale).
|
|
6827
|
+
* This is the signal to send the user back to the login screen.
|
|
6828
|
+
*
|
|
6829
|
+
* Returns an unsubscribe function.
|
|
6830
|
+
*
|
|
6831
|
+
* const off = client.onSessionExpired(() => router.push('/login'));
|
|
6832
|
+
*/
|
|
6833
|
+
onSessionExpired(handler) {
|
|
6834
|
+
this.sessionExpiredHandlers.add(handler);
|
|
6835
|
+
return () => this.sessionExpiredHandlers.delete(handler);
|
|
6836
|
+
}
|
|
6837
|
+
/**
|
|
6838
|
+
* Attempt a silent re-auth of the current user. Concurrent callers share a
|
|
6839
|
+
* single in-flight attempt (a stale token usually 401s several requests at
|
|
6840
|
+
* once — we re-auth once, not once per request). On failure the
|
|
6841
|
+
* session-expired event fires so the app can react. Exposed so apps can
|
|
6842
|
+
* also drive recovery proactively (e.g. on `visibilitychange`).
|
|
6843
|
+
*/
|
|
6844
|
+
recoverSession() {
|
|
6845
|
+
if (this.recoverInFlight)
|
|
6846
|
+
return this.recoverInFlight;
|
|
6847
|
+
this.recoverInFlight = this.auth.zk
|
|
6848
|
+
.recover()
|
|
6849
|
+
.then((ok) => {
|
|
6850
|
+
if (!ok) {
|
|
6851
|
+
for (const handler of this.sessionExpiredHandlers) {
|
|
6852
|
+
try {
|
|
6853
|
+
handler();
|
|
6854
|
+
}
|
|
6855
|
+
catch { /* a bad listener mustn't break recovery */ }
|
|
6856
|
+
}
|
|
6857
|
+
}
|
|
6858
|
+
return ok;
|
|
6859
|
+
})
|
|
6860
|
+
.finally(() => {
|
|
6861
|
+
this.recoverInFlight = null;
|
|
6862
|
+
});
|
|
6863
|
+
return this.recoverInFlight;
|
|
6864
|
+
}
|
|
6423
6865
|
}
|
|
6424
6866
|
|
|
6425
6867
|
/**
|
|
@@ -6488,5 +6930,5 @@ var bundledLoader = /*#__PURE__*/Object.freeze({
|
|
|
6488
6930
|
default: loadRsWasm
|
|
6489
6931
|
});
|
|
6490
6932
|
|
|
6491
|
-
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, MemorySessionStore, Message, Message as MessageDefault, MessageNamespace, PBKDF2_DEFAULT_ITERATIONS, PREIMAGE_POK_VERIFICATION_KEY, Packet, PersonalSpaceClient, Poseidon, PreimagePoK, ReedSolomonCodec, Space as Room, SerializeMessage, SessionState, ShardClient, SharedSpaceClient, Space, SpaceKeyring, SpaceNamespace, SpacePacketCipher, StorageNamespace, WSTransport, ZkAuth, buildCommitment, compilePrograms, decodeFromHex, decorators, decryptAesGcm, defaultCircuitUrls, defaultSessionStore, deriveAesKeyFromPassphrase, deriveBitsHkdf, deriveIdentity, encodeToHex, encryptAesGcm, exportPublicKeyBase64, exportPublicKeyHex, generateAuthProof, getSubtle, initBn128Wasm, initRsWasm, initializeCircuits, manifestToStat, poseidonHash, quickVerify, randomBytes, shardHash, signMessage, toField, unwrapWithPassphrase, verify, verifyGroth16, verifyHashKnowledge, verifyPreimagePoK, wrapWithPassphrase };
|
|
6933
|
+
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, KvNamespace, LocalStorageSessionStore, MemorySessionStore, Message, Message as MessageDefault, MessageNamespace, PBKDF2_DEFAULT_ITERATIONS, PREIMAGE_POK_VERIFICATION_KEY, Packet, PersonalSpaceClient, Poseidon, PreimagePoK, ReedSolomonCodec, Space as Room, SerializeMessage, SessionState, ShardClient, SharedSpaceClient, Space, SpaceKeyring, SpaceNamespace, SpacePacketCipher, StorageNamespace, WSTransport, ZkAuth, buildCommitment, compilePrograms, decodeFromHex, decorators, decryptAesGcm, defaultCircuitUrls, defaultSessionStore, deriveAesKeyFromPassphrase, deriveBitsHkdf, deriveIdentity, encodeToHex, encryptAesGcm, exportPublicKeyBase64, exportPublicKeyHex, generateAuthProof, getSubtle, initBn128Wasm, initRsWasm, initializeCircuits, manifestToStat, poseidonHash, quickVerify, randomBytes, shardHash, signMessage, toField, unwrapWithPassphrase, verify, verifyGroth16, verifyHashKnowledge, verifyPreimagePoK, wrapWithPassphrase };
|
|
6492
6934
|
//# sourceMappingURL=index.js.map
|