@calimero-network/mero-js 18.1.1 → 18.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/index.mjs CHANGED
@@ -886,8 +886,10 @@ var AdminApiClient = class {
886
886
  async getContextStorage(contextId) {
887
887
  return unwrap(await this.httpClient.get(`/admin-api/contexts/${contextId}/storage`));
888
888
  }
889
+ /** Sync one context, or every context when `contextId` is omitted. */
889
890
  async syncContext(contextId) {
890
- await this.httpClient.post(`/admin-api/contexts/sync/${contextId ?? ""}`, {});
891
+ const path = contextId ? `/admin-api/contexts/sync/${contextId}` : "/admin-api/contexts/sync";
892
+ await this.httpClient.post(path, {});
891
893
  }
892
894
  /**
893
895
  * Kick off a full state re-pull for a context (operator recovery for a
@@ -1102,6 +1104,32 @@ var AdminApiClient = class {
1102
1104
  )
1103
1105
  );
1104
1106
  }
1107
+ /**
1108
+ * Ask what this node can do for a member in `contextId`, before they sign
1109
+ * anything.
1110
+ *
1111
+ * The two facts {@link performIntent} needs and a client cannot derive: whose
1112
+ * account goes in the warrant's `executor`, and whether this node holds
1113
+ * `CAN_AUTHOR_ON_BEHALF` on the owning group. `canAuthorOnBehalf: false` comes
1114
+ * back as an answer rather than an error — it is the default state of every
1115
+ * context — so a caller can say "an admin of `groupId` must grant it to
1116
+ * `executorAccount`" instead of presenting a warrant that will be refused
1117
+ * after the author has spent a nonce on it.
1118
+ *
1119
+ * `404` means the context belongs to no group, or this node holds no account
1120
+ * yet; either way it can be named as no warrant's executor.
1121
+ *
1122
+ * For a caller with no credential on the node at all — a browser tab holding
1123
+ * one signing key — use `RelayClient`, which reads this same route on a node
1124
+ * run as a relay.
1125
+ */
1126
+ async getIntentRelay(contextId) {
1127
+ return unwrap(
1128
+ await this.httpClient.get(
1129
+ `/admin-api/contexts/${contextId}/intents`
1130
+ )
1131
+ );
1132
+ }
1105
1133
  async listNamespacesForApplication(applicationId) {
1106
1134
  return unwrap(await this.httpClient.get(`/admin-api/namespaces/for-application/${applicationId}`));
1107
1135
  }
@@ -2232,6 +2260,335 @@ var EphemeralClient = class {
2232
2260
  }
2233
2261
  };
2234
2262
 
