@calimero-network/mero-js 22.1.0 → 22.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/README.md CHANGED
@@ -132,11 +132,13 @@ builds the relay client. `sdk.cloud` exposes the same cloud API from an existing
132
132
  `MeroJs` instance for apps that have both a node and a cloud account, and
133
133
  `RelayClient` is available directly for a relay you were told about out of band.
134
134
 
135
- One precondition is not yours to satisfy: an admin of the namespace must grant
136
- the relay `CAN_AUTHOR_ON_BEHALF` (core implies it from nothing — not from
137
- membership, not from admin). Until then `connectCloud` throws naming the account
138
- that needs the grant, and `relay.describe(contextId)` reports
139
- `canAuthorOnBehalf: false` so a UI can say so rather than failing a write.
135
+ One precondition is not yours to satisfy: the node must relay. A fleet node
136
+ admitted as a `RelayTee` (the namespace's TEE admission policy set with
137
+ `mode: 'relay'`) relays by role; a `ReadOnlyTee` is a replica and never relays;
138
+ and a node too old to report a role needs an admin of the namespace to grant it
139
+ `CAN_AUTHOR_ON_BEHALF`. `connectCloud` picks a relay on the cloud's `canExecute`,
140
+ which folds that rule in, and otherwise throws saying which of those is missing —
141
+ naming the account that needs the grant when that is the fix.
140
142
 
