@calimero-network/mero-js 19.5.0 → 19.7.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.
@@ -94,6 +94,62 @@ export interface CloudRelay {
94
94
  lastSeenAt?: string | null;
95
95
  confirmedAt?: string | null;
96
96
  }
97
+ /**
98
+ * One node serving a namespace, as a caller with no cloud account sees it.
99
+ *
100
+ * The public counterpart to {@link CloudRelay}: same fleet rows, but keyed on a
101
+ * namespace id anyone holding an invitation already has, and answering both
102
+ * things a keyholder needs from a node rather than one of them.
103
+ */
104
+ export interface CloudNamespaceNode {
105
+ peerId: string;
106
+ /**
107
+ * The node's own account id, hex.
108
+ *
109
+ * Intersect this against the invitation's signed `admitters` list before
110
+ * using the node. That list is a MINT-TIME SNAPSHOT, so a node assigned after
111
+ * an invitation was created is live, healthy, listed here, and still refused
112
+ * with 403 — the cloud cannot know which invitation you hold.
113
+ */
114
+ account: string | null;
115
+ /** Base URL, or `null` when the cloud knows none yet. */
116
+ relayUrl: string | null;
117
+ /** Ready-made admission URL, so a caller never rebuilds the path. */
118
+ admitUrl: string | null;
119
+ /** `'assigned'` (admitted pending confirm) or `'active'`. */
120
+ status: string;
121
+ /** Whether the node's heartbeat for this namespace is recent. */
122
+ fresh: boolean;
123
+ /**
124
+ * Whether a signed join can be presented here now.
125
+ *
126
+ * Deliberately independent of {@link CloudNamespaceNode.authorshipReady}:
127
+ * relaying a join the joiner already signed is not authoring on anyone's
128
+ * behalf, so a node with no grant admits perfectly well.
129
+ */
130
+ canAdmit: boolean;
131
+ /** Whether the node holds `CAN_AUTHOR_ON_BEHALF` on the namespace root group. */
132
+ authorshipReady: boolean;
133
+ /**
134
+ * Whether a delegated write can be posted here now — a URL, an account, a
135
+ * fresh heartbeat and the grant.
136
+ *
137
+ * Namespace-level. A context in a RESTRICTED SUBGROUP is governed by that
138
+ * subgroup, so per-context routing remains the exact answer if a write is
139
+ * refused despite this being true.
140
+ */
141
+ canExecute: boolean;
142
+ }
143
+ /** What {@link CloudClient.getNamespaceRouting} reports. */
144
+ export interface CloudNamespaceRouting {
145
+ namespaceId: string;
146
+ /** Usable nodes first, then a stable order, so a polling caller sees no shuffle. */
147
+ nodes: CloudNamespaceNode[];
148
+ /** Whether any listed node can take a join now. */
149
+ servable: boolean;
150
+ /** Whether any listed node can take a delegated write now. */
151
+ writable: boolean;
152
+ }
97
153
  /** One namespace of the caller's that a machine is serving. */