2263
+ // src/cloud/cloud-client.ts
2264
+ var DEFAULT_CLOUD_BASE_URL = "https://cloud.calimero.network";
2265
+ var REFRESH_HEADER = "x-mdma-session-refresh";
2266
+ var REFRESH_EXPIRES_HEADER = "x-mdma-session-expires";
2267
+ var CloudClient = class {
2268
+ constructor(config = {}) {
2269
+ this.session = null;
2270
+ this.baseUrl = (config.cloudBaseUrl || DEFAULT_CLOUD_BASE_URL).replace(/\/+$/, "");
2271
+ this.onSession = config.onSession;
2272
+ const injected = config.fetch;
2273
+ this.fetchImpl = injected ? (input, init) => injected(input, init) : (input, init) => globalThis.fetch(input, init);
2274
+ this.timeoutMs = config.timeoutMs ?? 1e4;
2275
+ if (config.sessionToken) {
2276
+ this.session = {
2277
+ sessionToken: config.sessionToken,
2278
+ expiresAt: 0,
2279
+ user: { email: "" }
2280
+ };
2281
+ }
2282
+ }
2283
+ /** The current session, or `null` when nobody is signed in. */
2284
+ getSession() {
2285
+ return this.session;
2286
+ }
2287
+ /** Whether a session token is held. Says nothing about the server agreeing. */
2288
+ isSignedIn() {
2289
+ return this.session !== null;
2290
+ }
2291
+ /**
2292
+ * Exchange a Google OAuth2 ID token for an MDMA session.
2293
+ *
2294
+ * The app owns the Google flow (One Tap, an auth-code popup, a native
2295
+ * browser) and hands the resulting ID token here once. Everything after this
2296
+ * uses the returned session token, so the Google credential does not have to
2297
+ * be kept or re-minted per call.
2298
+ */
2299
+ async signInWithGoogle(idToken) {
2300
+ const body = await this.request("POST", "/api/auth/google", { body: { id_token: idToken }, anonymous: true });
2301
+ return this.adoptSession(body);
2302
+ }
2303
+ /**
2304
+ * Refresh the session token explicitly.
2305
+ *
2306
+ * Rarely needed: MDMA rolling-refreshes on ordinary authenticated calls and
2307
+ * this client adopts the replacement automatically. Use it when an app wants
2308
+ * a fresh token before going idle rather than on the next request.
2309
+ */
2310
+ async refreshSession() {
2311
+ const body = await this.request("POST", "/api/auth/refresh");
2312
+ return this.adoptSession(body);
2313
+ }
2314
+ /**
2315
+ * Revoke this session server-side and forget it locally.
2316
+ *
2317
+ * Local state is cleared even if the server call fails: the caller asked to
2318
+ * be signed out, and keeping a token they believe is gone is the worse of the
2319
+ * two failures.
2320
+ */
2321
+ async signOut() {
2322
+ try {
2323
+ if (this.session) await this.request("POST", "/api/auth/logout", { parse: "none" });
2324
+ } finally {
2325
+ this.session = null;
2326
+ void this.notifySession(null);
2327
+ }
2328
+ }
2329
+ /** Namespaces the signed-in user owns. */
2330
+ async getMyNamespaces() {
2331
+ const rows = await this.request(
2332
+ "GET",
2333
+ "/api/cloud/me/namespaces"
2334
+ );
2335
+ return rows.map((row) => ({
2336
+ namespaceId: String(row.namespace_id ?? row.group_id ?? ""),
2337
+ groupId: String(row.group_id ?? row.namespace_id ?? ""),
2338
+ haStatus: String(row.ha_status ?? "none"),
2339
+ haEnabledAt: row.ha_enabled_at ?? null,
2340
+ contexts: Array.isArray(row.contexts) ? row.contexts : [],
2341
+ fleetReplicas: row.fleet_replicas
2342
+ }));
2343
+ }
2344
+ /**
2345
+ * The relays serving one namespace: where to present an intent, and as whom.
2346
+ *
2347
+ * Everything a client needs in order to mint a warrant it can actually
2348
+ * spend. Filter on `authorshipReady` before signing — a warrant presented to
2349
+ * a relay with no grant is refused *after* the author has burned a nonce from
2350
+ * its monotonic sequence on it.
2351
+ */
2352
+ async getNamespaceRelays(namespaceId) {
2353
+ const body = await this.request(
2354
+ "GET",
2355
+ `/api/cloud/me/namespaces/${encodeURIComponent(namespaceId)}/relays`
2356
+ );
2357
+ return (body.relays ?? []).map((row) => ({
2358
+ peerId: String(row.peer_id ?? ""),
2359
+ relayUrl: row.relay_url ?? null,
2360
+ executorAccount: row.executor_account ?? null,
2361
+ status: String(row.status ?? ""),
2362
+ authorshipReady: row.authorship_ready === true,
2363
+ lastSeenAt: row.last_seen_at ?? null,
2364
+ confirmedAt: row.confirmed_at ?? null
2365
+ }));
2366
+ }
2367
+ /**
2368
+ * The first relay for `namespaceId` that can actually run an intent, or
2369
+ * `null`.
2370
+ *
2371
+ * "Can actually run one" is three conditions, and a client that checks fewer
2372
+ * gets a refusal it cannot explain: the cloud must know a URL, the relay must
2373
+ * have reported the executor account a warrant has to name, and the grant
2374
+ * must be in place.
2375
+ */
2376
+ async findExecutingRelay(namespaceId) {
2377
+ const relays = await this.getNamespaceRelays(namespaceId);
2378
+ return relays.find((r) => r.authorshipReady && r.relayUrl !== null && r.executorAccount !== null) ?? null;
2379
+ }
2380
+ /**
2381
+ * Every attested machine currently serving something this account owns.
2382
+ *
2383
+ * The account-wide view of the same rows {@link getNamespaceRelays} returns
2384
+ * per namespace, keyed by machine — so a UI can answer "what is running for
2385
+ * me?" in one call. `canExecute` folds the three-way precondition, so a
2386
+ * caller does not re-derive it.
2387
+ *
2388
+ * Only the caller's own namespaces appear on each machine. A plan's
2389
+ * `usersPerMachine` is greater than one, so a fleet node routinely serves
2390
+ * several accounts at once.
2391
+ */
2392
+ async getMyMachines() {
2393
+ const body = await this.request(
2394
+ "GET",
2395
+ "/api/cloud/me/machines"
2396
+ );
2397
+ return (body.machines ?? []).map((row) => ({
2398
+ peerId: String(row.peer_id ?? ""),
2399
+ relayUrl: row.relay_url ?? null,
2400
+ executorAccount: row.executor_account ?? null,
2401
+ canExecute: row.can_execute === true,
2402
+ namespaces: (Array.isArray(row.namespaces) ? row.namespaces : []).map((n) => {
2403
+ const ns = n;
2404
+ return {
2405
+ namespaceId: String(ns.namespace_id ?? ""),
2406
+ status: String(ns.status ?? ""),
2407
+ authorshipReady: ns.authorship_ready === true,
2408
+ fresh: ns.fresh === true,
2409
+ confirmedAt: ns.confirmed_at ?? null,
2410
+ lastSeenAt: ns.last_seen_at ?? null
2411
+ };
2412
+ })
2413
+ }));
2414
+ }
2415
+ /**
2416
+ * Claim a namespace under this account, proving ownership.
2417
+ *
2418
+ * The cloud has no other way to learn a namespace exists — namespaces are
2419
+ * created on a node, and there is deliberately no "create namespace" in the
2420
+ * cloud. `ownershipProof` comes from
2421
+ * `admin.issueNamespaceOwnershipProof(namespaceId, …)` on a node that is a
2422
+ * **direct admin** of that namespace; merod refuses to issue one otherwise.
2423
+ *
2424
+ * Pass merod's response object straight through. The cloud accepts core's
2425
+ * `signerPublicKey`/`signedPayload` casing as well as snake_case, so there is
2426
+ * no re-keying step — and getting that wrong used to surface as a `422`
2427
+ * naming three missing fields.
2428
+ *
2429
+ * Idempotent for the same account. A namespace already claimed by someone
2430
+ * else is a `409`; a replayed proof nonce is a `403`.
2431
+ */
2432
+ async claimNamespace(namespaceId, ownershipProof) {
2433
+ await this.request("POST", "/api/cloud/namespaces/claim", {
2434
+ body: { namespace_id: namespaceId, ownership_proof: ownershipProof }
2435
+ });
2436
+ }
2437
+ /**
2438
+ * Ask the fleet to host a claimed namespace — the step that gets a relay
2439
+ * assigned.
2440
+ *
2441
+ * Takes no body: {@link claimNamespace} already established ownership, and
2442
+ * this is the separate "now host it" half. Idempotent.
2443
+ *
2444
+ * Not to be confused with {@link enableHA}, which opens the cloud's web flow
2445
+ * in a browser tab for a user to complete by hand. This is the API call.
2446
+ */
2447
+ async enableNamespaceHa(namespaceId) {
2448
+ return this.request(
2449
+ "POST",
2450
+ `/api/cloud/namespaces/${encodeURIComponent(namespaceId)}/enable-ha`,
2451
+ { body: {} }
2452
+ );
2453
+ }
2454
+ /**
2455
+ * Stop hosting a claimed namespace.
2456
+ *
2457
+ * The fleet's own reconcile loop does the rest: each assigned node notices
2458
+ * the namespace is no longer assigned to it and **self-leaves**, which makes
2459
+ * core evict it and purge its local data and keys. So this is not just a
2460
+ * billing flip.
2461
+ */
2462
+ async disableNamespaceHa(namespaceId) {
2463
+ return this.request(
2464
+ "POST",
2465
+ `/api/cloud/namespaces/${encodeURIComponent(namespaceId)}/disable-ha`,
2466
+ { body: {} }
2467
+ );
2468
+ }
2469
+ /**
2470
+ * The node assigned to the signed-in user, or `null`.
2471
+ *
2472
+ * @deprecated Use {@link getMyMachines}. This reads the cloud's retired
2473
+ * `NodeAssignment` ledger, which nothing has written since the v1
2474
+ * retirement, so it answers `null` for every namespace-native account —
2475
+ * which reads as "you have no machine" when the truth is "that is no longer
2476
+ * how a machine is assigned".
2477
+ */
2478
+ async getMyNode() {
2479
+ const body = await this.request(
2480
+ "GET",
2481
+ "/api/cloud/me/node"
2482
+ );
2483
+ return body.node ?? null;
2484
+ }
2485
+ enableHA(options) {
2486
+ const params = new URLSearchParams({
2487
+ group_id: options.groupId,
2488
+ context_id: options.contextId
2489
+ });
2490
+ if (options.redirectUrl) {
2491
+ params.set("redirect_url", options.redirectUrl);
2492
+ }
2493
+ window.open(`${this.baseUrl}/enable-ha?${params.toString()}`);
2494
+ }
2495
+ disableHA(options) {
2496
+ const params = new URLSearchParams({
2497
+ group_id: options.groupId,
2498
+ context_id: options.contextId
2499
+ });
2500
+ if (options.redirectUrl) {
2501
+ params.set("redirect_url", options.redirectUrl);
2502
+ }
2503
+ window.open(`${this.baseUrl}/disable-ha?${params.toString()}`);
2504
+ }
2505
+ /** Record a freshly-issued session and tell the app to persist it. */
2506
+ adoptSession(body) {
2507
+ this.session = {
2508
+ sessionToken: body.session_token,
2509
+ expiresAt: body.expires_at,
2510
+ user: body.user
2511
+ };
2512
+ void this.notifySession(this.session);
2513
+ return this.session;
2514
+ }
2515
+ async notifySession(session) {
2516
+ try {
2517
+ await this.onSession?.(session);
2518
+ } catch {
2519
+ }
2520
+ }
2521
+ async request(method, path, options = {}) {
2522
+ if (!options.anonymous && !this.session) {
2523
+ throw new Error(
2524
+ "Not signed in to Calimero Cloud: call signInWithGoogle() or construct the client with a sessionToken"
2525
+ );
2526
+ }
2527
+ const headers = { Accept: "application/json" };
2528
+ if (options.body !== void 0) headers["Content-Type"] = "application/json";
2529
+ if (!options.anonymous && this.session) {
2530
+ headers.Authorization = `Bearer ${this.session.sessionToken}`;
2531
+ }
2532
+ const controller = new AbortController();
2533
+ const timer = setTimeout(() => controller.abort(), this.timeoutMs);
2534
+ let response;
2535
+ try {
2536
+ response = await this.fetchImpl(`${this.baseUrl}${path}`, {
2537
+ method,
2538
+ headers,
2539
+ body: options.body === void 0 ? void 0 : JSON.stringify(options.body),
2540
+ signal: controller.signal
2541
+ });
2542
+ } catch (err) {
2543
+ throw new HTTPError(
2544
+ 0,
2545
+ err instanceof Error ? err.name : "NetworkError",
2546
+ `${this.baseUrl}${path}`,
2547
+ new Headers(),
2548
+ err instanceof Error ? err.message : String(err)
2549
+ );
2550
+ } finally {
2551
+ clearTimeout(timer);
2552
+ }
2553
+ this.adoptRollingRefresh(response);
2554
+ if (!response.ok) {
2555
+ const bodyText = await response.text().catch(() => void 0);
2556
+ throw new HTTPError(
2557
+ response.status,
2558
+ response.statusText,
2559
+ `${this.baseUrl}${path}`,
2560
+ response.headers,
2561
+ bodyText
2562
+ );
2563
+ }
2564
+ if (options.parse === "none" || response.status === 204) return void 0;
2565
+ return await response.json();
2566
+ }
2567
+ /**
2568
+ * Adopt a session token the server refreshed mid-request.
2569
+ *
2570
+ * MDMA rotates the token on activity and returns the replacement in a
2571
+ * response header rather than making the client ask. Ignoring it costs
2572
+ * nothing immediately and then logs the user out a week later, which is the
2573
+ * kind of bug that is impossible to reproduce in a short session.
2574
+ *
2575
+ * The user identity is carried over: the header holds only a token, and the
2576
+ * refresh is the same logical session as the sign-in that named the user.
2577
+ */
2578
+ adoptRollingRefresh(response) {
2579
+ const token = response.headers.get(REFRESH_HEADER);
2580
+ if (!token || !this.session) return;
2581
+ const expiresRaw = response.headers.get(REFRESH_EXPIRES_HEADER);
2582
+ const expiresAt = Number(expiresRaw);
2583
+ this.session = {
2584
+ sessionToken: token,
2585
+ expiresAt: Number.isFinite(expiresAt) && expiresAt > 0 ? expiresAt : this.session.expiresAt,
2586
+ user: this.session.user
2587
+ };
2588
+ void this.notifySession(this.session);
2589
+ }
2590
+ };
2591
+
2235
2592
  // src/mero-js.ts
