@calimero-network/mero-js 25.0.3 → 25.0.5

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.
@@ -1,3 +1,4 @@
1
+ import { pathSegment } from '../http-client/path-segment.js';
1
2
  export class AuthApiClient {
2
3
  constructor(httpClient) {
3
4
  this.httpClient = httpClient;
@@ -89,7 +90,7 @@ export class AuthApiClient {
89
90
  return response.data;
90
91
  }
91
92
  async deleteRootKey(keyId) {
92
- return this.httpClient.delete(`/admin/keys/${keyId}`);
93
+ return this.httpClient.delete(`/admin/keys/${pathSegment(keyId)}`);
93
94
  }
94
95
  // Client Management Endpoints
95
96
  async listClientKeys() {
@@ -103,14 +104,14 @@ export class AuthApiClient {
103
104
  return this.httpClient.post('/admin/client-key', request);
104
105
  }
105
106
  async deleteClientKey(keyId, clientId) {
106
- return this.httpClient.delete(`/admin/keys/${keyId}/clients/${clientId}`);
107
+ return this.httpClient.delete(`/admin/keys/${pathSegment(keyId)}/clients/${pathSegment(clientId)}`);
107
108
  }
108
109
  // Permission Management Endpoints
109
110
  async getKeyPermissions(keyId) {
110
- return this.httpClient.get(`/admin/keys/${keyId}/permissions`);
111
+ return this.httpClient.get(`/admin/keys/${pathSegment(keyId)}/permissions`);
111
112
  }
112
113
  async updateKeyPermissions(keyId, changes) {
113
114
  // Core expects an { add, remove } delta, not a { permissions } replacement.
114
- return this.httpClient.put(`/admin/keys/${keyId}/permissions`, { add: changes.add, remove: changes.remove });
115
+ return this.httpClient.put(`/admin/keys/${pathSegment(keyId)}/permissions`, { add: changes.add, remove: changes.remove });
115
116
  }
116
117
  }
@@ -383,7 +383,7 @@ export interface AccountLinkCallback {
383
383
  grant?: string;
384
384
  /** The account the grant is for, as the portal echoed it. */
385
385
  accountId?: string;
386
- /** `'denied'` when the person cancelled. Absent on success. */
386
+ /** `'denied'` when the person cancelled, `'unexpected_callback'` for a grant this browser never asked for. */
387
387
  error?: string;
388
388
  }
389
389
  /** A single-use challenge for an account root to sign itself in with. */
@@ -876,6 +876,10 @@ export declare class CloudClient {
876
876
  * Returns `{}` when there is nothing to read, so it is safe to call on every
877
877
  * load — which is how a redirected app finds out it has been sent back.
878
878
  *
879
+ * A grant is only returned when it answers a handoff this browser began, for the
880
+ * account that handoff named; anything else comes back as
881
+ * `error: 'unexpected_callback'`, because the redirect URL can be forwarded.
882
+ *
879
883
  * @param href defaults to the current location, when there is one.
880
884
  */
881
885
  static readAccountLinkCallback(href?: string): AccountLinkCallback;
@@ -27,6 +27,8 @@
27
27
  import { signAccountHaClaim } from './account-ownership.js';
28
28
  import { resolveRoot, resolveRootPair, signAccountLink, signAccountLogin, } from '../account/index.js';
29
29
  import { HTTPError } from '../http-client/web-client.js';
30
+ import { pathSegment } from '../http-client/path-segment.js';
31
+ import { ACCOUNT_HEX, beginLinkState, takeLinkState } from './link-state.js';
30
32
  import { routingProofHeaders, } from './routing-proof.js';
31
33
  /**
32
34
  * The hosted cloud's **API** when a caller names no other.
@@ -87,7 +89,6 @@ function joinOf(row) {
87
89
  reportedAt: typeof join.reported_at === 'string' ? join.reported_at : null,
88
90
  };
89
91
  }
90
- const ACCOUNT_HEX = /^[0-9a-f]{64}$/;
91
92
  function accountHexOrNull(value) {
92
93
  return typeof value === 'string' && ACCOUNT_HEX.test(value) ? value : null;
93
94
  }
@@ -352,7 +353,7 @@ export class CloudClient {
352
353
  * monotonic sequence on it.
353
354
  */
354
355
  async getNamespaceRelays(namespaceId) {
355
- const body = await this.request('GET', `/api/cloud/me/namespaces/${encodeURIComponent(namespaceId)}/relays`);
356
+ const body = await this.request('GET', `/api/cloud/me/namespaces/${pathSegment(namespaceId)}/relays`);
356
357
  return (body.relays ?? []).map((row) => {
357
358
  const relayUrl = row.relay_url ?? null;
358
359
  const executorAccount = row.executor_account ?? null;
@@ -408,7 +409,7 @@ export class CloudClient {
408
409
  * a node holding the namespace, so there is no hosted admitter.
409
410
  */
410
411
  async getNamespaceRouting(namespaceId) {
411
- const body = await this.request('GET', `/api/cloud/namespaces/${encodeURIComponent(namespaceId)}/admitters`, {
412
+ const body = await this.request('GET', `/api/cloud/namespaces/${pathSegment(namespaceId)}/admitters`, {
412
413
  anonymous: true,
413
414
  headers: await this.routingProof(namespaceId),
414
415
  });
@@ -449,7 +450,7 @@ export class CloudClient {
449
450
  * challenge somewhere this client cannot reach the key.
450
451
  */
451
452
  async getRoutingChallenge(namespaceId) {
452
- const body = await this.request('GET', `/api/cloud/namespaces/${encodeURIComponent(namespaceId)}/challenge`, {
453
+ const body = await this.request('GET', `/api/cloud/namespaces/${pathSegment(namespaceId)}/challenge`, {
453
454
  anonymous: true,
454
455
  });
455
456
  return {
@@ -481,7 +482,7 @@ export class CloudClient {
481
482
  * the account from the certificate and refuses a mismatch.
482
483
  */
483
484
  async getAccountRelaysChallenge(accountId) {
484
- const body = await this.request('GET', `/api/cloud/accounts/${encodeURIComponent(accountId)}/challenge`, {
485
+ const body = await this.request('GET', `/api/cloud/accounts/${pathSegment(accountId)}/challenge`, {
485
486
  anonymous: true,
486
487
  });
487
488
  return {
@@ -516,7 +517,7 @@ export class CloudClient {
516
517
  'certificate and its signing secret, since this read is proven by the device, not by a session.');
517
518
  }
518
519
  const challenge = await this.getAccountRelaysChallenge(accountId);
519
- const body = await this.request('GET', `/api/cloud/accounts/${encodeURIComponent(accountId)}/relays`, {
520
+ const body = await this.request('GET', `/api/cloud/accounts/${pathSegment(accountId)}/relays`, {
520
521
  anonymous: true,
521
522
  headers: await routingProofHeaders(challenge, this.routingCredential),
522
523
  });
@@ -633,7 +634,7 @@ export class CloudClient {
633
634
  const body = options.admitterAddrs === undefined
634
635
  ? {}
635
636
  : { admitter_addrs: [...options.admitterAddrs] };
636
- return this.request('POST', `/api/cloud/namespaces/${encodeURIComponent(namespaceId)}/enable-ha`, { body });
637
+ return this.request('POST', `/api/cloud/namespaces/${pathSegment(namespaceId)}/enable-ha`, { body });
637
638
  }
638
639
  /**
639
640
  * Enable HA for a namespace this ACCOUNT founded through a relay, with the
@@ -642,7 +643,7 @@ export class CloudClient {
642
643
  * The login must be linked to that account.
643
644
  */
644
645
  async enableFoundedNamespaceHa(namespaceId, ownershipProof) {
645
- return this.request('POST', `/api/cloud/me/namespaces/${encodeURIComponent(namespaceId)}/enable-ha`, { body: { groups: [], ownership_proof: ownershipProof } });
646
+ return this.request('POST', `/api/cloud/me/namespaces/${pathSegment(namespaceId)}/enable-ha`, { body: { groups: [], ownership_proof: ownershipProof } });
646
647
  }
647
648
  /**
648
649
  * Enable HA for a namespace this account founded, with **no cloud session**.
@@ -677,7 +678,7 @@ export class CloudClient {
677
678
  const account = options.accountId.toLowerCase();
678
679
  const namespace = options.namespaceId.toLowerCase();
679
680
  try {
680
- return await this.request('POST', `/api/cloud/accounts/${encodeURIComponent(account)}/namespaces/${encodeURIComponent(namespace)}/enable-ha`, { body: { ownership_proof: proof }, anonymous: true });
681
+ return await this.request('POST', `/api/cloud/accounts/${pathSegment(account)}/namespaces/${pathSegment(namespace)}/enable-ha`, { body: { ownership_proof: proof }, anonymous: true });
681
682
  }
682
683
  catch (err) {
683
684
  if (err instanceof HTTPError && ACCOUNT_HA_REFUSALS[err.status]) {
@@ -699,7 +700,7 @@ export class CloudClient {
699
700
  * billing flip.
700
701
  */
701
702
  async disableNamespaceHa(namespaceId) {
702
- return this.request('POST', `/api/cloud/namespaces/${encodeURIComponent(namespaceId)}/disable-ha`, { body: {} });
703
+ return this.request('POST', `/api/cloud/namespaces/${pathSegment(namespaceId)}/disable-ha`, { body: {} });
703
704
  }
704
705
  /**
705
706
  * The node assigned to the signed-in user, or `null`.
@@ -830,7 +831,7 @@ export class CloudClient {
830
831
  const portal = options.portalUrl.replace(/\/+$/, '');
831
832
  const query = new URLSearchParams({
832
833
  'link-account': options.accountId,
833
- 'callback-url': options.callbackUrl,
834
+ 'callback-url': beginLinkState(options.accountId, options.callbackUrl),
834
835
  });
835
836
  return { url: `${portal}/?${query.toString()}`, accountId: options.accountId };
836
837
  }
@@ -840,6 +841,10 @@ export class CloudClient {
840
841
  * Returns `{}` when there is nothing to read, so it is safe to call on every
841
842
  * load — which is how a redirected app finds out it has been sent back.
842
843
  *
844
+ * A grant is only returned when it answers a handoff this browser began, for the
845
+ * account that handoff named; anything else comes back as
846
+ * `error: 'unexpected_callback'`, because the redirect URL can be forwarded.
847
+ *
843
848
  * @param href defaults to the current location, when there is one.
844
849
  */
845
850
  static readAccountLinkCallback(href) {
@@ -847,8 +852,9 @@ export class CloudClient {
847
852
  if (!source)
848
853
  return {};
849
854
  let hash;
855
+ let search;
850
856
  try {
851
- hash = new URL(source).hash;
857
+ ({ hash, search } = new URL(source));
852
858
  }
853
859
  catch {
854
860
  return {};
@@ -858,6 +864,11 @@ export class CloudClient {
858
864
  const accountId = params.get('account');
859
865
  const error = params.get('error');
860
866
  const out = {};
867
+ if (error)
868
+ takeLinkState(search);
869
+ if (grant && takeLinkState(search)?.account !== accountId) {
870
+ return { error: 'unexpected_callback' };
871
+ }
861
872
  if (grant)
862
873
  out.grant = grant;
863
874
  if (accountId)
@@ -45,7 +45,7 @@ export interface BeginCloudLinkOptions {
45
45
  /** The cloud's web origin. Defaults to the hosted one. */
46
46
  cloudWebUrl?: string;
47
47
  }
48
- /** The consent URL to send a person to. */
48
+ /** The consent URL to send a person to. Remembers the link so the callback can be matched. */
49
49
  export declare function cloudLinkUrl(options: BeginCloudLinkOptions): string;
50
50
  /** Send the person to the cloud to consent. Does not return. */
51
51
  export declare function beginCloudLink(options: BeginCloudLinkOptions): never;
@@ -59,11 +59,12 @@ export interface CloudLinkCallback {
59
59
  * Read a grant out of the current URL fragment, and strip it.
60
60
  *
61
61
  * Returns `null` when this is an ordinary page load, so a caller can run it
62
- * unconditionally at startup. Throws when the person declined, because that is
63
- * an outcome the app has to show rather than a state to wait in.
62
+ * unconditionally at startup. Throws when declined or when this browser did not
63
+ * begin the link.
64
64
  */
65
65
  export declare function readCloudLinkCallback(location?: {
66
66
  hash: string;
67
+ search?: string;
67
68
  }): CloudLinkCallback | null;
68
69
  export interface CompleteCloudLinkOptions extends CloudLinkCallback {
69
70
  /** The account root — a 64-hex secret, or a Signer over a key you cannot read. */
@@ -32,16 +32,14 @@
32
32
  */
33
33
  import { CloudClient } from './cloud-client.js';
34
34
  import { resolveRoot } from '../account/account.js';
35
+ import { beginLinkState, takeLinkState, withoutLinkState } from './link-state.js';
35
36
  /** Where the consent screen lives. The web app, not the manager API. */
36
37
  const DEFAULT_CLOUD_WEB_URL = 'https://cloud.calimero.network';
37
- /** The consent URL to send a person to. */
38
+ /** The consent URL to send a person to. Remembers the link so the callback can be matched. */
38
39
  export function cloudLinkUrl(options) {
39
- if (!/^[0-9a-f]{64}$/.test(options.accountId)) {
40
- throw new Error('accountId must be 64 lowercase hex characters');
41
- }
42
40
  const url = new URL(options.cloudWebUrl ?? DEFAULT_CLOUD_WEB_URL);
43
41
  url.searchParams.set('link-account', options.accountId);
44
- url.searchParams.set('callback-url', options.returnTo);
42
+ url.searchParams.set('callback-url', beginLinkState(options.accountId, options.returnTo));
45
43
  return url.toString();
46
44
  }
47
45
  /** Send the person to the cloud to consent. Does not return. */
@@ -55,13 +53,14 @@ export function beginCloudLink(options) {
55
53
  * Read a grant out of the current URL fragment, and strip it.
56
54
  *
57
55
  * Returns `null` when this is an ordinary page load, so a caller can run it
58
- * unconditionally at startup. Throws when the person declined, because that is
59
- * an outcome the app has to show rather than a state to wait in.
56
+ * unconditionally at startup. Throws when declined or when this browser did not
57
+ * begin the link.
60
58
  */
61
59
  export function readCloudLinkCallback(location = window.location) {
62
60
  const params = new URLSearchParams(location.hash.replace(/^#/, ''));
63
61
  const error = params.get('error');
64
62
  if (error) {
63
+ takeLinkState(location.search ?? '');
65
64
  stripFragment();
66
65
  throw new Error(error === 'denied'
67
66
  ? 'the account link was declined at the cloud'
@@ -71,6 +70,10 @@ export function readCloudLinkCallback(location = window.location) {
71
70
  const account = params.get('account');
72
71
  if (!grant || !account)
73
72
  return null;
73
+ if (takeLinkState(location.search ?? '')?.account !== account) {
74
+ stripFragment();
75
+ throw new Error('this account link answers a request this browser did not make - start the link again from here');
76
+ }
74
77
  // Consumed once. Leaving it in the bar means a reload replays a spent grant
75
78
  // and reports a confusing failure instead of the link that already worked.
76
79
  stripFragment();
@@ -79,7 +82,7 @@ export function readCloudLinkCallback(location = window.location) {
79
82
  function stripFragment() {
80
83
  if (typeof history === 'undefined' || !history.replaceState)
81
84
  return;
82
- history.replaceState(null, '', location.pathname + location.search);
85
+ history.replaceState(null, '', location.pathname + withoutLinkState(location.search));
83
86
  }
84
87
  /**
85
88
  * Spend the grant: sign it with the root and hand it back to the cloud.
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Links this browser began, so a callback can be matched to one. `localStorage`,
3
+ * not `sessionStorage`: consent opens in a `noopener` tab with empty session storage.
4
+ */
5
+ export declare const ACCOUNT_HEX: RegExp;
6
+ /** Remember a link for `account` and return `returnTo` carrying its state. */
7
+ export declare function beginLinkState(account: string, returnTo: string): string;
8
+ /** Removes and returns the link a callback answers, or null when this browser did not begin it. */
9
+ export declare function takeLinkState(search: string): {
10
+ account: string;
11
+ } | null;
12
+ /** The query with the link state removed, for rewriting the address bar. */
13
+ export declare function withoutLinkState(search: string): string;
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Links this browser began, so a callback can be matched to one. `localStorage`,
3
+ * not `sessionStorage`: consent opens in a `noopener` tab with empty session storage.
4
+ */
5
+ const KEY = "calimero.cloud.link.pending";
6
+ const STATE_PARAM = "link_state";
7
+ const LIFETIME_MS = 10 * 60 * 1000; // a grant lives 5 minutes, plus time to consent
8
+ export const ACCOUNT_HEX = /^[0-9a-f]{64}$/;
9
+ const MAX_PENDING = 20; // bounds storage when a page builds a consent URL on every render
10
+ function readPending(now) {
11
+ try {
12
+ const raw = localStorage.getItem(KEY);
13
+ const pending = raw ? JSON.parse(raw) : {};
14
+ return Object.fromEntries(Object.entries(pending).filter(([, p]) => p && typeof p.expiresAt === "number" && p.expiresAt > now));
15
+ }
16
+ catch {
17
+ return {};
18
+ }
19
+ }
20
+ function writePending(pending) {
21
+ try {
22
+ localStorage.setItem(KEY, JSON.stringify(pending));
23
+ }
24
+ catch {
25
+ // Storage blocked: the link cannot be matched later and fails closed.
26
+ }
27
+ }
28
+ /** Remember a link for `account` and return `returnTo` carrying its state. */
29
+ export function beginLinkState(account, returnTo) {
30
+ if (!ACCOUNT_HEX.test(account)) {
31
+ throw new Error("accountId must be 64 lowercase hex characters");
32
+ }
33
+ const url = new URL(returnTo);
34
+ const now = Date.now();
35
+ const pending = readPending(now);
36
+ const bytes = crypto.getRandomValues(new Uint8Array(16));
37
+ const state = Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
38
+ pending[state] = { account, expiresAt: now + LIFETIME_MS };
39
+ writePending(Object.fromEntries(Object.entries(pending).slice(-MAX_PENDING)));
40
+ url.searchParams.set(STATE_PARAM, state);
41
+ return url.toString();
42
+ }
43
+ /** Removes and returns the link a callback answers, or null when this browser did not begin it. */
44
+ export function takeLinkState(search) {
45
+ const state = new URLSearchParams(search).get(STATE_PARAM);
46
+ const pending = readPending(Date.now());
47
+ if (!state || !Object.prototype.hasOwnProperty.call(pending, state))
48
+ return null;
49
+ const entry = pending[state];
50
+ delete pending[state];
51
+ writePending(pending);
52
+ return entry;
53
+ }
54
+ /** The query with the link state removed, for rewriting the address bar. */
55
+ export function withoutLinkState(search) {
56
+ const params = new URLSearchParams(search);
57
+ params.delete(STATE_PARAM);
58
+ const rest = params.toString();
59
+ return rest ? `?${rest}` : "";
60
+ }
@@ -0,0 +1 @@
1
+ export declare function pathSegment(id: string): string;
@@ -0,0 +1,8 @@
1
+ // encodeURIComponent leaves "." and ".." intact and URL parsers collapse them,
2
+ // so those (and the empty id, which names the collection route) are refused.
3
+ export function pathSegment(id) {
4
+ if (id === '' || id === '.' || id === '..') {
5
+ throw new TypeError(`invalid request path segment: ${JSON.stringify(id)}`);
6
+ }
7
+ return encodeURIComponent(id);
8
+ }