@calimero-network/mero-js 23.8.2 → 23.9.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.
@@ -0,0 +1,789 @@
1
+ /**
2
+ * The delegated half of connecting: an account, a certified device, and a relay
3
+ * that writes on their behalf.
4
+ *
5
+ * Kept deliberately apart from the node-login path in `MeroContext`. That path
6
+ * is a redirect flow with token adoption, single-use refresh tokens and node-URL
7
+ * trust validation — none of which applies here, because there is no session
8
+ * token at all. A delegated client authenticates every request from a warrant it
9
+ * signs itself. Weaving the two together would put a token-rotation decision in
10
+ * front of a flow that has no tokens.
11
+ *
12
+ * Per-tab on purpose. The record lives in `sessionStorage`, so two tabs can hold
13
+ * two different connections — one a node you run, one a relay you do not — with
14
+ * the same application code in both.
15
+ */
16
+ import { attestRelayNodeKey, } from '../relay-attestation/index.js';
17
+ import { cloudNodeReleaseUrl, createSignedReleaseVerifier, DEFAULT_RELEASE_MIRROR, fetchNodeRelease, fetchNodeReleaseVersion, } from '../sealed/index.js';
18
+ import { createMeroClient, defaultAudience, } from '../transport/index.js';
19
+ import { login, } from '../login/index.js';
20
+ import { SseClient, } from '../events/index.js';
21
+ /** Where the per-tab record lives. */
22
+ const KEY = 'calimero.delegated.connection';
23
+ /**
24
+ * Where the enrolled credential lives when there is no relay for it yet.
25
+ *
26
+ * Separate from {@link KEY}, and deliberately not "a connection with a null
27
+ * relay": a record under that key means a usable client can be built from it,
28
+ * and every reader relies on that. An account that has just enrolled is a
29
+ * member of nothing, so the cloud names no relay for it and there is nothing to
30
+ * build — but the certificate is real and must survive, or the only way to
31
+ * bootstrap from an invitation would be to enrol again.
32
+ */
33
+ const CREDENTIAL_KEY = 'calimero.delegated.credential';
34
+ export function readDelegatedSession() {
35
+ if (typeof sessionStorage === 'undefined')
36
+ return null;
37
+ try {
38
+ const raw = sessionStorage.getItem(KEY);
39
+ return raw ? JSON.parse(raw) : null;
40
+ }
41
+ catch {
42
+ // Private mode, blocked site data, or a half-written record. Treat all
43
+ // three as "not connected" rather than throwing during render.
44
+ return null;
45
+ }
46
+ }
47
+ export function saveDelegatedSession(s) {
48
+ try {
49
+ sessionStorage.setItem(KEY, JSON.stringify(s));
50
+ }
51
+ catch {
52
+ // Non-fatal: the client still works for this page view, it just will not
53
+ // survive a reload. Better than refusing to connect.
54
+ }
55
+ }
56
+ export function clearDelegatedSession() {
57
+ try {
58
+ sessionStorage.removeItem(KEY);
59
+ }
60
+ catch {
61
+ /* nothing to clear if storage is unavailable */
62
+ }
63
+ }
64
+ /**
65
+ * The enrolled credential, whether or not a relay was ever found for it.
66
+ *
67
+ * A connected session is authoritative: it carries the same three fields and a
68
+ * relay, so it is read first and the standalone record is the fallback. Without
69
+ * that order a stale record from an earlier enrolment could out-rank the account
70
+ * actually connected.
71
+ */
72
+ export function readDelegatedCredential() {
73
+ const session = readDelegatedSession();
74
+ if (session) {
75
+ const { account, credential, deviceSecret } = session;
76
+ return { account, credential, deviceSecret };
77
+ }
78
+ if (typeof sessionStorage === 'undefined')
79
+ return null;
80
+ try {
81
+ const raw = sessionStorage.getItem(CREDENTIAL_KEY);
82
+ if (!raw)
83
+ return null;
84
+ const parsed = JSON.parse(raw);
85
+ // A half-written record is worse than none: it would be carried into a
86
+ // signing call and fail as a malformed credential, somewhere that cannot say
87
+ // where the bad value came from.
88
+ if (!parsed.account || !parsed.credential || !parsed.deviceSecret)
89
+ return null;
90
+ return parsed;
91
+ }
92
+ catch {
93
+ return null;
94
+ }
95
+ }
96
+ export function saveDelegatedCredential(c) {
97
+ try {
98
+ sessionStorage.setItem(CREDENTIAL_KEY, JSON.stringify(c));
99
+ }
100
+ catch {
101
+ /* this page view still holds it; it just will not survive a reload */
102
+ }
103
+ }
104
+ export function clearDelegatedCredential() {
105
+ try {
106
+ sessionStorage.removeItem(CREDENTIAL_KEY);
107
+ }
108
+ catch {
109
+ /* nothing to clear if storage is unavailable */
110
+ }
111
+ }
112
+ /**
113
+ * A warrant nonce is spent on the node, so the counter must outlive the page.
114
+ *
115
+ * In `localStorage`, not session: a reload that restarted from zero would
116
+ * replay a spent nonce and be refused. Keyed by relay and context because the
117
+ * ledger the node keeps is per `(context, author device)`.
118
+ *
119
+ * This is the client-side half of core #4018. The node can answer "where does
120
+ * my sequence stand" — `POST /contexts/{id}/warrant-nonce` with the caller's own
121
+ * `authorProof` — but that route is mounted on the protected router, so a
122
+ * delegated client on a relay cannot reach it. Until it moves, holding the
123
+ * counter locally is the only option, and losing storage means burning refusals
124
+ * until the counter catches up.
125
+ */
126
+ export function persistedNonces(relayUrl) {
127
+ // ONE sequence per relay, not one per context. A client is rebuilt whenever
128
+ // the chosen context changes, and every client calls `execute` for any
129
+ // context — so a per-context counter handed the same (context, device) ledger
130
+ // two sequences, and whichever lagged was refused as a replay. The ledger is a
131
+ // window that accepts any unseen nonce above its floor, so a single rising
132
+ // sequence shared by every context only ever skips, which is free.
133
+ const key = nonceKey(relayUrl);
134
+ return {
135
+ next: async () => {
136
+ let n = 0n;
137
+ try {
138
+ n = readNonceFloor(relayUrl);
139
+ }
140
+ catch {
141
+ n = 0n;
142
+ }
143
+ try {
144
+ localStorage.setItem(key, String(n + 1n));
145
+ }
146
+ catch {
147
+ /* unpersisted: this page view still advances in memory */
148
+ }
149
+ return n;
150
+ },
151
+ };
152
+ }
153
+ function nonceKey(relayUrl) {
154
+ return `calimero.nonce.${relayUrl}`;
155
+ }
156
+ /**
157
+ * Where this relay's sequence stands: its own counter, or — the first time,
158
+ * before one exists — the highest of the per-context counters an earlier build
159
+ * kept, so moving to one sequence replays nothing.
160
+ */
161
+ function readNonceFloor(relayUrl) {
162
+ const own = localStorage.getItem(nonceKey(relayUrl));
163
+ if (own !== null)
164
+ return BigInt(own);
165
+ const legacy = `${nonceKey(relayUrl)}.`;
166
+ let max = 0n;
167
+ for (let i = 0; i < localStorage.length; i++) {
168
+ const k = localStorage.key(i);
169
+ if (!k?.startsWith(legacy))
170
+ continue;
171
+ const v = BigInt(localStorage.getItem(k) ?? '0');
172
+ if (v > max)
173
+ max = v;
174
+ }
175
+ return max;
176
+ }
177
+ /**
178
+ * Record that `nonce` is spent for this device, so later intents start above
179
+ * it. A creation warrant's nonce is spent in the NEW context's own window; one
180
+ * sequence per relay covers that context like any other.
181
+ */
182
+ export function markContextNonceSpent(relayUrl, _contextId, nonce) {
183
+ try {
184
+ if (readNonceFloor(relayUrl) <= nonce)
185
+ localStorage.setItem(nonceKey(relayUrl), String(nonce + 1n));
186
+ }
187
+ catch {
188
+ /* unpersisted: the first write meets a spent nonce once, and retries above it */
189
+ }
190
+ }
191
+ const RELAY_NODE_KEY_PREFIX = 'calimero.delegated.relay-node-key.';
192
+ function relayOrigin(relayUrl) {
193
+ return relayUrl.replace(/\/+$/, '');
194
+ }
195
+ /**
196
+ * Pin a relay's device signing key, hex (32 bytes), learned out of band.
197
+ *
198
+ * `login()` binds the session to this key, and it must never be read from the
199
+ * relay being logged in to — the answering party would choose what the device
200
+ * signs about. Until the cloud publishes it (mdma#312), it comes from the
201
+ * relay's operator.
202
+ */
203
+ export function pinRelayNodeKey(relayUrl, nodeKey) {
204
+ localStorage.setItem(RELAY_NODE_KEY_PREFIX + relayOrigin(relayUrl), nodeKey.trim().toLowerCase());
205
+ }
206
+ /** The pinned key for a relay, or `null` if none was pinned. */
207
+ export function readPinnedRelayNodeKey(relayUrl) {
208
+ try {
209
+ const v = localStorage.getItem(RELAY_NODE_KEY_PREFIX + relayOrigin(relayUrl));
210
+ return v && /^[0-9a-f]{64}$/.test(v) ? v : null;
211
+ }
212
+ catch {
213
+ return null;
214
+ }
215
+ }
216
+ function isLoopback(relayUrl) {
217
+ try {
218
+ const { hostname } = new URL(relayUrl);
219
+ return hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '[::1]';
220
+ }
221
+ catch {
222
+ return false;
223
+ }
224
+ }
225
+ /**
226
+ * The relay's node key: the pinned one, or else learned from the relay's TEE
227
+ * attestation (see mero-js `attestRelayNodeKey`) and pinned for next time.
228
+ *
229
+ * A mock quote proves nothing about hardware, so one is accepted only from a
230
+ * loopback relay — a local rig. A real quote needs signature and measurement
231
+ * verification this client does not do yet, so a real relay still needs its
232
+ * key pinned; `null` then, and admin reads and events stay off.
233
+ */
234
+ /** The one relay image trusted: no shell, so nobody reads the TD. */
235
+ const TRUSTED_RELAY_PROFILE = 'locked-read-only';
236
+ /**
237
+ * The oldest relay release trusted: the first that serves delegated context
238
+ * creation and governance at the namespace-op schema this client signs.
239
+ */
240
+ const MIN_RELAY_RELEASE = '2.3.99';
241
+ let loadedDcap;
242
+ /**
243
+ * `verify` out of a dynamically imported `@phala/dcap-qvl`, whatever shape the
244
+ * bundler gave the module.
245
+ *
246
+ * The package is CommonJS (`module.exports = { verify, … }`). Node and Rollup
247
+ * expose its keys as named exports; a browser pre-bundle — Vite's dev optimizer
248
+ * among them — hands back a namespace whose only export is `default`. Reading
249
+ * only the named export there yields `undefined`, the quote check throws, no
250
+ * relay key is learned, and every admin read on a hosted relay goes out with no
251
+ * credential.
252
+ */
253
+ function dcapVerifyFrom(mod) {
254
+ const ns = mod;
255
+ const verify = typeof ns.verify === 'function' ? ns.verify : ns.default?.verify;
256
+ if (typeof verify !== 'function') {
257
+ throw new TypeError('@phala/dcap-qvl loaded without a verify function');
258
+ }
259
+ return verify;
260
+ }
261
+ /**
262
+ * Thrown by every step of learning a relay's key that only moves bytes: reading
263
+ * the release the relay names, fetching that release, loading the quote
264
+ * library, reaching the relay for its quote. Such a step failing says nothing
265
+ * about the relay's image, so the attempt is worth making again.
266
+ *
267
+ * Anything else thrown while learning the key is the verification itself
268
+ * saying no, and that is final.
269
+ */
270
+ class RelayKeyTransportError extends Error {
271
+ constructor(step, cause) {
272
+ super(`${step}: ${cause instanceof Error ? cause.message : String(cause)}`);
273
+ this.step = step;
274
+ this.cause = cause;
275
+ this.name = 'RelayKeyTransportError';
276
+ }
277
+ }
278
+ /** `step` run so that whatever it throws is a transport failure. */
279
+ async function transport(step, run) {
280
+ try {
281
+ return await run();
282
+ }
283
+ catch (e) {
284
+ throw e instanceof RelayKeyTransportError ? e : new RelayKeyTransportError(step, e);
285
+ }
286
+ }
287
+ /**
288
+ * `fetch` for the relay's own endpoints, failing as transport on no answer or
289
+ * a non-2xx one. The response is still handed back on a non-2xx status — the
290
+ * caller's own reading of it stays what it was — but the attempt is marked, so
291
+ * whatever the caller then throws about it is read as transport too.
292
+ */
293
+ function relayFetch(marked) {
294
+ return async (input, init) => {
295
+ let response;
296
+ try {
297
+ response = await globalThis.fetch(input, init);
298
+ }
299
+ catch (e) {
300
+ marked.failure = e;
301
+ throw new RelayKeyTransportError(`reaching ${String(input)}`, e);
302
+ }
303
+ if (!response.ok)
304
+ marked.failure ?? (marked.failure = new Error(`${String(input)} answered HTTP ${response.status}`));
305
+ return response;
306
+ };
307
+ }
308
+ /**
309
+ * A verifier for a hosted relay's quote: the signed mero-tee release it says it
310
+ * runs (fetched from the public mirror, trusted only for its signature), Intel's
311
+ * chain, and all five registers of that release's image.
312
+ *
313
+ * The cloud's mirror is the only source a browser can read. The same two files
314
+ * are assets of the mero-tee GitHub release, but GitHub serves release assets
315
+ * from `release-assets.githubusercontent.com` with no CORS header (and
316
+ * `github.com/.../releases/download` redirects there with none either), so a
317
+ * page cannot read them. A mirror that does not answer is therefore waited out
318
+ * by the caller ({@link learnRelayNodeKey}) rather than routed around.
319
+ *
320
+ * `@phala/dcap-qvl` is loaded on the first quote it checks: it is most of the
321
+ * weight, and an app that never meets a real relay never needs it. A failed
322
+ * download is forgotten, so the next attempt tries again.
323
+ */
324
+ function relayQuoteVerifier(relayUrl, fetchRelay) {
325
+ const verify = async (attestation) => {
326
+ const dcapVerify = await transport('loading the quote verifier', () => {
327
+ loadedDcap ?? (loadedDcap = import('@phala/dcap-qvl').then((mod) => dcapVerifyFrom(mod), (error) => {
328
+ loadedDcap = undefined;
329
+ throw error;
330
+ }));
331
+ return loadedDcap;
332
+ });
333
+ return createSignedReleaseVerifier({
334
+ dcapVerify,
335
+ // Fetched only. Its signature, its version and the quote's registers are
336
+ // all checked by the verifier after this returns, and none of that is a
337
+ // transport question.
338
+ release: () => transport('fetching the release the relay runs', async () => fetchNodeRelease(cloudNodeReleaseUrl(DEFAULT_RELEASE_MIRROR, await fetchNodeReleaseVersion(relayUrl, { fetch: fetchRelay })))),
339
+ profile: TRUSTED_RELAY_PROFILE,
340
+ minReleaseVersion: MIN_RELAY_RELEASE,
341
+ })(attestation);
342
+ };
343
+ return Object.assign(verify, { includeCollateral: true });
344
+ }
345
+ export async function attemptRelayNodeKey(relayUrl) {
346
+ const pinned = readPinnedRelayNodeKey(relayUrl);
347
+ if (pinned)
348
+ return { kind: 'learned', nodeKey: pinned };
349
+ const marked = { failure: null };
350
+ const fetchRelay = relayFetch(marked);
351
+ try {
352
+ // A loopback relay is a dev rig answering with mock quotes; anything else
353
+ // must prove its image.
354
+ const local = isLoopback(relayUrl);
355
+ const { nodeKey } = await attestRelayNodeKey({
356
+ relayUrl,
357
+ allowMock: local,
358
+ fetch: fetchRelay,
359
+ ...(local ? {} : { verify: relayQuoteVerifier(relayUrl, fetchRelay) }),
360
+ });
361
+ pinRelayNodeKey(relayUrl, nodeKey);
362
+ return { kind: 'learned', nodeKey };
363
+ }
364
+ catch (e) {
365
+ if (e instanceof RelayKeyTransportError || marked.failure)
366
+ return { kind: 'unavailable', error: e };
367
+ return { kind: 'refused', error: e };
368
+ }
369
+ }
370
+ /**
371
+ * The relay's node key: the pinned one, or else learned from the relay's TEE
372
+ * attestation in one attempt; `null` when that attempt did not learn it, for
373
+ * whatever reason. Callers that must end up with the key use
374
+ * {@link learnRelayNodeKey}.
375
+ */
376
+ export async function resolveRelayNodeKey(relayUrl) {
377
+ const attempt = await attemptRelayNodeKey(relayUrl);
378
+ if (attempt.kind === 'learned')
379
+ return attempt.nodeKey;
380
+ console.warn('[mero-js] could not learn the relay node key from its attestation', attempt.error);
381
+ return null;
382
+ }
383
+ /**
384
+ * How long to wait before each retry of an unavailable relay key: quickly at
385
+ * first, for a blip or a cold cache, then every 30 s for as long as it takes.
386
+ */
387
+ export const RELAY_NODE_KEY_RETRY_MS = [1000, 2000, 5000, 10000, 30000];
388
+ function pause(ms, signal) {
389
+ return new Promise((resolve) => {
390
+ if (signal?.aborted)
391
+ return resolve();
392
+ const timer = setTimeout(done, ms);
393
+ function done() {
394
+ clearTimeout(timer);
395
+ signal?.removeEventListener('abort', done);
396
+ resolve();
397
+ }
398
+ signal?.addEventListener('abort', done);
399
+ });
400
+ }
401
+ /**
402
+ * The relay's node key, however long the way to it is down.
403
+ *
404
+ * One failed fetch of the release used to cost the whole session: no key, so no
405
+ * relay session, so every admin read and every event stream unauthenticated
406
+ * until a reload. A transport failure here is now waited out with backoff
407
+ * ({@link RELAY_NODE_KEY_RETRY_MS}) until the key is learned or `signal` aborts.
408
+ *
409
+ * A refusal ends it at once with `null`. A quote that did not verify is an
410
+ * answer, and asking again is not how it becomes a different one.
411
+ */
412
+ export async function learnRelayNodeKey(relayUrl, options = {}) {
413
+ const { signal } = options;
414
+ for (let failures = 0; !signal?.aborted; failures += 1) {
415
+ const attempt = await attemptRelayNodeKey(relayUrl);
416
+ if (attempt.kind === 'learned')
417
+ return attempt.nodeKey;
418
+ if (attempt.kind === 'refused') {
419
+ console.warn("[mero-js] refusing the relay's attestation; its node key stays unknown", attempt.error);
420
+ return null;
421
+ }
422
+ const wait = RELAY_NODE_KEY_RETRY_MS[Math.min(failures, RELAY_NODE_KEY_RETRY_MS.length - 1)];
423
+ console.warn(`[mero-js] could not reach what proves the relay's node key; retrying in ${wait / 1000} s`, attempt.error);
424
+ await pause(wait, signal);
425
+ }
426
+ return null;
427
+ }
428
+ /** Milliseconds-since-epoch a JWT expires at, or `null` if unreadable. */
429
+ function jwtExpiryMs(token) {
430
+ try {
431
+ const payload = JSON.parse(atob(token.split('.')[1].replace(/-/g, '+').replace(/_/g, '/')));
432
+ return typeof payload.exp === 'number' ? payload.exp * 1000 : null;
433
+ }
434
+ catch {
435
+ return null;
436
+ }
437
+ }
438
+ /**
439
+ * A lazily minted `account_proof` session on the relay.
440
+ *
441
+ * A hosted relay's `/admin-api/` sits behind the node's forward-auth, which
442
+ * knows no request proof — it answers a Bearer token or 401. Logging in with
443
+ * the device certificate is how a keyholder gets that token. Minted on first
444
+ * use and re-minted once it has expired. On failure it yields `undefined`, the
445
+ * admin request then goes out unauthenticated and the node's 401 is what the
446
+ * caller sees; the next call tries to log in again.
447
+ */
448
+ function relaySession(s, nodeKey) {
449
+ let token = null;
450
+ let inflight = null;
451
+ return async () => {
452
+ const exp = token ? jwtExpiryMs(token) : null;
453
+ if (token && (exp === null || exp - Date.now() > 30000))
454
+ return token;
455
+ if (!inflight) {
456
+ inflight = login({
457
+ nodeUrl: s.relayUrl,
458
+ node: nodeKey,
459
+ deviceSecret: s.deviceSecret,
460
+ accountProof: s.credential,
461
+ audience: defaultAudience(),
462
+ })
463
+ .then((minted) => {
464
+ token = minted.accessToken;
465
+ return token;
466
+ })
467
+ .catch((e) => {
468
+ console.warn('[mero-js] relay login failed', e);
469
+ token = null;
470
+ return undefined;
471
+ })
472
+ .finally(() => {
473
+ inflight = null;
474
+ });
475
+ }
476
+ return inflight;
477
+ };
478
+ }
479
+ /** What the node said each `relay|context|method` is; survives client rebuilds. */
480
+ const methodKinds = new Map();
481
+ /** Forget what was learned about which methods read. For tests. */
482
+ export function forgetMethodKinds() {
483
+ methodKinds.clear();
484
+ }
485
+ /** Core's refusal of a query for a method the ABI does not declare read-only. */
486
+ function isNotAView(e) {
487
+ return typeof e === 'object' && e !== null && e.status === 409;
488
+ }
489
+ /**
490
+ * `client.rpc` with reads routed through `client.admin.queryContext`: the
491
+ * session-authenticated query for a method the node knows as a view, the
492
+ * warrant for everything else. Only for a client with a session, since the
493
+ * query route wants one.
494
+ */
495
+ function readsThroughQuery(client, relayUrl) {
496
+ const inner = client.rpc;
497
+ const origin = relayOrigin(relayUrl);
498
+ const run = async (params) => {
499
+ const key = `${origin}|${params.contextId}|${params.method}`;
500
+ if (methodKinds.get(key) !== 'write') {
501
+ try {
502
+ const { returns } = await client.admin.queryContext(params.contextId, {
503
+ method: params.method,
504
+ // The node defaults a warrant's missing `argsJson` to `{}`; the same here.
505
+ argsJson: params.argsJson ?? {},
506
+ });
507
+ methodKinds.set(key, 'read');
508
+ // No root hash: a read changes nothing, and `undefined` is "unknown
509
+ // here", never "nothing changed" (see mero-js `ExecuteResult`).
510
+ return { returns: returns, transport: 'relay' };
511
+ }
512
+ catch (e) {
513
+ if (isNotAView(e))
514
+ methodKinds.set(key, 'write');
515
+ }
516
+ }
517
+ return inner.executeWithMetadata(params);
518
+ };
519
+ return {
520
+ get kind() {
521
+ return inner.kind;
522
+ },
523
+ get canSubscribe() {
524
+ return inner.canSubscribe;
525
+ },
526
+ execute: async (params) => (await run(params)).returns,
527
+ executeWithMetadata: run,
528
+ migrateMyEntries: (contextId) => inner.migrateMyEntries(contextId),
529
+ countMyPending: (contextId) => inner.countMyPending(contextId),
530
+ };
531
+ }
532
+ /**
533
+ * An event stream that opens nothing: for a hosted relay whose node key is not
534
+ * attested yet.
535
+ *
536
+ * A hosted relay's `/sse` sits behind forward-auth that knows a Bearer token
537
+ * and nothing else, so the proof-authenticated stream mero-js builds without a
538
+ * node key is answered 401 `missing_token` — not one of the terminal auth
539
+ * errors, so `SseClient` reconnected on a timer for as long as the key stayed
540
+ * unknown. The hooks take `client.events` and connect without asking, so the
541
+ * stream handed out meanwhile is a real `SseClient` whose connect waits for a
542
+ * close instead of fetching: it holds its subscriptions, emits no error, and
543
+ * reaches the network never. The client built once the key is learned brings
544
+ * the session-backed stream, and the hooks' effects move onto it.
545
+ */
546
+ function dormantEvents(relayUrl) {
547
+ return new SseClient({
548
+ baseUrl: relayUrl,
549
+ authorize: async () => ({}),
550
+ fetch: (_url, init) => new Promise((_, reject) => {
551
+ const abort = () => reject(Object.assign(new Error('the stream was closed before the relay was attested'), { name: 'AbortError' }));
552
+ if (init?.signal?.aborted)
553
+ return abort();
554
+ init?.signal?.addEventListener('abort', abort, { once: true });
555
+ }),
556
+ });
557
+ }
558
+ /**
559
+ * The relay client as a delegated session uses it: `rpc` reading by query when
560
+ * the client has a session, and no events until the relay is attested.
561
+ * Everything else is the client's own, `this` kept.
562
+ */
563
+ function delegatedClient(client, opts) {
564
+ const rpc = opts.session ? readsThroughQuery(client, opts.relayUrl) : null;
565
+ let dormant = null;
566
+ return new Proxy(client, {
567
+ get(target, prop) {
568
+ if (prop === 'rpc' && rpc)
569
+ return rpc;
570
+ if (!opts.attested) {
571
+ if (prop === 'canSubscribe')
572
+ return false;
573
+ if (prop === 'events')
574
+ return (dormant ?? (dormant = dormantEvents(opts.relayUrl)));
575
+ if (prop === 'close') {
576
+ return () => {
577
+ dormant?.close();
578
+ target.close();
579
+ };
580
+ }
581
+ }
582
+ const value = Reflect.get(target, prop, target);
583
+ return typeof value === 'function' ? value.bind(target) : value;
584
+ },
585
+ });
586
+ }
587
+ /**
588
+ * Build the client a delegated session talks through.
589
+ *
590
+ * The result is a relay-transport `MeroClient`. Its `rpc.execute` takes the same
591
+ * named arguments a node client's does, which is what lets a generated ABI
592
+ * client — and every hook that only calls `execute` — work unchanged. What it
593
+ * also has is `admin` and `events`, pointed at the relay and authenticated as
594
+ * the account by the device certificate, so the caller-scoped reads answer this
595
+ * account's own contexts and namespaces rather than the node's.
596
+ */
597
+ export function buildDelegatedClient(s,
598
+ // Kept for callers; the client no longer depends on the chosen context.
599
+ _contextId) {
600
+ // No relay, no client — and deliberately no placeholder either.
601
+ //
602
+ // A relay-transport client IS a URL plus a credential; with no URL there is
603
+ // nothing to post an intent to. `null` propagates as "not connected for
604
+ // writing" while the session stays authenticated, which is the honest shape
605
+ // for a brand-new account: it exists, it has a certificate, and it has not
606
+ // been invited anywhere yet. A stand-in URL would make every write fail as a
607
+ // refused warrant against a node that was never chosen.
608
+ if (!s.relayUrl)
609
+ return null;
610
+ const nodeKey = readPinnedRelayNodeKey(s.relayUrl);
611
+ const client = createMeroClient({
612
+ transport: 'relay',
613
+ relay: {
614
+ relayUrl: s.relayUrl,
615
+ authorAccount: s.account,
616
+ authorProof: s.credential,
617
+ deviceSecret: s.deviceSecret,
618
+ // One sequence per relay, whatever context is chosen: `execute` takes a
619
+ // context per call, so the nonce source must not depend on this one.
620
+ nonces: persistedNonces(s.relayUrl),
621
+ },
622
+ // The certificate authenticates the requests too, not just the writes.
623
+ //
624
+ // This is the whole of what reads and events need: the device key signs each
625
+ // request, and the node verifies it against the account's certificate with no
626
+ // store read and no prior relationship. Measured against a node built from
627
+ // core `aecba573b`: `listNamespaces` and `getContexts` answer `200` and are
628
+ // caller-scoped, `/sse` answers `200`, and a real write arrives on the stream
629
+ // as a `StateMutation` frame.
630
+ //
631
+ // What this replaces is a session minted by `login()` plus the relay node's
632
+ // signing key learned out of band — two inputs, one of which the cloud does
633
+ // not publish, for an answer the certificate already gives.
634
+ proof: { credential: s.credential, deviceSecret: s.deviceSecret },
635
+ // A session, when this relay's node key is pinned. A hosted relay's
636
+ // forward-auth accepts a Bearer token and not the proof above, so without
637
+ // one every admin read there is a 401. Given a session the client uses it
638
+ // for every admin call; a relay with no pinned key keeps the proof path.
639
+ session: nodeKey ? relaySession({ ...s, relayUrl: s.relayUrl }, nodeKey) : undefined,
640
+ // Events too. `/sse` on a relay sits behind the same forward-auth, so the
641
+ // proof above gets a 401 there; with the node key the client observes
642
+ // through its own device-certificate login instead.
643
+ observe: nodeKey ? { nodeKey } : undefined,
644
+ });
645
+ // Events wait for the relay's node key on a hosted relay, whose forward-auth
646
+ // answers the proof-only stream 401 and would have it reconnect forever. A
647
+ // loopback relay is a dev rig that takes the proof as a node does.
648
+ return delegatedClient(client, {
649
+ relayUrl: s.relayUrl,
650
+ session: nodeKey !== null,
651
+ attested: nodeKey !== null || isLoopback(s.relayUrl),
652
+ });
653
+ }
654
+ // ------------------------------------------------------------------ relay map
655
+ //
656
+ // An account's namespaces may be served by different relays. A relay admits
657
+ // the account to a namespace and then serves that namespace's contexts, so the
658
+ // client keeps, per account, which relay serves which namespace and context:
659
+ //
660
+ // namespaces: { <namespace id>: <relay url> } — learned when a join succeeds
661
+ // contexts: { <context id>: <relay url> } — learned from each relay's
662
+ // caller-scoped listing
663
+ //
664
+ // In localStorage, beside the pinned node keys: URLs and ids only, nothing
665
+ // secret, and it has to survive a reload for the open context to find its
666
+ // relay. Losing it costs a re-listing, not access.
667
+ const RELAY_MAP_PREFIX = 'calimero.delegated.relays.';
668
+ export function readRelayMap(account) {
669
+ try {
670
+ const raw = localStorage.getItem(RELAY_MAP_PREFIX + account);
671
+ const parsed = raw ? JSON.parse(raw) : {};
672
+ return { namespaces: parsed.namespaces ?? {}, contexts: parsed.contexts ?? {} };
673
+ }
674
+ catch {
675
+ return { namespaces: {}, contexts: {} };
676
+ }
677
+ }
678
+ function writeRelayMap(account, map) {
679
+ try {
680
+ localStorage.setItem(RELAY_MAP_PREFIX + account, JSON.stringify(map));
681
+ }
682
+ catch {
683
+ /* unpersisted: this page view still routes from what it lists */
684
+ }
685
+ }
686
+ /** Record the relay that admitted this account to a namespace (and, when known, the context it came for). */
687
+ export function rememberRelay(account, relayUrl, at) {
688
+ const map = readRelayMap(account);
689
+ const url = relayOrigin(relayUrl);
690
+ if (at.namespaceId)
691
+ map.namespaces[at.namespaceId] = url;
692
+ if (at.contextId)
693
+ map.contexts[at.contextId] = url;
694
+ writeRelayMap(account, map);
695
+ }
696
+ /** The relay that serves a context, if this account has learned it. */
697
+ export function relayForContext(account, contextId) {
698
+ return readRelayMap(account).contexts[contextId] ?? null;
699
+ }
700
+ /**
701
+ * A session moving to `next`, keeping the relay's executor account the previous
702
+ * one knew when both are the same relay.
703
+ *
704
+ * A join hands back a session built from the admitting node alone, which has no
705
+ * executor on it. When that node is the relay the account was already on — the
706
+ * one the cloud assigned it, say — dropping the executor would cost nothing
707
+ * today (the joined namespace can answer for it) but leaves the session knowing
708
+ * less than it did. On a different relay the old executor is the wrong account,
709
+ * so it is left behind.
710
+ */
711
+ export function carryExecutorAccount(prev, next) {
712
+ if (next.executorAccount || !prev?.executorAccount || !prev.relayUrl || !next.relayUrl)
713
+ return next;
714
+ if (relayOrigin(prev.relayUrl) !== relayOrigin(next.relayUrl))
715
+ return next;
716
+ return { ...next, executorAccount: prev.executorAccount };
717
+ }
718
+ /** Every relay this account is known to use: the session's, and each in the map. */
719
+ export function knownRelays(s) {
720
+ const map = readRelayMap(s.account);
721
+ const all = [s.relayUrl, ...Object.values(map.namespaces), ...Object.values(map.contexts)]
722
+ .filter((u) => typeof u === 'string' && u.length > 0)
723
+ .map(relayOrigin);
724
+ return [...new Set(all)];
725
+ }
726
+ /**
727
+ * This account's contexts on every relay it knows, each tagged with the relay
728
+ * that listed it — and that relay recorded as the one serving it.
729
+ *
730
+ * Each relay answers only for itself and only for this caller, so the union is
731
+ * exactly the account's contexts across relays. A relay that cannot be reached
732
+ * or logged in to is skipped (its contexts simply do not appear), not fatal.
733
+ */
734
+ export async function listDelegatedContexts(s) {
735
+ const out = [];
736
+ const map = readRelayMap(s.account);
737
+ for (const relayUrl of knownRelays(s)) {
738
+ try {
739
+ if (!(await resolveRelayNodeKey(relayUrl)))
740
+ continue;
741
+ const client = buildDelegatedClient({ ...s, relayUrl }, null);
742
+ if (!client)
743
+ continue;
744
+ const { contexts } = await client.admin.getContexts();
745
+ for (const c of contexts ?? []) {
746
+ out.push({ ...c, relayUrl });
747
+ map.contexts[c.id] = relayUrl;
748
+ }
749
+ }
750
+ catch (e) {
751
+ console.warn(`[mero-js] could not list contexts on ${relayUrl}`, e);
752
+ }
753
+ }
754
+ writeRelayMap(s.account, map);
755
+ return out;
756
+ }
757
+ /**
758
+ * This account's namespaces on every relay it knows, each tagged with the relay
759
+ * that listed it and recorded as that namespace's relay. Same rules as
760
+ * {@link listDelegatedContexts}: each relay answers for itself and this caller
761
+ * only (`namespace:list-own`); an unreachable relay is skipped.
762
+ */
763
+ export async function listDelegatedNamespaces(s) {
764
+ var _a, _b;
765
+ const out = [];
766
+ const seen = new Set();
767
+ const map = readRelayMap(s.account);
768
+ for (const relayUrl of knownRelays(s)) {
769
+ try {
770
+ if (!(await resolveRelayNodeKey(relayUrl)))
771
+ continue;
772
+ const client = buildDelegatedClient({ ...s, relayUrl }, null);
773
+ if (!client)
774
+ continue;
775
+ for (const ns of await client.admin.listNamespaces()) {
776
+ if (seen.has(ns.namespaceId))
777
+ continue;
778
+ seen.add(ns.namespaceId);
779
+ out.push({ ...ns, relayUrl });
780
+ (_a = map.namespaces)[_b = ns.namespaceId] ?? (_a[_b] = relayUrl);
781
+ }
782
+ }
783
+ catch (e) {
784
+ console.warn(`[mero-js] could not list namespaces on ${relayUrl}`, e);
785
+ }
786
+ }
787
+ writeRelayMap(s.account, map);
788
+ return out;
789
+ }