2236
2593
  var REFRESH_LOCK_NAME = "mero-js:token-refresh";
2237
2594
  function getLockManager() {
@@ -2365,6 +2722,22 @@ var MeroJs = class {
2365
2722
  }
2366
2723
  return this.ephemeralClient;
2367
2724
  }
2725
+ /**
2726
+ * Calimero Cloud (lazy initialized).
2727
+ *
2728
+ * Available whether or not `cloud` was configured — an app that signs in
2729
+ * later gets an unauthenticated client it can call `signInWithGoogle` on,
2730
+ * which is one fewer construction path than making the getter conditional.
2731
+ * It is entirely independent of this instance's node credential: the cloud
2732
+ * knows nothing about the node's tokens and the node knows nothing about the
2733
+ * cloud session.
2734
+ */
2735
+ get cloud() {
2736
+ if (!this.cloudClient) {
2737
+ this.cloudClient = new CloudClient(this.config.cloud ?? {});
2738
+ }
2739
+ return this.cloudClient;
2740
+ }
2368
2741
  /**
2369
2742
  * Get the WebSocket event client (lazy initialized).
2370
2743
  * @experimental Use `events` (SSE) for production. WsClient is experimental.
@@ -2609,33 +2982,6 @@ var LocalStorageTokenStore = class {
2609
2982
  }
2610
2983
  };
2611
2984
 
2612
- // src/cloud/cloud-client.ts
2613
- var CloudClient = class {
2614
- constructor(config = {}) {
2615
- this.baseUrl = (config.cloudBaseUrl || "https://cloud.calimero.network").replace(/\/+$/, "");
2616
- }
2617
- enableHA(options) {
2618
- const params = new URLSearchParams({
2619
- group_id: options.groupId,
2620
- context_id: options.contextId
2621
- });
2622
- if (options.redirectUrl) {
2623
- params.set("redirect_url", options.redirectUrl);
2624
- }
2625
- window.open(`${this.baseUrl}/enable-ha?${params.toString()}`);
2626
- }
2627
- disableHA(options) {
2628
- const params = new URLSearchParams({
2629
- group_id: options.groupId,
2630
- context_id: options.contextId
2631
- });
2632
- if (options.redirectUrl) {
2633
- params.set("redirect_url", options.redirectUrl);
2634
- }
2635
- window.open(`${this.baseUrl}/disable-ha?${params.toString()}`);
2636
- }
2637
- };
2638
-
2639
2985
  // src/crypto/internal.ts
2640
2986
  var PKCS8_ED25519_PREFIX = new Uint8Array([
2641
2987
  48,
@@ -2777,6 +3123,245 @@ async function signWarrant(input) {
2777
3123
  );
2778
3124
  }
2779
3125
 
3126
+ // src/relay/relay-client.ts
3127
+ var DEFAULT_TTL_SECONDS = 300;
3128
+ var IntentRefusedError = class extends Error {
3129
+ constructor(reason, retryable, status) {
3130
+ super(`relay refused the intent (HTTP ${status}): ${reason}`);
3131
+ this.reason = reason;
3132
+ this.retryable = retryable;
3133
+ this.status = status;
3134
+ this.name = "IntentRefusedError";
3135
+ }
3136
+ };
3137
+ var RelayClient = class {
3138
+ constructor(config) {
3139
+ this.config = config;
3140
+ this.baseUrl = config.relayUrl.replace(/\/+$/, "");
3141
+ this.executorAccount = config.executorAccount;
3142
+ const injected = config.fetch;
3143
+ this.fetchImpl = injected ? (input, init) => injected(input, init) : (input, init) => globalThis.fetch(input, init);
3144
+ this.timeoutMs = config.timeoutMs ?? 1e4;
3145
+ }
3146
+ /**
3147
+ * Ask the relay what it can do in `contextId`.
3148
+ *
3149
+ * Cheap and unauthenticated on a relay that serves delegated execution
3150
+ * publicly, which is what makes it usable as a precondition check rather than
3151
+ * as diagnostics after a failure.
3152
+ */
3153
+ async describe(contextId) {
3154
+ const body = await this.json("GET", `/admin-api/contexts/${encodeURIComponent(contextId)}/intents`);
3155
+ this.executorAccount = body.data.executorAccount;
3156
+ return body.data;
3157
+ }
3158
+ /**
3159
+ * Mint a warrant for `method(args)` and present it to the relay.
3160
+ *
3161
+ * One intent, once: the warrant authorizes exactly this method and these
3162
+ * arguments in exactly this context, and its nonce is spent by the network on
3163
+ * apply. Nothing accumulates and nothing is reusable.
3164
+ */
3165
+ async execute(contextId, method, argsJson = {}) {
3166
+ const executor = this.executorAccount ?? (await this.describe(contextId)).executorAccount;
3167
+ const nonce = await this.config.nonces.next();
3168
+ const ttl = this.config.ttlSeconds ?? DEFAULT_TTL_SECONDS;
3169
+ const warrant = await signWarrant({
3170
+ context: contextId,
3171
+ authorAccount: this.config.authorAccount,
3172
+ executor,
3173
+ method,
3174
+ argsJson,
3175
+ nonce,
3176
+ notAfter: BigInt(Math.floor(Date.now() / 1e3)) + BigInt(ttl),
3177
+ deviceSecret: this.config.deviceSecret
3178
+ });
3179
+ const body = await this.json(
3180
+ "POST",
3181
+ `/admin-api/contexts/${encodeURIComponent(contextId)}/intents`,
3182
+ { method, argsJson, warrant, authorProof: this.config.authorProof }
3183
+ );
3184
+ return { rootHash: body.data.rootHash, returns: body.data.returns ?? null };
3185
+ }
3186
+ async json(method, path, body) {
3187
+ const headers = { Accept: "application/json" };
3188
+ if (body !== void 0) headers["Content-Type"] = "application/json";
3189
+ const controller = new AbortController();
3190
+ const timer = setTimeout(() => controller.abort(), this.timeoutMs);
3191
+ let response;
3192
+ try {
3193
+ response = await this.fetchImpl(`${this.baseUrl}${path}`, {
3194
+ method,
3195
+ headers,
3196
+ body: body === void 0 ? void 0 : JSON.stringify(body),
3197
+ signal: controller.signal
3198
+ });
3199
+ } catch (err) {
3200
+ throw new HTTPError(
3201
+ 0,
3202
+ err instanceof Error ? err.name : "NetworkError",
3203
+ `${this.baseUrl}${path}`,
3204
+ new Headers(),
3205
+ err instanceof Error ? err.message : String(err)
3206
+ );
3207
+ } finally {
3208
+ clearTimeout(timer);
3209
+ }
3210
+ if (!response.ok) {
3211
+ const text = await response.text().catch(() => "");
3212
+ if (response.status === 400 || response.status === 403) {
3213
+ const reason = extractReason(text);
3214
+ throw new IntentRefusedError(reason, /nonce/i.test(reason), response.status);
3215
+ }
3216
+ throw new HTTPError(
3217
+ response.status,
3218
+ response.statusText,
3219
+ `${this.baseUrl}${path}`,
3220
+ response.headers,
3221
+ text
3222
+ );
3223
+ }
3224
+ return await response.json();
3225
+ }
3226
+ };
3227
+ function extractReason(bodyText) {
3228
+ if (!bodyText) return "no reason given";
3229
+ try {
3230
+ const parsed = JSON.parse(bodyText);
3231
+ for (const candidate of [parsed.error, parsed.message]) {
3232
+ if (typeof candidate === "string" && candidate) return candidate;
3233
+ }
3234
+ } catch {
3235
+ }
3236
+ return bodyText;
3237
+ }
3238
+
3239
+ // src/relay/nonce-source.ts
3240
+ function createMemoryNonceSource(start = 1) {
3241
+ let next = BigInt(start);
3242
+ if (next < 1n) {
3243
+ throw new Error("nonce sequence starts at 1");
3244
+ }
3245
+ return {
3246
+ next() {
3247
+ const value = next;
3248
+ next += 1n;
3249
+ return Promise.resolve(value);
3250
+ }
3251
+ };
3252
+ }
3253
+ function createLocalStorageNonceSource(key, storage) {
3254
+ const store = storage ?? globalThis.localStorage;
3255
+ if (!store) {
3256
+ throw new Error(
3257
+ "no localStorage in this runtime: pass a storage implementation, or use createMemoryNonceSource for a process that owns its whole sequence"
3258
+ );
3259
+ }
3260
+ return {
3261
+ next() {
3262
+ const raw = store.getItem(key);
3263
+ let current = 0n;
3264
+ if (raw !== null && raw !== "") {
3265
+ try {
3266
+ const parsed = BigInt(raw);
3267
+ if (parsed > 0n) current = parsed;
3268
+ } catch {
3269
+ current = 0n;
3270
+ }
3271
+ }
3272
+ const value = current + 1n;
3273
+ store.setItem(key, value.toString());
3274
+ return Promise.resolve(value);
3275
+ }
3276
+ };
3277
+ }
3278
+
3279
+ // src/cloud/connect.ts
3280
+ async function connectCloud(options) {
3281
+ if (!options.sessionToken && !options.googleIdToken) {
3282
+ throw new Error("connectCloud needs either a sessionToken or a googleIdToken");
3283
+ }
3284
+ const cloud = new CloudClient({
3285
+ cloudBaseUrl: options.cloudBaseUrl,
3286
+ sessionToken: options.sessionToken,
3287
+ onSession: options.onSession,
3288
+ fetch: options.fetch,
3289
+ timeoutMs: options.timeoutMs
3290
+ });
3291
+ if (!options.sessionToken && options.googleIdToken) {
3292
+ await cloud.signInWithGoogle(options.googleIdToken);
3293
+ }
3294
+ const namespaces = await cloud.getMyNamespaces();
3295
+ const namespace = pickNamespace(namespaces, options.namespaceId);
3296
+ const relays = await cloud.getNamespaceRelays(namespace.namespaceId);
3297
+ const relayInfo = relays.find((r) => r.authorshipReady && r.relayUrl !== null && r.executorAccount !== null) ?? null;
3298
+ if (!relayInfo) {
3299
+ throw new Error(explainNoRelay(namespace, relays));
3300
+ }
3301
+ const relay = new RelayClient({
3302
+ // Checked by `findExecutingRelay`, which is the only thing that returns a
3303
+ // relay — narrowed here rather than re-validated, so the invariant lives in
3304
+ // one place.
3305
+ relayUrl: relayInfo.relayUrl,
3306
+ executorAccount: relayInfo.executorAccount,
3307
+ authorAccount: options.authorAccount,
3308
+ authorProof: options.authorProof,
3309
+ deviceSecret: options.deviceSecret,
3310
+ nonces: options.nonces ?? await defaultNonceSource(options.deviceSecret),
3311
+ ttlSeconds: options.ttlSeconds,
3312
+ fetch: options.fetch,
3313
+ timeoutMs: options.timeoutMs
3314
+ });
3315
+ return {
3316
+ cloud,
3317
+ relay,
3318
+ namespaceId: namespace.namespaceId,
3319
+ namespace,
3320
+ relayInfo,
3321
+ execute: (contextId, method, argsJson) => relay.execute(contextId, method, argsJson)
3322
+ };
3323
+ }
3324
+ function pickNamespace(namespaces, requested) {
3325
+ if (requested) {
3326
+ const found = namespaces.find((n) => n.namespaceId === requested);
3327
+ if (!found) {
3328
+ throw new Error(
3329
+ `this account owns no namespace ${requested}; it owns: ${namespaces.map((n) => n.namespaceId).join(", ") || "(none)"}`
3330
+ );
3331
+ }
3332
+ return found;
3333
+ }
3334
+ if (namespaces.length === 0) {
3335
+ throw new Error(
3336
+ "this account owns no namespaces in the cloud: claim one first, then enable HA on it so a relay is assigned"
3337
+ );
3338
+ }
3339
+ if (namespaces.length > 1) {
3340
+ throw new Error(
3341
+ `this account owns ${namespaces.length} namespaces \u2014 pass namespaceId to say which: ${namespaces.map((n) => n.namespaceId).join(", ")}`
3342
+ );
3343
+ }
3344
+ return namespaces[0];
3345
+ }
3346
+ function explainNoRelay(namespace, relays) {
3347
+ if (relays.length === 0) {
3348
+ return namespace.haStatus === "enabled" ? `namespace ${namespace.namespaceId} has HA enabled but no relay has been assigned to it yet \u2014 retry shortly` : `namespace ${namespace.namespaceId} has no relays: enable HA on it in the cloud to have an attested node assigned`;
3349
+ }
3350
+ const waitingOnGrant = relays.filter((r) => !r.authorshipReady);
3351
+ if (waitingOnGrant.length === relays.length) {
3352
+ return `every relay on namespace ${namespace.namespaceId} is waiting for the authorship grant: an admin of the namespace must grant CAN_AUTHOR_ON_BEHALF to ${waitingOnGrant.map((r) => r.executorAccount ?? r.peerId).join(", ") || "the relay account"}`;
3353
+ }
3354
+ return `namespace ${namespace.namespaceId} has ${relays.length} relay(s), but none reported both a URL and an executor account yet \u2014 retry shortly`;
3355
+ }
3356
+ async function defaultNonceSource(deviceSecret) {
3357
+ const key = `mero-js:warrant-nonce:${hex(await derivePublicKey(deviceSecret))}`;
3358
+ try {
3359
+ return createLocalStorageNonceSource(key);
3360
+ } catch {
3361
+ return createMemoryNonceSource(1);
3362
+ }
3363
+ }
3364
+
2780
3365
  // src/device-cert/device-cert.ts
