@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
|
@@ -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.
|
|
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
|
+
}
|
package/dist/cloud/index.d.ts
CHANGED
|
@@ -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';
|
package/dist/cloud/index.js
CHANGED