@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.
- package/README.md +47 -0
- package/dist/admin-api/admin-client.d.ts +22 -1
- package/dist/admin-api/admin-client.js +27 -1
- package/dist/admin-api/admin-types.d.ts +34 -0
- package/dist/capabilities.d.ts +14 -2
- package/dist/capabilities.js +15 -3
- package/dist/cloud/cloud-client.d.ts +264 -0
- package/dist/cloud/cloud-client.js +319 -1
- package/dist/cloud/connect.d.ts +94 -0
- package/dist/cloud/connect.js +150 -0
- package/dist/cloud/index.d.ts +3 -1
- package/dist/cloud/index.js +1 -0
- package/dist/index.browser.mjs +2 -2
- package/dist/index.browser.mjs.map +4 -4
- package/dist/index.cjs +664 -30
- package/dist/index.cjs.map +3 -3
- package/dist/index.d.ts +1 -0
- package/dist/index.js +3 -1
- package/dist/index.mjs +664 -30
- package/dist/index.mjs.map +3 -3
- package/dist/mero-js.d.ts +28 -0
- package/dist/mero-js.js +17 -0
- package/dist/relay/index.d.ts +4 -0
- package/dist/relay/index.js +2 -0
- package/dist/relay/nonce-source.d.ts +60 -0
- package/dist/relay/nonce-source.js +95 -0
- package/dist/relay/relay-client.d.ts +141 -0
- package/dist/relay/relay-client.js +157 -0
- package/dist/warrant/warrant.d.ts +89 -10
- package/dist/warrant/warrant.js +109 -12
- package/package.json +2 -1
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
|
-
|
|
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,
|
|
@@ -2736,8 +3082,10 @@ async function derivePublicKey(secretHex) {
|
|
|
2736
3082
|
}
|
|
2737
3083
|
|
|
2738
3084
|
// src/warrant/warrant.ts
|
|
2739
|
-
var SIGN_DOMAIN = new TextEncoder().encode("calimero.warrant.
|
|
3085
|
+
var SIGN_DOMAIN = new TextEncoder().encode("calimero.warrant.v2");
|
|
2740
3086
|
var INTENT_DOMAIN = new TextEncoder().encode("calimero.warrant.intent.v1");
|
|
3087
|
+
var MAX_CITED_HEADS = 64;
|
|
3088
|
+
var UNPINNED_APP = "00".repeat(32);
|
|
2741
3089
|
async function intentHash(method, argsJson) {
|
|
2742
3090
|
const args = new TextEncoder().encode(JSON.stringify(argsJson));
|
|
2743
3091
|
return domainHash(INTENT_DOMAIN, [new TextEncoder().encode(method), args]);
|
|
@@ -2746,8 +3094,16 @@ async function signWarrant(input) {
|
|
|
2746
3094
|
const context = fromHex(input.context, "context", 32);
|
|
2747
3095
|
const authorAccount = fromHex(input.authorAccount, "authorAccount", 32);
|
|
2748
3096
|
const executor = fromHex(input.executor, "executor", 32);
|
|
3097
|
+
const appVersion = fromHex(
|
|
3098
|
+
input.appVersion ?? UNPINNED_APP,
|
|
3099
|
+
"appVersion",
|
|
3100
|
+
32
|
|
3101
|
+
);
|
|
3102
|
+
const accountHeads = citedHeads(input.accountHeads, "accountHeads");
|
|
3103
|
+
const governanceFloor = citedHeads(input.governanceFloor, "governanceFloor");
|
|
2749
3104
|
const key = await importSigningKey(input.deviceSecret);
|
|
2750
3105
|
const publicKey = await derivePublicKey(input.deviceSecret);
|
|
3106
|
+
const method = new TextEncoder().encode(input.method);
|
|
2751
3107
|
const commitment = await intentHash(input.method, input.argsJson);
|
|
2752
3108
|
const nonce = u64le(input.nonce);
|
|
2753
3109
|
const notAfter = u64le(input.notAfter);
|
|
@@ -2756,7 +3112,13 @@ async function signWarrant(input) {
|
|
|
2756
3112
|
authorAccount,
|
|
2757
3113
|
publicKey,
|
|
2758
3114
|
executor,
|
|
3115
|
+
appVersion,
|
|
3116
|
+
method,
|
|
2759
3117
|
commitment,
|
|
3118
|
+
u64le(accountHeads.length),
|
|
3119
|
+
...accountHeads,
|
|
3120
|
+
u64le(governanceFloor.length),
|
|
3121
|
+
...governanceFloor,
|
|
2760
3122
|
nonce,
|
|
2761
3123
|
notAfter
|
|
2762
3124
|
]);
|
|
@@ -2769,13 +3131,268 @@ async function signWarrant(input) {
|
|
|
2769
3131
|
authorAccount,
|
|
2770
3132
|
publicKey,
|
|
2771
3133
|
executor,
|
|
3134
|
+
appVersion,
|
|
3135
|
+
u32le(method.length),
|
|
3136
|
+
method,
|
|
2772
3137
|
commitment,
|
|
3138
|
+
u32le(accountHeads.length),
|
|
3139
|
+
...accountHeads,
|
|
3140
|
+
u32le(governanceFloor.length),
|
|
3141
|
+
...governanceFloor,
|
|
2773
3142
|
nonce,
|
|
2774
3143
|
notAfter,
|
|
2775
3144
|
signature
|
|
2776
3145
|
)
|
|
2777
3146
|
);
|
|
2778
3147
|
}
|
|
3148
|
+
function citedHeads(heads, label) {
|
|
3149
|
+
const list = heads ?? [];
|
|
3150
|
+
if (list.length > MAX_CITED_HEADS) {
|
|
3151
|
+
throw new Error(
|
|
3152
|
+
`${label} cites ${list.length} heads, over the ${MAX_CITED_HEADS} a node accepts`
|
|
3153
|
+
);
|
|
3154
|
+
}
|
|
3155
|
+
return list.map((head, i) => fromHex(head, `${label}[${i}]`, 32));
|
|
3156
|
+
}
|
|
3157
|
+
|
|
3158
|
+
// src/relay/relay-client.ts
|
|
3159
|
+
var DEFAULT_TTL_SECONDS = 300;
|
|
3160
|
+
var IntentRefusedError = class extends Error {
|
|
3161
|
+
constructor(reason, retryable, status) {
|
|
3162
|
+
super(`relay refused the intent (HTTP ${status}): ${reason}`);
|
|
3163
|
+
this.reason = reason;
|
|
3164
|
+
this.retryable = retryable;
|
|
3165
|
+
this.status = status;
|
|
3166
|
+
this.name = "IntentRefusedError";
|
|
3167
|
+
}
|
|
3168
|
+
};
|
|
3169
|
+
var RelayClient = class {
|
|
3170
|
+
constructor(config) {
|
|
3171
|
+
this.config = config;
|
|
3172
|
+
this.baseUrl = config.relayUrl.replace(/\/+$/, "");
|
|
3173
|
+
this.executorAccount = config.executorAccount;
|
|
3174
|
+
const injected = config.fetch;
|
|
3175
|
+
this.fetchImpl = injected ? (input, init) => injected(input, init) : (input, init) => globalThis.fetch(input, init);
|
|
3176
|
+
this.timeoutMs = config.timeoutMs ?? 1e4;
|
|
3177
|
+
}
|
|
3178
|
+
/**
|
|
3179
|
+
* Ask the relay what it can do in `contextId`.
|
|
3180
|
+
*
|
|
3181
|
+
* Cheap and unauthenticated on a relay that serves delegated execution
|
|
3182
|
+
* publicly, which is what makes it usable as a precondition check rather than
|
|
3183
|
+
* as diagnostics after a failure.
|
|
3184
|
+
*/
|
|
3185
|
+
async describe(contextId) {
|
|
3186
|
+
const body = await this.json("GET", `/admin-api/contexts/${encodeURIComponent(contextId)}/intents`);
|
|
3187
|
+
this.executorAccount = body.data.executorAccount;
|
|
3188
|
+
return body.data;
|
|
3189
|
+
}
|
|
3190
|
+
/**
|
|
3191
|
+
* Mint a warrant for `method(args)` and present it to the relay.
|
|
3192
|
+
*
|
|
3193
|
+
* One intent, once: the warrant authorizes exactly this method and these
|
|
3194
|
+
* arguments in exactly this context, and its nonce is spent by the network on
|
|
3195
|
+
* apply. Nothing accumulates and nothing is reusable.
|
|
3196
|
+
*/
|
|
3197
|
+
async execute(contextId, method, argsJson = {}) {
|
|
3198
|
+
const executor = this.executorAccount ?? (await this.describe(contextId)).executorAccount;
|
|
3199
|
+
const nonce = await this.config.nonces.next();
|
|
3200
|
+
const ttl = this.config.ttlSeconds ?? DEFAULT_TTL_SECONDS;
|
|
3201
|
+
const warrant = await signWarrant({
|
|
3202
|
+
context: contextId,
|
|
3203
|
+
authorAccount: this.config.authorAccount,
|
|
3204
|
+
executor,
|
|
3205
|
+
method,
|
|
3206
|
+
argsJson,
|
|
3207
|
+
nonce,
|
|
3208
|
+
notAfter: BigInt(Math.floor(Date.now() / 1e3)) + BigInt(ttl),
|
|
3209
|
+
deviceSecret: this.config.deviceSecret
|
|
3210
|
+
});
|
|
3211
|
+
const body = await this.json(
|
|
3212
|
+
"POST",
|
|
3213
|
+
`/admin-api/contexts/${encodeURIComponent(contextId)}/intents`,
|
|
3214
|
+
{ method, argsJson, warrant, authorProof: this.config.authorProof }
|
|
3215
|
+
);
|
|
3216
|
+
return { rootHash: body.data.rootHash, returns: body.data.returns ?? null };
|
|
3217
|
+
}
|
|
3218
|
+
async json(method, path, body) {
|
|
3219
|
+
const headers = { Accept: "application/json" };
|
|
3220
|
+
if (body !== void 0) headers["Content-Type"] = "application/json";
|
|
3221
|
+
const controller = new AbortController();
|
|
3222
|
+
const timer = setTimeout(() => controller.abort(), this.timeoutMs);
|
|
3223
|
+
let response;
|
|
3224
|
+
try {
|
|
3225
|
+
response = await this.fetchImpl(`${this.baseUrl}${path}`, {
|
|
3226
|
+
method,
|
|
3227
|
+
headers,
|
|
3228
|
+
body: body === void 0 ? void 0 : JSON.stringify(body),
|
|
3229
|
+
signal: controller.signal
|
|
3230
|
+
});
|
|
3231
|
+
} catch (err) {
|
|
3232
|
+
throw new HTTPError(
|
|
3233
|
+
0,
|
|
3234
|
+
err instanceof Error ? err.name : "NetworkError",
|
|
3235
|
+
`${this.baseUrl}${path}`,
|
|
3236
|
+
new Headers(),
|
|
3237
|
+
err instanceof Error ? err.message : String(err)
|
|
3238
|
+
);
|
|
3239
|
+
} finally {
|
|
3240
|
+
clearTimeout(timer);
|
|
3241
|
+
}
|
|
3242
|
+
if (!response.ok) {
|
|
3243
|
+
const text = await response.text().catch(() => "");
|
|
3244
|
+
if (response.status === 400 || response.status === 403) {
|
|
3245
|
+
const reason = extractReason(text);
|
|
3246
|
+
throw new IntentRefusedError(reason, /nonce/i.test(reason), response.status);
|
|
3247
|
+
}
|
|
3248
|
+
throw new HTTPError(
|
|
3249
|
+
response.status,
|
|
3250
|
+
response.statusText,
|
|
3251
|
+
`${this.baseUrl}${path}`,
|
|
3252
|
+
response.headers,
|
|
3253
|
+
text
|
|
3254
|
+
);
|
|
3255
|
+
}
|
|
3256
|
+
return await response.json();
|
|
3257
|
+
}
|
|
3258
|
+
};
|
|
3259
|
+
function extractReason(bodyText) {
|
|
3260
|
+
if (!bodyText) return "no reason given";
|
|
3261
|
+
try {
|
|
3262
|
+
const parsed = JSON.parse(bodyText);
|
|
3263
|
+
for (const candidate of [parsed.error, parsed.message]) {
|
|
3264
|
+
if (typeof candidate === "string" && candidate) return candidate;
|
|
3265
|
+
}
|
|
3266
|
+
} catch {
|
|
3267
|
+
}
|
|
3268
|
+
return bodyText;
|
|
3269
|
+
}
|
|
3270
|
+
|
|
3271
|
+
// src/relay/nonce-source.ts
|
|
3272
|
+
function createMemoryNonceSource(start = 1) {
|
|
3273
|
+
let next = BigInt(start);
|
|
3274
|
+
if (next < 1n) {
|
|
3275
|
+
throw new Error("nonce sequence starts at 1");
|
|
3276
|
+
}
|
|
3277
|
+
return {
|
|
3278
|
+
next() {
|
|
3279
|
+
const value = next;
|
|
3280
|
+
next += 1n;
|
|
3281
|
+
return Promise.resolve(value);
|
|
3282
|
+
}
|
|
3283
|
+
};
|
|
3284
|
+
}
|
|
3285
|
+
function createLocalStorageNonceSource(key, storage) {
|
|
3286
|
+
const store = storage ?? globalThis.localStorage;
|
|
3287
|
+
if (!store) {
|
|
3288
|
+
throw new Error(
|
|
3289
|
+
"no localStorage in this runtime: pass a storage implementation, or use createMemoryNonceSource for a process that owns its whole sequence"
|
|
3290
|
+
);
|
|
3291
|
+
}
|
|
3292
|
+
return {
|
|
3293
|
+
next() {
|
|
3294
|
+
const raw = store.getItem(key);
|
|
3295
|
+
let current = 0n;
|
|
3296
|
+
if (raw !== null && raw !== "") {
|
|
3297
|
+
try {
|
|
3298
|
+
const parsed = BigInt(raw);
|
|
3299
|
+
if (parsed > 0n) current = parsed;
|
|
3300
|
+
} catch {
|
|
3301
|
+
current = 0n;
|
|
3302
|
+
}
|
|
3303
|
+
}
|
|
3304
|
+
const value = current + 1n;
|
|
3305
|
+
store.setItem(key, value.toString());
|
|
3306
|
+
return Promise.resolve(value);
|
|
3307
|
+
}
|
|
3308
|
+
};
|
|
3309
|
+
}
|
|
3310
|
+
|
|
3311
|
+
// src/cloud/connect.ts
|
|
3312
|
+
async function connectCloud(options) {
|
|
3313
|
+
if (!options.sessionToken && !options.googleIdToken) {
|
|
3314
|
+
throw new Error("connectCloud needs either a sessionToken or a googleIdToken");
|
|
3315
|
+
}
|
|
3316
|
+
const cloud = new CloudClient({
|
|
3317
|
+
cloudBaseUrl: options.cloudBaseUrl,
|
|
3318
|
+
sessionToken: options.sessionToken,
|
|
3319
|
+
onSession: options.onSession,
|
|
3320
|
+
fetch: options.fetch,
|
|
3321
|
+
timeoutMs: options.timeoutMs
|
|
3322
|
+
});
|
|
3323
|
+
if (!options.sessionToken && options.googleIdToken) {
|
|
3324
|
+
await cloud.signInWithGoogle(options.googleIdToken);
|
|
3325
|
+
}
|
|
3326
|
+
const namespaces = await cloud.getMyNamespaces();
|
|
3327
|
+
const namespace = pickNamespace(namespaces, options.namespaceId);
|
|
3328
|
+
const relays = await cloud.getNamespaceRelays(namespace.namespaceId);
|
|
3329
|
+
const relayInfo = relays.find((r) => r.authorshipReady && r.relayUrl !== null && r.executorAccount !== null) ?? null;
|
|
3330
|
+
if (!relayInfo) {
|
|
3331
|
+
throw new Error(explainNoRelay(namespace, relays));
|
|
3332
|
+
}
|
|
3333
|
+
const relay = new RelayClient({
|
|
3334
|
+
// Checked by `findExecutingRelay`, which is the only thing that returns a
|
|
3335
|
+
// relay — narrowed here rather than re-validated, so the invariant lives in
|
|
3336
|
+
// one place.
|
|
3337
|
+
relayUrl: relayInfo.relayUrl,
|
|
3338
|
+
executorAccount: relayInfo.executorAccount,
|
|
3339
|
+
authorAccount: options.authorAccount,
|
|
3340
|
+
authorProof: options.authorProof,
|
|
3341
|
+
deviceSecret: options.deviceSecret,
|
|
3342
|
+
nonces: options.nonces ?? await defaultNonceSource(options.deviceSecret),
|
|
3343
|
+
ttlSeconds: options.ttlSeconds,
|
|
3344
|
+
fetch: options.fetch,
|
|
3345
|
+
timeoutMs: options.timeoutMs
|
|
3346
|
+
});
|
|
3347
|
+
return {
|
|
3348
|
+
cloud,
|
|
3349
|
+
relay,
|
|
3350
|
+
namespaceId: namespace.namespaceId,
|
|
3351
|
+
namespace,
|
|
3352
|
+
relayInfo,
|
|
3353
|
+
execute: (contextId, method, argsJson) => relay.execute(contextId, method, argsJson)
|
|
3354
|
+
};
|
|
3355
|
+
}
|
|
3356
|
+
function pickNamespace(namespaces, requested) {
|
|
3357
|
+
if (requested) {
|
|
3358
|
+
const found = namespaces.find((n) => n.namespaceId === requested);
|
|
3359
|
+
if (!found) {
|
|
3360
|
+
throw new Error(
|
|
3361
|
+
`this account owns no namespace ${requested}; it owns: ${namespaces.map((n) => n.namespaceId).join(", ") || "(none)"}`
|
|
3362
|
+
);
|
|
3363
|
+
}
|
|
3364
|
+
return found;
|
|
3365
|
+
}
|
|
3366
|
+
if (namespaces.length === 0) {
|
|
3367
|
+
throw new Error(
|
|
3368
|
+
"this account owns no namespaces in the cloud: claim one first, then enable HA on it so a relay is assigned"
|
|
3369
|
+
);
|
|
3370
|
+
}
|
|
3371
|
+
if (namespaces.length > 1) {
|
|
3372
|
+
throw new Error(
|
|
3373
|
+
`this account owns ${namespaces.length} namespaces \u2014 pass namespaceId to say which: ${namespaces.map((n) => n.namespaceId).join(", ")}`
|
|
3374
|
+
);
|
|
3375
|
+
}
|
|
3376
|
+
return namespaces[0];
|
|
3377
|
+
}
|
|
3378
|
+
function explainNoRelay(namespace, relays) {
|
|
3379
|
+
if (relays.length === 0) {
|
|
3380
|
+
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`;
|
|
3381
|
+
}
|
|
3382
|
+
const waitingOnGrant = relays.filter((r) => !r.authorshipReady);
|
|
3383
|
+
if (waitingOnGrant.length === relays.length) {
|
|
3384
|
+
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"}`;
|
|
3385
|
+
}
|
|
3386
|
+
return `namespace ${namespace.namespaceId} has ${relays.length} relay(s), but none reported both a URL and an executor account yet \u2014 retry shortly`;
|
|
3387
|
+
}
|
|
3388
|
+
async function defaultNonceSource(deviceSecret) {
|
|
3389
|
+
const key = `mero-js:warrant-nonce:${hex(await derivePublicKey(deviceSecret))}`;
|
|
3390
|
+
try {
|
|
3391
|
+
return createLocalStorageNonceSource(key);
|
|
3392
|
+
} catch {
|
|
3393
|
+
return createMemoryNonceSource(1);
|
|
3394
|
+
}
|
|
3395
|
+
}
|
|
2779
3396
|
|
|
2780
3397
|
// src/device-cert/device-cert.ts
|
|
2781
3398
|
var CERT_DOMAIN = new TextEncoder().encode("calimero.device.cert.v1");
|
|
@@ -2973,7 +3590,19 @@ var CAPABILITIES = {
|
|
|
2973
3590
|
CAN_CREATE_SUBGROUP: 1 << 5,
|
|
2974
3591
|
CAN_DELETE_SUBGROUP: 1 << 6,
|
|
2975
3592
|
CAN_MANAGE_VISIBILITY: 1 << 7,
|
|
2976
|
-
CAN_MANAGE_METADATA: 1 << 8
|
|
3593
|
+
CAN_MANAGE_METADATA: 1 << 8,
|
|
3594
|
+
/**
|
|
3595
|
+
* Publish writes attributed to ANOTHER member, under a warrant that member
|
|
3596
|
+
* signed — the grant delegated execution runs on.
|
|
3597
|
+
*
|
|
3598
|
+
* Implied by nothing: not by membership, not by admin, and not propagated by
|
|
3599
|
+
* the subgroup-admit cascade. So every group is authorship-closed until an
|
|
3600
|
+
* admin sets this bit, and a relay without it is refused at
|
|
3601
|
+
* `performIntent` before anything executes. That is why it is worth naming
|
|
3602
|
+
* here rather than leaving callers to write `1 << 9`: a client granting it
|
|
3603
|
+
* has to be able to say what it is granting.
|
|
3604
|
+
*/
|
|
3605
|
+
CAN_AUTHOR_ON_BEHALF: 1 << 9
|
|
2977
3606
|
};
|
|
2978
3607
|
function hasCap(mask, cap) {
|
|
2979
3608
|
const capU32 = cap >>> 0;
|
|
@@ -3047,9 +3676,11 @@ export {
|
|
|
3047
3676
|
DEFAULT_LOCAL_NODE_PORTS,
|
|
3048
3677
|
EphemeralClient,
|
|
3049
3678
|
HTTPError,
|
|
3679
|
+
IntentRefusedError,
|
|
3050
3680
|
LocalStorageTokenStore,
|
|
3051
3681
|
MemoryTokenStore,
|
|
3052
3682
|
MeroJs,
|
|
3683
|
+
RelayClient,
|
|
3053
3684
|
RpcClient,
|
|
3054
3685
|
RpcError,
|
|
3055
3686
|
SIGNED_NAMESPACE_OP_SCHEMA_VERSION,
|
|
@@ -3060,6 +3691,7 @@ export {
|
|
|
3060
3691
|
buildAuthLoginUrl,
|
|
3061
3692
|
combineSignals,
|
|
3062
3693
|
compareSemver,
|
|
3694
|
+
connectCloud,
|
|
3063
3695
|
createAdminApiClient,
|
|
3064
3696
|
createAdminApiClientFromHttpClient,
|
|
3065
3697
|
createAuthApiClient,
|
|
@@ -3068,6 +3700,8 @@ export {
|
|
|
3068
3700
|
createBrowserAuthApiClient,
|
|
3069
3701
|
createBrowserHttpClient,
|
|
3070
3702
|
createHttpClient,
|
|
3703
|
+
createLocalStorageNonceSource,
|
|
3704
|
+
createMemoryNonceSource,
|
|
3071
3705
|
createMeroJs,
|
|
3072
3706
|
createNodeAdminApiClient,
|
|
3073
3707
|
createNodeAuthApiClient,
|