@calimero-network/mero-js 26.10.0 → 27.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.
Files changed (220) hide show
  1. package/README.md +73 -288
  2. package/dist/cloud-v2/attestation.d.ts +91 -0
  3. package/dist/cloud-v2/attestation.js +135 -0
  4. package/dist/cloud-v2/capabilities.d.ts +30 -0
  5. package/dist/cloud-v2/capabilities.js +31 -0
  6. package/dist/cloud-v2/client.d.ts +426 -0
  7. package/dist/cloud-v2/client.js +646 -0
  8. package/dist/cloud-v2/errors.d.ts +98 -0
  9. package/dist/cloud-v2/errors.js +165 -0
  10. package/dist/cloud-v2/index.d.ts +10 -0
  11. package/dist/cloud-v2/index.js +5 -0
  12. package/dist/cloud-v2/passkey.d.ts +27 -0
  13. package/dist/cloud-v2/passkey.js +73 -0
  14. package/dist/crypto/internal.d.ts +3 -37
  15. package/dist/crypto/internal.js +3 -72
  16. package/dist/index.browser.mjs +4 -2
  17. package/dist/index.browser.mjs.map +4 -4
  18. package/dist/index.cjs +1750 -12767
  19. package/dist/index.cjs.map +4 -4
  20. package/dist/index.d.ts +3 -56
  21. package/dist/index.js +14 -75
  22. package/dist/index.mjs +1750 -12767
  23. package/dist/index.mjs.map +4 -4
  24. package/dist/sealed/index.d.ts +4 -4
  25. package/dist/sealed/index.js +2 -2
  26. package/dist/sealed/release.d.ts +21 -76
  27. package/dist/sealed/release.js +24 -108
  28. package/dist/sealed/sealed.d.ts +8 -56
  29. package/dist/sealed/sealed.js +11 -89
  30. package/dist/sealed/verify.d.ts +7 -13
  31. package/dist/sealed/verify.js +3 -7
  32. package/package.json +8 -13
  33. package/dist/account/account-admin.d.ts +0 -80
  34. package/dist/account/account-admin.js +0 -346
  35. package/dist/account/account.d.ts +0 -196
  36. package/dist/account/account.js +0 -269
  37. package/dist/account/application-id.d.ts +0 -50
  38. package/dist/account/application-id.js +0 -83
  39. package/dist/account/bootstrap-from-invitation.d.ts +0 -110
  40. package/dist/account/bootstrap-from-invitation.js +0 -80
  41. package/dist/account/create-context.d.ts +0 -103
  42. package/dist/account/create-context.js +0 -267
  43. package/dist/account/govern.d.ts +0 -24
  44. package/dist/account/govern.js +0 -50
  45. package/dist/account/index.d.ts +0 -26
  46. package/dist/account/index.js +0 -15
  47. package/dist/account/join-as-account.d.ts +0 -23
  48. package/dist/account/join-as-account.js +0 -34
  49. package/dist/account/join-with-node.d.ts +0 -115
  50. package/dist/account/join-with-node.js +0 -161
  51. package/dist/account/node-admin.d.ts +0 -20
  52. package/dist/account/node-admin.js +0 -33
  53. package/dist/account/relay-from-invitation.d.ts +0 -137
  54. package/dist/account/relay-from-invitation.js +0 -180
  55. package/dist/account/relay-login-error.d.ts +0 -32
  56. package/dist/account/relay-login-error.js +0 -34
  57. package/dist/account/session.d.ts +0 -266
  58. package/dist/account/session.js +0 -915
  59. package/dist/account/wordlist.d.ts +0 -16
  60. package/dist/account/wordlist.js +0 -273
  61. package/dist/admin-api/admin-client.d.ts +0 -686
  62. package/dist/admin-api/admin-client.js +0 -1225
  63. package/dist/admin-api/admin-factory.d.ts +0 -14
  64. package/dist/admin-api/admin-factory.js +0 -22
  65. package/dist/admin-api/admin-types.d.ts +0 -2041
  66. package/dist/admin-api/admin-types.js +0 -12
  67. package/dist/admin-api/index.d.ts +0 -4
  68. package/dist/admin-api/index.js +0 -5
  69. package/dist/admin-api/redeem-invitation.d.ts +0 -101
  70. package/dist/admin-api/redeem-invitation.js +0 -200
  71. package/dist/auth/index.d.ts +0 -34
  72. package/dist/auth/index.js +0 -67
  73. package/dist/auth-api/auth-client.d.ts +0 -31
  74. package/dist/auth-api/auth-client.js +0 -117
  75. package/dist/auth-api/auth-factory.d.ts +0 -7
  76. package/dist/auth-api/auth-factory.js +0 -41
  77. package/dist/auth-api/auth-types.d.ts +0 -130
  78. package/dist/auth-api/auth-types.js +0 -2
  79. package/dist/auth-api/index.d.ts +0 -3
  80. package/dist/auth-api/index.js +0 -4
  81. package/dist/capabilities.d.ts +0 -84
  82. package/dist/capabilities.js +0 -117
  83. package/dist/cloud/account-ownership.d.ts +0 -63
  84. package/dist/cloud/account-ownership.js +0 -87
  85. package/dist/cloud/cloud-client.d.ts +0 -949
  86. package/dist/cloud/cloud-client.js +0 -1068
  87. package/dist/cloud/connect.d.ts +0 -213
  88. package/dist/cloud/connect.js +0 -260
  89. package/dist/cloud/enrol-redirect.d.ts +0 -181
  90. package/dist/cloud/enrol-redirect.js +0 -244
  91. package/dist/cloud/index.d.ts +0 -12
  92. package/dist/cloud/index.js +0 -10
  93. package/dist/cloud/link-redirect.d.ts +0 -95
  94. package/dist/cloud/link-redirect.js +0 -121
  95. package/dist/cloud/link-state.d.ts +0 -36
  96. package/dist/cloud/link-state.js +0 -104
  97. package/dist/cloud/routing-proof.d.ts +0 -113
  98. package/dist/cloud/routing-proof.js +0 -96
  99. package/dist/device-cert/device-cert.d.ts +0 -189
  100. package/dist/device-cert/device-cert.js +0 -239
  101. package/dist/device-cert/device-scope.d.ts +0 -58
  102. package/dist/device-cert/device-scope.js +0 -76
  103. package/dist/device-cert/index.d.ts +0 -4
  104. package/dist/device-cert/index.js +0 -3
  105. package/dist/ephemeral/codec.d.ts +0 -4
  106. package/dist/ephemeral/codec.js +0 -12
  107. package/dist/ephemeral/index.d.ts +0 -57
  108. package/dist/ephemeral/index.js +0 -101
  109. package/dist/ephemeral/types.d.ts +0 -27
  110. package/dist/ephemeral/types.js +0 -1
  111. package/dist/errors/classify.d.ts +0 -65
  112. package/dist/errors/classify.js +0 -117
  113. package/dist/errors/index.d.ts +0 -2
  114. package/dist/errors/index.js +0 -1
  115. package/dist/events/group.d.ts +0 -95
  116. package/dist/events/group.js +0 -10
  117. package/dist/events/index.d.ts +0 -5
  118. package/dist/events/index.js +0 -2
  119. package/dist/events/sse.d.ts +0 -127
  120. package/dist/events/sse.js +0 -469
  121. package/dist/events/ws.d.ts +0 -57
  122. package/dist/events/ws.js +0 -212
  123. package/dist/http-client/api-response.d.ts +0 -15
  124. package/dist/http-client/api-response.js +0 -1
  125. package/dist/http-client/http-factory.d.ts +0 -71
  126. package/dist/http-client/http-factory.js +0 -68
  127. package/dist/http-client/http-types.d.ts +0 -87
  128. package/dist/http-client/http-types.js +0 -1
  129. package/dist/http-client/index.d.ts +0 -7
  130. package/dist/http-client/index.js +0 -11
  131. package/dist/http-client/path-segment.d.ts +0 -1
  132. package/dist/http-client/path-segment.js +0 -8
  133. package/dist/http-client/retry.d.ts +0 -5
  134. package/dist/http-client/retry.js +0 -82
  135. package/dist/http-client/signal-utils.d.ts +0 -2
  136. package/dist/http-client/signal-utils.js +0 -41
  137. package/dist/http-client/web-client.d.ts +0 -80
  138. package/dist/http-client/web-client.js +0 -557
  139. package/dist/invitation/index.d.ts +0 -2
  140. package/dist/invitation/index.js +0 -2
  141. package/dist/invitation/invitation.d.ts +0 -106
  142. package/dist/invitation/invitation.js +0 -104
  143. package/dist/login/index.d.ts +0 -4
  144. package/dist/login/index.js +0 -3
  145. package/dist/login/login.d.ts +0 -111
  146. package/dist/login/login.js +0 -110
  147. package/dist/login/session.d.ts +0 -124
  148. package/dist/login/session.js +0 -141
  149. package/dist/mero-js.d.ts +0 -231
  150. package/dist/mero-js.js +0 -469
  151. package/dist/namespace-op/index.d.ts +0 -2
  152. package/dist/namespace-op/index.js +0 -3
  153. package/dist/namespace-op/namespace-op.d.ts +0 -150
  154. package/dist/namespace-op/namespace-op.js +0 -191
  155. package/dist/nodeDiscovery.d.ts +0 -47
  156. package/dist/nodeDiscovery.js +0 -101
  157. package/dist/owner-op/index.d.ts +0 -2
  158. package/dist/owner-op/index.js +0 -3
  159. package/dist/owner-op/owner-op.d.ts +0 -119
  160. package/dist/owner-op/owner-op.js +0 -214
  161. package/dist/presence/relay-presence.d.ts +0 -56
  162. package/dist/presence/relay-presence.js +0 -107
  163. package/dist/presence/statement.d.ts +0 -12
  164. package/dist/presence/statement.js +0 -17
  165. package/dist/relay/index.d.ts +0 -4
  166. package/dist/relay/index.js +0 -2
  167. package/dist/relay/nonce-source.d.ts +0 -72
  168. package/dist/relay/nonce-source.js +0 -111
  169. package/dist/relay/relay-client.d.ts +0 -472
  170. package/dist/relay/relay-client.js +0 -518
  171. package/dist/relay-attestation/attest-node-key.d.ts +0 -66
  172. package/dist/relay-attestation/attest-node-key.js +0 -110
  173. package/dist/relay-attestation/index.d.ts +0 -1
  174. package/dist/relay-attestation/index.js +0 -1
  175. package/dist/request/caller-proof.d.ts +0 -43
  176. package/dist/request/caller-proof.js +0 -36
  177. package/dist/request/index.d.ts +0 -3
  178. package/dist/request/index.js +0 -3
  179. package/dist/request/proof-signer.d.ts +0 -49
  180. package/dist/request/proof-signer.js +0 -59
  181. package/dist/request/request-sig.d.ts +0 -99
  182. package/dist/request/request-sig.js +0 -95
  183. package/dist/request-proof/authorizer.d.ts +0 -41
  184. package/dist/request-proof/authorizer.js +0 -60
  185. package/dist/request-proof/index.d.ts +0 -4
  186. package/dist/request-proof/index.js +0 -2
  187. package/dist/request-proof/request-proof.d.ts +0 -53
  188. package/dist/request-proof/request-proof.js +0 -95
  189. package/dist/rpc/index.d.ts +0 -64
  190. package/dist/rpc/index.js +0 -78
  191. package/dist/signer/index.d.ts +0 -2
  192. package/dist/signer/index.js +0 -3
  193. package/dist/signer/signer.d.ts +0 -92
  194. package/dist/signer/signer.js +0 -147
  195. package/dist/token-store/index.d.ts +0 -27
  196. package/dist/token-store/index.js +0 -62
  197. package/dist/transport/index.d.ts +0 -6
  198. package/dist/transport/index.js +0 -3
  199. package/dist/transport/mero-client.d.ts +0 -294
  200. package/dist/transport/mero-client.js +0 -386
  201. package/dist/transport/relay-observer.d.ts +0 -152
  202. package/dist/transport/relay-observer.js +0 -173
  203. package/dist/transport/relay-transport.d.ts +0 -73
  204. package/dist/transport/relay-transport.js +0 -94
  205. package/dist/transport/types.d.ts +0 -100
  206. package/dist/transport/types.js +0 -32
  207. package/dist/types/api-response.d.ts +0 -15
  208. package/dist/types/api-response.js +0 -1
  209. package/dist/types/index.d.ts +0 -1
  210. package/dist/types/index.js +0 -1
  211. package/dist/warrant/creation-warrant.d.ts +0 -163
  212. package/dist/warrant/creation-warrant.js +0 -194
  213. package/dist/warrant/governance-op.d.ts +0 -202
  214. package/dist/warrant/governance-op.js +0 -355
  215. package/dist/warrant/governance-warrant.d.ts +0 -124
  216. package/dist/warrant/governance-warrant.js +0 -164
  217. package/dist/warrant/index.d.ts +0 -8
  218. package/dist/warrant/index.js +0 -5
  219. package/dist/warrant/warrant.d.ts +0 -188
  220. package/dist/warrant/warrant.js +0 -202
