@serfab/cadre-core 1.16.0 → 1.18.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 +4 -3
- package/dist/bootstrap-peer-store-file.js +1 -1
- package/dist/bootstrap-peer-store.d.ts +1 -1
- package/dist/bootstrap-peer-store.js +4 -4
- package/dist/cadre-invite-protocol.d.ts +81 -142
- package/dist/cadre-invite-protocol.js +168 -458
- package/dist/cadre-invite-protocol.js.map +1 -1
- package/dist/cadre-join-request.d.ts +15 -2
- package/dist/cadre-join-request.js +22 -3
- package/dist/cadre-join-request.js.map +1 -1
- package/dist/cadre-node.d.ts +1089 -475
- package/dist/cadre-node.js +2170 -1037
- package/dist/cadre-node.js.map +1 -1
- package/dist/control-authorization.d.ts +36 -14
- package/dist/control-authorization.js +20 -3
- package/dist/control-authorization.js.map +1 -1
- package/dist/control-cohort.d.ts +88 -32
- package/dist/control-cohort.js +91 -33
- package/dist/control-cohort.js.map +1 -1
- package/dist/control-database.d.ts +856 -318
- package/dist/control-database.js +1422 -701
- package/dist/control-database.js.map +1 -1
- package/dist/control-formation-recorder.d.ts +46 -43
- package/dist/control-formation-recorder.js +77 -78
- package/dist/control-formation-recorder.js.map +1 -1
- package/dist/control-protocol-guard.d.ts +11 -4
- package/dist/control-protocol-guard.js +20 -5
- package/dist/control-protocol-guard.js.map +1 -1
- package/dist/control-schema-version.d.ts +97 -0
- package/dist/control-schema-version.js +225 -0
- package/dist/control-schema-version.js.map +1 -0
- package/dist/control-schema.d.ts +45 -5
- package/dist/control-schema.js +262 -78
- package/dist/control-schema.js.map +1 -1
- package/dist/control-write-retry.d.ts +45 -10
- package/dist/control-write-retry.js +92 -22
- package/dist/control-write-retry.js.map +1 -1
- package/dist/delegate-admission.d.ts +13 -0
- package/dist/delegate-admission.js +27 -0
- package/dist/delegate-admission.js.map +1 -1
- package/dist/ed25519-key.d.ts +10 -7
- package/dist/ed25519-key.js +23 -20
- package/dist/ed25519-key.js.map +1 -1
- package/dist/edge-node/config.d.ts +122 -0
- package/dist/edge-node/config.js +199 -0
- package/dist/edge-node/config.js.map +1 -0
- package/dist/edge-node/dissolve.d.ts +95 -0
- package/dist/edge-node/dissolve.js +184 -0
- package/dist/edge-node/dissolve.js.map +1 -0
- package/dist/edge-node/edge-join.d.ts +44 -0
- package/dist/edge-node/edge-join.js +227 -0
- package/dist/edge-node/edge-join.js.map +1 -0
- package/dist/edge-node/edge-node.d.ts +295 -0
- package/dist/edge-node/edge-node.js +704 -0
- package/dist/edge-node/edge-node.js.map +1 -0
- package/dist/edge-node/index.d.ts +18 -0
- package/dist/edge-node/index.js +18 -0
- package/dist/edge-node/index.js.map +1 -0
- package/dist/edge-node/join-credential.d.ts +11 -0
- package/dist/edge-node/join-credential.js +11 -0
- package/dist/edge-node/join-credential.js.map +1 -0
- package/dist/edge-node/lazy-raw-storage.d.ts +40 -0
- package/dist/edge-node/lazy-raw-storage.js +134 -0
- package/dist/edge-node/lazy-raw-storage.js.map +1 -0
- package/dist/edge-node/node-local.d.ts +60 -0
- package/dist/edge-node/node-local.js +58 -0
- package/dist/edge-node/node-local.js.map +1 -0
- package/dist/edge-node/options.d.ts +83 -0
- package/dist/edge-node/options.js +110 -0
- package/dist/edge-node/options.js.map +1 -0
- package/dist/edge-node/owner-grant.d.ts +41 -0
- package/dist/edge-node/owner-grant.js +38 -0
- package/dist/edge-node/owner-grant.js.map +1 -0
- package/dist/edge-node/platform.d.ts +140 -0
- package/dist/edge-node/platform.js +12 -0
- package/dist/edge-node/platform.js.map +1 -0
- package/dist/edge-node/strand-move.d.ts +115 -0
- package/dist/edge-node/strand-move.js +223 -0
- package/dist/edge-node/strand-move.js.map +1 -0
- package/dist/edge-node/strands.d.ts +28 -0
- package/dist/edge-node/strands.js +60 -0
- package/dist/edge-node/strands.js.map +1 -0
- package/dist/enrolled-machine-store.d.ts +3 -3
- package/dist/enrolled-machine-store.js +3 -3
- package/dist/formation-token.d.ts +48 -0
- package/dist/formation-token.js +26 -0
- package/dist/formation-token.js.map +1 -0
- package/dist/index.d.ts +27 -17
- package/dist/index.js +39 -17
- package/dist/index.js.map +1 -1
- package/dist/joined-strand-store.d.ts +29 -12
- package/dist/joined-strand-store.js +66 -27
- package/dist/joined-strand-store.js.map +1 -1
- package/dist/key-store.d.ts +9 -4
- package/dist/key-store.js +9 -4
- package/dist/key-store.js.map +1 -1
- package/dist/link-budget.d.ts +9 -7
- package/dist/link-budget.js +9 -7
- package/dist/link-budget.js.map +1 -1
- package/dist/machine-removal.d.ts +19 -39
- package/dist/machine-removal.js +24 -98
- package/dist/machine-removal.js.map +1 -1
- package/dist/member-redemption.d.ts +256 -0
- package/dist/member-redemption.js +490 -0
- package/dist/member-redemption.js.map +1 -0
- package/dist/membership-view.d.ts +91 -0
- package/dist/membership-view.js +110 -0
- package/dist/membership-view.js.map +1 -0
- package/dist/owner-anchor-sync.d.ts +4 -4
- package/dist/owner-anchor-sync.js +16 -15
- package/dist/owner-anchor-sync.js.map +1 -1
- package/dist/owner-grant-protocol.d.ts +251 -0
- package/dist/owner-grant-protocol.js +344 -0
- package/dist/owner-grant-protocol.js.map +1 -0
- package/dist/owner-key-export.d.ts +70 -0
- package/dist/owner-key-export.js +279 -0
- package/dist/owner-key-export.js.map +1 -0
- package/dist/owner-key-flows.d.ts +55 -0
- package/dist/owner-key-flows.js +60 -0
- package/dist/owner-key-flows.js.map +1 -0
- package/dist/owner-key-store.d.ts +31 -0
- package/dist/owner-key-store.js +81 -0
- package/dist/owner-key-store.js.map +1 -0
- package/dist/owner-keys.d.ts +78 -0
- package/dist/owner-keys.js +133 -0
- package/dist/owner-keys.js.map +1 -0
- package/dist/owner-signer.d.ts +54 -0
- package/dist/owner-signer.js +51 -0
- package/dist/owner-signer.js.map +1 -0
- package/dist/peer-addr-book.d.ts +10 -1
- package/dist/peer-addr-book.js +4 -3
- package/dist/peer-addr-book.js.map +1 -1
- package/dist/peer-authorization.d.ts +83 -18
- package/dist/peer-authorization.js +123 -53
- package/dist/peer-authorization.js.map +1 -1
- package/dist/peer-join-backfill.d.ts +89 -20
- package/dist/peer-join-backfill.js +265 -108
- package/dist/peer-join-backfill.js.map +1 -1
- package/dist/pending-join-runner.d.ts +48 -18
- package/dist/pending-join-runner.js +74 -56
- package/dist/pending-join-runner.js.map +1 -1
- package/dist/provider/rent-node.js +1 -1
- package/dist/provider/rent-node.js.map +1 -1
- package/dist/seed-bootstrap.d.ts +162 -133
- package/dist/seed-bootstrap.js +300 -283
- package/dist/seed-bootstrap.js.map +1 -1
- package/dist/seed-trust-policy.js +4 -4
- package/dist/strand-addr-protocol.d.ts +4 -1
- package/dist/strand-addr-protocol.js +8 -2
- package/dist/strand-addr-protocol.js.map +1 -1
- package/dist/strand-cohort.d.ts +3 -0
- package/dist/strand-cohort.js.map +1 -1
- package/dist/strand-database.d.ts +24 -2
- package/dist/strand-database.js +29 -6
- package/dist/strand-database.js.map +1 -1
- package/dist/strand-first-sync-gate.d.ts +176 -88
- package/dist/strand-first-sync-gate.js +288 -121
- package/dist/strand-first-sync-gate.js.map +1 -1
- package/dist/strand-formation-deadlines.d.ts +9 -9
- package/dist/strand-formation-deadlines.js +9 -9
- package/dist/strand-formation-manager.d.ts +23 -17
- package/dist/strand-formation-manager.js +46 -44
- package/dist/strand-formation-manager.js.map +1 -1
- package/dist/strand-formation-protocol.d.ts +5 -8
- package/dist/strand-formation-protocol.js +101 -16
- package/dist/strand-formation-protocol.js.map +1 -1
- package/dist/strand-instance-manager.d.ts +27 -18
- package/dist/strand-instance-manager.js +119 -50
- package/dist/strand-instance-manager.js.map +1 -1
- package/dist/strand-membership-reconciler.d.ts +16 -13
- package/dist/strand-membership-reconciler.js +30 -22
- package/dist/strand-membership-reconciler.js.map +1 -1
- package/dist/strand-membership-writer.d.ts +45 -22
- package/dist/strand-membership-writer.js +226 -131
- package/dist/strand-membership-writer.js.map +1 -1
- package/dist/strand-move.d.ts +6 -5
- package/dist/strand-move.js +36 -32
- package/dist/strand-move.js.map +1 -1
- package/dist/strand-peer-redial.js +2 -3
- package/dist/strand-peer-redial.js.map +1 -1
- package/dist/strand-revocation-enforcer.d.ts +134 -60
- package/dist/strand-revocation-enforcer.js +144 -71
- package/dist/strand-revocation-enforcer.js.map +1 -1
- package/dist/strand-solicitation.d.ts +50 -39
- package/dist/strand-solicitation.js +10 -3
- package/dist/strand-solicitation.js.map +1 -1
- package/dist/strand-watcher.d.ts +80 -13
- package/dist/strand-watcher.js +152 -48
- package/dist/strand-watcher.js.map +1 -1
- package/dist/table-change-watch.d.ts +63 -0
- package/dist/table-change-watch.js +209 -0
- package/dist/table-change-watch.js.map +1 -0
- package/dist/timeout-scheduler.d.ts +4 -3
- package/dist/timeout-scheduler.js +4 -3
- package/dist/timeout-scheduler.js.map +1 -1
- package/dist/types.d.ts +374 -150
- package/dist/types.js +4 -4
- package/dist/types.js.map +1 -1
- package/package.json +17 -8
- package/src/bootstrap-peer-store-file.ts +44 -44
- package/src/bootstrap-peer-store.ts +214 -214
- package/src/cadre-invite-protocol.ts +740 -1052
- package/src/cadre-join-request.ts +24 -3
- package/src/cadre-node.ts +2402 -1079
- package/src/control-authorization.ts +47 -14
- package/src/control-cohort.ts +204 -128
- package/src/control-database.ts +1749 -820
- package/src/control-formation-recorder.ts +359 -353
- package/src/control-protocol-guard.ts +23 -6
- package/src/control-schema-version.ts +301 -0
- package/src/control-schema.ts +273 -78
- package/src/control-write-retry.ts +641 -567
- package/src/delegate-admission.ts +351 -314
- package/src/ed25519-key.ts +174 -171
- package/src/edge-node/config.ts +277 -0
- package/src/edge-node/dissolve.ts +251 -0
- package/src/edge-node/edge-join.ts +319 -0
- package/src/edge-node/edge-node.ts +1045 -0
- package/src/edge-node/index.ts +59 -0
- package/src/edge-node/join-credential.ts +16 -0
- package/src/edge-node/lazy-raw-storage.ts +178 -0
- package/src/edge-node/node-local.ts +84 -0
- package/src/edge-node/options.ts +179 -0
- package/src/edge-node/owner-grant.ts +63 -0
- package/src/edge-node/platform.ts +151 -0
- package/src/edge-node/strand-move.ts +270 -0
- package/src/edge-node/strands.ts +72 -0
- package/src/enrolled-machine-store.ts +3 -3
- package/src/formation-token.ts +63 -0
- package/src/index.ts +899 -789
- package/src/joined-strand-store.ts +73 -28
- package/src/key-store.ts +10 -4
- package/src/link-budget.ts +9 -7
- package/src/machine-removal.ts +26 -111
- package/src/member-redemption.ts +649 -0
- package/src/membership-view.ts +144 -0
- package/src/owner-anchor-sync.ts +19 -18
- package/src/owner-grant-protocol.ts +547 -0
- package/src/owner-key-export.ts +348 -0
- package/src/owner-key-flows.ts +109 -0
- package/src/owner-key-store.ts +85 -0
- package/src/owner-keys.ts +178 -0
- package/src/owner-signer.ts +94 -0
- package/src/peer-addr-book.ts +4 -3
- package/src/peer-authorization.ts +558 -488
- package/src/peer-join-backfill.ts +971 -761
- package/src/pending-join-runner.ts +76 -53
- package/src/provider/rent-node.ts +1 -1
- package/src/seed-bootstrap.ts +343 -349
- package/src/seed-trust-policy.ts +4 -4
- package/src/strand-addr-protocol.ts +547 -538
- package/src/strand-cohort.ts +4 -0
- package/src/strand-database.ts +352 -319
- package/src/strand-first-sync-gate.ts +334 -133
- package/src/strand-formation-deadlines.ts +227 -227
- package/src/strand-formation-manager.ts +731 -725
- package/src/strand-formation-protocol.ts +122 -24
- package/src/strand-instance-manager.ts +2092 -2026
- package/src/strand-membership-reconciler.ts +33 -25
- package/src/strand-membership-writer.ts +2093 -1974
- package/src/strand-move.ts +50 -38
- package/src/strand-peer-redial.ts +2 -3
- package/src/strand-revocation-enforcer.ts +740 -654
- package/src/strand-solicitation.ts +61 -34
- package/src/strand-watcher.ts +175 -56
- package/src/table-change-watch.ts +285 -0
- package/src/timeout-scheduler.ts +29 -28
- package/src/types.ts +391 -152
|
@@ -0,0 +1,649 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The member-dialed redemption exchange shared by cadre invitations
|
|
3
|
+
* (`cadre-invite-protocol.ts`) and owner grants (`owner-grant-protocol.ts`): a holder dials ANY
|
|
4
|
+
* member machine its bundle names, proves possession in one signed frame, and the member writes
|
|
5
|
+
* rows on the issuer's behalf and answers in one frame. The issuer need not be online.
|
|
6
|
+
*
|
|
7
|
+
* What is the same for both credentials lives here, generic over the request and reply types:
|
|
8
|
+
*
|
|
9
|
+
* - the member side's hardening and sequencing ({@link MemberRedemptionHandler}): a concurrency
|
|
10
|
+
* cap, a read timeout, the checks before any database work, the push of this member's control
|
|
11
|
+
* store to the holder for a credential still live here, and the writes under that admission;
|
|
12
|
+
* - the holder side's dialing ({@link dialRedemption}, {@link dialRedemptionInPasses}): the
|
|
13
|
+
* bundle's addresses one at a time under a per-address deadline, one signed request for every
|
|
14
|
+
* address and every pass, and the pacing between passes;
|
|
15
|
+
* - the shape checks both directions apply off the wire, and the frame cap.
|
|
16
|
+
*
|
|
17
|
+
* What differs — the bundle, the request's fields and signatures, the refusal codes, the rows
|
|
18
|
+
* written and the reply's contents — stays in each protocol module, which passes it in. Same
|
|
19
|
+
* shape as the seed and wake protocols underneath: one length-prefixed JSON frame each way over
|
|
20
|
+
* the primitives in `control-stream.ts`.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import debug from 'debug';
|
|
24
|
+
import { multiaddr, type Multiaddr } from '@multiformats/multiaddr';
|
|
25
|
+
import type { Connection, Libp2p } from '@libp2p/interface';
|
|
26
|
+
import { type ControlStream, withDeadline, exchangeFrame, readStreamToEnd, replyAndClose } from './control-stream.js';
|
|
27
|
+
import { decodeLengthPrefixedFrame } from './seed-bootstrap.js';
|
|
28
|
+
import { relayedRequestBudgetMs } from './link-budget.js';
|
|
29
|
+
import { IssuedRowNotHereYetError, parseNullableStoredDatetimeMs } from './control-database.js';
|
|
30
|
+
import { requireEd25519PublicKeyB64 } from './ed25519-key.js';
|
|
31
|
+
import { dialAddr, nextMacrotask, trailingPeerId } from './peer-dial.js';
|
|
32
|
+
import { chainMessages } from './control-retry.js';
|
|
33
|
+
import { isRetriableControlWriteFailure } from './control-write-retry.js';
|
|
34
|
+
import type { CadreJoinProgress } from './types.js';
|
|
35
|
+
|
|
36
|
+
const log = debug('sereus:cadre:member-redemption');
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Cap on either frame of either protocol. A request is one signed row, two signatures and a few
|
|
40
|
+
* addresses; a reply adds at most a peer list of a cadre's size. Both fit in a few KB; 64 KiB is
|
|
41
|
+
* the defensive bound a stranger can make the handler buffer.
|
|
42
|
+
*/
|
|
43
|
+
export const MAX_REDEMPTION_FRAME_BYTES = 64 * 1024;
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Default time the member waits for the request frame before aborting the read (ms). The read
|
|
47
|
+
* only: a stranger opens the stream, so the bound is on a caller that opens one and never sends.
|
|
48
|
+
* Same figure as the seed handler's.
|
|
49
|
+
*/
|
|
50
|
+
// eslint-disable-next-line no-restricted-syntax -- link-independent: a receiver cap on one frame on a stream the peer already opened; it bounds a peer that opens a stream and never sends, not the dial
|
|
51
|
+
const DEFAULT_REDEEM_READ_TIMEOUT_MS = 10_000;
|
|
52
|
+
|
|
53
|
+
/** Default cap on concurrent inbound redemptions; over it the handler answers `busy`. */
|
|
54
|
+
const DEFAULT_MAX_CONCURRENT_REDEMPTIONS = 100;
|
|
55
|
+
|
|
56
|
+
/** Longest string field accepted off the wire; a stamp is 342 base64url chars, a signature 86. */
|
|
57
|
+
export const MAX_WIRE_FIELD_LENGTH = 1024;
|
|
58
|
+
|
|
59
|
+
const BASE64URL = /^[A-Za-z0-9_-]+$/;
|
|
60
|
+
|
|
61
|
+
// ── Shape checks (both directions, both protocols) ───────────────────────────
|
|
62
|
+
|
|
63
|
+
export function isBoundedString(value: unknown, max = MAX_WIRE_FIELD_LENGTH): value is string {
|
|
64
|
+
return typeof value === 'string' && value.length > 0 && value.length <= max;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function isEd25519KeyB64(value: unknown): value is string {
|
|
68
|
+
if (!isBoundedString(value) || !BASE64URL.test(value)) return false;
|
|
69
|
+
try {
|
|
70
|
+
requireEd25519PublicKeyB64(value, 'key');
|
|
71
|
+
return true;
|
|
72
|
+
} catch {
|
|
73
|
+
return false;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Is `value` a null-or-bounded-string column, as the nullable text columns of a travelling row are? */
|
|
78
|
+
export function isNullableBoundedString(value: unknown, max = MAX_WIRE_FIELD_LENGTH): value is string | null {
|
|
79
|
+
return value === null || isBoundedString(value, max);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Is `value` a null-or-positive-integer use cap? */
|
|
83
|
+
export function isNullableUseCap(value: unknown): value is number | null {
|
|
84
|
+
return value === null || (Number.isInteger(value) && (value as number) >= 1);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// ── Refusals ─────────────────────────────────────────────────────────────────
|
|
88
|
+
|
|
89
|
+
/** A refusal as the member sends it: a code, a reason, nothing else. */
|
|
90
|
+
export interface RedemptionRejection<Code extends string> {
|
|
91
|
+
accepted: false;
|
|
92
|
+
code: Code;
|
|
93
|
+
reason: string;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* A refusal as the holder reads it: the code and reason verbatim off the wire, untyped, so each
|
|
98
|
+
* protocol's rejected-error is the one place that classifies a code this build does not know
|
|
99
|
+
* (as `'unrecognized'`, retryable) rather than the decoder folding it into a code it does.
|
|
100
|
+
*/
|
|
101
|
+
export interface RedemptionRefusalFrame {
|
|
102
|
+
accepted: false;
|
|
103
|
+
code: unknown;
|
|
104
|
+
reason: unknown;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function rejection<Code extends string>(code: Code, reason: string): RedemptionRejection<Code> {
|
|
108
|
+
return { accepted: false, code, reason };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** One row of a protocol's constraint table: the constraint names the schema refuses a write with, and the code and reason each maps to. */
|
|
112
|
+
export type ConstraintRefusalRow<Code extends string> = readonly [RegExp, Code, string];
|
|
113
|
+
|
|
114
|
+
/** Which named constraint, if any, refused the write — read off the error's cause chain. */
|
|
115
|
+
export function constraintRefusal<Code extends string>(error: unknown, table: ReadonlyArray<ConstraintRefusalRow<Code>>): RedemptionRejection<Code> | null {
|
|
116
|
+
if (!(error instanceof Error)) return null;
|
|
117
|
+
const messages = chainMessages(error);
|
|
118
|
+
for (const [pattern, code, reason] of table) {
|
|
119
|
+
if (messages.some((message) => pattern.test(message))) return rejection(code, reason);
|
|
120
|
+
}
|
|
121
|
+
return null;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* A write failure that is not a refusal: this member cannot read the credential row the cadre
|
|
126
|
+
* holds yet, or the retry gave up (`conflict`), or something else (`internal`).
|
|
127
|
+
*/
|
|
128
|
+
export function writeFailureRefusal(error: unknown): RedemptionRejection<'conflict' | 'internal'> {
|
|
129
|
+
if (error instanceof IssuedRowNotHereYetError) {
|
|
130
|
+
return rejection('conflict', 'This member has not synced the credential yet; nothing was recorded');
|
|
131
|
+
}
|
|
132
|
+
// NOTE: a seat torn on a member alone with an uncaught-up device was measured answering
|
|
133
|
+
// `internal`, not `conflict` (cadre-invite-redeemed-at-a-member-without-the-row). Both are
|
|
134
|
+
// retryable, so the holder is unaffected; if the two codes ever diverge in retryability,
|
|
135
|
+
// find which tear `isRetriableControlWriteFailure` does not recognise.
|
|
136
|
+
return isRetriableControlWriteFailure(error)
|
|
137
|
+
? rejection('conflict', 'The redemption write failed transiently; nothing was recorded')
|
|
138
|
+
: rejection('internal', 'Internal redemption error');
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// ── Member side (handler base) ───────────────────────────────────────────────
|
|
142
|
+
|
|
143
|
+
export interface MemberRedemptionHandlerOptions {
|
|
144
|
+
/** This member's party id; a request for another party is refused by name. */
|
|
145
|
+
partyId: string;
|
|
146
|
+
/**
|
|
147
|
+
* Push this member's control store to the holder, resolving once the push has finished, and
|
|
148
|
+
* keep the holder counted in this member's control write cohort until the returned release
|
|
149
|
+
* is called. Called after the request verified and before the row is seated and the
|
|
150
|
+
* redemption is written; the handler releases once both have settled. Absent, the writes go
|
|
151
|
+
* ahead with the holder neither caught up nor in the cohort, so it does not hold them; see
|
|
152
|
+
* {@link MemberRedemptionHandler.admitIfLive}.
|
|
153
|
+
*/
|
|
154
|
+
admitDevice?: (peerId: string) => Promise<() => void>;
|
|
155
|
+
/** Time to wait for the request frame; default {@link DEFAULT_REDEEM_READ_TIMEOUT_MS}. */
|
|
156
|
+
redeemReadTimeoutMs?: number;
|
|
157
|
+
/** Cap on concurrent inbound redemptions; default {@link DEFAULT_MAX_CONCURRENT_REDEMPTIONS}. */
|
|
158
|
+
maxConcurrentRedemptions?: number;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Member side of one redemption protocol: registers the handler and drives each inbound stream
|
|
163
|
+
* through the checks in cost order — shape, then whatever the protocol checks without database
|
|
164
|
+
* work (party, signatures, and for an invitation the connecting identity and target device) —
|
|
165
|
+
* so no database work happens for a stranger that cannot prove anything; then admits the holder
|
|
166
|
+
* to this member's control replication (`admitDevice`) when the credential is live here, and
|
|
167
|
+
* runs the protocol's writes. Hardened as the seed handler is: a concurrency cap (over it,
|
|
168
|
+
* `busy`) and a read timeout.
|
|
169
|
+
*
|
|
170
|
+
* Registered only by a started `CadreNode`, after its control database is up, like the
|
|
171
|
+
* formation responder; nothing here is reachable on a node without one.
|
|
172
|
+
*
|
|
173
|
+
* A subclass supplies the six credential-specific pieces: the request's shape, the checks after
|
|
174
|
+
* it, the read of the credential row this member holds, the liveness judgement behind the push,
|
|
175
|
+
* the writes, and the two fixed refusals the base sends on its own (`busy`, and `internal` for a
|
|
176
|
+
* frame it could not read or a failure it did not expect).
|
|
177
|
+
*
|
|
178
|
+
* The held row is read once, after the checks, and carried to the liveness judgement and the
|
|
179
|
+
* writes: the schema refuses every update and delete of an issued credential row, so the row
|
|
180
|
+
* read before the write lock is the row a read inside it would return.
|
|
181
|
+
*/
|
|
182
|
+
export abstract class MemberRedemptionHandler<Request, Reply extends { accepted: boolean }, Held> {
|
|
183
|
+
protected readonly partyId: string;
|
|
184
|
+
private readonly admitDevice: MemberRedemptionHandlerOptions['admitDevice'];
|
|
185
|
+
private readonly readTimeoutMs: number;
|
|
186
|
+
private readonly maxConcurrent: number;
|
|
187
|
+
private readonly registered = new Set<Libp2p>();
|
|
188
|
+
private active = 0;
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* @param label - Names the credential in log lines and stream labels ("Cadre invite", "Owner grant").
|
|
192
|
+
* @param protocolId - The protocol id {@link register} handles by default.
|
|
193
|
+
*/
|
|
194
|
+
protected constructor(protected readonly label: string, protected readonly protocolId: string, options: MemberRedemptionHandlerOptions) {
|
|
195
|
+
this.partyId = options.partyId;
|
|
196
|
+
this.admitDevice = options.admitDevice;
|
|
197
|
+
this.readTimeoutMs = options.redeemReadTimeoutMs ?? DEFAULT_REDEEM_READ_TIMEOUT_MS;
|
|
198
|
+
this.maxConcurrent = options.maxConcurrentRedemptions ?? DEFAULT_MAX_CONCURRENT_REDEMPTIONS;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** The refusal for a caller over the concurrency cap. */
|
|
202
|
+
protected abstract readonly busyRefusal: Reply;
|
|
203
|
+
/** The refusal for a request frame the member could not read, or an unexpected failure: nothing was proved either way, so the holder stays free to try the next address. */
|
|
204
|
+
protected abstract readonly internalRefusal: Reply;
|
|
205
|
+
/** The refusal for a frame that is not a well-formed request. */
|
|
206
|
+
protected abstract readonly malformedRefusal: Reply;
|
|
207
|
+
/** The request's shape, every field in its wire form and bounded. */
|
|
208
|
+
protected abstract isWellFormedRequest(value: unknown): value is Request;
|
|
209
|
+
/** The checks after the shape that cost no database work; null to go on to the writes. */
|
|
210
|
+
protected abstract checkRequest(request: Request, remotePeerId: string): Reply | null;
|
|
211
|
+
/** The credential row this member holds under the (checked) request's key, or null; a failed read is answered `internal`. */
|
|
212
|
+
protected abstract readHeld(request: Request): Promise<Held | null>;
|
|
213
|
+
/** Is the credential the request carries still live on this member, judged on `held` (else the bundle's copy)? Decides whether the holder is pushed the store (see {@link admitIfLive}). */
|
|
214
|
+
protected abstract isLive(request: Request, held: Held | null): Promise<boolean>;
|
|
215
|
+
/** The protocol's writes under the admission, each answered by name when it fails; `held` is {@link readHeld}'s row. */
|
|
216
|
+
protected abstract redeemAdmitted(request: Request, remotePeerId: string, held: Held | null): Promise<Reply>;
|
|
217
|
+
|
|
218
|
+
/** In-flight inbound redemptions. */
|
|
219
|
+
get activeCount(): number {
|
|
220
|
+
return this.active;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Register on `node`; rejects when libp2p refuses the registration. `runOnLimitedConnection`
|
|
225
|
+
* because a phone redeems through a relay, whose connection libp2p marks limited; the
|
|
226
|
+
* exchange is two small frames, well inside any relay's cap.
|
|
227
|
+
*/
|
|
228
|
+
async register(node: Libp2p, protocolId: string = this.protocolId): Promise<void> {
|
|
229
|
+
if (this.registered.has(node)) return;
|
|
230
|
+
await node.handle(protocolId, async (rawStream: unknown, rawConnection: unknown) => {
|
|
231
|
+
await this.handleStream(rawStream as ControlStream, (rawConnection as Connection).remotePeer.toString());
|
|
232
|
+
}, { runOnLimitedConnection: true });
|
|
233
|
+
this.registered.add(node);
|
|
234
|
+
log('%s handler registered (%s)', this.label, protocolId);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
async unregister(node: Libp2p, protocolId: string = this.protocolId): Promise<void> {
|
|
238
|
+
if (!this.registered.has(node)) return;
|
|
239
|
+
await node.unhandle(protocolId);
|
|
240
|
+
this.registered.delete(node);
|
|
241
|
+
log('%s handler unregistered (%s)', this.label, protocolId);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* One inbound redemption: read the frame, decide, reply, close. Public as the seam a protocol
|
|
246
|
+
* spec drives directly; `remotePeerId` is the connection's authenticated peer.
|
|
247
|
+
*/
|
|
248
|
+
async handleStream(stream: ControlStream, remotePeerId: string): Promise<void> {
|
|
249
|
+
if (this.active >= this.maxConcurrent) {
|
|
250
|
+
log('%s: refusing redemption from %s: %d in flight at cap %d', this.label, remotePeerId, this.active, this.maxConcurrent);
|
|
251
|
+
await replyAndClose(stream, this.busyRefusal, this.label);
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
this.active++;
|
|
255
|
+
try {
|
|
256
|
+
const reply = await this.decide(stream, remotePeerId);
|
|
257
|
+
log('%s: redemption from %s: %s', this.label, remotePeerId, reply.accepted ? 'accepted' : `refused (${(reply as { code?: unknown }).code})`);
|
|
258
|
+
await replyAndClose(stream, reply, this.label);
|
|
259
|
+
} catch (error) {
|
|
260
|
+
log('%s: redemption from %s failed: %o', this.label, remotePeerId, error);
|
|
261
|
+
await replyAndClose(stream, this.internalRefusal, this.label);
|
|
262
|
+
} finally {
|
|
263
|
+
this.active--;
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Read the request frame to EOF, bounded and size-capped; JSON only, shape is the subclass's.
|
|
269
|
+
* Throws on a frame that cannot be read, which {@link handleStream} answers `internal`: a read
|
|
270
|
+
* timeout or a cut frame proves nothing about the credential, so the holder must stay free to
|
|
271
|
+
* try the next address.
|
|
272
|
+
*/
|
|
273
|
+
private async readRequest(stream: ControlStream): Promise<unknown> {
|
|
274
|
+
const data = await readStreamToEnd(stream, { maxBytes: MAX_REDEMPTION_FRAME_BYTES, timeoutMs: this.readTimeoutMs, label: this.label });
|
|
275
|
+
return JSON.parse(new TextDecoder().decode(decodeLengthPrefixedFrame(data, MAX_REDEMPTION_FRAME_BYTES)));
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
private async decide(stream: ControlStream, remotePeerId: string): Promise<Reply> {
|
|
279
|
+
const request = await this.readRequest(stream);
|
|
280
|
+
if (!this.isWellFormedRequest(request)) return this.malformedRefusal;
|
|
281
|
+
const refused = this.checkRequest(request, remotePeerId);
|
|
282
|
+
if (refused) return refused;
|
|
283
|
+
const held = await this.readHeld(request);
|
|
284
|
+
const release = await this.admitIfLive(request, remotePeerId, held);
|
|
285
|
+
try {
|
|
286
|
+
return await this.redeemAdmitted(request, remotePeerId, held);
|
|
287
|
+
} finally {
|
|
288
|
+
release();
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Admit the holder to this member's control replication before the first write of the
|
|
294
|
+
* exchange (the seat, when this member does not hold the row; otherwise the redemption), and
|
|
295
|
+
* resolve to the release that ends the admission. The holder is not a member, so only this
|
|
296
|
+
* puts it in this member's control write cohort, and it must be there to hold the rows the
|
|
297
|
+
* exchange writes. It is pushed the control store first, because a cohort node with no base
|
|
298
|
+
* revision of a block refuses a commit to it; a member alone with the holder would then have
|
|
299
|
+
* half its cohort refusing, and the write would tear instead of committing.
|
|
300
|
+
*
|
|
301
|
+
* Only for a credential still live here ({@link isLive}, judged on the row this member holds
|
|
302
|
+
* or, before the seat, on the bundle's copy once its issuer signature verifies): the holder
|
|
303
|
+
* of a withdrawn, expired or spent one, or of a forged copy naming a real owner, is refused
|
|
304
|
+
* without being sent anything. The retry of a redemption already written can read as spent
|
|
305
|
+
* too and is sent nothing; it needs nothing, since the rows it would hold are written and
|
|
306
|
+
* the ordinary catch-up reaches a member. Best-effort: a failed check or push is logged and
|
|
307
|
+
* the writes go ahead without the holder admitted, then commit or are answered retryably.
|
|
308
|
+
*/
|
|
309
|
+
private async admitIfLive(request: Request, remotePeerId: string, held: Held | null): Promise<() => void> {
|
|
310
|
+
const notAdmitted = (): void => undefined;
|
|
311
|
+
if (!this.admitDevice) return notAdmitted;
|
|
312
|
+
try {
|
|
313
|
+
if (!await this.isLive(request, held)) {
|
|
314
|
+
log('%s: the credential is not live here; %s is not admitted before the write', this.label, remotePeerId);
|
|
315
|
+
return notAdmitted;
|
|
316
|
+
}
|
|
317
|
+
// NOTE: accepted tradeoff — when a write then fails (the seat refused, the last use
|
|
318
|
+
// taken by a race, the row expiring between this check and the write), the holder keeps
|
|
319
|
+
// a copy of a control store it was not admitted to. It holds a live owner-signed
|
|
320
|
+
// credential and proved possession and consent, and a seed hands an un-enrolled machine
|
|
321
|
+
// the same information. Revisit if a control table ever carries data a non-member must not see.
|
|
322
|
+
return await this.admitDevice(remotePeerId);
|
|
323
|
+
} catch (error) {
|
|
324
|
+
log('%s: admitting %s before its write failed (writing anyway): %o', this.label, remotePeerId, error);
|
|
325
|
+
return notAdmitted;
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// ── Holder side (dialer) ─────────────────────────────────────────────────────
|
|
331
|
+
|
|
332
|
+
/** What one address the bundle named came to. */
|
|
333
|
+
export interface RedemptionAddressOutcome {
|
|
334
|
+
addr: string;
|
|
335
|
+
/** A dial or exchange failure, or a retryable refusal. */
|
|
336
|
+
error: Error;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/** A member's refusal as the holder classifies it; `retryable` decides whether the next address is tried. */
|
|
340
|
+
export type RedemptionRefusal = Error & { readonly retryable: boolean };
|
|
341
|
+
|
|
342
|
+
/** What a successful redemption yields the holder. */
|
|
343
|
+
export interface MemberRedemption<Accepted> {
|
|
344
|
+
reply: Accepted;
|
|
345
|
+
/** The answering member's peer id, from the address's trailing `/p2p/`; null when it named none. */
|
|
346
|
+
memberPeerId: string | null;
|
|
347
|
+
/** The address that answered. */
|
|
348
|
+
memberAddr: string;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
export interface MemberRedemptionOptions<Accepted> {
|
|
352
|
+
/** Names the credential in log lines and deadline labels ("Cadre invitation", "Owner grant"). */
|
|
353
|
+
label: string;
|
|
354
|
+
protocolId: string;
|
|
355
|
+
/** The bundle's multiaddrs ending in `/p2p/<peerId>`, dialed in order. */
|
|
356
|
+
members: readonly string[];
|
|
357
|
+
/** The signed request; the same one goes to every address (and every pass). */
|
|
358
|
+
request: unknown;
|
|
359
|
+
/** Read an acceptance frame's fields into `Accepted`, sanitizing them; throws on a malformed one, which moves the holder to the next address. */
|
|
360
|
+
decodeAccepted(frame: Record<string, unknown>): Accepted;
|
|
361
|
+
/** A refusal frame's code and reason, as the protocol classifies them. */
|
|
362
|
+
refusal(code: unknown, reason: unknown): RedemptionRefusal;
|
|
363
|
+
/** The holder's check on an acceptance; throws when it does not check out, which ends the call. */
|
|
364
|
+
verifyAccepted(reply: Accepted): void;
|
|
365
|
+
/** The error for every address tried and none accepting. */
|
|
366
|
+
unreachable(outcomes: readonly RedemptionAddressOutcome[]): Error;
|
|
367
|
+
/**
|
|
368
|
+
* This machine's declared link round trip (`NetworkConfig.linkRoundTripMs`), from which the
|
|
369
|
+
* per-address deadline derives: a dial that may need a relay plus one request
|
|
370
|
+
* (`relayedRequestBudgetMs`, 28.5 s at the default declaration), as seed delivery's is.
|
|
371
|
+
*/
|
|
372
|
+
linkRoundTripMs?: number;
|
|
373
|
+
/** Per-address deadline override; default derived from `linkRoundTripMs`. */
|
|
374
|
+
addressBudgetMs?: number;
|
|
375
|
+
/** Cancels the dial or stream in flight and ends the pass; the call throws the signal's reason. */
|
|
376
|
+
signal?: AbortSignal;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/**
|
|
380
|
+
* Dial `addr`, open one stream to it, send the request, half-close, read the one reply frame.
|
|
381
|
+
* `signal` is the per-address deadline: it cancels the dial and resets a live stream.
|
|
382
|
+
*
|
|
383
|
+
* The address is dialed outside libp2p's dial queue (`dialAddr`), so another dial of the
|
|
384
|
+
* member in flight cannot capture this attempt; an open connection is still reused.
|
|
385
|
+
*/
|
|
386
|
+
async function exchangeRedemption<Accepted>(
|
|
387
|
+
node: Libp2p,
|
|
388
|
+
addr: Multiaddr,
|
|
389
|
+
options: MemberRedemptionOptions<Accepted>,
|
|
390
|
+
signal: AbortSignal,
|
|
391
|
+
budgetMs: number
|
|
392
|
+
): Promise<Accepted | RedemptionRefusalFrame> {
|
|
393
|
+
const connection = await dialAddr(node, addr, signal, `${options.label} redemption`);
|
|
394
|
+
// `runOnLimitedConnection`: a member may be reachable only through a relay (see `register`).
|
|
395
|
+
const rawStream = await connection.newStream(options.protocolId, { runOnLimitedConnection: true, signal });
|
|
396
|
+
return await exchangeFrame(
|
|
397
|
+
rawStream as unknown as ControlStream,
|
|
398
|
+
signal,
|
|
399
|
+
options.request,
|
|
400
|
+
async (stream) => {
|
|
401
|
+
const data = await readStreamToEnd(stream, { maxBytes: MAX_REDEMPTION_FRAME_BYTES, timeoutMs: budgetMs, label: `${options.label} reply` });
|
|
402
|
+
return decodeReply(JSON.parse(new TextDecoder().decode(decodeLengthPrefixedFrame(data, MAX_REDEMPTION_FRAME_BYTES))), options);
|
|
403
|
+
},
|
|
404
|
+
`${options.label} redemption aborted by its deadline`
|
|
405
|
+
);
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
/**
|
|
409
|
+
* Read a reply frame into a typed reply. A refusal keeps only its code and reason, as sent;
|
|
410
|
+
* an acceptance is the protocol's to read. Throws on a frame that is neither.
|
|
411
|
+
*/
|
|
412
|
+
function decodeReply<Accepted>(value: unknown, options: MemberRedemptionOptions<Accepted>): Accepted | RedemptionRefusalFrame {
|
|
413
|
+
if (typeof value !== 'object' || value === null) {
|
|
414
|
+
throw new Error(`${options.label} reply is not an object`);
|
|
415
|
+
}
|
|
416
|
+
const reply = value as Record<string, unknown>;
|
|
417
|
+
if (reply.accepted !== true) {
|
|
418
|
+
return { accepted: false, code: reply.code, reason: reply.reason };
|
|
419
|
+
}
|
|
420
|
+
return options.decodeAccepted(reply);
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
function isRefusal<Accepted>(reply: Accepted | RedemptionRefusalFrame): reply is RedemptionRefusalFrame {
|
|
424
|
+
return (reply as { accepted?: unknown }).accepted === false;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
function asError(error: unknown): Error {
|
|
428
|
+
if (error instanceof Error) return error;
|
|
429
|
+
// A WebSocket transport rejects a refused dial with an `ErrorEvent`, whose text is its `message`.
|
|
430
|
+
const message = (error as { message?: unknown } | null)?.message;
|
|
431
|
+
return new Error(typeof message === 'string' && message.length > 0 ? message : String(error), { cause: error });
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
/**
|
|
435
|
+
* Run `op` with a signal that aborts when either `deadline` or the caller's `signal` does.
|
|
436
|
+
* Relayed by hand rather than with `AbortSignal.any`, which the phone runtimes lack.
|
|
437
|
+
*/
|
|
438
|
+
async function underDeadlineAndCaller<T>(
|
|
439
|
+
deadline: AbortSignal,
|
|
440
|
+
caller: AbortSignal | undefined,
|
|
441
|
+
op: (signal: AbortSignal) => Promise<T>
|
|
442
|
+
): Promise<T> {
|
|
443
|
+
if (!caller) return await op(deadline);
|
|
444
|
+
const either = new AbortController();
|
|
445
|
+
const abort = (): void => either.abort();
|
|
446
|
+
deadline.addEventListener('abort', abort, { once: true });
|
|
447
|
+
caller.addEventListener('abort', abort, { once: true });
|
|
448
|
+
// An abort between the caller's last check and the listener above fired no event.
|
|
449
|
+
if (caller.aborted) abort();
|
|
450
|
+
try {
|
|
451
|
+
return await op(either.signal);
|
|
452
|
+
} finally {
|
|
453
|
+
deadline.removeEventListener('abort', abort);
|
|
454
|
+
caller.removeEventListener('abort', abort);
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
/** One pass's answer: the acceptance, or what every address came to and whether any member answered at all. */
|
|
459
|
+
type PassOutcome<Accepted> =
|
|
460
|
+
| { redeemed: MemberRedemption<Accepted> }
|
|
461
|
+
| { outcomes: RedemptionAddressOutcome[]; memberAnswered: boolean };
|
|
462
|
+
|
|
463
|
+
/**
|
|
464
|
+
* One pass over the bundle's addresses, one at a time, each under its own deadline, stopping at
|
|
465
|
+
* the first acceptance or the first final refusal. A dial failure, a malformed reply or a
|
|
466
|
+
* retryable refusal moves to the next address. A final refusal and an acceptance that fails
|
|
467
|
+
* the holder's check are thrown from outside the per-address catch, so neither is ever
|
|
468
|
+
* mistaken for an address that could not be reached.
|
|
469
|
+
*/
|
|
470
|
+
async function dialOnce<Accepted>(node: Libp2p, options: MemberRedemptionOptions<Accepted>): Promise<PassOutcome<Accepted>> {
|
|
471
|
+
const budgetMs = options.addressBudgetMs ?? relayedRequestBudgetMs(options.linkRoundTripMs);
|
|
472
|
+
const outcomes: RedemptionAddressOutcome[] = [];
|
|
473
|
+
let memberAnswered = false;
|
|
474
|
+
for (const addr of options.members) {
|
|
475
|
+
options.signal?.throwIfAborted();
|
|
476
|
+
// Between attempts: a relayed address's relay leg goes through libp2p's dial queue, which
|
|
477
|
+
// needs a macrotask to drop an aborted job (see `tryAddrsInTurn`).
|
|
478
|
+
if (outcomes.length > 0) await nextMacrotask();
|
|
479
|
+
let parsed: Multiaddr;
|
|
480
|
+
try {
|
|
481
|
+
parsed = multiaddr(addr);
|
|
482
|
+
} catch (error) {
|
|
483
|
+
outcomes.push({ addr, error: new Error('not a multiaddr', { cause: error }) });
|
|
484
|
+
continue;
|
|
485
|
+
}
|
|
486
|
+
let reply: Accepted | RedemptionRefusalFrame;
|
|
487
|
+
try {
|
|
488
|
+
reply = await withDeadline(budgetMs, `${options.label} redemption via ${addr}`,
|
|
489
|
+
(deadline) => underDeadlineAndCaller(deadline, options.signal,
|
|
490
|
+
(signal) => exchangeRedemption(node, parsed, options, signal, budgetMs)));
|
|
491
|
+
} catch (error) {
|
|
492
|
+
// The caller's abort cut this attempt short; its reason, not the cut, is the answer.
|
|
493
|
+
options.signal?.throwIfAborted();
|
|
494
|
+
log('%s redemption via %s failed before an answer (%s); trying the next address', options.label, addr, asError(error).message);
|
|
495
|
+
outcomes.push({ addr, error: asError(error) });
|
|
496
|
+
continue;
|
|
497
|
+
}
|
|
498
|
+
if (isRefusal(reply)) {
|
|
499
|
+
const refusal = options.refusal(reply.code, reply.reason);
|
|
500
|
+
if (!refusal.retryable) throw refusal;
|
|
501
|
+
log('%s redemption via %s refused retryably (%s); trying the next address', options.label, addr, refusal.message);
|
|
502
|
+
outcomes.push({ addr, error: refusal });
|
|
503
|
+
memberAnswered = true;
|
|
504
|
+
continue;
|
|
505
|
+
}
|
|
506
|
+
options.verifyAccepted(reply);
|
|
507
|
+
return { redeemed: { reply, memberPeerId: trailingPeerId(parsed), memberAddr: addr } };
|
|
508
|
+
}
|
|
509
|
+
return { outcomes, memberAnswered };
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
/**
|
|
513
|
+
* Holder side: try the bundle's addresses one at a time ({@link dialOnce}) and throw the
|
|
514
|
+
* protocol's unreachable error when none accepts.
|
|
515
|
+
*
|
|
516
|
+
* Every address gets the SAME request, with one usage nonce: the usage table is keyed by it,
|
|
517
|
+
* so a member the holder gave up on that was still committing cannot redeem it twice, and a
|
|
518
|
+
* retry at the next member after a dropped reply is answered as already done.
|
|
519
|
+
*
|
|
520
|
+
* @throws the protocol's rejected error on a final refusal, its reply-invalid error on an
|
|
521
|
+
* acceptance that fails `verifyAccepted`, its unreachable error when every address was tried
|
|
522
|
+
* and none accepted, and the reason of `options.signal` once it aborts.
|
|
523
|
+
*/
|
|
524
|
+
export async function dialRedemption<Accepted>(node: Libp2p, options: MemberRedemptionOptions<Accepted>): Promise<MemberRedemption<Accepted>> {
|
|
525
|
+
const outcome = await dialOnce(node, options);
|
|
526
|
+
if ('redeemed' in outcome) return outcome.redeemed;
|
|
527
|
+
throw options.unreachable(outcome.outcomes);
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
/** Pause after the first pass in which no member accepted; doubled after each further one. */
|
|
531
|
+
const REDEEM_PASS_FIRST_PAUSE_MS = 5_000;
|
|
532
|
+
|
|
533
|
+
/** Longest pause between passes. */
|
|
534
|
+
const REDEEM_PASS_MAX_PAUSE_MS = 60_000;
|
|
535
|
+
|
|
536
|
+
export interface MemberRedemptionPassOptions<Accepted> extends MemberRedemptionOptions<Accepted> {
|
|
537
|
+
/** Keep making passes while every answer is retryable; false makes exactly one. */
|
|
538
|
+
waitForMember: boolean;
|
|
539
|
+
/** Called at each pass and each pause; an exception it throws is logged and ignored. */
|
|
540
|
+
onProgress?: (progress: CadreJoinProgress) => void;
|
|
541
|
+
/** The credential's stored expiry (its row's `expiresAt`, a `datetime` string); null for none. */
|
|
542
|
+
expiresAt: string | null;
|
|
543
|
+
/** The refusal a member would give the credential once expired, thrown when this holder's own clock says it has. */
|
|
544
|
+
expired(): Error;
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
/**
|
|
548
|
+
* Holder side, in passes: one {@link dialOnce} pass over the bundle's addresses, or, with
|
|
549
|
+
* `waitForMember`, as many as it takes, pausing between them (5 s after the first, doubling to
|
|
550
|
+
* 60 s, never past the credential's expiry). Every pass sends the same signed request, so a
|
|
551
|
+
* member that wrote the redemption on a pass whose reply was lost answers a later pass as
|
|
552
|
+
* already done, with no second usage row.
|
|
553
|
+
*
|
|
554
|
+
* A wait ends at the first acceptance, at a final refusal or an invalid reply (thrown as
|
|
555
|
+
* {@link dialRedemption} throws them), when the credential expires by this holder's clock
|
|
556
|
+
* (thrown as `expired()`), when `signal` aborts (its reason thrown), and when `node` stops. A
|
|
557
|
+
* bundle that names no address has nothing to wait for and throws at once.
|
|
558
|
+
*/
|
|
559
|
+
export async function dialRedemptionInPasses<Accepted>(node: Libp2p, options: MemberRedemptionPassOptions<Accepted>): Promise<MemberRedemption<Accepted>> {
|
|
560
|
+
for (let pass = 1; ; pass++) {
|
|
561
|
+
if (options.waitForMember) {
|
|
562
|
+
// One pass leaves expiry to the member, whose clock decides; a wait has nothing else to end it.
|
|
563
|
+
refuseIfExpired(options);
|
|
564
|
+
refuseIfStopped(node, options.label);
|
|
565
|
+
}
|
|
566
|
+
reportProgress(options.onProgress, { state: 'redeeming', pass });
|
|
567
|
+
const outcome = await dialOnce(node, options);
|
|
568
|
+
if ('redeemed' in outcome) return outcome.redeemed;
|
|
569
|
+
if (!options.waitForMember || outcome.outcomes.length === 0) throw options.unreachable(outcome.outcomes);
|
|
570
|
+
const pauseMs = pauseAfterPass(pass, options.expiresAt);
|
|
571
|
+
reportProgress(options.onProgress, {
|
|
572
|
+
state: 'waiting',
|
|
573
|
+
pass,
|
|
574
|
+
retryAtMs: Date.now() + pauseMs,
|
|
575
|
+
// Any refusal means a member answered and is not ready yet; otherwise none answered at all.
|
|
576
|
+
reason: outcome.memberAnswered ? 'member-not-ready' : 'no-member-answered',
|
|
577
|
+
outcomes: outcome.outcomes
|
|
578
|
+
});
|
|
579
|
+
log('%s redemption pass %d found no member ready; the next pass in %d ms', options.label, pass, pauseMs);
|
|
580
|
+
await pause(pauseMs, options.signal, node);
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
// NOTE: judged by this holder's clock, so one running ahead ends a wait before the members
|
|
585
|
+
// would refuse it. If waits are seen ending early on skewed phones, allow a grace margin here.
|
|
586
|
+
function refuseIfExpired<Accepted>(options: MemberRedemptionPassOptions<Accepted>): void {
|
|
587
|
+
const expiresAtMs = parseNullableStoredDatetimeMs(options.expiresAt);
|
|
588
|
+
if (expiresAtMs !== null && expiresAtMs <= Date.now()) {
|
|
589
|
+
throw options.expired();
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
/** A stopped node's every dial fails at once, which would otherwise read as "no member answered" until expiry. */
|
|
594
|
+
function refuseIfStopped(node: Libp2p, label: string): void {
|
|
595
|
+
if (node.status !== 'started') {
|
|
596
|
+
throw new Error(`${label} redemption stopped waiting: the node is ${node.status}`);
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
/** The pause after pass `pass`, cut short at the credential's expiry so the next check ends the wait then. */
|
|
601
|
+
function pauseAfterPass(pass: number, expiresAt: string | null): number {
|
|
602
|
+
const backoffMs = Math.min(REDEEM_PASS_FIRST_PAUSE_MS * 2 ** (pass - 1), REDEEM_PASS_MAX_PAUSE_MS);
|
|
603
|
+
const expiresAtMs = parseNullableStoredDatetimeMs(expiresAt);
|
|
604
|
+
return expiresAtMs === null ? backoffMs : Math.max(0, Math.min(backoffMs, expiresAtMs - Date.now()));
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
/** A UI callback must not break the join. */
|
|
608
|
+
function reportProgress(onProgress: ((progress: CadreJoinProgress) => void) | undefined, progress: CadreJoinProgress): void {
|
|
609
|
+
if (!onProgress) return;
|
|
610
|
+
try {
|
|
611
|
+
onProgress(progress);
|
|
612
|
+
} catch (error) {
|
|
613
|
+
log('redemption progress callback threw (ignored): %o', error);
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
/**
|
|
618
|
+
* Wait `ms`, or reject with `signal`'s reason as soon as it aborts. Ends early, resolving, when
|
|
619
|
+
* `node` stops (or is already stopped), so the next pass's stop check ends the wait without a
|
|
620
|
+
* timer holding the process open. Listeners and timer are cleared however it ends.
|
|
621
|
+
*/
|
|
622
|
+
function pause(ms: number, signal: AbortSignal | undefined, node: Libp2p): Promise<void> {
|
|
623
|
+
return new Promise((resolve, reject) => {
|
|
624
|
+
if (signal?.aborted) {
|
|
625
|
+
reject(signal.reason);
|
|
626
|
+
return;
|
|
627
|
+
}
|
|
628
|
+
if (node.status !== 'started') {
|
|
629
|
+
resolve();
|
|
630
|
+
return;
|
|
631
|
+
}
|
|
632
|
+
const settle = (): void => {
|
|
633
|
+
clearTimeout(timer);
|
|
634
|
+
signal?.removeEventListener('abort', onAbort);
|
|
635
|
+
node.removeEventListener('stop', onStop);
|
|
636
|
+
};
|
|
637
|
+
const onAbort = (): void => {
|
|
638
|
+
settle();
|
|
639
|
+
reject(signal!.reason);
|
|
640
|
+
};
|
|
641
|
+
const onStop = (): void => {
|
|
642
|
+
settle();
|
|
643
|
+
resolve();
|
|
644
|
+
};
|
|
645
|
+
const timer = setTimeout(onStop, ms);
|
|
646
|
+
signal?.addEventListener('abort', onAbort, { once: true });
|
|
647
|
+
node.addEventListener('stop', onStop, { once: true });
|
|
648
|
+
});
|
|
649
|
+
}
|