2781
3366
  var CERT_DOMAIN = new TextEncoder().encode("calimero.device.cert.v1");
2782
3367
  var ACCOUNT_ID_DOMAIN = new TextEncoder().encode("calimero.account.genesis.v1");
@@ -2973,7 +3558,19 @@ var CAPABILITIES = {
2973
3558
  CAN_CREATE_SUBGROUP: 1 << 5,
2974
3559
  CAN_DELETE_SUBGROUP: 1 << 6,
2975
3560
  CAN_MANAGE_VISIBILITY: 1 << 7,
2976
- CAN_MANAGE_METADATA: 1 << 8
3561
+ CAN_MANAGE_METADATA: 1 << 8,
3562
+ /**
3563
+ * Publish writes attributed to ANOTHER member, under a warrant that member
3564
+ * signed — the grant delegated execution runs on.
3565
+ *
3566
+ * Implied by nothing: not by membership, not by admin, and not propagated by
3567
+ * the subgroup-admit cascade. So every group is authorship-closed until an
3568
+ * admin sets this bit, and a relay without it is refused at
3569
+ * `performIntent` before anything executes. That is why it is worth naming
3570
+ * here rather than leaving callers to write `1 << 9`: a client granting it
3571
+ * has to be able to say what it is granting.
3572
+ */
3573
+ CAN_AUTHOR_ON_BEHALF: 1 << 9
2977
3574
  };