141
143
  See the [cloud client](https://calimero-network.github.io/mero-js/reference/cloud/),
142
144
  [relay client](https://calimero-network.github.io/mero-js/reference/relay/) and
@@ -25,6 +25,13 @@
25
25
  * to involve it.
26
26
  */
27
27
  import { type DiscoveryChallenge, type RoutingChallenge, type RoutingCredential } from './routing-proof.js';
28
+ /**
29
+ * The first relay a warrant can be spent at, or `null`.
30
+ *
31
+ * Shared by {@link CloudClient.findExecutingRelay} and `connectCloud`, which
32
+ * already holds the list and must not fetch it twice.
33
+ */
34
+ export declare function pickExecutingRelay(relays: readonly CloudRelay[]): CloudRelay | null;
28
35
  export interface CloudClientConfig {
29
36
  cloudBaseUrl?: string;
30
37
  /**
@@ -90,11 +97,13 @@ export interface CloudNamespace {
90
97
  * One attested node the cloud has assigned to a namespace, described as a
91
98
  * relay.
92
99
  *
93
- * `authorshipReady` is the field that decides whether a write can happen, and
94
- * it is reported rather than assumed because it is not the cloud's to grant:
95
- * `CAN_AUTHOR_ON_BEHALF` is a governance capability, set by an admin of the
96
- * namespace, and the cloud only relays what the node observed. A relay that is
97
- * `active` with `authorshipReady: false` is a healthy node waiting for a grant.
100
+ * `canExecute` is the field that decides whether a write can happen, and it is
101
+ * the cloud's own verdict rather than something to re-derive: a node that
102
+ * reports a role relays only as a `RelayTee` (the namespace's TEE admission
103
+ * policy set with `mode: 'relay'`), whatever its grant; a `ReadOnlyTee` is a
104
+ * replica and never relays. Only a node too old to report a role is decided by
105
+ * `authorshipReady` — the `CAN_AUTHOR_ON_BEHALF` grant, which is reported
106
+ * rather than assumed because it is not the cloud's to give.
98
107
  */
99
108
  export interface CloudRelay {
100
109
  peerId: string;
@@ -106,6 +115,20 @@ export interface CloudRelay {
106
115
  status: string;
107
116
  /** Whether this relay holds `CAN_AUTHOR_ON_BEHALF` on the namespace. */
108
117
  authorshipReady: boolean;
118
+ /**
119
+ * The core role the node reported for itself here — `'RelayTee'` or
120
+ * `'ReadOnlyTee'` — or `null` for a sidecar too old to report one.
121
+ */
122
+ teeRole: string | null;
123
+ /**
124
+ * Whether an intent can be presented here now: a URL, an executor account, a
125
+ * fresh heartbeat, and a node that relays by role (or, for one reporting no
126
+ * role, holds the grant).
127
+ *
128
+ * The cloud's `can_execute`. Against an older cloud that does not send it,
129
+ * derived from `authorshipReady` plus the URL and account, as before.
130
+ */
131
+ canExecute: boolean;
109
132
  lastSeenAt?: string | null;
110
133
  confirmedAt?: string | null;
111
134
  }
@@ -159,9 +182,12 @@ export interface CloudNamespaceNode {
159
182
  canAdmit: boolean;
160
183
  /** Whether the node holds `CAN_AUTHOR_ON_BEHALF` on the namespace root group. */
161
184
  authorshipReady: boolean;
185
+ /** `'RelayTee'`, `'ReadOnlyTee'`, or `null` for a node too old to report one. */
186
+ teeRole: string | null;
162
187
  /**
163
188
  * Whether a delegated write can be posted here now — a URL, an account, a
164
- * fresh heartbeat and the grant.
189
+ * fresh heartbeat, and a `RelayTee` (or, for a node reporting no role, the
190
+ * grant). A `ReadOnlyTee` is a replica and never relays.
165
191
  *
166
192
  * Namespace-level. A context in a RESTRICTED SUBGROUP is governed by that
167
193
  * subgroup, so per-context routing remains the exact answer if a write is
@@ -186,6 +212,8 @@ export interface CloudMachineNamespace {
186
212
  status: string;
187
213
  /** Whether the machine holds `CAN_AUTHOR_ON_BEHALF` on this namespace. */
188
214
  authorshipReady: boolean;
215
+ /** `'RelayTee'`, `'ReadOnlyTee'`, or `null` for a machine too old to report one. */
216
+ teeRole: string | null;
189
217
  /**
190
218
  * Whether the machine's heartbeat for this namespace is recent.
191
219
  *
@@ -210,7 +238,8 @@ export interface CloudMachine {
210
238
  executorAccount: string | null;
211
239
  /**
212
240
  * Whether this machine can write for the caller *somewhere* — a URL, an
213
- * executor account, and the grant on at least one namespace.
241
+ * executor account, and at least one namespace it relays in: as a
242
+ * `RelayTee`, or, reporting no role, holding the grant.
214
243
  *
215
244
  * Folded here so a client does not re-derive the three-way precondition and
216
245
  * get a refusal it cannot explain.
@@ -395,19 +424,20 @@ export declare class CloudClient {
395
424
  * The relays serving one namespace: where to present an intent, and as whom.
396
425
  *
397
426
  * Everything a client needs in order to mint a warrant it can actually
398
- * spend. Filter on `authorshipReady` before signing — a warrant presented to
399
- * a relay with no grant is refused *after* the author has burned a nonce from
400
- * its monotonic sequence on it.
427
+ * spend. Filter on `canExecute` before signing — a warrant presented to a
428
+ * relay that does not relay (a `ReadOnlyTee` replica, or a node with no role
429
+ * and no grant) is refused *after* the author has burned a nonce from its
430
+ * monotonic sequence on it.
401
431
  */
402
432
  getNamespaceRelays(namespaceId: string): Promise<CloudRelay[]>;
403
433
  /**
404
434
  * The first relay for `namespaceId` that can actually run an intent, or
405
435
  * `null`.
406
436
  *
407
- * "Can actually run one" is three conditions, and a client that checks fewer
408
- * gets a refusal it cannot explain: the cloud must know a URL, the relay must
409
- * have reported the executor account a warrant has to name, and the grant
410
- * must be in place.
437
+ * "Can actually run one" is several conditions, and a client that checks
438
+ * fewer gets a refusal it cannot explain: the cloud must know a URL, the
439
+ * relay must have reported the executor account a warrant has to name, and
440
+ * the node must relay — which the cloud folds into `canExecute`.
411
441
  */
412
442
  findExecutingRelay(namespaceId: string): Promise<CloudRelay | null>;
413
443
  /**
@@ -29,6 +29,31 @@ import { HTTPError } from '../http-client/web-client.js';
29
29
  import { routingProofHeaders, } from './routing-proof.js';
30
30
  /** Where the hosted cloud lives when a caller names no other. */
31
31
  const DEFAULT_CLOUD_BASE_URL = 'https://cloud.calimero.network';
32
+ /**
33
+ * The cloud's own `can_execute` where it sends one, else `legacy`.
34
+ *
35
+ * The cloud decides relaying by role (`RelayTee` relays, `ReadOnlyTee` never
36
+ * does) and only falls back to the `CAN_AUTHOR_ON_BEHALF` grant for a node
37
+ * that reports none, so re-deriving it here from `authorship_ready` would call
38
+ * a grant-holding `ReadOnlyTee` writable. `legacy` is the grant-based answer,
39
+ * for a cloud older than the field — the only rule such a cloud knew.
40
+ */
41
+ function canExecuteOf(row, legacy) {
42
+ return typeof row.can_execute === 'boolean' ? row.can_execute : legacy;
43
+ }
44
+ /** The reported `tee_role`, or `null` when absent (older cloud or sidecar). */
45
+ function teeRoleOf(row) {
46
+ return typeof row.tee_role === 'string' && row.tee_role !== '' ? row.tee_role : null;
47
+ }
48
+ /**
49
+ * The first relay a warrant can be spent at, or `null`.
50
+ *
51
+ * Shared by {@link CloudClient.findExecutingRelay} and `connectCloud`, which
52
+ * already holds the list and must not fetch it twice.
53
+ */
54
+ export function pickExecutingRelay(relays) {
55
+ return (relays.find((r) => r.canExecute && r.relayUrl !== null && r.executorAccount !== null) ?? null);
56
+ }
32
57
  /** Response headers MDMA uses to hand back a rolling-refreshed session. */
33
58
  const REFRESH_HEADER = 'x-mdma-session-refresh';
34
59
  const REFRESH_EXPIRES_HEADER = 'x-mdma-session-expires';
@@ -181,35 +206,42 @@ export class CloudClient {
181
206
  * The relays serving one namespace: where to present an intent, and as whom.
182
207
  *
183
208
  * Everything a client needs in order to mint a warrant it can actually
184
- * spend. Filter on `authorshipReady` before signing — a warrant presented to
185
- * a relay with no grant is refused *after* the author has burned a nonce from
186
- * its monotonic sequence on it.
209
+ * spend. Filter on `canExecute` before signing — a warrant presented to a
210
+ * relay that does not relay (a `ReadOnlyTee` replica, or a node with no role
211
+ * and no grant) is refused *after* the author has burned a nonce from its
212
+ * monotonic sequence on it.
187
213
  */
188
214
  async getNamespaceRelays(namespaceId) {
189
215
  const body = await this.request('GET', `/api/cloud/me/namespaces/${encodeURIComponent(namespaceId)}/relays`);
190
- return (body.relays ?? []).map((row) => ({
191
- peerId: String(row.peer_id ?? ''),
192
- relayUrl: row.relay_url ?? null,
193
- executorAccount: row.executor_account ?? null,
194
- status: String(row.status ?? ''),
195
- authorshipReady: row.authorship_ready === true,
196
- lastSeenAt: row.last_seen_at ?? null,
197
- confirmedAt: row.confirmed_at ?? null,
198
- }));
216
+ return (body.relays ?? []).map((row) => {
217
+ const relayUrl = row.relay_url ?? null;
218
+ const executorAccount = row.executor_account ?? null;
219
+ const authorshipReady = row.authorship_ready === true;
220
+ return {
221
+ peerId: String(row.peer_id ?? ''),
222
+ relayUrl,
223
+ executorAccount,
224
+ status: String(row.status ?? ''),
225
+ authorshipReady,
226
+ teeRole: teeRoleOf(row),
227
+ canExecute: canExecuteOf(row, authorshipReady && relayUrl !== null && executorAccount !== null),
228
+ lastSeenAt: row.last_seen_at ?? null,
229
+ confirmedAt: row.confirmed_at ?? null,
230
+ };
231
+ });
199
232
  }
200
233
  /**
201
234
  * The first relay for `namespaceId` that can actually run an intent, or
202
235
  * `null`.
203
236
  *
204
- * "Can actually run one" is three conditions, and a client that checks fewer
205
- * gets a refusal it cannot explain: the cloud must know a URL, the relay must
206
- * have reported the executor account a warrant has to name, and the grant
207
- * must be in place.
237
+ * "Can actually run one" is several conditions, and a client that checks
238
+ * fewer gets a refusal it cannot explain: the cloud must know a URL, the
239
+ * relay must have reported the executor account a warrant has to name, and
240
+ * the node must relay — which the cloud folds into `canExecute`.
208
241
  */
209
242
  async findExecutingRelay(namespaceId) {
210
243
  const relays = await this.getNamespaceRelays(namespaceId);
211
- return (relays.find((r) => r.authorshipReady && r.relayUrl !== null && r.executorAccount !== null) ??
212
- null);
244
+ return pickExecutingRelay(relays);
213
245
  }
214
246
  /**
215
247
  * Where to reach a namespace without owning it: one read, both verbs.
@@ -237,17 +269,24 @@ export class CloudClient {
237
269
  });
238
270
  return {
239
271
  namespaceId: String(body.namespace_id ?? namespaceId),
240
- nodes: (body.admitters ?? []).map((row) => ({
241
- peerId: String(row.peer_id ?? ''),
242
- account: row.account ?? null,
243
- relayUrl: row.relay_url ?? null,
244
- admitUrl: row.admit_url ?? null,
245
- status: String(row.status ?? ''),
246
- fresh: row.fresh === true,
247
- canAdmit: row.can_admit === true,
248
- authorshipReady: row.authorship_ready === true,
249
- canExecute: row.can_execute === true,
250
- })),
272
+ nodes: (body.admitters ?? []).map((row) => {
273
+ const account = row.account ?? null;
274
+ const relayUrl = row.relay_url ?? null;
275
+ const fresh = row.fresh === true;
276
+ const authorshipReady = row.authorship_ready === true;
277
+ return {
278
+ peerId: String(row.peer_id ?? ''),
279
+ account,
280
+ relayUrl,
281
+ admitUrl: row.admit_url ?? null,
282
+ status: String(row.status ?? ''),
283
+ fresh,
284
+ canAdmit: row.can_admit === true,
285
+ authorshipReady,
286
+ teeRole: teeRoleOf(row),
287
+ canExecute: canExecuteOf(row, authorshipReady && relayUrl !== null && account !== null && fresh),
288
+ };
289
+ }),
251
290
  servable: body.servable === true,
252
291
  writable: body.writable === true,
253
292
  };
@@ -374,23 +413,29 @@ export class CloudClient {
374
413
  */
375
414
  async getMyMachines() {
376
415
  const body = await this.request('GET', '/api/cloud/me/machines');
377
- return (body.machines ?? []).map((row) => ({
378
- peerId: String(row.peer_id ?? ''),
379
- relayUrl: row.relay_url ?? null,
380
- executorAccount: row.executor_account ?? null,
381
- canExecute: row.can_execute === true,
382
- namespaces: (Array.isArray(row.namespaces) ? row.namespaces : []).map((n) => {
416
+ return (body.machines ?? []).map((row) => {
417
+ const relayUrl = row.relay_url ?? null;
418
+ const executorAccount = row.executor_account ?? null;
419
+ const namespaces = (Array.isArray(row.namespaces) ? row.namespaces : []).map((n) => {
383
420
  const ns = n;
384
421
  return {
385
422
  namespaceId: String(ns.namespace_id ?? ''),
386
423
  status: String(ns.status ?? ''),
387
424
  authorshipReady: ns.authorship_ready === true,
425
+ teeRole: teeRoleOf(ns),
388
426
  fresh: ns.fresh === true,
389
427
  confirmedAt: ns.confirmed_at ?? null,
390
428
  lastSeenAt: ns.last_seen_at ?? null,
391
429
  };
392
- }),
393
- }));
430
+ });
431
+ return {
432
+ peerId: String(row.peer_id ?? ''),
433
+ relayUrl,
434
+ executorAccount,
435
+ canExecute: canExecuteOf(row, relayUrl !== null && executorAccount !== null && namespaces.some((n) => n.authorshipReady)),
436
+ namespaces,
437
+ };
438
+ });
394
439
  }
395
440
  /**
396
441
  * Claim a namespace under this account, proving ownership.
@@ -106,7 +106,8 @@ export interface CloudConnection {
106
106
  *
107
107
  * Throws rather than returning a half-usable connection, and the message says
108
108
  * which step is missing — no namespace, several to choose between, HA not
109
- * enabled, or no relay holding the authorship grant. Every one of those is
109
+ * enabled, or no relay that relays (only replicas, or relays waiting for a
110
+ * grant). Every one of those is
110
111
  * something the user has to go and do, and a client that reports them alike
111
112
  * cannot tell them what.
112
113
  */
@@ -34,7 +34,7 @@
34
34
  * rather than presenting a half-working `MeroJs` whose `admin` and `rpc` would
35
35
  * need a credential nobody holds.
36
36
  */
37
- import { CloudClient } from './cloud-client.js';
37
+ import { CloudClient, pickExecutingRelay } from './cloud-client.js';
38
38
  import { RelayClient } from '../relay/relay-client.js';
39
39
  import { createLocalStorageNonceSource, createMemoryNonceSource } from '../relay/nonce-source.js';
40
40
  import { derivePublicKey, hex } from '../crypto/internal.js';
@@ -44,7 +44,8 @@ import { createAttestedSealedFetch } from '../sealed/sealed.js';
44
44
  *
45
45
  * Throws rather than returning a half-usable connection, and the message says
46
46
  * which step is missing — no namespace, several to choose between, HA not
47
- * enabled, or no relay holding the authorship grant. Every one of those is
47
+ * enabled, or no relay that relays (only replicas, or relays waiting for a
48
+ * grant). Every one of those is
48
49
  * something the user has to go and do, and a client that reports them alike
49
50
  * cannot tell them what.
50
51
  */
@@ -69,13 +70,12 @@ export async function connectCloud(options) {
69
70
  // first — reporting "waiting for the grant" about a relay that was already
70
71
  // usable a moment ago.
71
72
  const relays = await cloud.getNamespaceRelays(namespace.namespaceId);
72
- const relayInfo = relays.find((r) => r.authorshipReady && r.relayUrl !== null && r.executorAccount !== null) ??
73
- null;
73
+ const relayInfo = pickExecutingRelay(relays);
74
74
  if (!relayInfo) {
75
75
  throw new Error(explainNoRelay(namespace, relays));
76
76
  }
77
77
  const relay = new RelayClient({
78
- // Checked by `findExecutingRelay`, which is the only thing that returns a
78
+ // Checked by `pickExecutingRelay`, which is the only thing that returns a
79
79
  // relay — narrowed here rather than re-validated, so the invariant lives in
80
80
  // one place.
81
81
  relayUrl: relayInfo.relayUrl,
@@ -125,11 +125,11 @@ function pickNamespace(namespaces, requested) {
125
125
  return namespaces[0];
126
126
  }
127
127
  /**
128
- * Why no relay can write here — the three states, kept apart.
128
+ * Why no relay can write here — the states, kept apart.
129
129
  *
130
- * They are three different asks of the user (turn HA on, wait, or get an admin
131
- * to grant a capability) and one message for all of them would send them to
132
- * the wrong one.
130
+ * They are different asks of the user (turn HA on, wait, have an admin admit
131
+ * TEEs as relays, or have an admin grant a capability) and one message for all
132
+ * of them would send them to the wrong one.
133
133
  */
134
134
  function explainNoRelay(namespace, relays) {
135
135
  if (relays.length === 0) {
@@ -137,8 +137,19 @@ function explainNoRelay(namespace, relays) {
137
137
  ? `namespace ${namespace.namespaceId} has HA enabled but no relay has been assigned to it yet — retry shortly`
138
138
  : `namespace ${namespace.namespaceId} has no relays: enable HA on it in the cloud to have an attested node assigned`;
139
139
  }
140
- const waitingOnGrant = relays.filter((r) => !r.authorshipReady);
141
- if (waitingOnGrant.length === relays.length) {
140
+ // A node that reports a role is decided by it alone: a `ReadOnlyTee` is a
141
+ // replica and never relays, whatever its grant, so asking for the grant would
142
+ // send the admin to the wrong fix. Only a node too old to report a role is
143
+ // still waiting on `CAN_AUTHOR_ON_BEHALF`.
144
+ const replicas = relays.filter((r) => r.teeRole !== null && r.teeRole !== 'RelayTee');
145
+ if (replicas.length === relays.length) {
146
+ return (`every node on namespace ${namespace.namespaceId} is a read-only TEE replica (ReadOnlyTee), ` +
147
+ `which never relays delegated writes: an admin of the namespace must set its TEE admission ` +
148
+ `policy with mode: 'relay' — AdminClient.setTeeAdmissionPolicy(namespaceId, { ..., mode: 'relay' }) ` +
149
+ `— so fleet nodes are admitted as RelayTee`);
150
+ }
151
+ const waitingOnGrant = relays.filter((r) => r.teeRole === null && !r.authorshipReady);
152
+ if (waitingOnGrant.length > 0 && waitingOnGrant.length + replicas.length === relays.length) {
142
153
  // Name the call, not just the capability. The grant is a governance op only
143
154
  // a namespace admin can publish, so this message is read by someone who has
144
155
  // to go ask for it — and "grant CAN_AUTHOR_ON_BEHALF" alone leaves them
@@ -149,7 +160,7 @@ function explainNoRelay(namespace, relays) {
149
160
  `an admin of the namespace must grant CAN_AUTHOR_ON_BEHALF to ${waitingOnGrant.map((r) => r.executorAccount ?? r.peerId).join(', ') || 'the relay account'} — with AdminClient.grantAuthorship(namespaceId, account) for these relays, and ` +
150
161
  `AdminClient.openToDelegatedExecution(namespaceId) so relays assigned later need no further op`);
151
162
  }
152
- return `namespace ${namespace.namespaceId} has ${relays.length} relay(s), but none reported both a URL and an executor account yet — retry shortly`;
163
+ return `namespace ${namespace.namespaceId} has ${relays.length} relay(s), but none is ready to relay yet (a URL, an executor account and a recent heartbeat) — retry shortly`;
153
164
  }
154
165
  /**
155
166
  * A persisted nonce source keyed by the author's device public key.