@@ -1,1068 +0,0 @@
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 { signAccountHaClaim } from './account-ownership.js';
28
- import { resolveRoot, resolveRootPair, signAccountLink, signAccountLogin, } from '../account/index.js';
29
- import { HTTPError } from '../http-client/web-client.js';
30
- import { pathSegment } from '../http-client/path-segment.js';
31
- import { ACCOUNT_HEX, VERIFIER_HEX, beginLinkState, linkChallenge, linkGrantClaims, takeLinkVerifier, } from './link-state.js';
32
- import { routingProofHeaders, } from './routing-proof.js';
33
- /**
34
- * The hosted cloud's **API** when a caller names no other.
35
- *
36
- * `manager.cloud.calimero.network`, not `cloud.calimero.network`: the latter
37
- * serves the React app, which answers an API call with 405 and an HTML body.
38
- * Every method on this client is an API call, so the bare `new CloudClient()`
39
- * used to fail at the first request with a parse error rather than at
40
- * construction — and the URL it was talking to looked right in the message.
41
- *
42
- * The web origin still exists in this package: the consent screen in
43
- * `link-redirect.ts` is a page a person visits, and that one is the app.
44
- */
45
- const DEFAULT_CLOUD_BASE_URL = 'https://manager.cloud.calimero.network';
46
- /**
47
- * The cloud's own `can_execute` where it sends one, else `legacy`.
48
- *
49
- * The cloud decides relaying by role (`RelayTee` relays, `ReadOnlyTee` never
50
- * does) and only falls back to the `CAN_AUTHOR_ON_BEHALF` grant for a node
51
- * that reports none, so re-deriving it here from `authorship_ready` would call
52
- * a grant-holding `ReadOnlyTee` writable. `legacy` is the grant-based answer,
53
- * for a cloud older than the field — the only rule such a cloud knew.
54
- */
55
- function canExecuteOf(row, legacy) {
56
- return typeof row.can_execute === 'boolean' ? row.can_execute : legacy;
57
- }
58
- /** The reported `tee_role`, or `null` when absent (older cloud or sidecar). */
59
- function teeRoleOf(row) {
60
- return typeof row.tee_role === 'string' && row.tee_role !== '' ? row.tee_role : null;
61
- }
62
- /**
63
- * The first relay a warrant can be spent at, or `null`.
64
- *
65
- * Shared by {@link CloudClient.findExecutingRelay} and `connectCloud`, which
66
- * already holds the list and must not fetch it twice.
67
- */
68
- export function pickExecutingRelay(relays) {
69
- return (relays.find((r) => r.canExecute && r.relayUrl !== null && r.executorAccount !== null) ?? null);
70
- }
71
- /** `join` from a relay row, or `null` when absent or not an object. */
72
- function joinOf(row) {
73
- const raw = row.join;
74
- if (!raw || typeof raw !== 'object' || Array.isArray(raw))
75
- return null;
76
- const join = raw;
77
- if (typeof join.state !== 'string')
78
- return null;
79
- const refusals = Array.isArray(join.refusals)
80
- ? join.refusals
81
- .filter((r) => !!r && typeof r === 'object')
82
- .map((r) => ({ peer: String(r.peer ?? ''), reason: String(r.reason ?? '') }))
83
- : [];
84
- return {
85
- state: join.state,
86
- attempts: typeof join.attempts === 'number' ? join.attempts : 0,
87
- refusals,
88
- error: typeof join.error === 'string' ? join.error : null,
89
- reportedAt: typeof join.reported_at === 'string' ? join.reported_at : null,
90
- };
91
- }
92
- function accountHexOrNull(value) {
93
- return typeof value === 'string' && ACCOUNT_HEX.test(value) ? value : null;
94
- }
95
- /**
96
- * The cloud's refusals of {@link CloudClient.enableHaAsAccount} that a
97
- * person can do something about. Both are `409`s with `{"error": code}`.
98
- *
99
- * Extends {@link HTTPError}, so `instanceof HTTPError` handling still catches it.
100
- */
101
- export class AccountHaRefusedError extends HTTPError {
102
- constructor(
103
- /** The cloud's `error` code, verbatim. */
104
- code, status, statusText, url, headers, bodyText) {
105
- super(status, statusText, url, headers, bodyText);
106
- this.code = code;
107
- this.name = 'AccountHaRefusedError';
108
- }
109
- }
110
- /**
111
- * `account_not_linked`: the account was never linked to a cloud user through
112
- * the wallet, so the cloud has nobody to host the namespace for. Linking it
113
- * fixes this; retrying does not.
114
- */
115
- export class AccountNotLinkedError extends AccountHaRefusedError {
116
- constructor() {
117
- super(...arguments);
118
- this.name = 'AccountNotLinkedError';
119
- }
120
- }
121
- AccountNotLinkedError.CODE = 'account_not_linked';
122
- /**
123
- * `account_linked_to_several_users`: more than one cloud user linked this
124
- * account, so the cloud cannot tell whose quota hosts the namespace. Unlink it
125
- * from all but one.
126
- */
127
- export class AccountLinkedToSeveralUsersError extends AccountHaRefusedError {
128
- constructor() {
129
- super(...arguments);
130
- this.name = 'AccountLinkedToSeveralUsersError';
131
- }
132
- }
133
- AccountLinkedToSeveralUsersError.CODE = 'account_linked_to_several_users';
134
- /**
135
- * `ha_request_pending` (409): another namespace of this account is waiting for
136
- * its first fleet node to be admitted. The route allows one at a time; it
137
- * clears once that node is admitted, or by disabling HA on it from a cloud
138
- * session. The claim was not spent.
139
- */
140
- export class HaRequestPendingError extends AccountHaRefusedError {
141
- constructor() {
142
- super(...arguments);
143
- this.name = 'HaRequestPendingError';
144
- }
145
- }
146
- HaRequestPendingError.CODE = 'ha_request_pending';
147
- /**
148
- * `unknown_relay` (422): the cloud runs no relay at `relayUrl`. The claim was
149
- * not spent.
150
- */
151
- export class UnknownRelayError extends AccountHaRefusedError {
152
- constructor() {
153
- super(...arguments);
154
- this.name = 'UnknownRelayError';
155
- }
156
- }
157
- UnknownRelayError.CODE = 'unknown_relay';
158
- /**
159
- * `relay_not_dialable` (422): the relay is the cloud's but has not reported
160
- * its addresses yet. The claim was not spent; retrying later can succeed.
161
- */
162
- export class RelayNotDialableError extends AccountHaRefusedError {
163
- constructor() {
164
- super(...arguments);
165
- this.name = 'RelayNotDialableError';
166
- }
167
- }
168
- RelayNotDialableError.CODE = 'relay_not_dialable';
169
- const ACCOUNT_HA_REFUSALS = {
170
- 409: [AccountNotLinkedError, AccountLinkedToSeveralUsersError, HaRequestPendingError],
171
- 422: [UnknownRelayError, RelayNotDialableError],
172
- };
173
- /**
174
- * The `error` code of a 409 body. Accepts `{"error": code}` (the contract) and
175
- * FastAPI's `{"detail": code}` / `{"detail": {"error": code}}`, so how the
176
- * route raises it does not change what a caller catches.
177
- */
178
- function conflictCode(bodyText) {
179
- if (!bodyText)
180
- return undefined;
181
- try {
182
- const body = JSON.parse(bodyText);
183
- if (typeof body?.error === 'string')
184
- return body.error;
185
- if (typeof body?.detail === 'string')
186
- return body.detail;
187
- const nested = body?.detail?.error;
188
- if (typeof nested === 'string')
189
- return nested;
190
- }
191
- catch {
192
- // Not JSON: not one of ours.
193
- }
194
- return undefined;
195
- }
196
- /** Response headers MDMA uses to hand back a rolling-refreshed session. */
197
- const REFRESH_HEADER = 'x-mdma-session-refresh';
198
- const REFRESH_EXPIRES_HEADER = 'x-mdma-session-expires';
199
- export class CloudClient {
200
- constructor(config = {}) {
201
- this.session = null;
202
- this.baseUrl = (config.cloudBaseUrl || DEFAULT_CLOUD_BASE_URL).replace(/\/+$/, '');
203
- this.onSession = config.onSession;
204
- // Bound through an arrow function: an unbound `globalThis.fetch` throws
205
- // "Illegal invocation" in a browser, the same reason the node HTTP clients
206
- // wrap it.
207
- const injected = config.fetch;
208
- this.fetchImpl = injected
209
- ? (input, init) => injected(input, init)
210
- : (input, init) => globalThis.fetch(input, init);
211
- this.timeoutMs = config.timeoutMs ?? 10000;
212
- this.routingCredential = config.routingCredential;
213
- if (config.sessionToken) {
214
- // Expiry unknown until the server tells us; 0 means "do not reason about
215
- // it locally", never "expired" — the server is the only judge and it
216
- // answers 401 when it disagrees.
217
- this.session = {
218
- sessionToken: config.sessionToken,
219
- expiresAt: 0,
220
- user: { email: '' },
221
- };
222
- }
223
- }
224
- /** The current session, or `null` when nobody is signed in. */
225
- getSession() {
226
- return this.session;
227
- }
228
- /** Whether a session token is held. Says nothing about the server agreeing. */
229
- isSignedIn() {
230
- return this.session !== null;
231
- }
232
- /**
233
- * Exchange a Google OAuth2 ID token for an MDMA session.
234
- *
235
- * The app owns the Google flow (One Tap, an auth-code popup, a native
236
- * browser) and hands the resulting ID token here once. Everything after this
237
- * uses the returned session token, so the Google credential does not have to
238
- * be kept or re-minted per call.
239
- */
240
- async signInWithGoogle(idToken) {
241
- const body = await this.request('POST', '/api/auth/google', { body: { id_token: idToken }, anonymous: true });
242
- return this.adoptSession(body);
243
- }
244
- /**
245
- * Sign in as a Calimero account, proving it with its root key.
246
- *
247
- * The password-free counterpart to {@link signInWithGoogle}: no Google, no
248
- * password, no server-held secret — the cloud mints a challenge, the account
249
- * root signs it, and the session that comes back is the same MDMA session
250
- * token every other cloud route already accepts.
251
- *
252
- * Doing this **once** is what makes an account's later device proofs mean
253
- * something. A routing read shows that the caller holds a device the root
254
- * certified, but certificates are public, so it can never show that anyone
255
- * held the root; the cloud records this claim and from then on knows the
256
- * account behind those reads was claimed by its owner. The claim is recorded
257
- * even when the session is refused.
258
- *
259
- * @param root the account root: its 64-hex secret, or a `Signer` over a root
260
- * this process may use and cannot read — the form a phrase-held root in a
261
- * browser takes. Either way nothing but a signature over the cloud's nonce
262
- * travels.
263
- *
264
- * A `403` means either the signature did not verify, or the account is not
265
- * linked to a cloud login: ownership alone says who you are, and the link
266
- * says what you are entitled to. Anyone can mint a root offline, so a session
267
- * on the proof alone would authenticate perfectly and authorize nothing. The
268
- * ownership claim is recorded either way, so linking later needs no re-proof.
269
- */
270
- async signInWithAccount(root) {
271
- const { signer, publicKey } = await resolveRoot(root);
272
- const { nonce } = await this.getAccountLoginChallenge();
273
- return this.submitAccountLogin({
274
- rootPublicKey: publicKey,
275
- nonce,
276
- signature: await signAccountLogin({ signer, nonce }),
277
- });
278
- }
279
- /**
280
- * Mint a challenge for an account root to sign.
281
- *
282
- * Public, and deliberately so: the caller has no session — obtaining one is
283
- * what this exchange does. The nonce is not a capability, it is the thing a
284
- * capability gets demonstrated against, and demonstrating it needs the root.
285
- *
286
- * Split out of {@link signInWithAccount} for the case that call cannot serve:
287
- * a root held by a hardware key, a desktop app or an air-gapped machine,
288
- * which signs without the secret ever reaching this process.
289
- */
290
- async getAccountLoginChallenge() {
291
- const body = await this.request('GET', '/api/auth/account/challenge', { anonymous: true });
292
- return { nonce: body.nonce, expiresAtMs: body.expires_at_ms };
293
- }
294
- /** Post a proof assembled elsewhere. The other half of {@link getAccountLoginChallenge}. */
295
- async submitAccountLogin(proof) {
296
- const body = await this.request('POST', '/api/auth/account', {
297
- body: {
298
- root_public_key: proof.rootPublicKey,
299
- nonce: proof.nonce,
300
- signature: proof.signature,
301
- },
302
- anonymous: true,
303
- });
304
- return this.adoptSession(body);
305
- }
306
- /**
307
- * Refresh the session token explicitly.
308
- *
309
- * Rarely needed: MDMA rolling-refreshes on ordinary authenticated calls and
310
- * this client adopts the replacement automatically. Use it when an app wants
311
- * a fresh token before going idle rather than on the next request.
312
- */
313
- async refreshSession() {
314
- const body = await this.request('POST', '/api/auth/refresh');
315
- return this.adoptSession(body);
316
- }
317
- /**
318
- * Revoke this session server-side and forget it locally.
319
- *
320
- * Local state is cleared even if the server call fails: the caller asked to
321
- * be signed out, and keeping a token they believe is gone is the worse of the
322
- * two failures.
323
- */
324
- async signOut() {
325
- try {
326
- if (this.session)
327
- await this.request('POST', '/api/auth/logout', { parse: 'none' });
328
- }
329
- finally {
330
- this.session = null;
331
- void this.notifySession(null);
332
- }
333
- }
334
- /** Namespaces the signed-in user owns. */
335
- async getMyNamespaces() {
336
- const rows = await this.request('GET', '/api/cloud/me/namespaces');
337
- return rows.map((row) => ({
338
- namespaceId: String(row.namespace_id ?? row.group_id ?? ''),
339
- groupId: String(row.group_id ?? row.namespace_id ?? ''),
340
- haStatus: String(row.ha_status ?? 'none'),
341
- haEnabledAt: row.ha_enabled_at ?? null,
342
- contexts: Array.isArray(row.contexts) ? row.contexts : [],
343
- fleetReplicas: row.fleet_replicas,
344
- }));
345
- }
346
- /**
347
- * The relays serving one namespace: where to present an intent, and as whom.
348
- *
349
- * Everything a client needs in order to mint a warrant it can actually
350
- * spend. Filter on `canExecute` before signing — a warrant presented to a
351
- * relay that does not relay (a `ReadOnlyTee` replica, or a node with no role
352
- * and no grant) is refused *after* the author has burned a nonce from its
353
- * monotonic sequence on it.
354
- */
355
- async getNamespaceRelays(namespaceId) {
356
- const body = await this.request('GET', `/api/cloud/me/namespaces/${pathSegment(namespaceId)}/relays`);
357
- return (body.relays ?? []).map((row) => {
358
- const relayUrl = row.relay_url ?? null;
359
- const executorAccount = row.executor_account ?? null;
360
- const authorshipReady = row.authorship_ready === true;
361
- return {
362
- peerId: String(row.peer_id ?? ''),
363
- relayUrl,
364
- executorAccount,
365
- nodeKey: row.node_key ??
366
- row.node_public_key ??
367
- null,
368
- status: String(row.status ?? ''),
369
- authorshipReady,
370
- teeRole: teeRoleOf(row),
371
- canExecute: canExecuteOf(row, authorshipReady && relayUrl !== null && executorAccount !== null),
372
- lastSeenAt: row.last_seen_at ?? null,
373
- confirmedAt: row.confirmed_at ?? null,
374
- admission: typeof row.admission === 'string' ? row.admission : null,
375
- admissionHint: typeof row.admission_hint === 'string' ? row.admission_hint : null,
376
- join: joinOf(row),
377
- };
378
- });
379
- }
380
- /**
381
- * The first relay for `namespaceId` that can actually run an intent, or
382
- * `null`.
383
- *
384
- * "Can actually run one" is several conditions, and a client that checks
385
- * fewer gets a refusal it cannot explain: the cloud must know a URL, the
386
- * relay must have reported the executor account a warrant has to name, and
387
- * the node must relay — which the cloud folds into `canExecute`.
388
- */
389
- async findExecutingRelay(namespaceId) {
390
- const relays = await this.getNamespaceRelays(namespaceId);
391
- return pickExecutingRelay(relays);
392
- }
393
- /**
394
- * Where to reach a namespace without owning it: one read, both verbs.
395
- *
396
- * **Anonymous.** A joiner is by construction not the namespace owner and holds
397
- * no cloud session — needing one is exactly what this path exists to avoid.
398
- * Discovery is not authorization: merod refuses an `/admit` call unless the
399
- * relaying node's account is on the invitation's signed `admitters` list, and
400
- * refuses an intent unless the warrant is genuinely the member's, so naming a
401
- * node grants the caller nothing it did not already have.
402
- *
403
- * A keyholder resolves a namespace to a node ONCE and then admits, reads and
404
- * posts intents against that same node, which is why admission and writability
405
- * are answered together rather than across two endpoints — the per-context
406
- * read needs a context id a joiner does not yet have.
407
- *
408
- * An empty `nodes` list is a state, not an error: the cloud has no record of
409
- * a node holding the namespace, so there is no hosted admitter.
410
- */
411
- async getNamespaceRouting(namespaceId) {
412
- const body = await this.request('GET', `/api/cloud/namespaces/${pathSegment(namespaceId)}/admitters`, {
413
- anonymous: true,
414
- headers: await this.routingProof(namespaceId),
415
- });
416
- return {
417
- namespaceId: String(body.namespace_id ?? namespaceId),
418
- nodes: (body.admitters ?? []).map((row) => {
419
- const account = row.account ?? null;
420
- const relayUrl = row.relay_url ?? null;
421
- const fresh = row.fresh === true;
422
- const authorshipReady = row.authorship_ready === true;
423
- return {
424
- peerId: String(row.peer_id ?? ''),
425
- account,
426
- relayUrl,
427
- admitUrl: row.admit_url ?? null,
428
- status: String(row.status ?? ''),
429
- fresh,
430
- canAdmit: row.can_admit === true,
431
- authorshipReady,
432
- teeRole: teeRoleOf(row),
433
- canExecute: canExecuteOf(row, authorshipReady && relayUrl !== null && account !== null && fresh),
434
- };
435
- }),
436
- servable: body.servable === true,
437
- writable: body.writable === true,
438
- };
439
- }
440
- /**
441
- * Ask the cloud for a challenge to prove an account against.
442
- *
443
- * Public and unauthenticated on purpose, and safe to be: the nonce is sealed
444
- * and bound to this one namespace, so it is not a capability — it is the
445
- * thing a capability gets demonstrated against. A caller with no credential
446
- * gains nothing by holding one.
447
- *
448
- * {@link getNamespaceRouting} calls this for you when the client was
449
- * constructed with a `routingCredential`. Call it directly only to sign a
450
- * challenge somewhere this client cannot reach the key.
451
- */
452
- async getRoutingChallenge(namespaceId) {
453
- const body = await this.request('GET', `/api/cloud/namespaces/${pathSegment(namespaceId)}/challenge`, {
454
- anonymous: true,
455
- });
456
- return {
457
- namespaceId: String(body.namespace_id ?? namespaceId),
458
- nonce: String(body.nonce ?? ''),
459
- expiresAtMs: Number(body.expires_at_ms ?? 0),
460
- };
461
- }
462
- /**
463
- * The proof headers for one routing read, or nothing when no credential was
464
- * configured.
465
- *
466
- * A challenge per read rather than a cached one: they expire in about two
467
- * minutes and are single-namespace, so caching would trade one cheap request
468
- * for a class of expiry bug that only shows up on a slow client.
469
- */
470
- async routingProof(namespaceId) {
471
- if (!this.routingCredential)
472
- return undefined;
473
- const challenge = await this.getRoutingChallenge(namespaceId);
474
- return routingProofHeaders(challenge, this.routingCredential);
475
- }
476
- /**
477
- * Ask the cloud for a challenge bound to one ACCOUNT.
478
- *
479
- * Public and unauthenticated like {@link getRoutingChallenge}: an account id
480
- * is `H(genesis(root_pk))` and travels in the clear in every device-link op,
481
- * so naming one here reveals nothing and authorises nothing. MDMA re-derives
482
- * the account from the certificate and refuses a mismatch.
483
- */
484
- async getAccountRelaysChallenge(accountId) {
485
- const body = await this.request('GET', `/api/cloud/accounts/${pathSegment(accountId)}/challenge`, {
486
- anonymous: true,
487
- });
488
- return {
489
- accountId: String(body.account_id ?? accountId),
490
- nonce: String(body.nonce ?? ''),
491
- expiresAtMs: Number(body.expires_at_ms ?? 0),
492
- };
493
- }
494
- /**
495
- * Which relays serve this account — the bootstrap, and nothing more.
496
- *
497
- * A device holding a certificate can resolve a namespace it can *name*
498
- * ({@link getNamespaceRouting}) and can ask a node it can *reach* what its
499
- * account belongs to. It starts with neither. This closes that one gap.
500
- *
501
- * **It does not return namespaces, deliberately.** The node answers that from
502
- * the governance DAG, scoped to the calling account: log in at one of these
503
- * relays with an account proof and read its namespace listing. A cloud-side
504
- * copy would be a second source of truth built from rows that linger for an
505
- * account which left a namespace.
506
- *
507
- * So the sequence is: this → log in at a relay → ask it what you are in.
508
- *
509
- * Proven with the **device certificate**, not a cloud session, so it needs a
510
- * `routingCredential` and throws without one rather than returning an empty
511
- * list — "you configured no credential" and "you have no relays" are
512
- * different answers and must not look alike.
513
- */
514
- async getAccountRelays(accountId) {
515
- if (!this.routingCredential) {
516
- throw new Error('getAccountRelays needs a routingCredential: construct the client with the device ' +
517
- 'certificate and its signing secret, since this read is proven by the device, not by a session.');
518
- }
519
- const challenge = await this.getAccountRelaysChallenge(accountId);
520
- const body = await this.request('GET', `/api/cloud/accounts/${pathSegment(accountId)}/relays`, {
521
- anonymous: true,
522
- headers: await routingProofHeaders(challenge, this.routingCredential),
523
- });
524
- return (body.relays ?? []).map((row) => ({
525
- peerId: String(row.peer_id ?? ''),
526
- relayUrl: row.relay_url ?? null,
527
- fresh: row.fresh === true,
528
- executorAccount: accountHexOrNull(row.executor_account),
529
- assigned: row.assigned === true,
530
- }));
531
- }
532
- /**
533
- * The node to send a signed join to, chosen from those an invitation names.
534
- *
535
- * Pass the invitation's `admitters` and the intersection is done here, because
536
- * skipping it is the failure this method exists to prevent: the cloud lists
537
- * every node assigned to the namespace, while `admitters` is a snapshot signed
538
- * when the invitation was minted. A node added afterwards looks perfect in
539
- * every field here and answers 403.
540
- *
541
- * Omit `admitters` only for an invitation that names none — an empty list on
542
- * the wire authorises any node to admit.
543
- */
544
- async findAdmitter(namespaceId, admitters) {
545
- const { nodes } = await this.getNamespaceRouting(namespaceId);
546
- const allowed = admitters && admitters.length > 0 ? new Set(admitters.map((a) => a.toLowerCase())) : null;
547
- return (nodes.find((n) => n.canAdmit &&
548
- (allowed === null || (n.account !== null && allowed.has(n.account.toLowerCase())))) ?? null);
549
- }
550
- /**
551
- * Every attested machine currently serving something this account owns.
552
- *
553
- * The account-wide view of the same rows {@link getNamespaceRelays} returns
554
- * per namespace, keyed by machine — so a UI can answer "what is running for
555
- * me?" in one call. `canExecute` folds the three-way precondition, so a
556
- * caller does not re-derive it.
557
- *
558
- * Only the caller's own namespaces appear on each machine. A plan's
559
- * `usersPerMachine` is greater than one, so a fleet node routinely serves
560
- * several accounts at once.
561
- */
562
- async getMyMachines() {
563
- const body = await this.request('GET', '/api/cloud/me/machines');
564
- return (body.machines ?? []).map((row) => {
565
- const relayUrl = row.relay_url ?? null;
566
- const executorAccount = row.executor_account ?? null;
567
- const namespaces = (Array.isArray(row.namespaces) ? row.namespaces : []).map((n) => {
568
- const ns = n;
569
- return {
570
- namespaceId: String(ns.namespace_id ?? ''),
571
- status: String(ns.status ?? ''),
572
- authorshipReady: ns.authorship_ready === true,
573
- teeRole: teeRoleOf(ns),
574
- fresh: ns.fresh === true,
575
- confirmedAt: ns.confirmed_at ?? null,
576
- lastSeenAt: ns.last_seen_at ?? null,
577
- };
578
- });
579
- return {
580
- peerId: String(row.peer_id ?? ''),
581
- relayUrl,
582
- executorAccount,
583
- canExecute: canExecuteOf(row, relayUrl !== null && executorAccount !== null && namespaces.some((n) => n.authorshipReady)),
584
- namespaces,
585
- };
586
- });
587
- }
588
- /**
589
- * Claim a namespace under this account, proving ownership.
590
- *
591
- * The cloud has no other way to learn a namespace exists — namespaces are
592
- * created on a node, and there is deliberately no "create namespace" in the
593
- * cloud. `ownershipProof` comes from
594
- * `admin.issueNamespaceOwnershipProof(namespaceId, …)` on a node that is a
595
- * **direct admin** of that namespace; merod refuses to issue one otherwise.
596
- *
597
- * Pass merod's response object straight through. The cloud accepts core's
598
- * `signerPublicKey`/`signedPayload` casing as well as snake_case, so there is
599
- * no re-keying step — and getting that wrong used to surface as a `422`
600
- * naming three missing fields. Passing it whole also carries the `founding`
601
- * and `credential` merod attaches: the cloud accepts a namespace proof only
602
- * from a node of the account that founded the namespace, checked with those
603
- * two, and only when that account is linked to this login. A node that
604
- * predates them, or an admin that did not found the namespace, gets a `422`.
605
- *
606
- * Idempotent for the same account. A namespace already claimed by someone
607
- * else is a `409`; a replayed proof nonce is a `403`.
608
- */
609
- async claimNamespace(namespaceId, ownershipProof) {
610
- await this.request('POST', '/api/cloud/namespaces/claim', {
611
- body: { namespace_id: namespaceId, ownership_proof: ownershipProof },
612
- });
613
- }
614
- /**
615
- * Ask the fleet to host a claimed namespace — the step that gets a relay
616
- * assigned.
617
- *
618
- * Carries no proof: {@link claimNamespace} already established ownership,
619
- * and this is the separate "now host it" half. Idempotent.
620
- *
621
- * `admitterAddrs` is where the owner's own node can be dialed — take it from
622
- * that node's network status with {@link admitterAddrsFromNetworkStatus}.
623
- * The first fleet node placed on a namespace has nobody but the owner to ask
624
- * for admission, and without these it can only broadcast and hope the
625
- * owner's node — often a laptop behind a relay — hears it. They are a dial
626
- * hint, not a grant: the owner's node admits by its own policy. Omit them to
627
- * leave whatever the cloud already holds; pass them again when the node's
628
- * relay address changes, and they replace the stored ones.
629
- *
630
- * Not to be confused with {@link enableHA}, which opens the cloud's web flow
631
- * in a browser tab for a user to complete by hand. This is the API call.
632
- */
633
- async enableNamespaceHa(namespaceId, options = {}) {
634
- const body = options.admitterAddrs === undefined
635
- ? {}
636
- : { admitter_addrs: [...options.admitterAddrs] };
637
- return this.request('POST', `/api/cloud/namespaces/${pathSegment(namespaceId)}/enable-ha`, { body });
638
- }
639
- /**
640
- * Enable HA for a namespace this ACCOUNT founded through a relay, with the
641
- * founder's claim (`signAccountOwnershipClaim`) instead of a node's
642
- * ownership proof: a nodeless account holds no node that could issue one.
643
- * The login must be linked to that account.
644
- */
645
- async enableFoundedNamespaceHa(namespaceId, ownershipProof) {
646
- return this.request('POST', `/api/cloud/me/namespaces/${pathSegment(namespaceId)}/enable-ha`, { body: { groups: [], ownership_proof: ownershipProof } });
647
- }
648
- /**
649
- * Enable HA for a namespace this account founded, with **no cloud session**.
650
- *
651
- * The anonymous sibling of {@link enableFoundedNamespaceHa}: it signs the
652
- * founder's claim itself ({@link signAccountHaClaim} — no `subject`, its own
653
- * audience) and posts it to
654
- * `/api/cloud/accounts/{accountId}/namespaces/{namespaceId}/enable-ha`. The
655
- * cloud bills the user the account is linked to through the wallet, so the
656
- * account must be linked to exactly one:
657
- *
658
- * - not linked at all → {@link AccountNotLinkedError};
659
- * - linked to several → {@link AccountLinkedToSeveralUsersError}.
660
- *
661
- * A claim that does not verify is a `403` `HTTPError`; quota and the rest
662
- * answer as the session route does. Returns the cloud's response body, the
663
- * same shape the session route's enable-ha returns.
664
- */
665
- async enableHaAsAccount(options) {
666
- const proof = await signAccountHaClaim({
667
- namespaceId: options.namespaceId,
668
- accountId: options.accountId,
669
- salt: options.salt,
670
- credential: options.credential,
671
- deviceSecret: options.deviceSecret,
672
- signer: options.signer,
673
- ttlMs: options.ttlMs,
674
- relayUrl: options.relayUrl,
675
- });
676
- // The path carries the ids as the claim does — lowercase hex — so a caller
677
- // that kept them uppercase does not address a different resource.
678
- const account = options.accountId.toLowerCase();
679
- const namespace = options.namespaceId.toLowerCase();
680
- try {
681
- return await this.request('POST', `/api/cloud/accounts/${pathSegment(account)}/namespaces/${pathSegment(namespace)}/enable-ha`, { body: { ownership_proof: proof }, anonymous: true });
682
- }
683
- catch (err) {
684
- if (err instanceof HTTPError && ACCOUNT_HA_REFUSALS[err.status]) {
685
- const code = conflictCode(err.bodyText);
686
- const Typed = ACCOUNT_HA_REFUSALS[err.status].find((t) => t.CODE === code);
687
- if (Typed && code) {
688
- throw new Typed(code, err.status, err.statusText, err.url, err.headers, err.bodyText);
689
- }
690
- }
691
- throw err;
692
- }
693
- }
694
- /**
695
- * Stop hosting a claimed namespace.
696
- *
697
- * The fleet's own reconcile loop does the rest: each assigned node notices
698
- * the namespace is no longer assigned to it and **self-leaves**, which makes
699
- * core evict it and purge its local data and keys. So this is not just a
700
- * billing flip.
701
- */
702
- async disableNamespaceHa(namespaceId) {
703
- return this.request('POST', `/api/cloud/namespaces/${pathSegment(namespaceId)}/disable-ha`, { body: {} });
704
- }
705
- /**
706
- * The node assigned to the signed-in user, or `null`.
707
- *
708
- * @deprecated Use {@link getMyMachines}. This reads the cloud's retired
709
- * `NodeAssignment` ledger, which nothing has written since the v1
710
- * retirement, so it answers `null` for every namespace-native account —
711
- * which reads as "you have no machine" when the truth is "that is no longer
712
- * how a machine is assigned".
713
- */
714
- async getMyNode() {
715
- const body = await this.request('GET', '/api/cloud/me/node');
716
- return body.node ?? null;
717
- }
718
- enableHA(options) {
719
- const params = new URLSearchParams({
720
- group_id: options.groupId,
721
- context_id: options.contextId,
722
- });
723
- if (options.redirectUrl) {
724
- params.set('redirect_url', options.redirectUrl);
725
- }
726
- window.open(`${this.baseUrl}/enable-ha?${params.toString()}`);
727
- }
728
- disableHA(options) {
729
- const params = new URLSearchParams({
730
- group_id: options.groupId,
731
- context_id: options.contextId,
732
- });
733
- if (options.redirectUrl) {
734
- params.set('redirect_url', options.redirectUrl);
735
- }
736
- window.open(`${this.baseUrl}/disable-ha?${params.toString()}`);
737
- }
738
- /** Record a freshly-issued session and tell the app to persist it. */
739
- /**
740
- * Link a Calimero account to this cloud login, proving possession of its root.
741
- *
742
- * Two hops, folded into one call because they are useless apart: fetch a
743
- * single-use challenge, then sign it with the account root and post the proof.
744
- * The secret never leaves this process — only a signature over a nonce the
745
- * cloud minted does.
746
- *
747
- * This is the binding the cloud bills against. A relay does not need it to run
748
- * an intent; the cloud needs it to know whose plan a hosted node is spending,
749
- * and to sign that account back in later.
750
- *
751
- * @param root the account root: its 64-hex secret — from
752
- * {@link generateAccountRoot}, a restored recovery phrase, or a desktop app
753
- * or hardware key that already holds one — or a `Signer` over a root this
754
- * process cannot read, which is what a phrase-held browser root is.
755
- *
756
- * Idempotent for the same login. An account already linked to a *different*
757
- * cloud login is a `409`; a replayed or expired challenge is a `403`; and
758
- * exceeding the plan's account limit is a `402`.
759
- */
760
- async linkAccount(root) {
761
- const { signer, publicKey, accountId } = await resolveRoot(root);
762
- const { nonce } = await this.getAccountLinkChallenge();
763
- return this.submitAccountLink({
764
- accountId,
765
- rootPublicKey: publicKey,
766
- nonce,
767
- signature: await signAccountLink({ signer, nonce }),
768
- });
769
- }
770
- /**
771
- * Mint a single-use challenge for an account root to sign.
772
- *
773
- * The split half of {@link linkAccount}, for the case that call cannot serve:
774
- * a root held by a desktop app, a hardware key or an air-gapped machine, which
775
- * signs without the secret ever reaching this process. Fetch the challenge
776
- * here, have the holder sign it, then hand the proof to
777
- * {@link submitAccountLink}.
778
- *
779
- * Bound to the signed-in login and short-lived, so it is not a standing
780
- * capability if it leaks — but it IS single-use, so a challenge fetched and
781
- * abandoned is spent.
782
- */
783
- async getAccountLinkChallenge() {
784
- const body = await this.request('GET', '/api/cloud/me/accounts/challenge');
785
- return { nonce: body.nonce, expiresAtMs: body.expires_at_ms ?? null };
786
- }
787
- /**
788
- * Post a proof assembled elsewhere. The other half of
789
- * {@link getAccountLinkChallenge}.
790
- *
791
- * `accountId` must be the account `rootPublicKey` names — the cloud refuses a
792
- * valid signature by a key that names a different account, or a caller could
793
- * prove one root and link somebody else's account. Derive both from the same
794
- * key rather than letting a user type either.
795
- */
796
- async submitAccountLink(proof) {
797
- const body = await this.request('POST', '/api/cloud/me/accounts', {
798
- body: {
799
- account_id: proof.accountId,
800
- root_public_key: proof.rootPublicKey,
801
- nonce: proof.nonce,
802
- signature: proof.signature,
803
- },
804
- });
805
- return {
806
- accountId: String(body.account_id ?? proof.accountId),
807
- linkedAt: body.linked_at ?? null,
808
- alreadyLinked: body.already_linked === true,
809
- };
810
- }
811
- /**
812
- * Where to send someone to authorise linking this account, and what to expect
813
- * back. Pairs with {@link readAccountLinkCallback} and
814
- * {@link linkAccountWithGrant}.
815
- *
816
- * This exists because a browser app is in a bind: it holds an account root and
817
- * can never hold a *first* cloud session, since only Google issues one. So the
818
- * person goes to the cloud, where they are signed in, and consents there. What
819
- * comes back is a grant — consent to link this one account, spendable only by
820
- * whoever can sign with its root.
821
- *
822
- * @param callbackUrl where the portal sends the person back. Must be an origin
823
- * the cloud allows; unlisted ones are refused when the grant is minted, not
824
- * here. The grant arrives in the URL **fragment**, which browsers do not
825
- * send to servers.
826
- *
827
- * Static, so it needs no client and no session: there is nothing to
828
- * authenticate as yet.
829
- */
830
- static async accountLinkHandoff(options) {
831
- const portal = options.portalUrl.replace(/\/+$/, '');
832
- const { callbackUrl, challenge } = await beginLinkState(options.accountId, options.callbackUrl);
833
- const query = new URLSearchParams({
834
- 'link-account': options.accountId,
835
- challenge,
836
- 'callback-url': callbackUrl,
837
- });
838
- return { url: `${portal}/?${query.toString()}`, accountId: options.accountId };
839
- }
840
- /**
841
- * Read the portal's answer out of a callback URL's fragment.
842
- *
843
- * Returns `{}` when there is nothing to read, so it is safe to call on every
844
- * load — which is how a redirected app finds out it has been sent back.
845
- *
846
- * A grant is only returned when it answers a handoff this browser began, for the
847
- * account that handoff named; anything else comes back as
848
- * `error: 'unexpected_callback'`, because the redirect URL can be forwarded.
849
- *
850
- * @param href defaults to the current location, when there is one.
851
- */
852
- static readAccountLinkCallback(href) {
853
- const source = href ?? (typeof window === 'undefined' ? '' : window.location.href);
854
- if (!source)
855
- return {};
856
- let hash;
857
- let search;
858
- try {
859
- ({ hash, search } = new URL(source));
860
- }
861
- catch {
862
- return {};
863
- }
864
- const params = new URLSearchParams(hash.replace(/^#/, ''));
865
- const grant = params.get('grant');
866
- const accountId = params.get('account');
867
- const error = params.get('error');
868
- const out = {};
869
- if (error)
870
- takeLinkVerifier(search, null);
871
- if (grant) {
872
- const verifier = takeLinkVerifier(search, accountId);
873
- const claims = linkGrantClaims(grant);
874
- if (!verifier || claims?.account !== accountId)
875
- return { error: 'unexpected_callback' };
876
- out.grant = grant;
877
- out.login = claims.login;
878
- out.verifier = verifier;
879
- }
880
- if (accountId)
881
- out.accountId = accountId;
882
- if (error)
883
- out.error = error;
884
- return out;
885
- }
886
- /**
887
- * Spend a link grant: sign it with the account root and post the pair.
888
- *
889
- * Anonymous, and it has to be — the app doing this holds no cloud session,
890
- * which is the whole reason the grant exists. The grant carries the consenting
891
- * login sealed inside it, so nothing here names one; a caller-supplied email
892
- * would be a field to lie in.
893
- *
894
- * The signature is an ordinary account-link signature with the grant as the
895
- * nonce, so the same {@link signAccountLink} every other link path uses signs
896
- * this one. The grant is bound to the verifier of the browser that asked for
897
- * it, and is refused here, before the root signs, unless it carries that
898
- * verifier's challenge and names this root's account.
899
- *
900
- * A `403` means the grant was bad, expired, already spent, or signed by a key
901
- * that names a different account; `409` that the account belongs to another
902
- * login; `402` that the plan is full.
903
- */
904
- async linkAccountWithGrant(options) {
905
- if (typeof options.verifier !== 'string' || !VERIFIER_HEX.test(options.verifier)) {
906
- throw new Error('no verifier for this grant - start the link again from this browser');
907
- }
908
- const claims = linkGrantClaims(options.grant);
909
- if (claims?.challenge !== (await linkChallenge(options.verifier))) {
910
- throw new Error('this grant was not issued to this link request - start the link again from this browser');
911
- }
912
- const { signer, publicKey, accountId } = await resolveRootPair(options.rootSecret, options.signer);
913
- if (claims.account !== accountId) {
914
- throw new Error(`this grant is for account ${claims.account}, but the root here derives ` +
915
- `${accountId} - it was minted for a different account`);
916
- }
917
- const body = await this.request('POST', '/api/cloud/accounts/link', {
918
- body: {
919
- grant: options.grant,
920
- verifier: options.verifier,
921
- root_public_key: publicKey,
922
- signature: await signAccountLink({
923
- signer,
924
- nonce: options.grant,
925
- }),
926
- },
927
- anonymous: true,
928
- });
929
- return {
930
- accountId: String(body.account_id ?? accountId),
931
- linkedAt: body.linked_at ?? null,
932
- alreadyLinked: body.already_linked === true,
933
- };
934
- }
935
- /** The accounts linked to this login, and how many the plan allows. */
936
- async getMyAccounts() {
937
- const body = await this.request('GET', '/api/cloud/me/accounts');
938
- return {
939
- accounts: (body.accounts ?? []).map((row) => ({
940
- accountId: String(row.account_id ?? ''),
941
- linkedAt: row.linked_at ?? null,
942
- hasRecoveryEnvelope: row.has_recovery_envelope === true,
943
- })),
944
- limit: body.limit ?? null,
945
- };
946
- }
947
- adoptSession(body) {
948
- this.session = {
949
- sessionToken: body.session_token,
950
- expiresAt: body.expires_at,
951
- user: body.user,
952
- };
953
- void this.notifySession(this.session);
954
- return this.session;
955
- }
956
- async notifySession(session) {
957
- try {
958
- await this.onSession?.(session);
959
- }
960
- catch {
961
- // A failing persistence callback must not mask the call's own result.
962
- }
963
- }
964
- async request(method, path, options = {}) {
965
- if (!options.anonymous && !this.session) {
966
- throw new Error('Not signed in to Calimero Cloud: call signInWithGoogle() or construct the client with a sessionToken');
967
- }
968
- // Caller headers go in FIRST, so the lines below win on any collision: a
969
- // per-call header must never be able to replace the session's
970
- // `Authorization` or change how the body is parsed.
971
- const headers = {
972
- ...options.headers,
973
- Accept: 'application/json',
974
- };
975
- if (options.body !== undefined)
976
- headers['Content-Type'] = 'application/json';
977
- if (!options.anonymous && this.session) {
978
- headers.Authorization = `Bearer ${this.session.sessionToken}`;
979
- }
980
- const controller = new AbortController();
981
- const timer = setTimeout(() => controller.abort(), this.timeoutMs);
982
- let response;
983
- try {
984
- response = await this.fetchImpl(`${this.baseUrl}${path}`, {
985
- method,
986
- headers,
987
- body: options.body === undefined ? undefined : JSON.stringify(options.body),
988
- signal: controller.signal,
989
- });
990
- }
991
- catch (err) {
992
- // Same shape the node clients use for a transport failure: an `HTTPError`
993
- // with `status: 0`, so a caller has one error type to handle rather than
994
- // one per layer.
995
- throw new HTTPError(0, err instanceof Error ? err.name : 'NetworkError', `${this.baseUrl}${path}`, new Headers(), err instanceof Error ? err.message : String(err));
996
- }
997
- finally {
998
- clearTimeout(timer);
999
- }
1000
- this.adoptRollingRefresh(response);
1001
- if (!response.ok) {
1002
- const bodyText = await response.text().catch(() => undefined);
1003
- throw new HTTPError(response.status, response.statusText, `${this.baseUrl}${path}`, response.headers, bodyText);
1004
- }
1005
- if (options.parse === 'none' || response.status === 204)
1006
- return undefined;
1007
- return (await response.json());
1008
- }
1009
- /**
1010
- * Adopt a session token the server refreshed mid-request.
1011
- *
1012
- * MDMA rotates the token on activity and returns the replacement in a
1013
- * response header rather than making the client ask. Ignoring it costs
1014
- * nothing immediately and then logs the user out a week later, which is the
1015
- * kind of bug that is impossible to reproduce in a short session.
1016
- *
1017
- * The user identity is carried over: the header holds only a token, and the
1018
- * refresh is the same logical session as the sign-in that named the user.
1019
- */
1020
- adoptRollingRefresh(response) {
1021
- const token = response.headers.get(REFRESH_HEADER);
1022
- if (!token || !this.session)
1023
- return;
1024
- const expiresRaw = response.headers.get(REFRESH_EXPIRES_HEADER);
1025
- const expiresAt = Number(expiresRaw);
1026
- this.session = {
1027
- sessionToken: token,
1028
- expiresAt: Number.isFinite(expiresAt) && expiresAt > 0 ? expiresAt : this.session.expiresAt,
1029
- user: this.session.user,
1030
- };
1031
- void this.notifySession(this.session);
1032
- }
1033
- }
1034
- /**
1035
- * The addresses a fleet node can dial to ask this node for admission, from the
1036
- * node's `GET /admin-api/network/status` (see `AdminApiClient`).
1037
- *
1038
- * Its `externalAddrs`, each made to end in `/p2p/<localPeerId>`: that is the
1039
- * shape core dials, and an external address is usually reported without it. A
1040
- * relay-circuit address already contains `/p2p/` — the relay's — so the test is
1041
- * whether it ends in this node's own id, not whether it names a peer at all.
1042
- * Anything that is not a multiaddr is dropped. Empty when the status names no
1043
- * peer id or no external address, which is an honest "not dialable yet".
1044
- */
1045
- export function admitterAddrsFromNetworkStatus(status) {
1046
- const record = status && typeof status === 'object'
1047
- ? (status.data ?? status)
1048
- : undefined;
1049
- if (!record || typeof record !== 'object')
1050
- return [];
1051
- const peerId = record.localPeerId;
1052
- const external = record.externalAddrs;
1053
- if (typeof peerId !== 'string' || !peerId || !Array.isArray(external))
1054
- return [];
1055
- const suffix = `/p2p/${peerId}`;
1056
- const out = [];
1057
- for (const entry of external) {
1058
- if (typeof entry !== 'string')
1059
- continue;
1060
- const addr = entry.trim();
1061
- if (!addr.startsWith('/'))
1062
- continue;
1063
- const dialable = addr.endsWith(suffix) ? addr : `${addr}${suffix}`;
1064
- if (!out.includes(dialable))
1065
- out.push(dialable);
1066
- }
1067
- return out;
1068
- }