@calimero-network/mero-js 23.8.2 → 24.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.
@@ -0,0 +1,216 @@
1
+ /**
2
+ * Resolving a relay from an INVITATION rather than from an account.
3
+ *
4
+ * # The gap this closes
5
+ *
6
+ * `ConnectButtonAccount` finds a relay by asking the cloud what serves the
7
+ * *account* (`CloudClient.getAccountRelays`). A freshly enrolled account is a
8
+ * member of nothing, so that read correctly answers `[]` and the button
9
+ * correctly refuses to connect: there is nowhere to write, and a typed guess
10
+ * would be a node that refuses every warrant.
11
+ *
12
+ * But a newcomer is not stuck — they are holding an invitation, and an
13
+ * invitation names a namespace. `CloudClient.getNamespaceRouting` answers which
14
+ * nodes serve *that namespace*, and it is deliberately anonymous: a joiner has
15
+ * no cloud session, and needing one is exactly what this path exists to avoid.
16
+ * So the bootstrap order is namespace first, account second — admission is what
17
+ * makes `getAccountRelays` answer at all, not the other way round.
18
+ *
19
+ * # Two sources, one intersection, and why it must not be skipped
20
+ *
21
+ * The invitation's `admitters` list sits INSIDE the body the group admin
22
+ * signed, so it is authorization: merod answers `403` to an `/admit` from a node
23
+ * that list does not name, whatever else is true of it. The cloud's list is
24
+ * every node currently assigned to the namespace — it cannot know which
25
+ * invitation the caller holds, so it cannot filter by the signed list, and a
26
+ * node assigned *after* the invitation was minted looks perfect in every field
27
+ * the cloud reports and still refuses the claim.
28
+ *
29
+ * Intersecting here is therefore not belt-and-braces: it is the check that
30
+ * turns an unexplainable `403` into "your invitation names none of these nodes,
31
+ * ask for a fresh one". It is never relaxed to make a flow work.
32
+ *
33
+ * # `servable` and `canAdmit` are advisory; `writable` is information
34
+ *
35
+ * `servable` and `canAdmit` are derived from assignment heartbeats, and a lapsed
36
+ * heartbeat is not the same fact as a node that will refuse. Measured against
37
+ * prod: the cloud reported `servable: false, canAdmit: false, fresh: false` for a
38
+ * node whose `/admin-api/health` answered `{"status":"alive"}` and which had just
39
+ * minted the invitation being claimed. Gating on them meant refusing to knock on
40
+ * an open door, so a node with an address is tried and the NODE decides —
41
+ * `admit_join` re-verifies the invitation, the signature and the admitters list,
42
+ * so asking loosens nothing and a refusal names its own cause. `stale` on the
43
+ * result says the attempt rests on advisory data.
44
+ *
45
+ * `writable` — and its per-node form, `canExecute` — is about `CAN_AUTHOR_ON_BEHALF`,
46
+ * which admission does **not** require: relaying a join the joiner signed is not
47
+ * authoring on anyone's behalf. So a non-writable namespace is not a refusal
48
+ * here; it is reported on the result, because it is precisely why the writes
49
+ * would fail *after* connecting, and a caller that cannot tell the two apart
50
+ * sends the user to the wrong place.
51
+ */
52
+ import { CloudClient, } from '../cloud/index.js';
53
+ /**
54
+ * Normalise a hex account for comparison: lower-cased, `0x` stripped.
55
+ *
56
+ * Both sides are hex for the same 32 bytes, and a spelling mismatch would
57
+ * present as "your invitation does not name this node" — a refusal that looks
58
+ * like policy and is really formatting.
59
+ */
60
+ export function normaliseAccount(account) {
61
+ if (!account)
62
+ return null;
63
+ const trimmed = account.trim().toLowerCase();
64
+ const bare = trimmed.startsWith('0x') ? trimmed.slice(2) : trimmed;
65
+ return bare.length > 0 ? bare : null;
66
+ }
67
+ /** The http(s) origins among an invitation's admitter addresses, trailing slash dropped. */
68
+ function httpAdmitterOrigins(addrs) {
69
+ const out = [];
70
+ for (const addr of addrs ?? []) {
71
+ try {
72
+ const url = new URL(addr);
73
+ if (url.protocol === 'http:' || url.protocol === 'https:')
74
+ out.push(addr.replace(/\/+$/, ''));
75
+ }
76
+ catch {
77
+ // A multiaddr, or nothing a browser can dial.
78
+ }
79
+ }
80
+ return out;
81
+ }
82
+ /** Whether the invitation's signed list names this node. */
83
+ function isInvited(node, invited) {
84
+ if (invited.size === 0)
85
+ return true;
86
+ const account = normaliseAccount(node.account);
87
+ return account !== null && invited.has(account);
88
+ }
89
+ /**
90
+ * Resolve a relay to bootstrap through, from an invitation and nothing else.
91
+ *
92
+ * Reads the cloud's routing for the namespace, intersects it with the
93
+ * invitation's signed `admitters`, and returns the node to use — or which step
94
+ * stopped it and what that means. It performs no admission and mints no keys:
95
+ * the credential passed in is the one the wallet already certified.
96
+ */
97
+ export async function resolveRelayFromInvitation(input) {
98
+ const cloud = input.cloud ??
99
+ new CloudClient({
100
+ cloudBaseUrl: input.cloudBaseUrl,
101
+ routingCredential: { credential: input.credential, deviceSecret: input.deviceSecret },
102
+ });
103
+ let nodes;
104
+ let servable;
105
+ try {
106
+ const routing = await cloud.getNamespaceRouting(input.namespaceId);
107
+ nodes = routing.nodes;
108
+ servable = routing.servable;
109
+ }
110
+ catch (e) {
111
+ // Named as its own step: nothing has been learned about the namespace, so
112
+ // reporting this as "no nodes serve it" would be a claim the read never
113
+ // made — and would send the user to ask for a new invitation over what is
114
+ // usually a refused proof or a network blip.
115
+ return {
116
+ ok: false,
117
+ step: 'admitters-lookup',
118
+ reason: `The cloud could not be asked which nodes serve namespace ${input.namespaceId}: ` +
119
+ `${e instanceof Error ? e.message : String(e)}. Nothing is known about the ` +
120
+ 'invitation yet — this is the lookup failing, not the invitation.',
121
+ };
122
+ }
123
+ if (nodes.length === 0) {
124
+ // The cloud routes nothing, but the invitation may say where an admitter
125
+ // is: an account's relay writes its origin into `admitter_addrs` when it
126
+ // mints for a namespace the cloud does not host. The relay's `admit_join`
127
+ // re-verifies the invitation and its admitters, so nothing is loosened by
128
+ // knocking there; the account is the invitation's, when it names one.
129
+ const [relayUrl] = httpAdmitterOrigins(input.admitterAddrs);
130
+ if (relayUrl) {
131
+ const admitters = (input.admitters ?? []).map(normaliseAccount).filter((a) => a !== null);
132
+ return {
133
+ ok: true,
134
+ relayUrl,
135
+ admitUrl: `${relayUrl}/admin-api/namespaces/${input.namespaceId}/admit`,
136
+ admitterAccount: admitters.length === 1 ? admitters[0] : null,
137
+ peerId: null,
138
+ // The relay a founder's session runs through executes that founder's
139
+ // warrants, so it authors on behalf; the node's answer to the first
140
+ // write is the authoritative one, as for a cloud-routed node.
141
+ writable: true,
142
+ stale: false,
143
+ via: 'invitation',
144
+ };
145
+ }
146
+ return {
147
+ ok: false,
148
+ step: 'no-nodes',
149
+ reason: 'The cloud lists no nodes serving this namespace, so there is no hosted node to be ' +
150
+ 'admitted through. The namespace has no fleet assignment — whoever invited you is ' +
151
+ 'running their own node, and admission has to go through a node the cloud routes to.',
152
+ };
153
+ }
154
+ const invited = new Set((input.admitters ?? [])
155
+ .map(normaliseAccount)
156
+ .filter((a) => a !== null));
157
+ const named = nodes.filter((n) => isInvited(n, invited));
158
+ if (named.length === 0) {
159
+ return {
160
+ ok: false,
161
+ step: 'not-invited',
162
+ reason: `${nodes.length} node${nodes.length === 1 ? '' : 's'} serve this namespace, but your ` +
163
+ 'invitation names none of them. The signed admitter list is a snapshot from when the ' +
164
+ 'invitation was minted, so a node assigned since is healthy and will still refuse the ' +
165
+ 'claim. Ask for a fresh invitation.',
166
+ };
167
+ }
168
+ // `canAdmit` and `servable` are ADVISORY here, not gates.
169
+ //
170
+ // They are derived from assignment heartbeats, and a lapsed heartbeat is not
171
+ // the same fact as a node that will refuse: measured against prod, the cloud
172
+ // reported `servable: false, canAdmit: false, fresh: false` for a node whose
173
+ // `/admin-api/health` answered `{"status":"alive"}` and which had just minted
174
+ // the invitation being claimed. Refusing on those flags meant refusing to
175
+ // knock on a door that was open.
176
+ //
177
+ // So a node with an address is tried, and the node itself decides. Its answer
178
+ // is the authoritative one: `admit_join` re-verifies the invitation, the
179
+ // signature and the admitters list, so nothing is loosened by asking — the
180
+ // worst case is a 403 that names its own cause, which is strictly better than
181
+ // a client-side guess that names a heartbeat.
182
+ const usable = named.filter((n) => n.canAdmit);
183
+ const candidates = usable.length > 0 ? usable : named;
184
+ const advisory = usable.length === 0 || !servable;
185
+ const addressable = candidates.filter((n) => typeof n.relayUrl === 'string' && n.relayUrl.length > 0);
186
+ if (addressable.length === 0) {
187
+ return {
188
+ ok: false,
189
+ step: 'no-relay-url',
190
+ reason: 'A node your invitation names can admit you, but the cloud knows no address for it ' +
191
+ 'yet, so there would be nothing to connect through afterwards. Try again shortly.',
192
+ };
193
+ }
194
+ // Prefer a node that can do both legs — admit now and author later — so the
195
+ // session lands on one node where possible. Preference only: admission is the
196
+ // step that cannot proceed without a node, and authorship is not on the
197
+ // invitation's list at all, so insisting would refuse a perfectly good
198
+ // admitter for a permission admission never needed.
199
+ const chosen = addressable.find((n) => n.canExecute) ?? addressable.find((n) => n.fresh) ?? addressable[0];
200
+ return {
201
+ ok: true,
202
+ relayUrl: chosen.relayUrl,
203
+ // The cloud's ready-made URL when it gave one, so the path is never rebuilt
204
+ // from a guess; the fallback is the documented route on the same origin.
205
+ admitUrl: chosen.admitUrl ??
206
+ `${chosen.relayUrl.replace(/\/+$/, '')}/admin-api/namespaces/${input.namespaceId}/admit`,
207
+ admitterAccount: chosen.account,
208
+ peerId: chosen.peerId,
209
+ writable: chosen.canExecute,
210
+ // Stale when the node's own heartbeat lapsed OR when the cloud's summary
211
+ // disagrees with trying it at all. Both mean the same thing to a caller:
212
+ // this was attempted on advisory data, so if the admit fails that is why.
213
+ stale: !chosen.fresh || advisory,
214
+ via: 'cloud',
215
+ };
216
+ }
@@ -0,0 +1,214 @@
1
+ import { type MeroClient } from '../transport/index.js';
2
+ /**
3
+ * What a delegated connection needs to be rebuilt after a reload.
4
+ *
5
+ * `deviceSecret` is here because the device key is what signs warrants, and a
6
+ * reload that could not sign would be a dead session. It is a per-tab secret for
7
+ * a certificate the account can revoke — not the account root, which never
8
+ * leaves the wallet's origin.
9
+ */
10
+ /**
11
+ * What the wallet certified: an account, a device it trusts, and the key that
12
+ * device signs with. Everything except somewhere to send the result.
13
+ *
14
+ * Split out of {@link DelegatedSession} because it is reachable one step
15
+ * earlier. An account that has just enrolled holds exactly this and no relay —
16
+ * it is a member of nothing, so the cloud has no relay to name — and this is the
17
+ * input that resolves one from an invitation instead.
18
+ */
19
+ export interface DelegatedCredential {
20
+ /** The author's account id, hex. */
21
+ account: string;
22
+ /** The author's `AccountProof<DeviceCert>`, hex borsh. */
23
+ credential: string;
24
+ /** The certified device's ed25519 signing secret, hex. */
25
+ deviceSecret: string;
26
+ }
27
+ export interface DelegatedSession extends DelegatedCredential {
28
+ /**
29
+ * The relay to write through — a node origin, or `null` for none yet.
30
+ *
31
+ * **`null` is a logged-in state, not a broken one.** A brand-new account is a
32
+ * member of nothing, so the cloud names no relay for it — and that is the
33
+ * normal condition of an account that has just been created, because being
34
+ * signed in is how you come to be invited in the first place. Refusing to
35
+ * connect without a relay locked a new account out of the only path that
36
+ * would earn it one.
37
+ *
38
+ * What it does NOT mean is that writes quietly do nothing. There is no
39
+ * client at all without a URL ({@link buildDelegatedClient} returns `null`),
40
+ * so `mero` stays `null`, the session is authenticated, and anything reaching
41
+ * for `rpc.execute` is told the relay is missing. A placeholder URL would be
42
+ * the one unacceptable answer: it would look connected and refuse every
43
+ * warrant.
44
+ */
45
+ relayUrl: string | null;
46
+ /**
47
+ * The relay's executor account, hex, when the app knows it — the cloud's
48
+ * machine page names it beside the relay's address.
49
+ *
50
+ * Founding a namespace through a relay names the relay's account in the
51
+ * warrant, and a namespace that does not exist yet cannot be asked for it.
52
+ * Without this the account learns it from a namespace it is already in, so a
53
+ * brand-new account would have to join one first; with it, the account founds
54
+ * on `relayUrl` directly.
55
+ */
56
+ executorAccount?: string | null;
57
+ }
58
+ export declare function readDelegatedSession(): DelegatedSession | null;
59
+ export declare function saveDelegatedSession(s: DelegatedSession): void;
60
+ export declare function clearDelegatedSession(): void;
61
+ /**
62
+ * The enrolled credential, whether or not a relay was ever found for it.
63
+ *
64
+ * A connected session is authoritative: it carries the same three fields and a
65
+ * relay, so it is read first and the standalone record is the fallback. Without
66
+ * that order a stale record from an earlier enrolment could out-rank the account
67
+ * actually connected.
68
+ */
69
+ export declare function readDelegatedCredential(): DelegatedCredential | null;
70
+ export declare function saveDelegatedCredential(c: DelegatedCredential): void;
71
+ export declare function clearDelegatedCredential(): void;
72
+ /**
73
+ * A warrant nonce is spent on the node, so the counter must outlive the page.
74
+ *
75
+ * In `localStorage`, not session: a reload that restarted from zero would
76
+ * replay a spent nonce and be refused. Keyed by relay and context because the
77
+ * ledger the node keeps is per `(context, author device)`.
78
+ *
79
+ * This is the client-side half of core #4018. The node can answer "where does
80
+ * my sequence stand" — `POST /contexts/{id}/warrant-nonce` with the caller's own
81
+ * `authorProof` — but that route is mounted on the protected router, so a
82
+ * delegated client on a relay cannot reach it. Until it moves, holding the
83
+ * counter locally is the only option, and losing storage means burning refusals
84
+ * until the counter catches up.
85
+ */
86
+ export declare function persistedNonces(relayUrl: string): {
87
+ next: () => Promise<bigint>;
88
+ };
89
+ /**
90
+ * Record that `nonce` is spent for this device, so later intents start above
91
+ * it. A creation warrant's nonce is spent in the NEW context's own window; one
92
+ * sequence per relay covers that context like any other.
93
+ */
94
+ export declare function markContextNonceSpent(relayUrl: string, _contextId: string, nonce: bigint): void;
95
+ /**
96
+ * Pin a relay's device signing key, hex (32 bytes), learned out of band.
97
+ *
98
+ * `login()` binds the session to this key, and it must never be read from the
99
+ * relay being logged in to — the answering party would choose what the device
100
+ * signs about. Until the cloud publishes it (mdma#312), it comes from the
101
+ * relay's operator.
102
+ */
103
+ export declare function pinRelayNodeKey(relayUrl: string, nodeKey: string): void;
104
+ /** The pinned key for a relay, or `null` if none was pinned. */
105
+ export declare function readPinnedRelayNodeKey(relayUrl: string): string | null;
106
+ /**
107
+ * One attempt at a relay's node key, and what became of it:
108
+ *
109
+ * - `learned`: verified (or already pinned), and pinned now;
110
+ * - `unavailable`: something on the way did not answer — the relay, the release
111
+ * mirror, the quote library's download. Nothing was decided; try again.
112
+ * - `refused`: the relay answered and its proof did not hold — a quote that is
113
+ * not of a trusted image, not bound to this request, a mock from a hosted
114
+ * relay. Trying again cannot change that answer into a yes, and must not.
115
+ */
116
+ export type RelayNodeKeyAttempt = {
117
+ kind: 'learned';
118
+ nodeKey: string;
119
+ } | {
120
+ kind: 'unavailable';
121
+ error: unknown;
122
+ } | {
123
+ kind: 'refused';
124
+ error: unknown;
125
+ };
126
+ export declare function attemptRelayNodeKey(relayUrl: string): Promise<RelayNodeKeyAttempt>;
127
+ /**
128
+ * The relay's node key: the pinned one, or else learned from the relay's TEE
129
+ * attestation in one attempt; `null` when that attempt did not learn it, for
130
+ * whatever reason. Callers that must end up with the key use
131
+ * {@link learnRelayNodeKey}.
132
+ */
133
+ export declare function resolveRelayNodeKey(relayUrl: string): Promise<string | null>;
134
+ /**
135
+ * How long to wait before each retry of an unavailable relay key: quickly at
136
+ * first, for a blip or a cold cache, then every 30 s for as long as it takes.
137
+ */
138
+ export declare const RELAY_NODE_KEY_RETRY_MS: readonly number[];
139
+ /**
140
+ * The relay's node key, however long the way to it is down.
141
+ *
142
+ * One failed fetch of the release used to cost the whole session: no key, so no
143
+ * relay session, so every admin read and every event stream unauthenticated
144
+ * until a reload. A transport failure here is now waited out with backoff
145
+ * ({@link RELAY_NODE_KEY_RETRY_MS}) until the key is learned or `signal` aborts.
146
+ *
147
+ * A refusal ends it at once with `null`. A quote that did not verify is an
148
+ * answer, and asking again is not how it becomes a different one.
149
+ */
150
+ export declare function learnRelayNodeKey(relayUrl: string, options?: {
151
+ signal?: AbortSignal;
152
+ }): Promise<string | null>;
153
+ /** Forget what was learned about which methods read. For tests. */
154
+ export declare function forgetMethodKinds(): void;
155
+ /**
156
+ * Build the client a delegated session talks through.
157
+ *
158
+ * The result is a relay-transport `MeroClient`. Its `rpc.execute` takes the same
159
+ * named arguments a node client's does, which is what lets a generated ABI
160
+ * client — and every hook that only calls `execute` — work unchanged. What it
161
+ * also has is `admin` and `events`, pointed at the relay and authenticated as
162
+ * the account by the device certificate, so the caller-scoped reads answer this
163
+ * account's own contexts and namespaces rather than the node's.
164
+ */
165
+ export declare function buildDelegatedClient(s: DelegatedSession, _contextId: string | null): MeroClient | null;
166
+ export interface RelayMap {
167
+ namespaces: Record<string, string>;
168
+ contexts: Record<string, string>;
169
+ }
170
+ export declare function readRelayMap(account: string): RelayMap;
171
+ /** Record the relay that admitted this account to a namespace (and, when known, the context it came for). */
172
+ export declare function rememberRelay(account: string, relayUrl: string, at: {
173
+ namespaceId?: string;
174
+ contextId?: string;
175
+ }): void;
176
+ /** The relay that serves a context, if this account has learned it. */
177
+ export declare function relayForContext(account: string, contextId: string): string | null;
178
+ /**
179
+ * A session moving to `next`, keeping the relay's executor account the previous
180
+ * one knew when both are the same relay.
181
+ *
182
+ * A join hands back a session built from the admitting node alone, which has no
183
+ * executor on it. When that node is the relay the account was already on — the
184
+ * one the cloud assigned it, say — dropping the executor would cost nothing
185
+ * today (the joined namespace can answer for it) but leaves the session knowing
186
+ * less than it did. On a different relay the old executor is the wrong account,
187
+ * so it is left behind.
188
+ */
189
+ export declare function carryExecutorAccount(prev: DelegatedSession | null, next: DelegatedSession): DelegatedSession;
190
+ /** Every relay this account is known to use: the session's, and each in the map. */
191
+ export declare function knownRelays(s: DelegatedSession): string[];
192
+ type ListedContext = Awaited<ReturnType<MeroClient['admin']['getContexts']>>['contexts'][number];
193
+ /**
194
+ * This account's contexts on every relay it knows, each tagged with the relay
195
+ * that listed it — and that relay recorded as the one serving it.
196
+ *
197
+ * Each relay answers only for itself and only for this caller, so the union is
198
+ * exactly the account's contexts across relays. A relay that cannot be reached
199
+ * or logged in to is skipped (its contexts simply do not appear), not fatal.
200
+ */
201
+ export declare function listDelegatedContexts(s: DelegatedSession): Promise<Array<ListedContext & {
202
+ relayUrl: string;
203
+ }>>;
204
+ type ListedNamespace = Awaited<ReturnType<MeroClient['admin']['listNamespaces']>>[number];
205
+ /**
206
+ * This account's namespaces on every relay it knows, each tagged with the relay
207
+ * that listed it and recorded as that namespace's relay. Same rules as
208
+ * {@link listDelegatedContexts}: each relay answers for itself and this caller
209
+ * only (`namespace:list-own`); an unreachable relay is skipped.
210
+ */
211
+ export declare function listDelegatedNamespaces(s: DelegatedSession): Promise<Array<ListedNamespace & {
212
+ relayUrl: string;
213
+ }>>;
214
+ export {};