@calimero-network/mero-js 26.0.0 → 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.
@@ -21,18 +21,13 @@
21
21
  * `signer == credential.statement.sign_pk` before applying a join — so an
22
22
  * admitter can carry this and can never author it: it cannot admit a different
23
23
  * account, change the group, or grant a role. It can refuse, and that is all.
24
- * This is also why the fleet ingress leaves the route unauthenticated, and why
25
- * this posts with `fetch` rather than through `AdminApiClient`, which is built
26
- * around a node credential this caller does not have.
27
- *
28
- * ## Why it lives here and not in mero-js
29
- *
30
- * Two reasons. `mero-js` is read-only for this change by instruction, and the
31
- * shape that is actually useful to a caller is the one that hands back a
32
- * {@link DelegatedSession} — the node that admitted you is the node to connect
33
- * through — which is `mero-react`'s concern. The pieces that are pure SDK, the
34
- * borsh op layout and the request type, are used from `mero-js` unchanged and
35
- * are not reimplemented here.
24
+ * This is also why core serves the route without a credential (it sits on the
25
+ * public admin router, beside `/health` and the intent routes, on every node
26
+ * whatever its auth mode), why the fleet ingress exempts the one anchored path,
27
+ * and why this posts with `fetch` rather than through `AdminApiClient`, which is
28
+ * built around a node credential this caller does not have. A node that predates
29
+ * that change and runs embedded auth answers 401 here; the failure is reported at
30
+ * the `admit` step like any other refusal.
36
31
  *
37
32
  * ## Why the op has no parents
38
33
  *
@@ -21,18 +21,13 @@
21
21
  * `signer == credential.statement.sign_pk` before applying a join — so an
22
22
  * admitter can carry this and can never author it: it cannot admit a different
23
23
  * account, change the group, or grant a role. It can refuse, and that is all.
24
- * This is also why the fleet ingress leaves the route unauthenticated, and why
25
- * this posts with `fetch` rather than through `AdminApiClient`, which is built
26
- * around a node credential this caller does not have.
27
- *
28
- * ## Why it lives here and not in mero-js
29
- *
30
- * Two reasons. `mero-js` is read-only for this change by instruction, and the
31
- * shape that is actually useful to a caller is the one that hands back a
32
- * {@link DelegatedSession} — the node that admitted you is the node to connect
33
- * through — which is `mero-react`'s concern. The pieces that are pure SDK, the
34
- * borsh op layout and the request type, are used from `mero-js` unchanged and
35
- * are not reimplemented here.
24
+ * This is also why core serves the route without a credential (it sits on the
25
+ * public admin router, beside `/health` and the intent routes, on every node
26
+ * whatever its auth mode), why the fleet ingress exempts the one anchored path,
27
+ * and why this posts with `fetch` rather than through `AdminApiClient`, which is
28
+ * built around a node credential this caller does not have. A node that predates
29
+ * that change and runs embedded auth answers 401 here; the failure is reported at
30
+ * the `admit` step like any other refusal.
36
31
  *
37
32
  * ## Why the op has no parents
38
33
  *
@@ -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)