@calimero-network/mero-js 18.2.0 → 19.0.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.
@@ -1,6 +1,243 @@
1
+ /**
2
+ * Calimero Cloud (MDMA) client.
3
+ *
4
+ * # Why the SDK knows about the cloud at all
5
+ *
6
+ * Delegated execution lets an account that holds one signing key write into a
7
+ * context without running a node — but only through a *relay*, and a relay is
8
+ * something the client has to be told about. A warrant names an executor
9
+ * account, so a client that cannot discover which relay serves its namespace,
10
+ * and which account that relay writes as, cannot mint a usable warrant at all.
11
+ *
12
+ * For a self-hosted namespace the operator hands those two facts over out of
13
+ * band. For the hosted tier the cloud is the directory: the user signs in, the
14
+ * cloud knows which namespaces they own and which attested TEE nodes are
15
+ * assigned to each, and it answers with the relay's URL and executor account.
16
+ * That is what this client is for — it is the discovery half of
17
+ * {@link RelayClient}, not a second way to talk to a node.
18
+ *
19
+ * # What it deliberately is not
20
+ *
21
+ * It never sees a warrant, a device secret, or an intent. Discovery goes
22
+ * through the cloud; the write goes straight from the client to the relay. A
23
+ * cloud that proxied intents would read every method and argument its users
24
+ * ever sent, and would be a throughput bottleneck on a path that has no reason
25
+ * to involve it.
26
+ */
27
+ import { HTTPError } from '../http-client/web-client.js';
28
+ /** Where the hosted cloud lives when a caller names no other. */
29
+ const DEFAULT_CLOUD_BASE_URL = 'https://cloud.calimero.network';
30
+ /** Response headers MDMA uses to hand back a rolling-refreshed session. */
31
+ const REFRESH_HEADER = 'x-mdma-session-refresh';
32
+ const REFRESH_EXPIRES_HEADER = 'x-mdma-session-expires';
1
33
  export class CloudClient {
2
34
  constructor(config = {}) {
3
- this.baseUrl = (config.cloudBaseUrl || 'https://cloud.calimero.network').replace(/\/+$/, '');
35
+ this.session = null;
36
+ this.baseUrl = (config.cloudBaseUrl || DEFAULT_CLOUD_BASE_URL).replace(/\/+$/, '');
37
+ this.onSession = config.onSession;
38
+ // Bound through an arrow function: an unbound `globalThis.fetch` throws
39
+ // "Illegal invocation" in a browser, the same reason the node HTTP clients
40
+ // wrap it.
41
+ const injected = config.fetch;
42
+ this.fetchImpl = injected
43
+ ? (input, init) => injected(input, init)
44
+ : (input, init) => globalThis.fetch(input, init);
45
+ this.timeoutMs = config.timeoutMs ?? 10000;
46
+ if (config.sessionToken) {
47
+ // Expiry unknown until the server tells us; 0 means "do not reason about
48
+ // it locally", never "expired" — the server is the only judge and it
49
+ // answers 401 when it disagrees.
50
+ this.session = {
51
+ sessionToken: config.sessionToken,
52
+ expiresAt: 0,
53
+ user: { email: '' },
54
+ };
55
+ }
56
+ }
57
+ /** The current session, or `null` when nobody is signed in. */
58
+ getSession() {
59
+ return this.session;
60
+ }
61
+ /** Whether a session token is held. Says nothing about the server agreeing. */
62
+ isSignedIn() {
63
+ return this.session !== null;
64
+ }
65
+ /**
66
+ * Exchange a Google OAuth2 ID token for an MDMA session.
67
+ *
68
+ * The app owns the Google flow (One Tap, an auth-code popup, a native
69
+ * browser) and hands the resulting ID token here once. Everything after this
70
+ * uses the returned session token, so the Google credential does not have to
71
+ * be kept or re-minted per call.
72
+ */
73
+ async signInWithGoogle(idToken) {
74
+ const body = await this.request('POST', '/api/auth/google', { body: { id_token: idToken }, anonymous: true });
75
+ return this.adoptSession(body);
76
+ }
77
+ /**
78
+ * Refresh the session token explicitly.
79
+ *
80
+ * Rarely needed: MDMA rolling-refreshes on ordinary authenticated calls and
81
+ * this client adopts the replacement automatically. Use it when an app wants
82
+ * a fresh token before going idle rather than on the next request.
83
+ */
84
+ async refreshSession() {
85
+ const body = await this.request('POST', '/api/auth/refresh');
86
+ return this.adoptSession(body);
87
+ }
88
+ /**
89
+ * Revoke this session server-side and forget it locally.
90
+ *
91
+ * Local state is cleared even if the server call fails: the caller asked to
92
+ * be signed out, and keeping a token they believe is gone is the worse of the
93
+ * two failures.
94
+ */
95
+ async signOut() {
96
+ try {
97
+ if (this.session)
98
+ await this.request('POST', '/api/auth/logout', { parse: 'none' });
99
+ }
100
+ finally {
101
+ this.session = null;
102
+ void this.notifySession(null);
103
+ }
104
+ }
105
+ /** Namespaces the signed-in user owns. */
106
+ async getMyNamespaces() {
107
+ const rows = await this.request('GET', '/api/cloud/me/namespaces');
108
+ return rows.map((row) => ({
109
+ namespaceId: String(row.namespace_id ?? row.group_id ?? ''),
110
+ groupId: String(row.group_id ?? row.namespace_id ?? ''),
111
+ haStatus: String(row.ha_status ?? 'none'),
112
+ haEnabledAt: row.ha_enabled_at ?? null,
113
+ contexts: Array.isArray(row.contexts) ? row.contexts : [],
114
+ fleetReplicas: row.fleet_replicas,
115
+ }));
116
+ }
117
+ /**
118
+ * The relays serving one namespace: where to present an intent, and as whom.
119
+ *
120
+ * Everything a client needs in order to mint a warrant it can actually
121
+ * spend. Filter on `authorshipReady` before signing — a warrant presented to
122
+ * a relay with no grant is refused *after* the author has burned a nonce from
123
+ * its monotonic sequence on it.
124
+ */
125
+ async getNamespaceRelays(namespaceId) {
126
+ const body = await this.request('GET', `/api/cloud/me/namespaces/${encodeURIComponent(namespaceId)}/relays`);
127
+ return (body.relays ?? []).map((row) => ({
128
+ peerId: String(row.peer_id ?? ''),
129
+ relayUrl: row.relay_url ?? null,
130
+ executorAccount: row.executor_account ?? null,
131
+ status: String(row.status ?? ''),
132
+ authorshipReady: row.authorship_ready === true,
133
+ lastSeenAt: row.last_seen_at ?? null,
134
+ confirmedAt: row.confirmed_at ?? null,
135
+ }));
136
+ }
137
+ /**
138
+ * The first relay for `namespaceId` that can actually run an intent, or
139
+ * `null`.
140
+ *
141
+ * "Can actually run one" is three conditions, and a client that checks fewer
142
+ * gets a refusal it cannot explain: the cloud must know a URL, the relay must
143
+ * have reported the executor account a warrant has to name, and the grant
144
+ * must be in place.
145
+ */
146
+ async findExecutingRelay(namespaceId) {
147
+ const relays = await this.getNamespaceRelays(namespaceId);
148
+ return (relays.find((r) => r.authorshipReady && r.relayUrl !== null && r.executorAccount !== null) ??
149
+ null);
150
+ }
151
+ /**
152
+ * Every attested machine currently serving something this account owns.
153
+ *
154
+ * The account-wide view of the same rows {@link getNamespaceRelays} returns
155
+ * per namespace, keyed by machine — so a UI can answer "what is running for
156
+ * me?" in one call. `canExecute` folds the three-way precondition, so a
157
+ * caller does not re-derive it.
158
+ *
159
+ * Only the caller's own namespaces appear on each machine. A plan's
160
+ * `usersPerMachine` is greater than one, so a fleet node routinely serves
161
+ * several accounts at once.
162
+ */
163
+ async getMyMachines() {
164
+ const body = await this.request('GET', '/api/cloud/me/machines');
165
+ return (body.machines ?? []).map((row) => ({
166
+ peerId: String(row.peer_id ?? ''),
167
+ relayUrl: row.relay_url ?? null,
168
+ executorAccount: row.executor_account ?? null,
169
+ canExecute: row.can_execute === true,
170
+ namespaces: (Array.isArray(row.namespaces) ? row.namespaces : []).map((n) => {
171
+ const ns = n;
172
+ return {
173
+ namespaceId: String(ns.namespace_id ?? ''),
174
+ status: String(ns.status ?? ''),
175
+ authorshipReady: ns.authorship_ready === true,
176
+ fresh: ns.fresh === true,
177
+ confirmedAt: ns.confirmed_at ?? null,
178
+ lastSeenAt: ns.last_seen_at ?? null,
179
+ };
180
+ }),
181
+ }));
182
+ }
183
+ /**
184
+ * Claim a namespace under this account, proving ownership.
185
+ *
186
+ * The cloud has no other way to learn a namespace exists — namespaces are
187
+ * created on a node, and there is deliberately no "create namespace" in the
188
+ * cloud. `ownershipProof` comes from
189
+ * `admin.issueNamespaceOwnershipProof(namespaceId, …)` on a node that is a
190
+ * **direct admin** of that namespace; merod refuses to issue one otherwise.
191
+ *
192
+ * Pass merod's response object straight through. The cloud accepts core's
193
+ * `signerPublicKey`/`signedPayload` casing as well as snake_case, so there is
194
+ * no re-keying step — and getting that wrong used to surface as a `422`
195
+ * naming three missing fields.
196
+ *
197
+ * Idempotent for the same account. A namespace already claimed by someone
198
+ * else is a `409`; a replayed proof nonce is a `403`.
199
+ */
200
+ async claimNamespace(namespaceId, ownershipProof) {
201
+ await this.request('POST', '/api/cloud/namespaces/claim', {
202
+ body: { namespace_id: namespaceId, ownership_proof: ownershipProof },
203
+ });
204
+ }
205
+ /**
206
+ * Ask the fleet to host a claimed namespace — the step that gets a relay
207
+ * assigned.
208
+ *
209
+ * Takes no body: {@link claimNamespace} already established ownership, and
210
+ * this is the separate "now host it" half. Idempotent.
211
+ *
212
+ * Not to be confused with {@link enableHA}, which opens the cloud's web flow
213
+ * in a browser tab for a user to complete by hand. This is the API call.
214
+ */
215
+ async enableNamespaceHa(namespaceId) {
216
+ return this.request('POST', `/api/cloud/namespaces/${encodeURIComponent(namespaceId)}/enable-ha`, { body: {} });
217
+ }
218
+ /**
219
+ * Stop hosting a claimed namespace.
220
+ *
221
+ * The fleet's own reconcile loop does the rest: each assigned node notices
222
+ * the namespace is no longer assigned to it and **self-leaves**, which makes
223
+ * core evict it and purge its local data and keys. So this is not just a
224
+ * billing flip.
225
+ */
226
+ async disableNamespaceHa(namespaceId) {
227
+ return this.request('POST', `/api/cloud/namespaces/${encodeURIComponent(namespaceId)}/disable-ha`, { body: {} });
228
+ }
229
+ /**
230
+ * The node assigned to the signed-in user, or `null`.
231
+ *
232
+ * @deprecated Use {@link getMyMachines}. This reads the cloud's retired
233
+ * `NodeAssignment` ledger, which nothing has written since the v1
234
+ * retirement, so it answers `null` for every namespace-native account —
235
+ * which reads as "you have no machine" when the truth is "that is no longer
236
+ * how a machine is assigned".
237
+ */
238
+ async getMyNode() {
239
+ const body = await this.request('GET', '/api/cloud/me/node');
240
+ return body.node ?? null;
4
241
  }
5
242
  enableHA(options) {
6
243
  const params = new URLSearchParams({
@@ -22,4 +259,85 @@ export class CloudClient {
22
259
  }
23
260
  window.open(`${this.baseUrl}/disable-ha?${params.toString()}`);
24
261
  }
262
+ /** Record a freshly-issued session and tell the app to persist it. */
263
+ adoptSession(body) {
264
+ this.session = {
265
+ sessionToken: body.session_token,
266
+ expiresAt: body.expires_at,
267
+ user: body.user,
268
+ };
269
+ void this.notifySession(this.session);
270
+ return this.session;
271
+ }
272
+ async notifySession(session) {
273
+ try {
274
+ await this.onSession?.(session);
275
+ }
276
+ catch {
277
+ // A failing persistence callback must not mask the call's own result.
278
+ }
279
+ }
280
+ async request(method, path, options = {}) {
281
+ if (!options.anonymous && !this.session) {
282
+ throw new Error('Not signed in to Calimero Cloud: call signInWithGoogle() or construct the client with a sessionToken');
283
+ }
284
+ const headers = { Accept: 'application/json' };
285
+ if (options.body !== undefined)
286
+ headers['Content-Type'] = 'application/json';
287
+ if (!options.anonymous && this.session) {
288
+ headers.Authorization = `Bearer ${this.session.sessionToken}`;
289
+ }
290
+ const controller = new AbortController();
291
+ const timer = setTimeout(() => controller.abort(), this.timeoutMs);
292
+ let response;
293
+ try {
294
+ response = await this.fetchImpl(`${this.baseUrl}${path}`, {
295
+ method,
296
+ headers,
297
+ body: options.body === undefined ? undefined : JSON.stringify(options.body),
298
+ signal: controller.signal,
299
+ });
300
+ }
301
+ catch (err) {
302
+ // Same shape the node clients use for a transport failure: an `HTTPError`
303
+ // with `status: 0`, so a caller has one error type to handle rather than
304
+ // one per layer.
305
+ throw new HTTPError(0, err instanceof Error ? err.name : 'NetworkError', `${this.baseUrl}${path}`, new Headers(), err instanceof Error ? err.message : String(err));
306
+ }
307
+ finally {
308
+ clearTimeout(timer);
309
+ }
310
+ this.adoptRollingRefresh(response);
311
+ if (!response.ok) {
312
+ const bodyText = await response.text().catch(() => undefined);
313
+ throw new HTTPError(response.status, response.statusText, `${this.baseUrl}${path}`, response.headers, bodyText);
314
+ }
315
+ if (options.parse === 'none' || response.status === 204)
316
+ return undefined;
317
+ return (await response.json());
318
+ }
319
+ /**
320
+ * Adopt a session token the server refreshed mid-request.
321
+ *
322
+ * MDMA rotates the token on activity and returns the replacement in a
323
+ * response header rather than making the client ask. Ignoring it costs
324
+ * nothing immediately and then logs the user out a week later, which is the
325
+ * kind of bug that is impossible to reproduce in a short session.
326
+ *
327
+ * The user identity is carried over: the header holds only a token, and the
328
+ * refresh is the same logical session as the sign-in that named the user.
329
+ */
330
+ adoptRollingRefresh(response) {
331
+ const token = response.headers.get(REFRESH_HEADER);
332
+ if (!token || !this.session)
333
+ return;
334
+ const expiresRaw = response.headers.get(REFRESH_EXPIRES_HEADER);
335
+ const expiresAt = Number(expiresRaw);
336
+ this.session = {
337
+ sessionToken: token,
338
+ expiresAt: Number.isFinite(expiresAt) && expiresAt > 0 ? expiresAt : this.session.expiresAt,
339
+ user: this.session.user,
340
+ };
341
+ void this.notifySession(this.session);
342
+ }
25
343
  }
@@ -0,0 +1,94 @@
1
+ /**
2
+ * The second way to log in: sign in to Calimero Cloud instead of connecting to
3
+ * a node.
4
+ *
5
+ * # Two connection shapes, one SDK
6
+ *
7
+ * `createMeroJs({ baseUrl })` is the node connection: point at a node, hold a
8
+ * credential on it, and drive its admin API and JSON-RPC. It assumes the caller
9
+ * has a node — their own, or one they have an account on.
10
+ *
11
+ * `connectCloud(...)` is for the caller who has neither. They sign in with the
12
+ * identity they already use (Google, through the cloud), and the cloud answers
13
+ * with an attested TEE relay serving their namespace. Writes then go through
14
+ * delegated execution: signed here, run there, attributed here.
15
+ *
16
+ * The two are not alternatives to be picked at random — they answer different
17
+ * questions. "Which node do I talk to?" has an answer for a desktop app and no
18
+ * answer at all for a web app whose user has never installed anything. This
19
+ * function is that missing answer, which is why the login step it replaces is
20
+ * the *node URL prompt* rather than the authentication.
21
+ *
22
+ * # What it does not do
23
+ *
24
+ * It does not read state. A relay writes on the author's behalf; reads still
25
+ * come from a node the client can query, or from the app's own projection of
26
+ * the events it receives. `connectCloud` is deliberately narrow about this
27
+ * rather than presenting a half-working `MeroJs` whose `admin` and `rpc` would
28
+ * need a credential nobody holds.
29
+ */
30
+ import { CloudClient } from './cloud-client.js';
31
+ import type { CloudClientConfig, CloudNamespace, CloudRelay } from './cloud-client.js';
32
+ import { RelayClient } from '../relay/relay-client.js';
33
+ import type { IntentResult, NonceSource } from '../relay/index.js';
34
+ export interface ConnectCloudOptions {
35
+ /** Defaults to the hosted cloud. */
36
+ cloudBaseUrl?: string;
37
+ /** A stored MDMA session token. Provide this or `googleIdToken`. */
38
+ sessionToken?: string;
39
+ /** A Google ID token to exchange for a session. Provide this or `sessionToken`. */
40
+ googleIdToken?: string;
41
+ /** Persist a new or rolling-refreshed session. See {@link CloudClientConfig.onSession}. */
42
+ onSession?: CloudClientConfig['onSession'];
43
+ /** The author's account, hex — whose writes these will be. */
44
+ authorAccount: string;
45
+ /** The author's `AccountProof<DeviceCert>`, hex-encoded borsh. */
46
+ authorProof: string;
47
+ /** The author device's ed25519 signing secret, hex. Never transmitted. */
48
+ deviceSecret: string;
49
+ /**
50
+ * Which namespace to write in.
51
+ *
52
+ * Optional only when the user owns exactly one: guessing among several would
53
+ * pick a tenant for them, and the failure would be a write landing in the
54
+ * wrong place rather than an error.
55
+ */
56
+ namespaceId?: string;
57
+ /**
58
+ * Where nonces come from.
59
+ *
60
+ * Defaults to `localStorage`, keyed by the author's device public key, so a
61
+ * reload continues the sequence instead of replaying it. Pass
62
+ * {@link createMemoryNonceSource} explicitly for a process that owns its
63
+ * whole sequence, or your own source to coordinate across tabs.
64
+ */
65
+ nonces?: NonceSource;
66
+ /** Seconds a minted warrant stays presentable. Defaults to 300. */
67
+ ttlSeconds?: number;
68
+ fetch?: typeof fetch;
69
+ timeoutMs?: number;
70
+ }
71
+ /** A signed-in cloud session with a relay ready to write through. */
72
+ export interface CloudConnection {
73
+ /** The cloud API, for namespaces, billing, HA and sign-out. */
74
+ cloud: CloudClient;
75
+ /** The relay chosen for this namespace. */
76
+ relay: RelayClient;
77
+ /** The namespace writes will land in. */
78
+ namespaceId: string;
79
+ namespace: CloudNamespace;
80
+ /** What the cloud reported about the chosen relay. */
81
+ relayInfo: CloudRelay;
82
+ /** Run one method in one context, on the author's behalf. */
83
+ execute<T = unknown>(contextId: string, method: string, argsJson?: unknown): Promise<IntentResult<T>>;
84
+ }
85
+ /**
86
+ * Sign in to the cloud and return a connection that can write.
87
+ *
88
+ * Throws rather than returning a half-usable connection, and the message says
89
+ * which step is missing — no namespace, several to choose between, HA not
90
+ * enabled, or no relay holding the authorship grant. Every one of those is
91
+ * something the user has to go and do, and a client that reports them alike
92
+ * cannot tell them what.
93
+ */
94
+ export declare function connectCloud(options: ConnectCloudOptions): Promise<CloudConnection>;
@@ -0,0 +1,150 @@
1
+ /**
2
+ * The second way to log in: sign in to Calimero Cloud instead of connecting to
3
+ * a node.
4
+ *
5
+ * # Two connection shapes, one SDK
6
+ *
7
+ * `createMeroJs({ baseUrl })` is the node connection: point at a node, hold a
8
+ * credential on it, and drive its admin API and JSON-RPC. It assumes the caller
9
+ * has a node — their own, or one they have an account on.
10
+ *
11
+ * `connectCloud(...)` is for the caller who has neither. They sign in with the
12
+ * identity they already use (Google, through the cloud), and the cloud answers
13
+ * with an attested TEE relay serving their namespace. Writes then go through
14
+ * delegated execution: signed here, run there, attributed here.
15
+ *
16
+ * The two are not alternatives to be picked at random — they answer different
17
+ * questions. "Which node do I talk to?" has an answer for a desktop app and no
18
+ * answer at all for a web app whose user has never installed anything. This
19
+ * function is that missing answer, which is why the login step it replaces is
20
+ * the *node URL prompt* rather than the authentication.
21
+ *
22
+ * # What it does not do
23
+ *
24
+ * It does not read state. A relay writes on the author's behalf; reads still
25
+ * come from a node the client can query, or from the app's own projection of
26
+ * the events it receives. `connectCloud` is deliberately narrow about this
27
+ * rather than presenting a half-working `MeroJs` whose `admin` and `rpc` would
28
+ * need a credential nobody holds.
29
+ */
30
+ import { CloudClient } from './cloud-client.js';
31
+ import { RelayClient } from '../relay/relay-client.js';
32
+ import { createLocalStorageNonceSource, createMemoryNonceSource } from '../relay/nonce-source.js';
33
+ import { derivePublicKey, hex } from '../crypto/internal.js';
34
+ /**
35
+ * Sign in to the cloud and return a connection that can write.
36
+ *
37
+ * Throws rather than returning a half-usable connection, and the message says
38
+ * which step is missing — no namespace, several to choose between, HA not
39
+ * enabled, or no relay holding the authorship grant. Every one of those is
40
+ * something the user has to go and do, and a client that reports them alike
41
+ * cannot tell them what.
42
+ */
43
+ export async function connectCloud(options) {
44
+ if (!options.sessionToken && !options.googleIdToken) {
45
+ throw new Error('connectCloud needs either a sessionToken or a googleIdToken');
46
+ }
47
+ const cloud = new CloudClient({
48
+ cloudBaseUrl: options.cloudBaseUrl,
49
+ sessionToken: options.sessionToken,
50
+ onSession: options.onSession,
51
+ fetch: options.fetch,
52
+ timeoutMs: options.timeoutMs,
53
+ });
54
+ if (!options.sessionToken && options.googleIdToken) {
55
+ await cloud.signInWithGoogle(options.googleIdToken);
56
+ }
57
+ const namespaces = await cloud.getMyNamespaces();
58
+ const namespace = pickNamespace(namespaces, options.namespaceId);
59
+ // One read, used for both the choice and the diagnostic. `findExecutingRelay`
60
+ // would fetch again to explain itself, and a second call can disagree with the
61
+ // first — reporting "waiting for the grant" about a relay that was already
62
+ // usable a moment ago.
63
+ const relays = await cloud.getNamespaceRelays(namespace.namespaceId);
64
+ const relayInfo = relays.find((r) => r.authorshipReady && r.relayUrl !== null && r.executorAccount !== null) ??
65
+ null;
66
+ if (!relayInfo) {
67
+ throw new Error(explainNoRelay(namespace, relays));
68
+ }
69
+ const relay = new RelayClient({
70
+ // Checked by `findExecutingRelay`, which is the only thing that returns a
71
+ // relay — narrowed here rather than re-validated, so the invariant lives in
72
+ // one place.
73
+ relayUrl: relayInfo.relayUrl,
74
+ executorAccount: relayInfo.executorAccount,
75
+ authorAccount: options.authorAccount,
76
+ authorProof: options.authorProof,
77
+ deviceSecret: options.deviceSecret,
78
+ nonces: options.nonces ?? (await defaultNonceSource(options.deviceSecret)),
79
+ ttlSeconds: options.ttlSeconds,
80
+ fetch: options.fetch,
81
+ timeoutMs: options.timeoutMs,
82
+ });
83
+ return {
84
+ cloud,
85
+ relay,
86
+ namespaceId: namespace.namespaceId,
87
+ namespace,
88
+ relayInfo,
89
+ execute: (contextId, method, argsJson) => relay.execute(contextId, method, argsJson),
90
+ };
91
+ }
92
+ /** The one namespace to write in, or an error naming the actual ambiguity. */
93
+ function pickNamespace(namespaces, requested) {
94
+ if (requested) {
95
+ const found = namespaces.find((n) => n.namespaceId === requested);
96
+ if (!found) {
97
+ throw new Error(`this account owns no namespace ${requested}; it owns: ${namespaces.map((n) => n.namespaceId).join(', ') || '(none)'}`);
98
+ }
99
+ return found;
100
+ }
101
+ if (namespaces.length === 0) {
102
+ throw new Error('this account owns no namespaces in the cloud: claim one first, then enable HA on it so a relay is assigned');
103
+ }
104
+ if (namespaces.length > 1) {
105
+ throw new Error(`this account owns ${namespaces.length} namespaces — pass namespaceId to say which: ${namespaces
106
+ .map((n) => n.namespaceId)
107
+ .join(', ')}`);
108
+ }
109
+ return namespaces[0];
110
+ }
111
+ /**
112
+ * Why no relay can write here — the three states, kept apart.
113
+ *
114
+ * They are three different asks of the user (turn HA on, wait, or get an admin
115
+ * to grant a capability) and one message for all of them would send them to
116
+ * the wrong one.
117
+ */
118
+ function explainNoRelay(namespace, relays) {
119
+ if (relays.length === 0) {
120
+ return namespace.haStatus === 'enabled'
121
+ ? `namespace ${namespace.namespaceId} has HA enabled but no relay has been assigned to it yet — retry shortly`
122
+ : `namespace ${namespace.namespaceId} has no relays: enable HA on it in the cloud to have an attested node assigned`;
123
+ }
124
+ const waitingOnGrant = relays.filter((r) => !r.authorshipReady);
125
+ if (waitingOnGrant.length === relays.length) {
126
+ 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'}`);
128
+ }
129
+ return `namespace ${namespace.namespaceId} has ${relays.length} relay(s), but none reported both a URL and an executor account yet — retry shortly`;
130
+ }
131
+ /**
132
+ * A persisted nonce source keyed by the author's device public key.
133
+ *
134
+ * The key is the *public* key, derived locally: a per-device key is required
135
+ * (two devices of one account are independent replicas) and the secret must
136
+ * never end up in a storage key an extension or another script can enumerate.
137
+ *
138
+ * Falls back to memory where there is no `localStorage` — Node, a worker, an
139
+ * edge runtime. Those are processes that own their whole sequence, which is
140
+ * exactly the case a memory counter is correct for.
141
+ */
142
+ async function defaultNonceSource(deviceSecret) {
143
+ const key = `mero-js:warrant-nonce:${hex(await derivePublicKey(deviceSecret))}`;
144
+ try {
145
+ return createLocalStorageNonceSource(key);
146
+ }
147
+ catch {
148
+ return createMemoryNonceSource(1);
149
+ }
150
+ }
@@ -1,2 +1,4 @@
1
1
  export { CloudClient } from './cloud-client.js';
2
- export type { CloudClientConfig, EnableHAOptions, DisableHAOptions } from './cloud-client.js';
2
+ export type { CloudClientConfig, CloudSession, CloudNamespace, CloudRelay, CloudMachine, CloudMachineNamespace, EnableHAOptions, DisableHAOptions, } from './cloud-client.js';
3
+ export { connectCloud } from './connect.js';
4
+ export type { ConnectCloudOptions, CloudConnection } from './connect.js';
@@ -1 +1,2 @@
1
1
  export { CloudClient } from './cloud-client.js';
2
+ export { connectCloud } from './connect.js';