@calimero-network/mero-js 25.0.2 → 25.0.4

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.
@@ -29,10 +29,8 @@ export declare class NoRelayError extends Error {
29
29
  * cloud's routing, intersected with the invitation's signed admitters, so an
30
30
  * invitation the cloud routes to none of them can only fail in their hands.
31
31
  *
32
- * - `not-hosted` — the cloud serves the namespace on no node AND no relay is a
33
- * member of it, so nothing admits a node-less claimant. A namespace with a
34
- * relay in it is minted for even when the cloud routes it nowhere: the relay
35
- * admits through its own `/admit`, and the invitation carries its address.
32
+ * - `not-hosted` - the cloud lists no node holding the namespace. A relay it
33
+ * does not list cannot stand in: an address on the invitation is unsigned.
36
34
  * - `no-named-node` — the cloud's nodes are not among the admitters this
37
35
  * invitation would name.
38
36
  */
@@ -26,17 +26,15 @@ export class NoRelayError extends Error {
26
26
  * cloud's routing, intersected with the invitation's signed admitters, so an
27
27
  * invitation the cloud routes to none of them can only fail in their hands.
28
28
  *
29
- * - `not-hosted` — the cloud serves the namespace on no node AND no relay is a
30
- * member of it, so nothing admits a node-less claimant. A namespace with a
31
- * relay in it is minted for even when the cloud routes it nowhere: the relay
32
- * admits through its own `/admit`, and the invitation carries its address.
29
+ * - `not-hosted` - the cloud lists no node holding the namespace. A relay it
30
+ * does not list cannot stand in: an address on the invitation is unsigned.
33
31
  * - `no-named-node` — the cloud's nodes are not among the admitters this
34
32
  * invitation would name.
35
33
  */
36
34
  export class InvitationNotClaimableError extends Error {
37
35
  constructor(namespaceId, reason) {
38
36
  super(reason === 'not-hosted'
39
- ? `nobody could claim an invitation to ${namespaceId} yet: the namespace is not hosted in the cloud and has no relay in it, so an invitee with no node has no relay to be admitted through. Link this account to your cloud user in the wallet and enable HA for the namespace, then invite.`
37
+ ? `nobody could claim an invitation to ${namespaceId} yet: the cloud lists no node holding the namespace, so an invitee with no node of their own has no relay to be admitted through.`
40
38
  : `nobody could claim an invitation to ${namespaceId}: the cloud routes to none of the relays or admins it would name as admitters`);
41
39
  this.namespaceId = namespaceId;
42
40
  this.reason = reason;
@@ -264,9 +262,6 @@ export function createAccountAdmin(input, deps = {}) {
264
262
  const relays = members
265
263
  .filter((m) => m.role === 'RelayTee')
266
264
  .map((m) => m.identity.toLowerCase());
267
- // Where the claimant reaches an admitter when the cloud routes them to
268
- // none: set only then, so a routed namespace stays the cloud's to route.
269
- let admitterAddrs;
270
265
  if (routing) {
271
266
  // Who the claimant's client will look for: the admitters this
272
267
  // invitation names (the relays, else signGroupInvitation's admins).
@@ -281,19 +276,9 @@ export function createAccountAdmin(input, deps = {}) {
281
276
  // Nothing learned: the claimant's own lookup will say what is wrong.
282
277
  }
283
278
  if (nodes !== null) {
284
- if (nodes.length === 0) {
285
- // The cloud routes a namespace only once its founder has linked
286
- // the account (HA). Unlinked, the namespace is still served by the
287
- // relay that founded it and serves this session, a RelayTee
288
- // member; its own `/admit` takes the join (measured on prod: 200,
289
- // where the cloud's lookup answered no nodes). The claimant's
290
- // client cannot find that relay through the cloud, so the
291
- // invitation says where it is.
292
- if (relays.length === 0 || !s.relayUrl)
293
- throw new InvitationNotClaimableError(namespaceId, 'not-hosted');
294
- admitterAddrs = [s.relayUrl];
295
- }
296
- else if (!nodes.some((n) => n.account !== null && named.has(n.account.toLowerCase()))) {
279
+ if (nodes.length === 0)
280
+ throw new InvitationNotClaimableError(namespaceId, 'not-hosted');
281
+ if (!nodes.some((n) => n.account !== null && named.has(n.account.toLowerCase()))) {
297
282
  throw new InvitationNotClaimableError(namespaceId, 'no-named-node');
298
283
  }
299
284
  }
@@ -303,7 +288,6 @@ export function createAccountAdmin(input, deps = {}) {
303
288
  inviterAccount: s.account,
304
289
  deviceSecret: s.deviceSecret,
305
290
  ...(relays.length > 0 ? { admitters: relays } : {}),
306
- ...(admitterAddrs ? { admitterAddrs } : {}),
307
291
  members: members,
308
292
  applicationId: info.targetApplicationId,
309
293
  appKey: info.appKey,
@@ -20,16 +20,11 @@
20
20
  *
21
21
  * # Where the node URL comes from, in preference order
22
22
  *
23
- * 1. one passed in — an operator's, or one a future invitation carries. Core
24
- * today emits only libp2p multiaddrs on an invitation (`resolve_admitter_addrs`)
25
- * because a node cannot know the URL its admin API is proxied on, so there is
26
- * nothing to read off the invitation yet and this is the seam for when there
27
- * is;
23
+ * 1. one the caller passes in, such as an operator's or a local rig's;
28
24
  * 2. the cloud's `/api/cloud/namespaces/{ns}/admitters`, intersected with the
29
- * invitation's signed `admitters` — see `resolveRelayFromInvitation`;
30
- * 3. an http(s) origin in the invitation's `admitter_addrs`, when the cloud
31
- * routes the namespace to no node: the relay of a founder who never linked
32
- * the account writes its own origin there when it mints.
25
+ * invitation's signed `admitters`, see `resolveRelayFromInvitation`.
26
+ *
27
+ * Never from the invitation, signed or not: the inviter may be the attacker.
33
28
  *
34
29
  * # Nothing here is minted, weakened or worked around
35
30
  *
@@ -90,10 +85,8 @@ export interface BootstrapFromInvitationInput {
90
85
  /**
91
86
  * A node to present the join to, skipping the cloud lookup.
92
87
  *
93
- * Use it when something already knows the node — an operator, a local rig, or
94
- * an invitation that carries a claim URL once core emits one. Without it the
95
- * cloud's admitters lookup answers, and the invitation's signed list is what
96
- * narrows the answer.
88
+ * For a node the caller already knows (an operator's, a local rig); never one
89
+ * from the invitation. Without it the cloud's lookup answers, narrowed by the signed admitters.
97
90
  */
98
91
  readonly nodeUrl?: string;
99
92
  /** Point at a cloud other than the hosted manager. For local rigs. */
@@ -20,16 +20,11 @@
20
20
  *
21
21
  * # Where the node URL comes from, in preference order
22
22
  *
23
- * 1. one passed in — an operator's, or one a future invitation carries. Core
24
- * today emits only libp2p multiaddrs on an invitation (`resolve_admitter_addrs`)
25
- * because a node cannot know the URL its admin API is proxied on, so there is
26
- * nothing to read off the invitation yet and this is the seam for when there
27
- * is;
23
+ * 1. one the caller passes in, such as an operator's or a local rig's;
28
24
  * 2. the cloud's `/api/cloud/namespaces/{ns}/admitters`, intersected with the
29
- * invitation's signed `admitters` — see `resolveRelayFromInvitation`;
30
- * 3. an http(s) origin in the invitation's `admitter_addrs`, when the cloud
31
- * routes the namespace to no node: the relay of a founder who never linked
32
- * the account writes its own origin there when it mints.
25
+ * invitation's signed `admitters`, see `resolveRelayFromInvitation`.
26
+ *
27
+ * Never from the invitation, signed or not: the inviter may be the attacker.
33
28
  *
34
29
  * # Nothing here is minted, weakened or worked around
35
30
  *
@@ -61,7 +56,6 @@ export async function bootstrapFromInvitation(input) {
61
56
  // separately: two sources for one fact is how an intersection ends up
62
57
  // being performed against the wrong list.
63
58
  admitters: input.invitation.invitation.admitters,
64
- admitterAddrs: input.invitation.admitter_addrs,
65
59
  credential: input.credential.credential,
66
60
  deviceSecret: input.credential.deviceSecret,
67
61
  cloudBaseUrl: input.cloudBaseUrl,
@@ -89,7 +89,7 @@ export interface FoundedDelegatedNamespace {
89
89
  haError?: string;
90
90
  }
91
91
  /** What an app can tell a person whose account the cloud cannot place. */
92
- export declare const HA_ACCOUNT_NOT_LINKED_MESSAGE = "link this account to your cloud user in the wallet so invitees can find this namespace";
92
+ export declare const HA_ACCOUNT_NOT_LINKED_MESSAGE = "link this account to your cloud user in the wallet so the cloud can host this namespace on its fleet";
93
93
  /**
94
94
  * What to tell a person for each refusal the cloud names, keyed by its code.
95
95
  * Every one leaves the namespace founded; only hosting was refused.
@@ -197,7 +197,7 @@ async function attestedNodeKey(relay) {
197
197
  : "the relay's attestation was refused, so founding through it is not possible: it is not a signed locked-read-only release");
198
198
  }
199
199
  /** What an app can tell a person whose account the cloud cannot place. */
200
- export const HA_ACCOUNT_NOT_LINKED_MESSAGE = 'link this account to your cloud user in the wallet so invitees can find this namespace';
200
+ export const HA_ACCOUNT_NOT_LINKED_MESSAGE = 'link this account to your cloud user in the wallet so the cloud can host this namespace on its fleet';
201
201
  /**
202
202
  * What to tell a person for each refusal the cloud names, keyed by its code.
203
203
  * Every one leaves the namespace founded; only hosting was refused.
@@ -226,9 +226,8 @@ function blockingNamespace(e) {
226
226
  * the namespace id) and the cloud bills the user it is linked to.
227
227
  *
228
228
  * Best-effort: the namespace exists whatever the cloud answers, so a refusal is
229
- * reported, never thrown. Without HA nothing in the cloud knows the namespace,
230
- * so an invitee cannot find its relay; hence the not-linked case gets a
231
- * sentence that says what to do.
229
+ * reported, never thrown. The cloud records the founding relay before its link
230
+ * gate, so invitees find the namespace either way; HA adds fleet hosting.
232
231
  */
233
232
  async function enableHaBestEffort(s, namespaceId, salt, relayUrl, deps) {
234
233
  try {
@@ -80,12 +80,8 @@ export interface JoinWithNodeInput {
80
80
  /**
81
81
  * The node to present the join to — its admin API origin.
82
82
  *
83
- * The primary parameter, so all three sources of a node URL feed one method:
84
- * one carried by the invitation (core emits only libp2p multiaddrs today —
85
- * see `resolve_admitter_addrs`, a node cannot know the URL its admin API is
86
- * proxied on — so this is the seam for when invitations carry a claim URL),
87
- * one resolved from the cloud's `/admitters` lookup, and one an operator
88
- * simply knows.
83
+ * Resolved from the cloud's `/admitters` lookup or supplied by the caller;
84
+ * never read off the invitation, signed or not: the inviter may be the attacker.
89
85
  */
90
86
  readonly nodeUrl: string;
91
87
  /**
@@ -56,11 +56,8 @@ import { CloudClient } from '../cloud/index.js';
56
56
  *
57
57
  * - `admitters-lookup` — the cloud read itself failed (offline, 5xx, a refused
58
58
  * routing proof). Nothing has been learned about the namespace.
59
- * - `no-nodes` — the read succeeded and the cloud lists nothing, and the
60
- * invitation carries no http(s) address of an admitter either. The namespace
61
- * has no fleet assignment, so there is no cloud node to admit through; the
62
- * invitation's admitters are somebody's self-hosted or desktop node, which the
63
- * cloud neither knows nor routes to.
59
+ * - `no-nodes` - the cloud lists no node to admit through. An address on the
60
+ * invitation is not one: `admitter_addrs` is unsigned.
64
61
  * - `not-invited` — nodes serve the namespace, and the invitation names none of
65
62
  * them. Terminal for this invitation: ask for a fresh one.
66
63
  * - `invited-unreachable` — a named node exists but cannot take a join now (no
@@ -78,13 +75,10 @@ export interface ResolvedInvitationRelay {
78
75
  readonly relayUrl: string;
79
76
  /** Where to POST the signed join — the cloud's own, never rebuilt when given. */
80
77
  readonly admitUrl: string;
81
- /**
82
- * The admitter's account: as the cloud reported it, or — for a relay the
83
- * invitation itself named — the one admitter it names, when it names one.
84
- */
78
+ /** The admitter's account, as the cloud reported it. */
85
79
  readonly admitterAccount: string | null;
86
- /** The node's peer id as the cloud reported it; `null` for a relay the invitation named. */
87
- readonly peerId: string | null;
80
+ /** The node's peer id, as the cloud reported it. */
81
+ readonly peerId: string;
88
82
  /**
89
83
  * Whether this node can also run delegated writes (`CAN_AUTHOR_ON_BEHALF`).
90
84
  *
@@ -95,11 +89,6 @@ export interface ResolvedInvitationRelay {
95
89
  readonly writable: boolean;
96
90
  /** Whether the chosen node's heartbeat for this namespace has lapsed. */
97
91
  readonly stale: boolean;
98
- /**
99
- * Who named this relay: the cloud's routing, or the invitation's own
100
- * `admitter_addrs` when the cloud routed the namespace to no node.
101
- */
102
- readonly via: 'cloud' | 'invitation';
103
92
  }
104
93
  export interface RelayResolutionFailure {
105
94
  readonly ok: false;
@@ -120,15 +109,6 @@ export interface ResolveRelayFromInvitationInput {
120
109
  * here that means "nobody may admit", because an invitation cannot say that.
121
110
  */
122
111
  readonly admitters?: readonly string[];
123
- /**
124
- * The invitation's `admitter_addrs`: where its admitters are reached. Core
125
- * emits libp2p multiaddrs here; an account minting through mero-react adds
126
- * its relay's http(s) origin. Only the http(s) entries are of use to a
127
- * browser, and only when the cloud routes the namespace to no node — the
128
- * case of a namespace whose founder never linked the account, which the
129
- * cloud does not route yet whose own relay admits.
130
- */
131
- readonly admitterAddrs?: readonly string[];
132
112
  /** The delegated credential — an `AccountProof<DeviceCert>`, hex. */
133
113
  readonly credential: string;
134
114
  /** The certified device's ed25519 signing secret, hex. */
@@ -64,21 +64,6 @@ export function normaliseAccount(account) {
64
64
  const bare = trimmed.startsWith('0x') ? trimmed.slice(2) : trimmed;
65
65
  return bare.length > 0 ? bare : null;
66
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
67
  /** Whether the invitation's signed list names this node. */
83
68
  function isInvited(node, invited) {
84
69
  if (invited.size === 0)
@@ -121,28 +106,6 @@ export async function resolveRelayFromInvitation(input) {
121
106
  };
122
107
  }
123
108
  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
109
  return {
147
110
  ok: false,
148
111
  step: 'no-nodes',
@@ -211,6 +174,5 @@ export async function resolveRelayFromInvitation(input) {
211
174
  // disagrees with trying it at all. Both mean the same thing to a caller:
212
175
  // this was attempted on advisory data, so if the admit fails that is why.
213
176
  stale: !chosen.fresh || advisory,
214
- via: 'cloud',
215
177
  };
216
178
  }
@@ -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. */
@@ -624,9 +624,8 @@ export declare class CloudClient {
624
624
  * are answered together rather than across two endpoints — the per-context
625
625
  * read needs a context id a joiner does not yet have.
626
626
  *
627
- * An empty `nodes` list is a state, not an error: a namespace with no fleet
628
- * assignment has no cloud node, and the joiner must use one of the
629
- * invitation's other admitters — another admin's node, or a self-hosted peer.
627
+ * An empty `nodes` list is a state, not an error: the cloud has no record of
628
+ * a node holding the namespace, so there is no hosted admitter.
630
629
  */
631
630
  getNamespaceRouting(namespaceId: string): Promise<CloudNamespaceRouting>;
632
631
  /**
@@ -877,6 +876,10 @@ export declare class CloudClient {
877
876
  * Returns `{}` when there is nothing to read, so it is safe to call on every
878
877
  * load — which is how a redirected app finds out it has been sent back.
879
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
+ *
880
883
  * @param href defaults to the current location, when there is one.
881
884
  */
882
885
  static readAccountLinkCallback(href?: string): AccountLinkCallback;
@@ -27,6 +27,7 @@
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 { ACCOUNT_HEX, beginLinkState, takeLinkState } from './link-state.js';
30
31
  import { routingProofHeaders, } from './routing-proof.js';
31
32
  /**
32
33
  * The hosted cloud's **API** when a caller names no other.
@@ -87,7 +88,6 @@ function joinOf(row) {
87
88
  reportedAt: typeof join.reported_at === 'string' ? join.reported_at : null,
88
89
  };
89
90
  }
90
- const ACCOUNT_HEX = /^[0-9a-f]{64}$/;
91
91
  function accountHexOrNull(value) {
92
92
  return typeof value === 'string' && ACCOUNT_HEX.test(value) ? value : null;
93
93
  }
@@ -404,9 +404,8 @@ export class CloudClient {
404
404
  * are answered together rather than across two endpoints — the per-context
405
405
  * read needs a context id a joiner does not yet have.
406
406
  *
407
- * An empty `nodes` list is a state, not an error: a namespace with no fleet
408
- * assignment has no cloud node, and the joiner must use one of the
409
- * invitation's other admitters — another admin's node, or a self-hosted peer.
407
+ * An empty `nodes` list is a state, not an error: the cloud has no record of
408
+ * a node holding the namespace, so there is no hosted admitter.
410
409
  */
411
410
  async getNamespaceRouting(namespaceId) {
412
411
  const body = await this.request('GET', `/api/cloud/namespaces/${encodeURIComponent(namespaceId)}/admitters`, {
@@ -831,7 +830,7 @@ export class CloudClient {
831
830
  const portal = options.portalUrl.replace(/\/+$/, '');
832
831
  const query = new URLSearchParams({
833
832
  'link-account': options.accountId,
834
- 'callback-url': options.callbackUrl,
833
+ 'callback-url': beginLinkState(options.accountId, options.callbackUrl),
835
834
  });
836
835
  return { url: `${portal}/?${query.toString()}`, accountId: options.accountId };
837
836
  }
@@ -841,6 +840,10 @@ export class CloudClient {
841
840
  * Returns `{}` when there is nothing to read, so it is safe to call on every
842
841
  * load — which is how a redirected app finds out it has been sent back.
843
842
  *
843
+ * A grant is only returned when it answers a handoff this browser began, for the
844
+ * account that handoff named; anything else comes back as
845
+ * `error: 'unexpected_callback'`, because the redirect URL can be forwarded.
846
+ *
844
847
  * @param href defaults to the current location, when there is one.
845
848
  */
846
849
  static readAccountLinkCallback(href) {
@@ -848,8 +851,9 @@ export class CloudClient {
848
851
  if (!source)
849
852
  return {};
850
853
  let hash;
854
+ let search;
851
855
  try {
852
- hash = new URL(source).hash;
856
+ ({ hash, search } = new URL(source));
853
857
  }
854
858
  catch {
855
859
  return {};
@@ -859,6 +863,11 @@ export class CloudClient {
859
863
  const accountId = params.get('account');
860
864
  const error = params.get('error');
861
865
  const out = {};
866
+ if (error)
867
+ takeLinkState(search);
868
+ if (grant && takeLinkState(search)?.account !== accountId) {
869
+ return { error: 'unexpected_callback' };
870
+ }
862
871
  if (grant)
863
872
  out.grant = grant;
864
873
  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
+ }