@calimero-network/mero-js 19.12.0 → 19.13.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.
@@ -24,7 +24,7 @@
24
24
  * ever sent, and would be a throughput bottleneck on a path that has no reason
25
25
  * to involve it.
26
26
  */
27
- import { type RoutingChallenge, type RoutingCredential } from './routing-proof.js';
27
+ import { type DiscoveryChallenge, type RoutingChallenge, type RoutingCredential } from './routing-proof.js';
28
28
  export interface CloudClientConfig {
29
29
  cloudBaseUrl?: string;
30
30
  /**
@@ -116,6 +116,20 @@ export interface CloudRelay {
116
116
  * namespace id anyone holding an invitation already has, and answering both
117
117
  * things a keyholder needs from a node rather than one of them.
118
118
  */
119
+ /**
120
+ * One relay that serves an account, as the bootstrap read reports it.
121
+ *
122
+ * `fresh` and a null `relayUrl` are reported rather than filtered, because
123
+ * "your relay is down", "we have no address for it" and "you have no relay"
124
+ * need different actions from a caller and an empty list erases the
125
+ * distinction. A caller looking for somewhere to talk to wants
126
+ * `fresh && relayUrl`; one diagnosing wants the rest.
127
+ */
128
+ export interface CloudAccountRelay {
129
+ peerId: string;
130
+ relayUrl: string | null;
131
+ fresh: boolean;
132
+ }
119
133
  export interface CloudNamespaceNode {
120
134
  peerId: string;
121
135
  /**
@@ -438,6 +452,36 @@ export declare class CloudClient {
438
452
  * for a class of expiry bug that only shows up on a slow client.
439
453
  */
440
454
  private routingProof;
455
+ /**
456
+ * Ask the cloud for a challenge bound to one ACCOUNT.
457
+ *
458
+ * Public and unauthenticated like {@link getRoutingChallenge}: an account id
459
+ * is `H(genesis(root_pk))` and travels in the clear in every device-link op,
460
+ * so naming one here reveals nothing and authorises nothing. MDMA re-derives
461
+ * the account from the certificate and refuses a mismatch.
462
+ */
463
+ getAccountRelaysChallenge(accountId: string): Promise<DiscoveryChallenge>;
464
+ /**
465
+ * Which relays serve this account — the bootstrap, and nothing more.
466
+ *
467
+ * A device holding a certificate can resolve a namespace it can *name*
468
+ * ({@link getNamespaceRouting}) and can ask a node it can *reach* what its
469
+ * account belongs to. It starts with neither. This closes that one gap.
470
+ *
471
+ * **It does not return namespaces, deliberately.** The node answers that from
472
+ * the governance DAG, scoped to the calling account: log in at one of these
473
+ * relays with an account proof and read its namespace listing. A cloud-side
474
+ * copy would be a second source of truth built from rows that linger for an
475
+ * account which left a namespace.
476
+ *
477
+ * So the sequence is: this → log in at a relay → ask it what you are in.
478
+ *
479
+ * Proven with the **device certificate**, not a cloud session, so it needs a
480
+ * `routingCredential` and throws without one rather than returning an empty
481
+ * list — "you configured no credential" and "you have no relays" are
482
+ * different answers and must not look alike.
483
+ */
484
+ getAccountRelays(accountId: string): Promise<CloudAccountRelay[]>;
441
485
  /**
442
486
  * The node to send a signed join to, chosen from those an invitation names.
443
487
  *
@@ -288,6 +288,60 @@ export class CloudClient {
288
288
  const challenge = await this.getRoutingChallenge(namespaceId);
289
289
  return routingProofHeaders(challenge, this.routingCredential);
290
290
  }
291
+ /**
292
+ * Ask the cloud for a challenge bound to one ACCOUNT.
293
+ *
294
+ * Public and unauthenticated like {@link getRoutingChallenge}: an account id
295
+ * is `H(genesis(root_pk))` and travels in the clear in every device-link op,
296
+ * so naming one here reveals nothing and authorises nothing. MDMA re-derives
297
+ * the account from the certificate and refuses a mismatch.
298
+ */
299
+ async getAccountRelaysChallenge(accountId) {
300
+ const body = await this.request('GET', `/api/cloud/accounts/${encodeURIComponent(accountId)}/challenge`, {
301
+ anonymous: true,
302
+ });
303
+ return {
304
+ accountId: String(body.account_id ?? accountId),
305
+ nonce: String(body.nonce ?? ''),
306
+ expiresAtMs: Number(body.expires_at_ms ?? 0),
307
+ };
308
+ }
309
+ /**
310
+ * Which relays serve this account — the bootstrap, and nothing more.
311
+ *
312
+ * A device holding a certificate can resolve a namespace it can *name*
313
+ * ({@link getNamespaceRouting}) and can ask a node it can *reach* what its
314
+ * account belongs to. It starts with neither. This closes that one gap.
315
+ *
316
+ * **It does not return namespaces, deliberately.** The node answers that from
317
+ * the governance DAG, scoped to the calling account: log in at one of these
318
+ * relays with an account proof and read its namespace listing. A cloud-side
319
+ * copy would be a second source of truth built from rows that linger for an
320
+ * account which left a namespace.
321
+ *
322
+ * So the sequence is: this → log in at a relay → ask it what you are in.
323
+ *
324
+ * Proven with the **device certificate**, not a cloud session, so it needs a
325
+ * `routingCredential` and throws without one rather than returning an empty
326
+ * list — "you configured no credential" and "you have no relays" are
327
+ * different answers and must not look alike.
328
+ */
329
+ async getAccountRelays(accountId) {
330
+ if (!this.routingCredential) {
331
+ throw new Error('getAccountRelays needs a routingCredential: construct the client with the device ' +
332
+ 'certificate and its signing secret, since this read is proven by the device, not by a session.');
333
+ }
334
+ const challenge = await this.getAccountRelaysChallenge(accountId);
335
+ const body = await this.request('GET', `/api/cloud/accounts/${encodeURIComponent(accountId)}/relays`, {
336
+ anonymous: true,
337
+ headers: await routingProofHeaders(challenge, this.routingCredential),
338
+ });
339
+ return (body.relays ?? []).map((row) => ({
340
+ peerId: String(row.peer_id ?? ''),
341
+ relayUrl: row.relay_url ?? null,
342
+ fresh: row.fresh === true,
343
+ }));
344
+ }
291
345
  /**
292
346
  * The node to send a signed join to, chosen from those an invitation names.
293
347
  *
@@ -1,6 +1,6 @@
1
1
  export { CloudClient } from './cloud-client.js';
2
- export type { CloudClientConfig, CloudSession, CloudNamespace, CloudRelay, CloudNamespaceNode, CloudNamespaceRouting, CloudMachine, CloudMachineNamespace, CloudLinkedAccount, CloudLinkedAccounts, CloudAccountLink, AccountLinkChallenge, AccountLinkProof, AccountLinkHandoff, AccountLinkCallback, AccountLoginChallenge, AccountLoginProof, EnableHAOptions, DisableHAOptions, } from './cloud-client.js';
2
+ export type { CloudClientConfig, CloudSession, CloudNamespace, CloudRelay, CloudAccountRelay, CloudNamespaceNode, CloudNamespaceRouting, CloudMachine, CloudMachineNamespace, CloudLinkedAccount, CloudLinkedAccounts, CloudAccountLink, AccountLinkChallenge, AccountLinkProof, AccountLinkHandoff, AccountLinkCallback, AccountLoginChallenge, AccountLoginProof, EnableHAOptions, DisableHAOptions, } from './cloud-client.js';
3
3
  export { connectCloud } from './connect.js';
4
4
  export type { ConnectCloudOptions, CloudConnection } from './connect.js';
5
5
  export { signRoutingChallenge, routingProofHeaders } from './routing-proof.js';
6
- export type { RoutingCredential, RoutingChallenge, RoutingProofHeaders, } from './routing-proof.js';
6
+ export type { RoutingCredential, DiscoveryChallenge, RoutingChallenge, RoutingProofHeaders, } from './routing-proof.js';
@@ -61,6 +61,23 @@ export interface RoutingChallenge {
61
61
  /** Epoch **milliseconds**, as MDMA reports it. Nonces live about two minutes. */
62
62
  expiresAtMs: number;
63
63
  }
64
+ /**
65
+ * A challenge bound to an ACCOUNT rather than a namespace, for asking which
66
+ * relays serve that account.
67
+ *
68
+ * Separate from {@link RoutingChallenge} because the binding is the security
69
+ * property, not a field name: MDMA seals the account into this one and the
70
+ * namespace into that one, and refuses each at the other's route. Keeping the
71
+ * TypeScript types distinct is what stops a caller passing the wrong one and
72
+ * learning about it as a 403.
73
+ */
74
+ export interface DiscoveryChallenge {
75
+ accountId: string;
76
+ /** Opaque and sealed — the client neither parses nor constructs this. */
77
+ nonce: string;
78
+ /** Epoch **milliseconds**, as MDMA reports it. Nonces live about two minutes. */
79
+ expiresAtMs: number;
80
+ }
64
81
  /**
65
82
  * The three header values a proven routing read carries.
66
83
  *
@@ -88,5 +105,9 @@ export declare function signRoutingChallenge(nonce: string, deviceSecret: string
88
105
  * Separated from the request so a caller that fetches its own challenge — a
89
106
  * test, or a client batching several reads against one nonce — can use the same
90
107
  * encoding rather than a second spelling of it.
108
+ *
109
+ * Takes either challenge kind: only the nonce is signed and both are signed
110
+ * over the same domain, so one encoder serves both. What separates them is what
111
+ * MDMA sealed inside, which is checked server-side.
91
112
  */
92
- export declare function routingProofHeaders(challenge: RoutingChallenge, credential: RoutingCredential): Promise<RoutingProofHeaders>;
113
+ export declare function routingProofHeaders(challenge: RoutingChallenge | DiscoveryChallenge, credential: RoutingCredential): Promise<RoutingProofHeaders>;
@@ -69,6 +69,10 @@ export async function signRoutingChallenge(nonce, deviceSecret) {
69
69
  * Separated from the request so a caller that fetches its own challenge — a
70
70
  * test, or a client batching several reads against one nonce — can use the same
71
71
  * encoding rather than a second spelling of it.
72
+ *
73
+ * Takes either challenge kind: only the nonce is signed and both are signed
74
+ * over the same domain, so one encoder serves both. What separates them is what
75
+ * MDMA sealed inside, which is checked server-side.
72
76
  */
73
77
  export async function routingProofHeaders(challenge, credential) {
74
78
  return {