@calimero-network/mero-js 26.0.1 → 26.1.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.
@@ -27,7 +27,14 @@ export type InviteFailureReason =
27
27
  'expired'
28
28
  /** The link is malformed or names nothing joinable. Only a new link helps. */
29
29
  | 'invalid'
30
- /** The node refused this person (removed, blocked, not allowed). */
30
+ /**
31
+ * This person already joined with this link once, and has since left. A link
32
+ * admits each person once: only a new one helps.
33
+ */
34
+ | 'already-used'
35
+ /** An admin removed this person. No link readmits them; an admin must re-add them. */
36
+ | 'removed'
37
+ /** The node refused this person for another reason (blocked, not allowed). */
31
38
  | 'refused'
32
39
  /** This client's session with the node lapsed; signing in finishes it. */
33
40
  | 'signed-out'
@@ -28,9 +28,46 @@ export function isSettled(outcome) {
28
28
  export function shouldRetain(outcome) {
29
29
  return !isSettled(outcome);
30
30
  }
31
+ /**
32
+ * The refusal codes a join route answers with beside `error` (core's
33
+ * `join_refusal_code`), and what each one tells a person.
34
+ *
35
+ * `join_refused_by_member` is absent on purpose: a member that refused may only
36
+ * be behind, so it says nothing final on its own, and the member's own words
37
+ * (in the message) are read by {@link reasonFromMessage} instead.
38
+ */
39
+ const REASON_FOR_CODE = {
40
+ invitation_expired: 'expired',
41
+ invitation_invalid: 'invalid',
42
+ invitation_already_used: 'already-used',
43
+ removed_from_group: 'removed',
44
+ reentry_blocked: 'refused',
45
+ };
46
+ /**
47
+ * The machine-readable `code` a node put beside its `error`, if any: on
48
+ * mero-js's `HTTPError` it is in the body text, on an axios-style error in
49
+ * `response.data`.
50
+ */
51
+ function refusalCodeOf(err) {
52
+ if (!err || typeof err !== 'object')
53
+ return undefined;
54
+ const shaped = err;
55
+ if (typeof shaped.response?.data?.code === 'string')
56
+ return shaped.response.data.code;
57
+ if (typeof shaped.bodyText !== 'string')
58
+ return undefined;
59
+ try {
60
+ const body = JSON.parse(shaped.bodyText);
61
+ return typeof body?.code === 'string' ? body.code : undefined;
62
+ }
63
+ catch {
64
+ return undefined;
65
+ }
66
+ }
31
67
  /**
32
68
  * Which reason the message alone names, for a node that answers without a
33
- * useful status — older than rc.56, where every refusal was a bare 500.
69
+ * useful status — older than rc.56, where every refusal was a bare 500 — or
70
+ * without a `code`, or for a member's refusal quoted inside a retryable answer.
34
71
  *
35
72
  * Kept narrow on purpose. Anything not recognised here stays retryable, because
36
73
  * the cost of retrying a dead link is one more attempt, while the cost of
@@ -51,8 +88,19 @@ function reasonFromMessage(message) {
51
88
  /\binvitation for group .+? is invalid:/.test(m)) {
52
89
  return 'invalid';
53
90
  }
91
+ // Core's own words for a spent link and a removal (`MembershipError`), which a
92
+ // refusing member also quotes back to the joiner.
93
+ if (m.includes('has already used this invitation'))
94
+ return 'already-used';
95
+ if (/\bwas removed from group\b/.test(m))
96
+ return 'removed';
54
97
  return null;
55
98
  }
99
+ /**
100
+ * The reasons that are final whatever status carried them: no later attempt
101
+ * with this link can succeed for this person.
102
+ */
103
+ const FINAL_FROM_TEXT = new Set(['already-used', 'removed']);
56
104
  /**
57
105
  * Why a join failed, and whether a later attempt could work.
58
106
  *
@@ -65,6 +113,21 @@ function reasonFromMessage(message) {
65
113
  function classifyJoinFailure(err, message) {
66
114
  const { kind, status } = classifyError(err);
67
115
  const fromText = reasonFromMessage(message);
116
+ // A code is the node naming the refusal itself: nothing below can know better.
117
+ const code = refusalCodeOf(err);
118
+ const fromCode = code ? REASON_FOR_CODE[code] : undefined;
119
+ if (fromCode)
120
+ return { reason: fromCode, retryable: false };
121
+ // A spent link or a removal is final even when a member said it inside a
122
+ // retryable answer (a 503 for a join every peer refused): asking again only
123
+ // asks the same members, who will say the same.
124
+ if (fromText && FINAL_FROM_TEXT.has(fromText))
125
+ return { reason: fromText, retryable: false };
126
+ // Any other member refusal is not "no one is online": someone answered, and
127
+ // their words are the message. Kept, since a member behind on the team's
128
+ // history refuses what it has not seen yet.
129
+ if (code === 'join_refused_by_member')
130
+ return { reason: 'unknown', retryable: true };
68
131
  if (status === 401)
69
132
  return { reason: 'signed-out', retryable: true };
70
133
  if (status === 400)