@calimero-network/mero-js 23.2.0 → 23.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/ephemeral/index.d.ts +6 -0
- package/dist/ephemeral/index.js +39 -29
- package/dist/ephemeral/types.d.ts +5 -0
- package/dist/index.browser.mjs +2 -2
- package/dist/index.browser.mjs.map +4 -4
- package/dist/index.cjs +170 -38
- package/dist/index.cjs.map +3 -3
- package/dist/index.d.ts +3 -1
- package/dist/index.js +3 -1
- package/dist/index.mjs +170 -38
- package/dist/index.mjs.map +3 -3
- package/dist/presence/relay-presence.d.ts +56 -0
- package/dist/presence/relay-presence.js +107 -0
- package/dist/presence/statement.d.ts +12 -0
- package/dist/presence/statement.js +17 -0
- package/dist/relay/relay-client.d.ts +13 -0
- package/dist/relay/relay-client.js +12 -0
- package/dist/transport/mero-client.d.ts +11 -12
- package/dist/transport/mero-client.js +14 -15
- package/package.json +1 -1
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* An account's ephemeral presence, through its relay.
|
|
3
|
+
*
|
|
4
|
+
* A node publishes and heartbeats its own presence; an account has no node, so
|
|
5
|
+
* this client signs each update with the device key, posts it to the relay's
|
|
6
|
+
* `presence-intents`, and resends the last state every 2.5 s until
|
|
7
|
+
* `set(contextId, null)` or `close()`. The relay checks the update and seals
|
|
8
|
+
* and gossips it; other members verify the device's signature and certificate
|
|
9
|
+
* themselves, so the relay cannot invent or alter an account's presence.
|
|
10
|
+
*/
|
|
11
|
+
import type { SseClient } from '../events/index.js';
|
|
12
|
+
import type { Codec, EphemeralEntry } from '../ephemeral/types.js';
|
|
13
|
+
import type { RelayClient } from '../relay/relay-client.js';
|
|
14
|
+
type PresenceRelay = Pick<RelayClient, 'authorProof' | 'authorSigner' | 'presenceIntent'>;
|
|
15
|
+
export declare class RelayPresenceClient {
|
|
16
|
+
private readonly relay;
|
|
17
|
+
private readonly events;
|
|
18
|
+
private readonly heartbeatMs;
|
|
19
|
+
private readonly now;
|
|
20
|
+
private readonly timers;
|
|
21
|
+
private readonly lastSeq;
|
|
22
|
+
private readonly refused;
|
|
23
|
+
/**
|
|
24
|
+
* Bumped by every `set` and `close`, per context. A `set` installs its
|
|
25
|
+
* resends only if nothing newer happened while its first send was in flight,
|
|
26
|
+
* so a retract (or a newer state) can never be overtaken by an older one.
|
|
27
|
+
*/
|
|
28
|
+
private readonly generation;
|
|
29
|
+
/**
|
|
30
|
+
* @param opts.events the relay's event stream, asked for only on `subscribe`:
|
|
31
|
+
* publishing needs none, so a client with no node key can still publish.
|
|
32
|
+
*/
|
|
33
|
+
constructor(opts: {
|
|
34
|
+
relay: PresenceRelay;
|
|
35
|
+
events: () => SseClient;
|
|
36
|
+
heartbeatMs?: number;
|
|
37
|
+
now?: () => number;
|
|
38
|
+
});
|
|
39
|
+
/**
|
|
40
|
+
* Set this account's presence in `contextId`, or retract it with `null`.
|
|
41
|
+
* Throws when the relay refuses this first send for good (a 4xx other than
|
|
42
|
+
* 429), which also stops the resends. A rate limit, a network failure or a
|
|
43
|
+
* 5xx is not thrown: the next resend covers it.
|
|
44
|
+
*/
|
|
45
|
+
set<T>(contextId: string, state: T | null, codec?: Codec<T>): Promise<void>;
|
|
46
|
+
subscribe<T>(contextId: string, handler: (entry: EphemeralEntry<T>) => void, codec?: Codec<T>): () => void;
|
|
47
|
+
/** Stop every resend. Entries expire on the relay and its peers within 7 s. */
|
|
48
|
+
close(): void;
|
|
49
|
+
/** Stop the context's resends and invalidate any `set` still in flight. */
|
|
50
|
+
private bump;
|
|
51
|
+
private stop;
|
|
52
|
+
/** Wall-clock seeded and strictly rising, so a relay restart never sees an old seq. */
|
|
53
|
+
private nextSeq;
|
|
54
|
+
private send;
|
|
55
|
+
}
|
|
56
|
+
export {};
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { hex } from '../crypto/internal.js';
|
|
2
|
+
import { jsonCodec, subscribePresence } from '../ephemeral/index.js';
|
|
3
|
+
import { presenceStatementBytes } from './statement.js';
|
|
4
|
+
/** The node's own heartbeat interval, so a relayed entry never outlives the 7 s TTL. */
|
|
5
|
+
const HEARTBEAT_MS = 2500;
|
|
6
|
+
export class RelayPresenceClient {
|
|
7
|
+
/**
|
|
8
|
+
* @param opts.events the relay's event stream, asked for only on `subscribe`:
|
|
9
|
+
* publishing needs none, so a client with no node key can still publish.
|
|
10
|
+
*/
|
|
11
|
+
constructor(opts) {
|
|
12
|
+
this.timers = new Map();
|
|
13
|
+
this.lastSeq = new Map();
|
|
14
|
+
this.refused = new Set();
|
|
15
|
+
/**
|
|
16
|
+
* Bumped by every `set` and `close`, per context. A `set` installs its
|
|
17
|
+
* resends only if nothing newer happened while its first send was in flight,
|
|
18
|
+
* so a retract (or a newer state) can never be overtaken by an older one.
|
|
19
|
+
*/
|
|
20
|
+
this.generation = new Map();
|
|
21
|
+
this.relay = opts.relay;
|
|
22
|
+
this.events = opts.events;
|
|
23
|
+
this.heartbeatMs = opts.heartbeatMs ?? HEARTBEAT_MS;
|
|
24
|
+
this.now = opts.now ?? Date.now;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Set this account's presence in `contextId`, or retract it with `null`.
|
|
28
|
+
* Throws when the relay refuses this first send for good (a 4xx other than
|
|
29
|
+
* 429), which also stops the resends. A rate limit, a network failure or a
|
|
30
|
+
* 5xx is not thrown: the next resend covers it.
|
|
31
|
+
*/
|
|
32
|
+
async set(contextId, state, codec = jsonCodec()) {
|
|
33
|
+
const generation = this.bump(contextId);
|
|
34
|
+
this.refused.delete(contextId);
|
|
35
|
+
const bytes = state === null ? null : new Uint8Array(codec.encode(state));
|
|
36
|
+
await this.send(contextId, bytes);
|
|
37
|
+
if (bytes === null || this.refused.has(contextId))
|
|
38
|
+
return;
|
|
39
|
+
if (this.generation.get(contextId) !== generation)
|
|
40
|
+
return; // a newer set or close won
|
|
41
|
+
this.timers.set(contextId, setInterval(() => void this.send(contextId, bytes).catch(() => undefined), this.heartbeatMs));
|
|
42
|
+
}
|
|
43
|
+
subscribe(contextId, handler, codec = jsonCodec()) {
|
|
44
|
+
return subscribePresence(this.events(), contextId, handler, codec);
|
|
45
|
+
}
|
|
46
|
+
/** Stop every resend. Entries expire on the relay and its peers within 7 s. */
|
|
47
|
+
close() {
|
|
48
|
+
for (const contextId of new Set([...this.timers.keys(), ...this.generation.keys()])) {
|
|
49
|
+
this.bump(contextId);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
/** Stop the context's resends and invalidate any `set` still in flight. */
|
|
53
|
+
bump(contextId) {
|
|
54
|
+
this.stop(contextId);
|
|
55
|
+
const next = (this.generation.get(contextId) ?? 0) + 1;
|
|
56
|
+
this.generation.set(contextId, next);
|
|
57
|
+
return next;
|
|
58
|
+
}
|
|
59
|
+
stop(contextId) {
|
|
60
|
+
const timer = this.timers.get(contextId);
|
|
61
|
+
if (timer !== undefined)
|
|
62
|
+
clearInterval(timer);
|
|
63
|
+
this.timers.delete(contextId);
|
|
64
|
+
}
|
|
65
|
+
/** Wall-clock seeded and strictly rising, so a relay restart never sees an old seq. */
|
|
66
|
+
nextSeq(contextId) {
|
|
67
|
+
const seq = Math.max(this.now(), (this.lastSeq.get(contextId) ?? 0) + 1);
|
|
68
|
+
this.lastSeq.set(contextId, seq);
|
|
69
|
+
return seq;
|
|
70
|
+
}
|
|
71
|
+
async send(contextId, state) {
|
|
72
|
+
if (this.refused.has(contextId))
|
|
73
|
+
return;
|
|
74
|
+
const signer = await this.relay.authorSigner();
|
|
75
|
+
const seq = this.nextSeq(contextId);
|
|
76
|
+
const sentAtMs = this.now();
|
|
77
|
+
const statement = await presenceStatementBytes({
|
|
78
|
+
contextId,
|
|
79
|
+
author: signer.publicKey,
|
|
80
|
+
seq: BigInt(seq),
|
|
81
|
+
sentAtMs: BigInt(sentAtMs),
|
|
82
|
+
state,
|
|
83
|
+
});
|
|
84
|
+
const signature = hex(await signer.sign(statement));
|
|
85
|
+
try {
|
|
86
|
+
await this.relay.presenceIntent(contextId, {
|
|
87
|
+
state: state === null ? null : hex(state),
|
|
88
|
+
seq,
|
|
89
|
+
sentAtMs,
|
|
90
|
+
signature,
|
|
91
|
+
authorProof: this.relay.authorProof,
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
catch (err) {
|
|
95
|
+
const status = err.status;
|
|
96
|
+
// Rate limited, a network failure, or the relay unable right now (5xx,
|
|
97
|
+
// e.g. no current key mid-rotation): the next resend covers it.
|
|
98
|
+
if (status === undefined || status === 0 || status === 429 || status >= 500)
|
|
99
|
+
return;
|
|
100
|
+
// Any other refusal (403: not a member or revoked; 400: malformed or a
|
|
101
|
+
// clock outside the relay's window) cannot be fixed by resending.
|
|
102
|
+
this.refused.add(contextId);
|
|
103
|
+
this.stop(contextId);
|
|
104
|
+
throw err;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/** core's `PRESENCE_DOMAIN`: part of the signed bytes. */
|
|
2
|
+
export declare const PRESENCE_DOMAIN: Uint8Array;
|
|
3
|
+
/** `sha256(borsh(Option<Vec<u8>>))`: `None` is `[0]`, `Some(v)` is `[1] ‖ u32le(len) ‖ v`. */
|
|
4
|
+
export declare function stateHash(state: Uint8Array | null): Promise<Uint8Array>;
|
|
5
|
+
/** What the device key signs, raw: domain ‖ context ‖ author ‖ seq ‖ sentAtMs ‖ stateHash. */
|
|
6
|
+
export declare function presenceStatementBytes(input: {
|
|
7
|
+
contextId: string;
|
|
8
|
+
author: string;
|
|
9
|
+
seq: bigint;
|
|
10
|
+
sentAtMs: bigint;
|
|
11
|
+
state: Uint8Array | null;
|
|
12
|
+
}): Promise<Uint8Array>;
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The bytes a device signs for one presence update: the borsh of core's
|
|
3
|
+
* `PresenceStatement` (`calimero_node_primitives::presence`). Pinned to core's
|
|
4
|
+
* vector in `statement.test.ts`, so the two encoders cannot drift.
|
|
5
|
+
*/
|
|
6
|
+
import { concat, fromHex, u32le, u64le } from '../crypto/internal.js';
|
|
7
|
+
/** core's `PRESENCE_DOMAIN`: part of the signed bytes. */
|
|
8
|
+
export const PRESENCE_DOMAIN = new TextEncoder().encode('calimero/presence/1');
|
|
9
|
+
/** `sha256(borsh(Option<Vec<u8>>))`: `None` is `[0]`, `Some(v)` is `[1] ‖ u32le(len) ‖ v`. */
|
|
10
|
+
export async function stateHash(state) {
|
|
11
|
+
const borsh = state === null ? new Uint8Array([0]) : concat(new Uint8Array([1]), u32le(state.length), state);
|
|
12
|
+
return new Uint8Array(await crypto.subtle.digest('SHA-256', borsh));
|
|
13
|
+
}
|
|
14
|
+
/** What the device key signs, raw: domain ‖ context ‖ author ‖ seq ‖ sentAtMs ‖ stateHash. */
|
|
15
|
+
export async function presenceStatementBytes(input) {
|
|
16
|
+
return concat(PRESENCE_DOMAIN, fromHex(input.contextId, 'contextId', 32), fromHex(input.author, 'author', 32), u64le(input.seq), u64le(input.sentAtMs), await stateHash(input.state));
|
|
17
|
+
}
|
|
@@ -324,6 +324,19 @@ export declare class RelayClient {
|
|
|
324
324
|
* apply. Nothing accumulates and nothing is reusable.
|
|
325
325
|
*/
|
|
326
326
|
execute<T = unknown>(contextId: string, method: string, argsJson?: unknown): Promise<IntentResult<T>>;
|
|
327
|
+
/**
|
|
328
|
+
* Publish one presence statement through the relay (`presence-intents`).
|
|
329
|
+
*
|
|
330
|
+
* Not retried on 429: presence resends every 2.5 s anyway, and a retry would
|
|
331
|
+
* only queue behind the next resend. A refusal throws with its `status`.
|
|
332
|
+
*/
|
|
333
|
+
presenceIntent(contextId: string, body: {
|
|
334
|
+
state: string | null;
|
|
335
|
+
seq: number;
|
|
336
|
+
sentAtMs: number;
|
|
337
|
+
signature: string;
|
|
338
|
+
authorProof: string;
|
|
339
|
+
}): Promise<void>;
|
|
327
340
|
/**
|
|
328
341
|
* Ask the relay what it can do about creating contexts in `groupId`.
|
|
329
342
|
*
|
|
@@ -169,6 +169,15 @@ export class RelayClient {
|
|
|
169
169
|
const body = await this.json('POST', `/admin-api/contexts/${encodeURIComponent(contextId)}/intents`, { method, argsJson, warrant, authorProof: this.config.authorProof });
|
|
170
170
|
return { rootHash: body.data.rootHash, returns: body.data.returns ?? null };
|
|
171
171
|
}
|
|
172
|
+
/**
|
|
173
|
+
* Publish one presence statement through the relay (`presence-intents`).
|
|
174
|
+
*
|
|
175
|
+
* Not retried on 429: presence resends every 2.5 s anyway, and a retry would
|
|
176
|
+
* only queue behind the next resend. A refusal throws with its `status`.
|
|
177
|
+
*/
|
|
178
|
+
async presenceIntent(contextId, body) {
|
|
179
|
+
await this.jsonOnce('POST', `/admin-api/contexts/${encodeURIComponent(contextId)}/presence-intents`, body);
|
|
180
|
+
}
|
|
172
181
|
/**
|
|
173
182
|
* Ask the relay what it can do about creating contexts in `groupId`.
|
|
174
183
|
*
|
|
@@ -466,6 +475,9 @@ export class RelayClient {
|
|
|
466
475
|
}
|
|
467
476
|
throw new HTTPError(response.status, response.statusText, `${this.baseUrl}${path}`, response.headers, text);
|
|
468
477
|
}
|
|
478
|
+
// `presence-intents` answers an accepted update with no body.
|
|
479
|
+
if (response.status === 204)
|
|
480
|
+
return undefined;
|
|
469
481
|
return (await response.json());
|
|
470
482
|
}
|
|
471
483
|
}
|
|
@@ -59,6 +59,7 @@ import { SseClient } from '../events/sse.js';
|
|
|
59
59
|
import type { AuthApiClient } from '../auth-api/index.js';
|
|
60
60
|
import type { WsClient } from '../events/ws.js';
|
|
61
61
|
import type { EphemeralClient } from '../ephemeral/index.js';
|
|
62
|
+
import { RelayPresenceClient } from '../presence/relay-presence.js';
|
|
62
63
|
import type { CloudClient } from '../cloud/cloud-client.js';
|
|
63
64
|
/** The node transport: everything `createMeroJs` takes, plus an explicit opt-in. */
|
|
64
65
|
export interface NodeTransportConfig extends MeroJsConfig {
|
|
@@ -155,6 +156,7 @@ export declare class MeroClient {
|
|
|
155
156
|
private readonly proofAuthorizer;
|
|
156
157
|
private readonly relayUrl;
|
|
157
158
|
private proofEvents;
|
|
159
|
+
private relayPresence;
|
|
158
160
|
constructor(config: MeroClientConfig);
|
|
159
161
|
/**
|
|
160
162
|
* The write surface. Identical on both transports — this is the whole point.
|
|
@@ -234,19 +236,16 @@ export declare class MeroClient {
|
|
|
234
236
|
/**
|
|
235
237
|
* Ephemeral presence. Node transport only.
|
|
236
238
|
*
|
|
237
|
-
*
|
|
238
|
-
*
|
|
239
|
-
*
|
|
240
|
-
*
|
|
241
|
-
*
|
|
242
|
-
*
|
|
243
|
-
*
|
|
244
|
-
*
|
|
245
|
-
* The rejected alternative was to expose it over the relay session and let
|
|
246
|
-
* `set` fail at the node. That trades a clear construction-time answer for a
|
|
247
|
-
* runtime failure inside a UI that has already rendered.
|
|
239
|
+
* On a relay client this is an account's presence client
|
|
240
|
+
* ({@link RelayPresenceClient}): `set` signs each update with the device key
|
|
241
|
+
* and posts it to the relay's `presence-intents`, resending every 2.5 s, and
|
|
242
|
+
* `subscribe` reads the relay's event stream. The relay checks and forwards
|
|
243
|
+
* the update; other members verify the device's signature and certificate
|
|
244
|
+
* themselves, so it can drop or delay an account's presence but not invent or
|
|
245
|
+
* alter it. Only `subscribe` needs the relay's node key (for the event
|
|
246
|
+
* stream), so publishing works on any relay client.
|
|
248
247
|
*/
|
|
249
|
-
get ephemeral(): EphemeralClient;
|
|
248
|
+
get ephemeral(): EphemeralClient | RelayPresenceClient;
|
|
250
249
|
/**
|
|
251
250
|
* Calimero Cloud.
|
|
252
251
|
*
|
|
@@ -55,6 +55,7 @@ import { createAdminApiClient, createAdminApiClientFromHttpClient } from '../adm
|
|
|
55
55
|
import { createHttpClient } from '../http-client/index.js';
|
|
56
56
|
import { createProofAuthorizer } from '../request-proof/index.js';
|
|
57
57
|
import { SseClient } from '../events/sse.js';
|
|
58
|
+
import { RelayPresenceClient } from '../presence/relay-presence.js';
|
|
58
59
|
/** Message shared by every node-only accessor, so the reason is stated once. */
|
|
59
60
|
function relayHasNo(surface, why) {
|
|
60
61
|
return new Error(`this client was constructed with the relay transport, which has no ${surface}: ${why}. ` +
|
|
@@ -127,6 +128,7 @@ export class MeroClient {
|
|
|
127
128
|
this.proofAuthorizer = null;
|
|
128
129
|
this.relayUrl = null;
|
|
129
130
|
this.proofEvents = null;
|
|
131
|
+
this.relayPresence = null;
|
|
130
132
|
if (config.transport === 'relay') {
|
|
131
133
|
this.transport = 'relay';
|
|
132
134
|
this.nodeClient = null;
|
|
@@ -308,23 +310,20 @@ export class MeroClient {
|
|
|
308
310
|
/**
|
|
309
311
|
* Ephemeral presence. Node transport only.
|
|
310
312
|
*
|
|
311
|
-
*
|
|
312
|
-
*
|
|
313
|
-
*
|
|
314
|
-
*
|
|
315
|
-
*
|
|
316
|
-
*
|
|
317
|
-
*
|
|
318
|
-
*
|
|
319
|
-
* The rejected alternative was to expose it over the relay session and let
|
|
320
|
-
* `set` fail at the node. That trades a clear construction-time answer for a
|
|
321
|
-
* runtime failure inside a UI that has already rendered.
|
|
313
|
+
* On a relay client this is an account's presence client
|
|
314
|
+
* ({@link RelayPresenceClient}): `set` signs each update with the device key
|
|
315
|
+
* and posts it to the relay's `presence-intents`, resending every 2.5 s, and
|
|
316
|
+
* `subscribe` reads the relay's event stream. The relay checks and forwards
|
|
317
|
+
* the update; other members verify the device's signature and certificate
|
|
318
|
+
* themselves, so it can drop or delay an account's presence but not invent or
|
|
319
|
+
* alter it. Only `subscribe` needs the relay's node key (for the event
|
|
320
|
+
* stream), so publishing works on any relay client.
|
|
322
321
|
*/
|
|
323
322
|
get ephemeral() {
|
|
324
|
-
if (
|
|
325
|
-
|
|
326
|
-
}
|
|
327
|
-
return this.
|
|
323
|
+
if (this.nodeClient)
|
|
324
|
+
return this.nodeClient.ephemeral;
|
|
325
|
+
this.relayPresence ?? (this.relayPresence = new RelayPresenceClient({ relay: this.relay, events: () => this.events }));
|
|
326
|
+
return this.relayPresence;
|
|
328
327
|
}
|
|
329
328
|
/**
|
|
330
329
|
* Calimero Cloud.
|