98
154
  export interface CloudMachineNamespace {
99
155
  namespaceId: string;
@@ -134,6 +190,52 @@ export interface CloudMachine {
134
190
  /** Only the caller's own namespaces, even on a machine shared with others. */
135
191
  namespaces: CloudMachineNamespace[];
136
192
  }
193
+ /** A Calimero account linked to this cloud login. */
194
+ export interface CloudLinkedAccount {
195
+ /** The account id, 64 hex. */
196
+ accountId: string;
197
+ linkedAt?: string | null;
198
+ /**
199
+ * Whether a recovery envelope has been stored for this account, so a UI can
200
+ * say "recoverable" without fetching the blob.
201
+ */
202
+ hasRecoveryEnvelope: boolean;
203
+ }
204
+ /** What {@link CloudClient.getMyAccounts} reports. */
205
+ export interface CloudLinkedAccounts {
206
+ accounts: CloudLinkedAccount[];
207
+ /** How many the caller's plan allows. `null` means no limit. */
208
+ limit: number | null;
209
+ }
210
+ /** A single-use challenge to sign with an account root. */
211
+ export interface AccountLinkChallenge {
212
+ /** Sign this verbatim — it is an opaque sealed string, not a decodable id. */
213
+ nonce: string;
214
+ /** When it stops being accepted, epoch milliseconds. */
215
+ expiresAtMs: number | null;
216
+ }
217
+ /** A proof assembled by whoever holds the root, for {@link CloudClient.submitAccountLink}. */
218
+ export interface AccountLinkProof {
219
+ /** The account being claimed, 64 hex. */
220
+ accountId: string;
221
+ /** The root that names it, 64 hex. */
222
+ rootPublicKey: string;
223
+ /** The challenge that was signed. */
224
+ nonce: string;
225
+ /** `signAccountLink`'s output — standard base64. */
226
+ signature: string;
227
+ }
228
+ /** The outcome of {@link CloudClient.linkAccount}. */
229
+ export interface CloudAccountLink {
230
+ accountId: string;
231
+ linkedAt?: string | null;
232
+ /**
233
+ * True when this account was already linked to this same login, which the
234
+ * cloud treats as a 200 no-op rather than an error. A caller re-running
235
+ * onboarding gets success, not a confusing failure.
236
+ */
237
+ alreadyLinked: boolean;
238
+ }
137
239
  export interface EnableHAOptions {
138
240
  groupId: string;
139
241
  contextId: string;
@@ -201,6 +303,39 @@ export declare class CloudClient {
201
303
  * must be in place.
202
304
  */
203
305
  findExecutingRelay(namespaceId: string): Promise<CloudRelay | null>;
306
+ /**
307
+ * Where to reach a namespace without owning it: one read, both verbs.
308
+ *
309
+ * **Anonymous.** A joiner is by construction not the namespace owner and holds
310
+ * no cloud session — needing one is exactly what this path exists to avoid.
311
+ * Discovery is not authorization: merod refuses an `/admit` call unless the
312
+ * relaying node's account is on the invitation's signed `admitters` list, and
313
+ * refuses an intent unless the warrant is genuinely the member's, so naming a
314
+ * node grants the caller nothing it did not already have.
315
+ *
316
+ * A keyholder resolves a namespace to a node ONCE and then admits, reads and
317
+ * posts intents against that same node, which is why admission and writability
318
+ * are answered together rather than across two endpoints — the per-context
319
+ * read needs a context id a joiner does not yet have.
320
+ *
321
+ * An empty `nodes` list is a state, not an error: a namespace with no fleet
322
+ * assignment has no cloud node, and the joiner must use one of the
323
+ * invitation's other admitters — another admin's node, or a self-hosted peer.
324
+ */
325
+ getNamespaceRouting(namespaceId: string): Promise<CloudNamespaceRouting>;
326
+ /**
327
+ * The node to send a signed join to, chosen from those an invitation names.
328
+ *
329
+ * Pass the invitation's `admitters` and the intersection is done here, because
330
+ * skipping it is the failure this method exists to prevent: the cloud lists
331
+ * every node assigned to the namespace, while `admitters` is a snapshot signed
332
+ * when the invitation was minted. A node added afterwards looks perfect in
333
+ * every field here and answers 403.
334
+ *
335
+ * Omit `admitters` only for an invitation that names none — an empty list on
336
+ * the wire authorises any node to admit.
337
+ */
338
+ findAdmitter(namespaceId: string, admitters?: readonly string[]): Promise<CloudNamespaceNode | null>;
204
339
  /**
205
340
  * Every attested machine currently serving something this account owns.
206
341
  *
@@ -265,6 +400,53 @@ export declare class CloudClient {
265
400
  enableHA(options: EnableHAOptions): void;
266
401
  disableHA(options: DisableHAOptions): void;
267
402
  /** Record a freshly-issued session and tell the app to persist it. */
403
+ /**
404
+ * Link a Calimero account to this cloud login, proving possession of its root.
405
+ *
406
+ * Two hops, folded into one call because they are useless apart: fetch a
407
+ * single-use challenge, then sign it with the account root and post the proof.
408
+ * The secret never leaves this process — only a signature over a nonce the
409
+ * cloud minted does.
410
+ *
411
+ * This is the binding the cloud bills against. A relay does not need it to run
412
+ * an intent; the cloud needs it to know whose plan a hosted node is spending,
413
+ * and to sign that account back in later.
414
+ *
415
+ * @param rootSecret the account root's signing secret, 64 hex — from
416
+ * {@link generateAccountRoot}, from a restored recovery phrase, or from a
417
+ * desktop app or hardware key that already holds one.
418
+ *
419
+ * Idempotent for the same login. An account already linked to a *different*
420
+ * cloud login is a `409`; a replayed or expired challenge is a `403`; and
421
+ * exceeding the plan's account limit is a `402`.
422
+ */
423
+ linkAccount(rootSecret: string): Promise<CloudAccountLink>;
424
+ /**
425
+ * Mint a single-use challenge for an account root to sign.
426
+ *
427
+ * The split half of {@link linkAccount}, for the case that call cannot serve:
428
+ * a root held by a desktop app, a hardware key or an air-gapped machine, which
429
+ * signs without the secret ever reaching this process. Fetch the challenge
430
+ * here, have the holder sign it, then hand the proof to
431
+ * {@link submitAccountLink}.
432
+ *
433
+ * Bound to the signed-in login and short-lived, so it is not a standing
434
+ * capability if it leaks — but it IS single-use, so a challenge fetched and
435
+ * abandoned is spent.
436
+ */
437
+ getAccountLinkChallenge(): Promise<AccountLinkChallenge>;
438
+ /**
439
+ * Post a proof assembled elsewhere. The other half of
440
+ * {@link getAccountLinkChallenge}.
441
+ *
442
+ * `accountId` must be the account `rootPublicKey` names — the cloud refuses a
443
+ * valid signature by a key that names a different account, or a caller could
444
+ * prove one root and link somebody else's account. Derive both from the same
445
+ * key rather than letting a user type either.
446
+ */
447
+ submitAccountLink(proof: AccountLinkProof): Promise<CloudAccountLink>;
448
+ /** The accounts linked to this login, and how many the plan allows. */
449
+ getMyAccounts(): Promise<CloudLinkedAccounts>;
268
450
  private adoptSession;
269
451
  private notifySession;
270
452
  private request;
@@ -24,6 +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 { accountRootFromSecret, signAccountLink, } from '../account/index.js';
27
28
  import { HTTPError } from '../http-client/web-client.js';
28
29
  /** Where the hosted cloud lives when a caller names no other. */
29
30
  const DEFAULT_CLOUD_BASE_URL = 'https://cloud.calimero.network';
@@ -148,6 +149,64 @@ export class CloudClient {
148
149
  return (relays.find((r) => r.authorshipReady && r.relayUrl !== null && r.executorAccount !== null) ??
149
150
  null);
150
151
  }
152
+ /**
153
+ * Where to reach a namespace without owning it: one read, both verbs.
154
+ *
155
+ * **Anonymous.** A joiner is by construction not the namespace owner and holds
156
+ * no cloud session — needing one is exactly what this path exists to avoid.
157
+ * Discovery is not authorization: merod refuses an `/admit` call unless the
158
+ * relaying node's account is on the invitation's signed `admitters` list, and
159
+ * refuses an intent unless the warrant is genuinely the member's, so naming a
160
+ * node grants the caller nothing it did not already have.
161
+ *
162
+ * A keyholder resolves a namespace to a node ONCE and then admits, reads and
163
+ * posts intents against that same node, which is why admission and writability
164
+ * are answered together rather than across two endpoints — the per-context
165
+ * read needs a context id a joiner does not yet have.
166
+ *
167
+ * An empty `nodes` list is a state, not an error: a namespace with no fleet
168
+ * assignment has no cloud node, and the joiner must use one of the
169
+ * invitation's other admitters — another admin's node, or a self-hosted peer.
170
+ */
171
+ async getNamespaceRouting(namespaceId) {
172
+ const body = await this.request('GET', `/api/cloud/namespaces/${encodeURIComponent(namespaceId)}/admitters`, {
173
+ anonymous: true,
174
+ });
175
+ return {
176
+ namespaceId: String(body.namespace_id ?? namespaceId),
177
+ nodes: (body.admitters ?? []).map((row) => ({
178
+ peerId: String(row.peer_id ?? ''),
179
+ account: row.account ?? null,
180
+ relayUrl: row.relay_url ?? null,
181
+ admitUrl: row.admit_url ?? null,
182
+ status: String(row.status ?? ''),
183
+ fresh: row.fresh === true,
184
+ canAdmit: row.can_admit === true,
185
+ authorshipReady: row.authorship_ready === true,
186
+ canExecute: row.can_execute === true,
187
+ })),
188
+ servable: body.servable === true,
189
+ writable: body.writable === true,
190
+ };
191
+ }
192
+ /**
193
+ * The node to send a signed join to, chosen from those an invitation names.
194
+ *
195
+ * Pass the invitation's `admitters` and the intersection is done here, because
196
+ * skipping it is the failure this method exists to prevent: the cloud lists
197
+ * every node assigned to the namespace, while `admitters` is a snapshot signed
198
+ * when the invitation was minted. A node added afterwards looks perfect in
199
+ * every field here and answers 403.
200
+ *
201
+ * Omit `admitters` only for an invitation that names none — an empty list on
202
+ * the wire authorises any node to admit.
203
+ */
204
+ async findAdmitter(namespaceId, admitters) {
205
+ const { nodes } = await this.getNamespaceRouting(namespaceId);
206
+ const allowed = admitters && admitters.length > 0 ? new Set(admitters.map((a) => a.toLowerCase())) : null;
207
+ return (nodes.find((n) => n.canAdmit &&
208
+ (allowed === null || (n.account !== null && allowed.has(n.account.toLowerCase())))) ?? null);
209
+ }
151
210
  /**
152
211
  * Every attested machine currently serving something this account owns.
153
212
  *
@@ -260,6 +319,89 @@ export class CloudClient {
260
319
  window.open(`${this.baseUrl}/disable-ha?${params.toString()}`);
261
320
  }
262
321
  /** Record a freshly-issued session and tell the app to persist it. */
322
+ /**
323
+ * Link a Calimero account to this cloud login, proving possession of its root.
324
+ *
325
+ * Two hops, folded into one call because they are useless apart: fetch a
326
+ * single-use challenge, then sign it with the account root and post the proof.
327
+ * The secret never leaves this process — only a signature over a nonce the
328
+ * cloud minted does.
329
+ *
330
+ * This is the binding the cloud bills against. A relay does not need it to run
331
+ * an intent; the cloud needs it to know whose plan a hosted node is spending,
332
+ * and to sign that account back in later.
333
+ *
334
+ * @param rootSecret the account root's signing secret, 64 hex — from
335
+ * {@link generateAccountRoot}, from a restored recovery phrase, or from a
336
+ * desktop app or hardware key that already holds one.
337
+ *
338
+ * Idempotent for the same login. An account already linked to a *different*
339
+ * cloud login is a `409`; a replayed or expired challenge is a `403`; and
340
+ * exceeding the plan's account limit is a `402`.
341
+ */
342
+ async linkAccount(rootSecret) {
343
+ const root = await accountRootFromSecret(rootSecret);
344
+ const { nonce } = await this.getAccountLinkChallenge();
345
+ return this.submitAccountLink({
346
+ accountId: root.accountId,
347
+ rootPublicKey: root.publicKey,
348
+ nonce,
349
+ signature: await signAccountLink({ rootSecret, nonce }),
350
+ });
351
+ }
352
+ /**
353
+ * Mint a single-use challenge for an account root to sign.
354
+ *
355
+ * The split half of {@link linkAccount}, for the case that call cannot serve:
356
+ * a root held by a desktop app, a hardware key or an air-gapped machine, which
357
+ * signs without the secret ever reaching this process. Fetch the challenge
358
+ * here, have the holder sign it, then hand the proof to
359
+ * {@link submitAccountLink}.
360
+ *
361
+ * Bound to the signed-in login and short-lived, so it is not a standing
362
+ * capability if it leaks — but it IS single-use, so a challenge fetched and
363
+ * abandoned is spent.
364
+ */
365
+ async getAccountLinkChallenge() {
366
+ const body = await this.request('GET', '/api/cloud/me/accounts/challenge');
367
+ return { nonce: body.nonce, expiresAtMs: body.expires_at_ms ?? null };
368
+ }
369
+ /**
370
+ * Post a proof assembled elsewhere. The other half of
371
+ * {@link getAccountLinkChallenge}.
372
+ *
373
+ * `accountId` must be the account `rootPublicKey` names — the cloud refuses a
374
+ * valid signature by a key that names a different account, or a caller could
375
+ * prove one root and link somebody else's account. Derive both from the same
376
+ * key rather than letting a user type either.
377
+ */
378
+ async submitAccountLink(proof) {
379
+ const body = await this.request('POST', '/api/cloud/me/accounts', {
380
+ body: {
381
+ account_id: proof.accountId,
382
+ root_public_key: proof.rootPublicKey,
383
+ nonce: proof.nonce,
384
+ signature: proof.signature,
385
+ },
386
+ });
387
+ return {
388
+ accountId: String(body.account_id ?? proof.accountId),
389
+ linkedAt: body.linked_at ?? null,
390
+ alreadyLinked: body.already_linked === true,
391
+ };
392
+ }
393
+ /** The accounts linked to this login, and how many the plan allows. */
394
+ async getMyAccounts() {
395
+ const body = await this.request('GET', '/api/cloud/me/accounts');
396
+ return {
397
+ accounts: (body.accounts ?? []).map((row) => ({
398
+ accountId: String(row.account_id ?? ''),
399
+ linkedAt: row.linked_at ?? null,
400
+ hasRecoveryEnvelope: row.has_recovery_envelope === true,
401
+ })),
402
+ limit: body.limit ?? null,
403
+ };
404
+ }
263
405
  adoptSession(body) {
264
406
  this.session = {
265
407
  sessionToken: body.session_token,
@@ -123,8 +123,15 @@ function explainNoRelay(namespace, relays) {
123
123
  }
124
124
  const waitingOnGrant = relays.filter((r) => !r.authorshipReady);
125
125
  if (waitingOnGrant.length === relays.length) {
126
+ // Name the call, not just the capability. The grant is a governance op only
127
+ // a namespace admin can publish, so this message is read by someone who has
128
+ // to go ask for it — and "grant CAN_AUTHOR_ON_BEHALF" alone leaves them
129
+ // relaying a constant to an admin who then has to work out the command.
130
+ // `openToDelegatedExecution` is the durable half (every relay assigned from
131
+ // then on lands open), `grantAuthorship` the one for relays already here.
126
132
  return (`every relay on namespace ${namespace.namespaceId} is waiting for the authorship grant: ` +
127
- `an admin of the namespace must grant CAN_AUTHOR_ON_BEHALF to ${waitingOnGrant.map((r) => r.executorAccount ?? r.peerId).join(', ') || 'the relay account'}`);
133
+ `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 ` +
134
+ `AdminClient.openToDelegatedExecution(namespaceId) so relays assigned later need no further op`);
128
135
  }
129
136
  return `namespace ${namespace.namespaceId} has ${relays.length} relay(s), but none reported both a URL and an executor account yet — retry shortly`;
130
137
  }
@@ -1,4 +1,4 @@
1
1
  export { CloudClient } from './cloud-client.js';
2
- export type { CloudClientConfig, CloudSession, CloudNamespace, CloudRelay, CloudMachine, CloudMachineNamespace, EnableHAOptions, DisableHAOptions, } from './cloud-client.js';
2
+ export type { CloudClientConfig, CloudSession, CloudNamespace, CloudRelay, CloudNamespaceNode, CloudNamespaceRouting, CloudMachine, CloudMachineNamespace, CloudLinkedAccount, CloudLinkedAccounts, CloudAccountLink, AccountLinkChallenge, AccountLinkProof, EnableHAOptions, DisableHAOptions, } from './cloud-client.js';
3
3
  export { connectCloud } from './connect.js';
4
4
  export type { ConnectCloudOptions, CloudConnection } from './connect.js';