2978
3575
  function hasCap(mask, cap) {
2979
3576
  const capU32 = cap >>> 0;
@@ -3047,9 +3644,11 @@ export {
3047
3644
  DEFAULT_LOCAL_NODE_PORTS,
3048
3645
  EphemeralClient,
3049
3646
  HTTPError,
3647
+ IntentRefusedError,
3050
3648
  LocalStorageTokenStore,
3051
3649
  MemoryTokenStore,
3052
3650
  MeroJs,
3651
+ RelayClient,
3053
3652
  RpcClient,
3054
3653
  RpcError,
3055
3654
  SIGNED_NAMESPACE_OP_SCHEMA_VERSION,
@@ -3060,6 +3659,7 @@ export {
3060
3659
  buildAuthLoginUrl,
3061
3660
  combineSignals,
3062
3661
  compareSemver,
3662
+ connectCloud,
3063
3663
  createAdminApiClient,
3064
3664
  createAdminApiClientFromHttpClient,
3065
3665
  createAuthApiClient,
@@ -3068,6 +3668,8 @@ export {
3068
3668
  createBrowserAuthApiClient,
3069
3669
  createBrowserHttpClient,
3070
3670
  createHttpClient,
3671
+ createLocalStorageNonceSource,
3672
+ createMemoryNonceSource,
3071
3673
  createMeroJs,
3072
3674
  createNodeAdminApiClient,
3073
3675
  createNodeAuthApiClient,