@calimero-network/mero-js 21.2.2 → 21.4.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.
- package/README.md +7 -0
- package/dist/admin-api/admin-client.d.ts +24 -0
- package/dist/admin-api/admin-client.js +29 -0
- package/dist/admin-api/admin-types.d.ts +3 -24
- package/dist/admin-api/index.d.ts +1 -0
- package/dist/admin-api/index.js +1 -0
- package/dist/admin-api/redeem-invitation.d.ts +94 -0
- package/dist/admin-api/redeem-invitation.js +131 -0
- package/dist/errors/classify.d.ts +65 -0
- package/dist/errors/classify.js +121 -0
- package/dist/errors/index.d.ts +2 -0
- package/dist/errors/index.js +1 -0
- package/dist/http-client/web-client.d.ts +6 -0
- package/dist/http-client/web-client.js +1 -0
- package/dist/index.browser.mjs +2 -2
- package/dist/index.browser.mjs.map +4 -4
- package/dist/index.cjs +707 -2
- package/dist/index.cjs.map +4 -4
- package/dist/index.d.ts +1 -0
- package/dist/index.js +2 -0
- package/dist/index.mjs +707 -2
- package/dist/index.mjs.map +4 -4
- package/dist/namespace-op/namespace-op.d.ts +1 -1
- package/dist/namespace-op/namespace-op.js +2 -2
- package/dist/sealed/index.d.ts +3 -0
- package/dist/sealed/index.js +1 -0
- package/dist/sealed/release.d.ts +213 -0
- package/dist/sealed/release.js +291 -0
- package/dist/sealed/sigstore-trust-root.d.ts +52 -0
- package/dist/sealed/sigstore-trust-root.js +76 -0
- package/dist/sealed/sigstore.d.ts +41 -0
- package/dist/sealed/sigstore.js +207 -0
- package/dist/sealed/x509.d.ts +67 -0
- package/dist/sealed/x509.js +240 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -85,6 +85,13 @@ Every failure surfaces as a typed error:
|
|
|
85
85
|
- **`RpcError`** — the WASM contract returned a JSON-RPC error (`code`, `message`,
|
|
86
86
|
optional `type`/`data`).
|
|
87
87
|
|
|
88
|
+
`classifyError(err)` turns any of these into `{ kind, retryable, message }`.
|
|
89
|
+
`kind` is read off the status (`'conflict'` for a 409, `'unavailable'` for a
|
|
90
|
+
503, `'unreachable'` for status 0, and so on). `retryable` says whether the same
|
|
91
|
+
request could work later. `message` is the node's own words. To follow an
|
|
92
|
+
invitation, use `sdk.admin.redeemInvitation`: a join that failed but landed
|
|
93
|
+
comes back as `already-member` rather than as an error.
|
|
94
|
+
|
|
88
95
|
`401`s are handled internally: the SDK refreshes the token and retries once, so
|
|
89
96
|
you only see a `401` if the refresh itself fails. Full details in the
|
|
90
97
|
[error model reference](https://calimero-network.github.io/mero-js/reference/error-model/).
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { HttpClient } from '../http-client/index.js';
|
|
2
2
|
import type { HealthStatus, ReadinessStatus, AdminAuthStatus, InstallApplicationRequest, InstallApplicationResponseData, InstallDevApplicationRequest, UninstallApplicationResponseData, ListApplicationsResponseData, GetApplicationResponseData, ApplicationVersionEntry, GetApplicationAbiResponseData, GetLatestVersionResponseData, ListPackagesResponseData, ListVersionsResponseData, CreateContextRequest, CreateContextResponseData, DeleteContextResponseData, GetContextsResponseData, Context, GenerateContextIdentityResponseData, GetContextIdentitiesResponseData, JoinContextResponseData, JoinSubgroupInheritanceResponseData, ContextGroupResponseData, ContextStorageResponseData, InviteSpecializedNodeRequest, InviteSpecializedNodeResponseData, UpdateContextApplicationRequest, ResyncContextRequest, ResyncContextResponseData, ContextsWithExecutorsResponseData, UploadBlobRequest, UploadBlobResponseData, DeleteBlobResponseData, ListBlobsResponseData, GetBlobInfoResponseData, BlobReadOptions, CreateContextAliasRequest, CreateApplicationAliasRequest, CreateDeviceAliasRequest, CreateAliasResponseData, LookupAliasResponseData, DeleteAliasResponseData, ListAliasesResponseData, ListNamespacesResponseData, CreateNamespaceRequest, CreateNamespaceResponseData, DeleteNamespaceResponseData, CreateNamespaceInvitationRequest, CreateNamespaceInvitationResponseData, CreateRecursiveInvitationResponseData, JoinNamespaceRequest, JoinNamespaceResponseData, CreateGroupInNamespaceRequest, CreateGroupInNamespaceResponseData, SubgroupEntry, Namespace, NamespaceIdentity, NodeIdentity, AccountPairInitRequest, AccountPairInitResponseData, AccountPairCompleteRequest, AccountPairCompleteResponseData, AccountSignWithRootRequest, AccountSignWithRootResponseData, RelinkDeviceRequest, RelinkDeviceResponseData, RescopeDeviceRequest, RescopeDeviceResponseData, LabelDeviceRequest, LabelDeviceResponseData, AccountDeviceEntry, AccountApplicationEntry, RevokeAccountDeviceRequest, RevokeAccountDeviceResponseData, GroupInfoResponseData, CreateGroupRequest, CreateGroupResponseData, DeleteGroupResponseData, ListGroupMembersResponseData, ListGroupContextsResponseData, AddGroupMembersRequest, RemoveGroupMembersRequest, UpdateMemberRoleRequest, MemberCapabilities, SetMemberCapabilitiesRequest, SetDefaultCapabilitiesRequest, SetMemberAutoFollowRequest, SetSubgroupVisibilityRequest, SetTeeAdmissionPolicyRequest, GetTeeAdmissionPolicyResponseData, SetTeeAuthoringPolicyRequest, SetGroupMetadataRequest, SetMemberMetadataRequest, SetContextMetadataRequest, MetadataRecord, SyncGroupResponseData, UpgradeGroupRequest, UpgradeGroupResponseData, GroupUpgradeStatusResponseData, MigrationStatus, CascadeStatusEntry, RetryGroupUpgradeResponseData, ReparentGroupRequest, ReparentGroupResponseData, IssueOwnershipProofRequest, IssueNamespaceOwnershipProofRequest, IssueOwnershipProofResponseData, CreateGroupInvitationRequest, CreateGroupInvitationResponseData, CreateRecursiveGroupInvitationResponseData, JoinGroupRequest, JoinGroupResponseData, TeeInfoResponseData, TeeAttestRequest, TeeAttestResponseData, TeeVerifyQuoteRequest, TeeVerifyQuoteResponseData, PerformIntentRequest, PerformIntentResponseData, IntentRelayInfo, QueryContextRequest, QueryContextResponseData, ListMemberDevicesOptions, ListMemberDevicesResponseData, SealToAccountRequest, SealedEnvelope, AdmitJoinRequest, AdmitJoinResponseData } from './admin-types.js';
|
|
3
|
+
import { type RedeemOutcome } from './redeem-invitation.js';
|
|
3
4
|
/**
|
|
4
5
|
* Compare two version strings by SemVer 2.0 precedence, ascending: negative if
|
|
5
6
|
* `a < b`, positive if `a > b`, `0` if equal. Build metadata (`+...`) is stripped
|
|
@@ -237,6 +238,29 @@ export declare class AdminApiClient {
|
|
|
237
238
|
*/
|
|
238
239
|
admitJoin(namespaceId: string, request: AdmitJoinRequest): Promise<AdmitJoinResponseData>;
|
|
239
240
|
joinNamespace(namespaceId: string, request: JoinNamespaceRequest): Promise<JoinNamespaceResponseData>;
|
|
241
|
+
/**
|
|
242
|
+
* Join the namespace an invitation grants, and say what happened — including
|
|
243
|
+
* when the request failed but the join landed anyway.
|
|
244
|
+
*
|
|
245
|
+
* Use this rather than {@link joinNamespace} whenever someone is following an
|
|
246
|
+
* invitation. A join waits for a member to come online and can outlast any
|
|
247
|
+
* proxy in front of the node; the request then fails while the join
|
|
248
|
+
* succeeds. This sends the join once, then checks {@link listNamespaces}:
|
|
249
|
+
* a failed request whose namespace is listed is `already-member`, a success.
|
|
250
|
+
*
|
|
251
|
+
* Never throws for an ordinary failure. A failed outcome carries a `reason`
|
|
252
|
+
* read off the node's status (core rc.56+), and `retryable`, which says
|
|
253
|
+
* whether to keep the invitation for another attempt.
|
|
254
|
+
*
|
|
255
|
+
* @param namespaceId the namespace the invitation names, 64 hex
|
|
256
|
+
* @param invitation the signed invitation, as the inviter's node minted it
|
|
257
|
+
* @param options `groupName` is sent with the join; `teamName` is echoed back
|
|
258
|
+
* on a successful outcome for the caller's UI
|
|
259
|
+
*/
|
|
260
|
+
redeemInvitation(namespaceId: string, invitation: JoinNamespaceRequest['invitation'], options?: {
|
|
261
|
+
groupName?: string;
|
|
262
|
+
teamName?: string;
|
|
263
|
+
}): Promise<RedeemOutcome>;
|
|
240
264
|
createGroupInNamespace(namespaceId: string, request?: CreateGroupInNamespaceRequest): Promise<CreateGroupInNamespaceResponseData>;
|
|
241
265
|
listNamespaceGroups(namespaceId: string): Promise<SubgroupEntry[]>;
|
|
242
266
|
/**
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { withRetry } from '../http-client/index.js';
|
|
2
2
|
import { CAPABILITIES, hasCap, withCap } from '../capabilities.js';
|
|
3
|
+
import { redeemInvitation } from './redeem-invitation.js';
|
|
3
4
|
/**
|
|
4
5
|
* Helper: server wraps most responses in `{ data: T }`.
|
|
5
6
|
* This extracts `.data` so callers get the inner payload directly. Null-safe so an
|
|
@@ -545,6 +546,34 @@ export class AdminApiClient {
|
|
|
545
546
|
}
|
|
546
547
|
return data;
|
|
547
548
|
}
|
|
549
|
+
/**
|
|
550
|
+
* Join the namespace an invitation grants, and say what happened — including
|
|
551
|
+
* when the request failed but the join landed anyway.
|
|
552
|
+
*
|
|
553
|
+
* Use this rather than {@link joinNamespace} whenever someone is following an
|
|
554
|
+
* invitation. A join waits for a member to come online and can outlast any
|
|
555
|
+
* proxy in front of the node; the request then fails while the join
|
|
556
|
+
* succeeds. This sends the join once, then checks {@link listNamespaces}:
|
|
557
|
+
* a failed request whose namespace is listed is `already-member`, a success.
|
|
558
|
+
*
|
|
559
|
+
* Never throws for an ordinary failure. A failed outcome carries a `reason`
|
|
560
|
+
* read off the node's status (core rc.56+), and `retryable`, which says
|
|
561
|
+
* whether to keep the invitation for another attempt.
|
|
562
|
+
*
|
|
563
|
+
* @param namespaceId the namespace the invitation names, 64 hex
|
|
564
|
+
* @param invitation the signed invitation, as the inviter's node minted it
|
|
565
|
+
* @param options `groupName` is sent with the join; `teamName` is echoed back
|
|
566
|
+
* on a successful outcome for the caller's UI
|
|
567
|
+
*/
|
|
568
|
+
async redeemInvitation(namespaceId, invitation, options = {}) {
|
|
569
|
+
const { groupName, teamName } = options;
|
|
570
|
+
return redeemInvitation({ namespaceId, invitation, teamName }, {
|
|
571
|
+
join: async () => {
|
|
572
|
+
await this.joinNamespace(namespaceId, groupName === undefined ? { invitation } : { invitation, groupName });
|
|
573
|
+
},
|
|
574
|
+
memberships: async () => ((await this.listNamespaces()) ?? []).map((n) => n.namespaceId),
|
|
575
|
+
});
|
|
576
|
+
}
|
|
548
577
|
async createGroupInNamespace(namespaceId, request) {
|
|
549
578
|
return unwrap(await this.httpClient.post(`/admin-api/namespaces/${namespaceId}/groups`, request ?? {}));
|
|
550
579
|
}
|
|
@@ -370,32 +370,11 @@ export interface Namespace {
|
|
|
370
370
|
*/
|
|
371
371
|
appVersion?: string;
|
|
372
372
|
/**
|
|
373
|
-
* What the namespace id was derived from, on nodes that hold it
|
|
374
|
-
*
|
|
375
|
-
*
|
|
373
|
+
* What the namespace id was derived from, on nodes that hold it. Every
|
|
374
|
+
* namespace's id is derived from its founder since core's schema v10, so
|
|
375
|
+
* this is absent only on nodes that predate it.
|
|
376
376
|
*/
|
|
377
377
|
founding?: NamespaceFounding;
|
|
378
|
-
/**
|
|
379
|
-
* The founder and genesis op of a namespace created BEFORE ids were derived,
|
|
380
|
-
* on this node's copy. A separate field from `founding`, never inside it: it
|
|
381
|
-
* proves nothing about the id, so it must not be accepted where a derived-id
|
|
382
|
-
* founding is checked. Absent for derived namespaces, and on nodes that
|
|
383
|
-
* predate it.
|
|
384
|
-
*/
|
|
385
|
-
legacyFounding?: NamespaceLegacyFounding;
|
|
386
|
-
}
|
|
387
|
-
/**
|
|
388
|
-
* The founder a pre-derivation namespace's plain genesis names, and the hash
|
|
389
|
-
* of that genesis op (its id in the namespace governance DAG). Lets a client
|
|
390
|
-
* see which founder and which exact genesis a replica's copy was founded by,
|
|
391
|
-
* and compare replicas — not a proof of founding: a random id commits to no
|
|
392
|
-
* founder.
|
|
393
|
-
*/
|
|
394
|
-
export interface NamespaceLegacyFounding {
|
|
395
|
-
/** Hex `AccountId` of the founder the genesis names. */
|
|
396
|
-
founderAccountId: string;
|
|
397
|
-
/** Lowercase hex 32-byte content hash of the genesis op. */
|
|
398
|
-
genesisOpHash: string;
|
|
399
378
|
}
|
|
400
379
|
/**
|
|
401
380
|
* The founder and salt a namespace id was derived from:
|
package/dist/admin-api/index.js
CHANGED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/** The two calls a redeem needs, so any client (or any app's join) can drive it. */
|
|
2
|
+
export interface InviteRedeemer {
|
|
3
|
+
/**
|
|
4
|
+
* Send the join. Resolve on success, throw on failure.
|
|
5
|
+
*
|
|
6
|
+
* It does not have to be reliable, and it does not have to be idempotent on
|
|
7
|
+
* the client — the node's join is idempotent, and `memberships()` settles any
|
|
8
|
+
* ambiguity this throws.
|
|
9
|
+
*/
|
|
10
|
+
join(namespaceId: string, invitation: unknown): Promise<void>;
|
|
11
|
+
/**
|
|
12
|
+
* Namespace ids this node is a member of.
|
|
13
|
+
*
|
|
14
|
+
* Throwing is a legitimate answer ("could not tell") and is treated as such:
|
|
15
|
+
* it never turns a join that succeeded into a failure.
|
|
16
|
+
*/
|
|
17
|
+
memberships(): Promise<readonly string[]>;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Why a join failed, as far as it tells a person what to do next.
|
|
21
|
+
*
|
|
22
|
+
* Read off the HTTP status where the node gives one (core rc.56+ answers a
|
|
23
|
+
* refused join with a 4xx, not a 500), and off the message otherwise.
|
|
24
|
+
*/
|
|
25
|
+
export type InviteFailureReason =
|
|
26
|
+
/** The invitation's time ran out. Only a new link helps. */
|
|
27
|
+
'expired'
|
|
28
|
+
/** The link is malformed or names nothing joinable. Only a new link helps. */
|
|
29
|
+
| 'invalid'
|
|
30
|
+
/** The node refused this person (removed, blocked, not allowed). */
|
|
31
|
+
| 'refused'
|
|
32
|
+
/** This client's session with the node lapsed; signing in finishes it. */
|
|
33
|
+
| 'signed-out'
|
|
34
|
+
/** No member was reachable to let them in yet. Trying later can work. */
|
|
35
|
+
| 'no-one-online'
|
|
36
|
+
/** This client could not reach its own node at all. */
|
|
37
|
+
| 'node-unreachable'
|
|
38
|
+
/** Nothing more specific is known; the node's message is all there is. */
|
|
39
|
+
| 'unknown';
|
|
40
|
+
/**
|
|
41
|
+
* What happened, in the terms a UI needs.
|
|
42
|
+
*
|
|
43
|
+
* `already-member` is deliberately a success and deliberately distinct: it is
|
|
44
|
+
* the outcome of following a link twice, or of the timeout above, and telling
|
|
45
|
+
* someone "you are already in this one" is neither an error nor the same
|
|
46
|
+
* message as having just joined.
|
|
47
|
+
*/
|
|
48
|
+
export type RedeemOutcome = {
|
|
49
|
+
status: 'joined';
|
|
50
|
+
namespaceId: string;
|
|
51
|
+
teamName?: string;
|
|
52
|
+
} | {
|
|
53
|
+
status: 'already-member';
|
|
54
|
+
namespaceId: string;
|
|
55
|
+
teamName?: string;
|
|
56
|
+
} | {
|
|
57
|
+
status: 'failed';
|
|
58
|
+
namespaceId: string | null;
|
|
59
|
+
/** The node's own words, for logs and for a UI that shows detail. */
|
|
60
|
+
message: string;
|
|
61
|
+
/** Why, in terms a person can act on. */
|
|
62
|
+
reason: InviteFailureReason;
|
|
63
|
+
/**
|
|
64
|
+
* Whether trying again later could plausibly work. A malformed token
|
|
65
|
+
* cannot; an unreachable peer can. A caller holding the invitation uses
|
|
66
|
+
* this to decide whether to keep it — see {@link shouldRetain}.
|
|
67
|
+
*/
|
|
68
|
+
retryable: boolean;
|
|
69
|
+
};
|
|
70
|
+
/** Whether an outcome means the invitation is finished with. */
|
|
71
|
+
export declare function isSettled(outcome: RedeemOutcome): boolean;
|
|
72
|
+
/**
|
|
73
|
+
* Whether the invitation should be kept for another attempt later.
|
|
74
|
+
*
|
|
75
|
+
* The inverse of {@link isSettled}, named for the decision it drives.
|
|
76
|
+
*/
|
|
77
|
+
export declare function shouldRetain(outcome: RedeemOutcome): boolean;
|
|
78
|
+
/**
|
|
79
|
+
* Join the namespace an invitation names, and report what happened.
|
|
80
|
+
*
|
|
81
|
+
* Sends the join once and never retries it: a failed request whose namespace
|
|
82
|
+
* is then listed is `already-member`, not a failure. Never throws for an
|
|
83
|
+
* ordinary failure — the outcome carries it, because every caller has to branch
|
|
84
|
+
* on "already a member" anyway.
|
|
85
|
+
*
|
|
86
|
+
* Most callers want {@link AdminApiClient.redeemInvitation}, which supplies the
|
|
87
|
+
* redeemer from the admin API. This form is for a join that is more than one
|
|
88
|
+
* call — a namespace followed by the context inside it, say.
|
|
89
|
+
*/
|
|
90
|
+
export declare function redeemInvitation(parsed: {
|
|
91
|
+
namespaceId: string;
|
|
92
|
+
invitation: unknown;
|
|
93
|
+
teamName?: string;
|
|
94
|
+
}, redeemer: InviteRedeemer): Promise<RedeemOutcome>;
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
// ── Redeeming an invitation: joining once, and knowing whether it worked ──────
|
|
2
|
+
//
|
|
3
|
+
// Deciding "did we join?" by whether the join request resolved is the wrong
|
|
4
|
+
// question, and on a real node (core rc.41, 2026-09-22) it failed two ways:
|
|
5
|
+
//
|
|
6
|
+
// 1. The desktop proxy aborts every admin request at 30s. A namespace join
|
|
7
|
+
// whose members are all offline waits for one to appear — measured at 95s.
|
|
8
|
+
// So the request failed, the join landed anyway, and the app reported
|
|
9
|
+
// "could not join" for a namespace it was already in. It kept the
|
|
10
|
+
// invitation, and replayed it on every load.
|
|
11
|
+
//
|
|
12
|
+
// 2. A refresh after the join shared its `try`, so a refresh that threw
|
|
13
|
+
// turned a real join into a reported failure.
|
|
14
|
+
//
|
|
15
|
+
// So membership is the question, and there are two independent proofs of it:
|
|
16
|
+
// the request resolving, and the namespace being listed. Either one is enough,
|
|
17
|
+
// which matters because either one can be missing.
|
|
18
|
+
import { classifyError, messageOf } from '../errors/classify.js';
|
|
19
|
+
/** Whether an outcome means the invitation is finished with. */
|
|
20
|
+
export function isSettled(outcome) {
|
|
21
|
+
return outcome.status !== 'failed' || !outcome.retryable;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Whether the invitation should be kept for another attempt later.
|
|
25
|
+
*
|
|
26
|
+
* The inverse of {@link isSettled}, named for the decision it drives.
|
|
27
|
+
*/
|
|
28
|
+
export function shouldRetain(outcome) {
|
|
29
|
+
return !isSettled(outcome);
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* 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.
|
|
34
|
+
*
|
|
35
|
+
* Kept narrow on purpose. Anything not recognised here stays retryable, because
|
|
36
|
+
* the cost of retrying a dead link is one more attempt, while the cost of
|
|
37
|
+
* discarding a live one is someone who cannot join at all.
|
|
38
|
+
*/
|
|
39
|
+
function reasonFromMessage(message) {
|
|
40
|
+
const m = message.toLowerCase();
|
|
41
|
+
if (m.includes('invitation has expired') ||
|
|
42
|
+
m.includes('invitation expired') ||
|
|
43
|
+
// Core rc.56+ names the group between the words: "invitation for group
|
|
44
|
+
// <id> expired at <secs>".
|
|
45
|
+
/\binvitation for group .+? expired at \d+/.test(m)) {
|
|
46
|
+
return 'expired';
|
|
47
|
+
}
|
|
48
|
+
if (m.includes('malformed') ||
|
|
49
|
+
m.includes('does not match namespace_id') ||
|
|
50
|
+
m.includes('invalid invitation') ||
|
|
51
|
+
/\binvitation for group .+? is invalid:/.test(m)) {
|
|
52
|
+
return 'invalid';
|
|
53
|
+
}
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Why a join failed, and whether a later attempt could work.
|
|
58
|
+
*
|
|
59
|
+
* The status decides where there is one. A 4xx the node chose is a definitive
|
|
60
|
+
* answer about this invitation or this person, so it is not retried; a 5xx or
|
|
61
|
+
* no answer at all is about the moment, so it is. Two exceptions keep an
|
|
62
|
+
* invitation that is still good: a 401 is the client's session, not the
|
|
63
|
+
* invitation, and a 404 is not specific enough to throw a link away on.
|
|
64
|
+
*/
|
|
65
|
+
function classifyJoinFailure(err, message) {
|
|
66
|
+
const { kind, status } = classifyError(err);
|
|
67
|
+
const fromText = reasonFromMessage(message);
|
|
68
|
+
if (status === 401)
|
|
69
|
+
return { reason: 'signed-out', retryable: true };
|
|
70
|
+
if (status === 400)
|
|
71
|
+
return { reason: fromText ?? 'invalid', retryable: false };
|
|
72
|
+
if (status === 410)
|
|
73
|
+
return { reason: 'expired', retryable: false };
|
|
74
|
+
if (status === 403)
|
|
75
|
+
return { reason: 'refused', retryable: false };
|
|
76
|
+
// An expired invitation, a spent one, a member who was removed or blocked:
|
|
77
|
+
// every 409 a join can answer is final.
|
|
78
|
+
if (status === 409)
|
|
79
|
+
return { reason: fromText ?? 'refused', retryable: false };
|
|
80
|
+
if (kind === 'unavailable')
|
|
81
|
+
return { reason: 'no-one-online', retryable: true };
|
|
82
|
+
if (kind === 'unreachable')
|
|
83
|
+
return { reason: 'node-unreachable', retryable: true };
|
|
84
|
+
// A 500 from a node older than rc.56 may still be a refusal in disguise;
|
|
85
|
+
// anything else has only its message to go on.
|
|
86
|
+
if (fromText)
|
|
87
|
+
return { reason: fromText, retryable: false };
|
|
88
|
+
return { reason: 'unknown', retryable: true };
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Join the namespace an invitation names, and report what happened.
|
|
92
|
+
*
|
|
93
|
+
* Sends the join once and never retries it: a failed request whose namespace
|
|
94
|
+
* is then listed is `already-member`, not a failure. Never throws for an
|
|
95
|
+
* ordinary failure — the outcome carries it, because every caller has to branch
|
|
96
|
+
* on "already a member" anyway.
|
|
97
|
+
*
|
|
98
|
+
* Most callers want {@link AdminApiClient.redeemInvitation}, which supplies the
|
|
99
|
+
* redeemer from the admin API. This form is for a join that is more than one
|
|
100
|
+
* call — a namespace followed by the context inside it, say.
|
|
101
|
+
*/
|
|
102
|
+
export async function redeemInvitation(parsed, redeemer) {
|
|
103
|
+
const { namespaceId, invitation, teamName } = parsed;
|
|
104
|
+
let joinError = null;
|
|
105
|
+
try {
|
|
106
|
+
await redeemer.join(namespaceId, invitation);
|
|
107
|
+
}
|
|
108
|
+
catch (err) {
|
|
109
|
+
joinError = err;
|
|
110
|
+
}
|
|
111
|
+
// The request resolving is proof on its own, so this must not be able to
|
|
112
|
+
// demote a real join: a `memberships()` that throws is "could not tell".
|
|
113
|
+
let listed = null;
|
|
114
|
+
try {
|
|
115
|
+
listed = (await redeemer.memberships()).includes(namespaceId);
|
|
116
|
+
}
|
|
117
|
+
catch {
|
|
118
|
+
listed = null;
|
|
119
|
+
}
|
|
120
|
+
if (joinError === null) {
|
|
121
|
+
return { status: 'joined', namespaceId, teamName };
|
|
122
|
+
}
|
|
123
|
+
if (listed === true) {
|
|
124
|
+
// The join landed despite the error — an aborted request, or a link
|
|
125
|
+
// followed twice.
|
|
126
|
+
return { status: 'already-member', namespaceId, teamName };
|
|
127
|
+
}
|
|
128
|
+
const message = messageOf(joinError, 'Could not join. Check the invitation.');
|
|
129
|
+
const { reason, retryable } = classifyJoinFailure(joinError, message);
|
|
130
|
+
return { status: 'failed', namespaceId, message, reason, retryable };
|
|
131
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/** The kind of failure, as far as it decides what the caller does next. */
|
|
2
|
+
export type ErrorKind =
|
|
3
|
+
/** 400. The request is wrong; sending it again cannot help. */
|
|
4
|
+
'invalid'
|
|
5
|
+
/** 401. This client's session lapsed; signing in again can finish it. */
|
|
6
|
+
| 'unauthorized'
|
|
7
|
+
/** 403. The node will not do this for this caller. */
|
|
8
|
+
| 'forbidden'
|
|
9
|
+
/** 404. What the request names does not exist on this node (yet). */
|
|
10
|
+
| 'not-found'
|
|
11
|
+
/** 409. The request contradicts the current state: expired, spent, removed. */
|
|
12
|
+
| 'conflict'
|
|
13
|
+
/** 410. It existed and is gone for good. */
|
|
14
|
+
| 'gone'
|
|
15
|
+
/** 413. The body is over the node's limit. */
|
|
16
|
+
| 'too-large'
|
|
17
|
+
/** 429. The node asked the caller to slow down. */
|
|
18
|
+
| 'rate-limited'
|
|
19
|
+
/** 503 or 504. The node cannot serve this yet — no peer online, say. */
|
|
20
|
+
| 'unavailable'
|
|
21
|
+
/** Any other 5xx. The node failed; the request may well have been fine. */
|
|
22
|
+
| 'server'
|
|
23
|
+
/** Status 0. The request never reached the node: offline, CORS, a timeout. */
|
|
24
|
+
| 'unreachable'
|
|
25
|
+
/** Not an HTTP failure, or one with nothing more specific to go on. */
|
|
26
|
+
| 'unknown';
|
|
27
|
+
export interface ClassifiedError {
|
|
28
|
+
kind: ErrorKind;
|
|
29
|
+
/** The HTTP status, when the error carries one. 0 means no response. */
|
|
30
|
+
status: number | undefined;
|
|
31
|
+
/**
|
|
32
|
+
* Whether the same request could succeed later without anything changing on
|
|
33
|
+
* the caller's side. False for a refusal the node chose — asking again gets
|
|
34
|
+
* the same answer — and true for a failure about the moment.
|
|
35
|
+
*/
|
|
36
|
+
retryable: boolean;
|
|
37
|
+
/**
|
|
38
|
+
* The node's own explanation, without an `HTTP <status>` prefix, falling back
|
|
39
|
+
* to the error's message. For logs and for a UI that shows detail; a UI that
|
|
40
|
+
* wants friendly copy should switch on `kind`.
|
|
41
|
+
*/
|
|
42
|
+
message: string;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* The HTTP status an error carries, in any shape a client throws: mero-js's
|
|
46
|
+
* `HTTPError.status`, an axios-style `response.status`, or a bare `statusCode`.
|
|
47
|
+
* Undefined when there is none — a thrown string, or an error raised before the
|
|
48
|
+
* request was sent.
|
|
49
|
+
*/
|
|
50
|
+
export declare function statusOf(err: unknown): number | undefined;
|
|
51
|
+
/**
|
|
52
|
+
* The most specific human-readable explanation an error carries, or
|
|
53
|
+
* `fallback`. Prefers the node's words (`HTTPError.explanation`, or an axios
|
|
54
|
+
* body's `error`/`message`) over a message that repeats the status line.
|
|
55
|
+
*/
|
|
56
|
+
export declare function messageOf(err: unknown, fallback?: string): string;
|
|
57
|
+
/**
|
|
58
|
+
* What a failed request means: its kind, whether a later attempt could work,
|
|
59
|
+
* and the node's own words for it.
|
|
60
|
+
*
|
|
61
|
+
* Accepts anything a `catch` hands over. A browser `fetch` that could not
|
|
62
|
+
* connect throws a `TypeError`, which is read as `unreachable` like mero-js's
|
|
63
|
+
* own status-0 `HTTPError`.
|
|
64
|
+
*/
|
|
65
|
+
export declare function classifyError(err: unknown): ClassifiedError;
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
// ── What a failed request means, in terms a caller can act on ────────────────
|
|
2
|
+
//
|
|
3
|
+
// Core rc.56 onwards (calimero-network/core#4151 to #4164) answers a refused
|
|
4
|
+
// admin request with the status that names the refusal: a malformed body is a
|
|
5
|
+
// 400, a caller the node will not serve a 403, a missing resource a 404, a
|
|
6
|
+
// state the request contradicts (an expired invitation, a removed member) a
|
|
7
|
+
// 409, an oversized upload a 413, and a request the node cannot serve *yet*
|
|
8
|
+
// (no member online to hand over a group key) a 503. Before that, every one of
|
|
9
|
+
// them was a bare 500 and the only way to tell them apart was to match text.
|
|
10
|
+
//
|
|
11
|
+
// Every caller asks the same two questions of such an error: what kind of
|
|
12
|
+
// failure is this, and would doing it again later help? This answers both once,
|
|
13
|
+
// so apps stop keeping their own copies of the status table.
|
|
14
|
+
const RETRYABLE = {
|
|
15
|
+
invalid: false,
|
|
16
|
+
// The session, not the request: signing in again and repeating it works.
|
|
17
|
+
unauthorized: true,
|
|
18
|
+
forbidden: false,
|
|
19
|
+
// Too broad to give up on — the resource may simply not have synced yet.
|
|
20
|
+
'not-found': true,
|
|
21
|
+
conflict: false,
|
|
22
|
+
gone: false,
|
|
23
|
+
'too-large': false,
|
|
24
|
+
'rate-limited': true,
|
|
25
|
+
unavailable: true,
|
|
26
|
+
server: true,
|
|
27
|
+
unreachable: true,
|
|
28
|
+
unknown: true,
|
|
29
|
+
};
|
|
30
|
+
function kindForStatus(status) {
|
|
31
|
+
switch (status) {
|
|
32
|
+
case 0:
|
|
33
|
+
return 'unreachable';
|
|
34
|
+
case 400:
|
|
35
|
+
return 'invalid';
|
|
36
|
+
case 401:
|
|
37
|
+
return 'unauthorized';
|
|
38
|
+
case 403:
|
|
39
|
+
return 'forbidden';
|
|
40
|
+
case 404:
|
|
41
|
+
return 'not-found';
|
|
42
|
+
case 409:
|
|
43
|
+
return 'conflict';
|
|
44
|
+
case 410:
|
|
45
|
+
return 'gone';
|
|
46
|
+
case 413:
|
|
47
|
+
return 'too-large';
|
|
48
|
+
case 429:
|
|
49
|
+
return 'rate-limited';
|
|
50
|
+
case 503:
|
|
51
|
+
case 504:
|
|
52
|
+
return 'unavailable';
|
|
53
|
+
}
|
|
54
|
+
if (status >= 500 && status < 600)
|
|
55
|
+
return 'server';
|
|
56
|
+
if (status >= 400 && status < 500)
|
|
57
|
+
return 'invalid';
|
|
58
|
+
return 'unknown';
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* The HTTP status an error carries, in any shape a client throws: mero-js's
|
|
62
|
+
* `HTTPError.status`, an axios-style `response.status`, or a bare `statusCode`.
|
|
63
|
+
* Undefined when there is none — a thrown string, or an error raised before the
|
|
64
|
+
* request was sent.
|
|
65
|
+
*/
|
|
66
|
+
export function statusOf(err) {
|
|
67
|
+
if (!err || typeof err !== 'object')
|
|
68
|
+
return undefined;
|
|
69
|
+
const shaped = err;
|
|
70
|
+
for (const candidate of [shaped.status, shaped.response?.status, shaped.statusCode]) {
|
|
71
|
+
if (typeof candidate === 'number' && Number.isFinite(candidate))
|
|
72
|
+
return candidate;
|
|
73
|
+
}
|
|
74
|
+
return undefined;
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* The most specific human-readable explanation an error carries, or
|
|
78
|
+
* `fallback`. Prefers the node's words (`HTTPError.explanation`, or an axios
|
|
79
|
+
* body's `error`/`message`) over a message that repeats the status line.
|
|
80
|
+
*/
|
|
81
|
+
export function messageOf(err, fallback = 'Request failed') {
|
|
82
|
+
if (typeof err === 'string')
|
|
83
|
+
return err.trim() || fallback;
|
|
84
|
+
if (!err || typeof err !== 'object')
|
|
85
|
+
return fallback;
|
|
86
|
+
const shaped = err;
|
|
87
|
+
for (const candidate of [
|
|
88
|
+
shaped.explanation,
|
|
89
|
+
shaped.response?.data?.error,
|
|
90
|
+
shaped.response?.data?.message,
|
|
91
|
+
shaped.data?.message,
|
|
92
|
+
shaped.error,
|
|
93
|
+
shaped.message,
|
|
94
|
+
]) {
|
|
95
|
+
if (typeof candidate === 'string' && candidate.trim())
|
|
96
|
+
return candidate;
|
|
97
|
+
}
|
|
98
|
+
return fallback;
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* What a failed request means: its kind, whether a later attempt could work,
|
|
102
|
+
* and the node's own words for it.
|
|
103
|
+
*
|
|
104
|
+
* Accepts anything a `catch` hands over. A browser `fetch` that could not
|
|
105
|
+
* connect throws a `TypeError`, which is read as `unreachable` like mero-js's
|
|
106
|
+
* own status-0 `HTTPError`.
|
|
107
|
+
*/
|
|
108
|
+
export function classifyError(err) {
|
|
109
|
+
const status = statusOf(err);
|
|
110
|
+
let kind;
|
|
111
|
+
if (status !== undefined) {
|
|
112
|
+
kind = kindForStatus(status);
|
|
113
|
+
}
|
|
114
|
+
else if (err instanceof TypeError) {
|
|
115
|
+
kind = 'unreachable';
|
|
116
|
+
}
|
|
117
|
+
else {
|
|
118
|
+
kind = 'unknown';
|
|
119
|
+
}
|
|
120
|
+
return { kind, status, retryable: RETRYABLE[kind], message: messageOf(err) };
|
|
121
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { classifyError } from './classify.js';
|
|
@@ -6,6 +6,12 @@ export declare class HTTPError extends Error {
|
|
|
6
6
|
headers: Headers;
|
|
7
7
|
bodyText?: string;
|
|
8
8
|
name: string;
|
|
9
|
+
/**
|
|
10
|
+
* The node's own words for the failure, without the `HTTP <status>` prefix
|
|
11
|
+
* `message` carries: the body's `error` (or `detail`) for a response, the
|
|
12
|
+
* thrown error's text for status 0. Undefined when there was none.
|
|
13
|
+
*/
|
|
14
|
+
readonly explanation: string | undefined;
|
|
9
15
|
constructor(status: number, statusText: string, url: string, headers: Headers, bodyText?: string);
|
|
10
16
|
toJSON(): {
|
|
11
17
|
status: number;
|