@serfab/cadre-core 1.15.0 → 1.17.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 +1 -1
- 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 +55 -138
- package/dist/cadre-invite-protocol.js +94 -449
- 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 +530 -247
- package/dist/cadre-node.js +1046 -525
- 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 +22 -23
- package/dist/control-cohort.js +24 -24
- package/dist/control-cohort.js.map +1 -1
- package/dist/control-database.d.ts +416 -136
- package/dist/control-database.js +757 -378
- package/dist/control-database.js.map +1 -1
- package/dist/control-protocol-guard.d.ts +5 -4
- package/dist/control-protocol-guard.js +6 -3
- package/dist/control-protocol-guard.js.map +1 -1
- package/dist/control-schema-version.d.ts +80 -0
- package/dist/control-schema-version.js +203 -0
- package/dist/control-schema-version.js.map +1 -0
- package/dist/control-schema.d.ts +45 -5
- package/dist/control-schema.js +254 -70
- package/dist/control-schema.js.map +1 -1
- package/dist/control-write-retry.d.ts +5 -5
- package/dist/control-write-retry.js +10 -8
- 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/index.d.ts +16 -7
- package/dist/index.js +26 -7
- package/dist/index.js.map +1 -1
- package/dist/joined-strand-store.d.ts +3 -3
- package/dist/joined-strand-store.js +3 -3
- 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/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 +250 -0
- package/dist/member-redemption.js +485 -0
- package/dist/member-redemption.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 +247 -0
- package/dist/owner-grant-protocol.js +330 -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-authorization.d.ts +83 -18
- package/dist/peer-authorization.js +123 -53
- package/dist/peer-authorization.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 +131 -109
- package/dist/seed-bootstrap.js +260 -248
- 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 +1 -0
- package/dist/strand-database.js +3 -0
- package/dist/strand-database.js.map +1 -1
- package/dist/strand-first-sync-gate.d.ts +4 -3
- package/dist/strand-first-sync-gate.js +4 -3
- package/dist/strand-first-sync-gate.js.map +1 -1
- package/dist/strand-instance-manager.d.ts +1 -1
- package/dist/strand-membership-writer.d.ts +13 -2
- package/dist/strand-membership-writer.js +52 -16
- package/dist/strand-membership-writer.js.map +1 -1
- package/dist/strand-move.d.ts +6 -5
- package/dist/strand-move.js +31 -30
- 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/types.d.ts +286 -103
- package/dist/types.js.map +1 -1
- package/package.json +16 -7
- package/src/bootstrap-peer-store-file.ts +44 -44
- package/src/bootstrap-peer-store.ts +214 -214
- package/src/cadre-invite-protocol.ts +131 -531
- package/src/cadre-join-request.ts +188 -167
- package/src/cadre-node.ts +1166 -541
- package/src/control-authorization.ts +47 -14
- package/src/control-cohort.ts +127 -128
- package/src/control-database.ts +940 -456
- package/src/control-protocol-guard.ts +7 -4
- package/src/control-schema-version.ts +251 -0
- package/src/control-schema.ts +265 -70
- package/src/control-write-retry.ts +569 -567
- package/src/delegate-admission.ts +37 -0
- 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/index.ts +102 -5
- package/src/joined-strand-store.ts +4 -4
- package/src/key-store.ts +10 -4
- package/src/machine-removal.ts +26 -111
- package/src/member-redemption.ts +642 -0
- package/src/owner-anchor-sync.ts +19 -18
- package/src/owner-grant-protocol.ts +532 -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-authorization.ts +558 -488
- package/src/provider/rent-node.ts +1 -1
- package/src/seed-bootstrap.ts +301 -312
- 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 +3 -0
- package/src/strand-first-sync-gate.ts +4 -3
- package/src/strand-instance-manager.ts +1 -1
- package/src/strand-membership-writer.ts +61 -16
- package/src/strand-move.ts +45 -36
- package/src/strand-peer-redial.ts +2 -3
- package/src/types.ts +304 -103
|
@@ -8,38 +8,52 @@
|
|
|
8
8
|
* ownership) through `ControlDatabase.redeemCadreInvite`, and answers with the party id,
|
|
9
9
|
* the row and dial hints. The owner that minted the invitation need not be online.
|
|
10
10
|
*
|
|
11
|
-
*
|
|
12
|
-
* the
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
11
|
+
* The exchange shape — one frame each way, the member's hardening and admission of the
|
|
12
|
+
* device, the device's address-by-address dialing in passes — is `member-redemption.ts`'s,
|
|
13
|
+
* shared with the owner-grant protocol; this module supplies the invitation's bundle, request,
|
|
14
|
+
* checks, writes and refusal codes. Stranger-facing by design — the device is not a member
|
|
15
|
+
* when it dials — so the connection gate admits a stranger while a live invitation exists
|
|
16
|
+
* (`membership-connection-gater.ts`), and the handler's own trust decision is the proof of
|
|
17
|
+
* possession: nothing touches the database for a caller that cannot prove it holds the
|
|
18
|
+
* invitation and is the identity it names.
|
|
17
19
|
*
|
|
18
20
|
* The seed protocol is left untouched so `SeedMessage` can grow independently.
|
|
19
21
|
*/
|
|
20
22
|
|
|
21
23
|
import debug from 'debug';
|
|
22
|
-
import { multiaddr
|
|
23
|
-
import type {
|
|
24
|
+
import { multiaddr } from '@multiformats/multiaddr';
|
|
25
|
+
import type { Libp2p } from '@libp2p/interface';
|
|
24
26
|
import { toString as uint8ArrayToString, fromString as uint8ArrayFromString } from 'uint8arrays';
|
|
25
27
|
import { sign } from '@optimystic/quereus-plugin-crypto';
|
|
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
28
|
import {
|
|
30
29
|
cadreInviteRedeemMessage,
|
|
31
30
|
cadreInviteConsentMessage,
|
|
32
31
|
CadreInviteIssuerUnknownError,
|
|
33
32
|
InvitationExhaustedError,
|
|
34
|
-
parseNullableStoredDatetimeMs,
|
|
35
33
|
type ControlDatabase
|
|
36
34
|
} from './control-database.js';
|
|
37
35
|
import { verifyCadreInviteRow, verifyCadreInviteRedemption } from './peer-authorization.js';
|
|
38
|
-
import { ed25519PublicKeyB64FromPeerId
|
|
39
|
-
import {
|
|
40
|
-
import {
|
|
41
|
-
|
|
42
|
-
|
|
36
|
+
import { ed25519PublicKeyB64FromPeerId } from './ed25519-key.js';
|
|
37
|
+
import { CADRE_INVITATION_PREFIX, CADRE_JOIN_REQUEST_PREFIX, OWNER_GRANT_PREFIX, OWNER_KEY_EXPORT_STEM } from './cadre-join-request.js';
|
|
38
|
+
import {
|
|
39
|
+
MemberRedemptionHandler,
|
|
40
|
+
constraintRefusal,
|
|
41
|
+
dialRedemption,
|
|
42
|
+
dialRedemptionInPasses,
|
|
43
|
+
isBoundedString,
|
|
44
|
+
isEd25519KeyB64,
|
|
45
|
+
isNullableBoundedString,
|
|
46
|
+
isNullableUseCap,
|
|
47
|
+
rejection,
|
|
48
|
+
writeFailureRefusal,
|
|
49
|
+
type ConstraintRefusalRow,
|
|
50
|
+
type MemberRedemption,
|
|
51
|
+
type MemberRedemptionHandlerOptions,
|
|
52
|
+
type MemberRedemptionOptions,
|
|
53
|
+
type MemberRedemptionPassOptions,
|
|
54
|
+
type RedemptionAddressOutcome,
|
|
55
|
+
type RedemptionRejection
|
|
56
|
+
} from './member-redemption.js';
|
|
43
57
|
import type { CadreInviteRow, CadreJoinProgress, SeedPeer } from './types.js';
|
|
44
58
|
|
|
45
59
|
const log = debug('sereus:cadre:invite-proto');
|
|
@@ -47,26 +61,8 @@ const log = debug('sereus:cadre:invite-proto');
|
|
|
47
61
|
/** Protocol id for cadre invitation redemption (parallel to `/sereus/seed/1.0.0`). */
|
|
48
62
|
export const CADRE_INVITE_PROTOCOL = '/sereus/cadre-invite/1.0.0';
|
|
49
63
|
|
|
50
|
-
/**
|
|
51
|
-
|
|
52
|
-
* a reply adds a peer list of a cadre's size. Both fit in a few KB; 64 KiB is the
|
|
53
|
-
* defensive bound a stranger can make the handler buffer.
|
|
54
|
-
*/
|
|
55
|
-
const MAX_CADRE_INVITE_MSG_SIZE = 64 * 1024;
|
|
56
|
-
|
|
57
|
-
/**
|
|
58
|
-
* Default time the member waits for the request frame before aborting the read (ms). The
|
|
59
|
-
* read only: a stranger opens the stream, so the bound is on a caller that opens one and
|
|
60
|
-
* never sends. Same figure as the seed handler's.
|
|
61
|
-
*/
|
|
62
|
-
// 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
|
|
63
|
-
const DEFAULT_REDEEM_READ_TIMEOUT_MS = 10_000;
|
|
64
|
-
|
|
65
|
-
/** Default cap on concurrent inbound redemptions; over it the handler answers `busy`. */
|
|
66
|
-
const DEFAULT_MAX_CONCURRENT_REDEMPTIONS = 100;
|
|
67
|
-
|
|
68
|
-
/** Longest string field accepted off the wire; a stamp is 342 base64url chars, a signature 86. */
|
|
69
|
-
const MAX_WIRE_FIELD_LENGTH = 1024;
|
|
64
|
+
/** What the dialer calls the credential in log lines, deadline labels and error text. */
|
|
65
|
+
const LABEL = 'Cadre invitation';
|
|
70
66
|
|
|
71
67
|
/** Most addresses a request may carry for the device, and a reply per peer (`sanitizeAddrs`). */
|
|
72
68
|
const MAX_ADDRS = 16;
|
|
@@ -77,16 +73,14 @@ const MAX_BUNDLE_MEMBERS = 64;
|
|
|
77
73
|
/** Most peers a reply may carry as dial hints. */
|
|
78
74
|
const MAX_REPLY_PEERS = 256;
|
|
79
75
|
|
|
80
|
-
const BASE64URL = /^[A-Za-z0-9_-]+$/;
|
|
81
|
-
|
|
82
76
|
// ── The invitation bundle ────────────────────────────────────────────────────
|
|
83
77
|
|
|
84
78
|
/**
|
|
85
79
|
* What an owner hands out (QR, paste, link) and a device redeems. An invitation is an
|
|
86
80
|
* ed25519 keypair: the row holds the public half, `invitePrivateKey` is the proof of
|
|
87
81
|
* possession, and whoever holds this bundle can redeem it — treat it as a bearer
|
|
88
|
-
* credential for the row's lifetime
|
|
89
|
-
*
|
|
82
|
+
* credential for the row's lifetime. It admits a machine and grants nothing else; an owner
|
|
83
|
+
* grant (`owner-grant-protocol.ts`) is the credential that makes a key an owner.
|
|
90
84
|
*
|
|
91
85
|
* Carrying the signed row is what makes redemption work at a member that has not received
|
|
92
86
|
* the row by replication: the member seats it from the bundle under its stored issuer
|
|
@@ -119,7 +113,8 @@ export interface CadreInvitation {
|
|
|
119
113
|
/**
|
|
120
114
|
* Encode a bundle for out-of-band delivery: {@link CADRE_INVITATION_PREFIX}, then base64url
|
|
121
115
|
* over JSON. The prefix lets an app tell an invitation from a join request
|
|
122
|
-
* (`cadre-join-request.ts`)
|
|
116
|
+
* (`cadre-join-request.ts`) or an owner grant (`owner-grant-protocol.ts`) and name the mistake
|
|
117
|
+
* when one is pasted for the other.
|
|
123
118
|
*/
|
|
124
119
|
export function encodeCadreInvitation(invitation: CadreInvitation): string {
|
|
125
120
|
return `${CADRE_INVITATION_PREFIX}${uint8ArrayToString(new TextEncoder().encode(JSON.stringify(invitation)), 'base64url')}`;
|
|
@@ -131,11 +126,17 @@ export function encodeCadreInvitation(invitation: CadreInvitation): string {
|
|
|
131
126
|
* without a node (a settings screen that wants to show which cadre an invitation joins) can
|
|
132
127
|
* decode one.
|
|
133
128
|
*
|
|
134
|
-
* @throws on a join request, a string without the prefix or
|
|
135
|
-
* it, or one that decodes to another shape or version.
|
|
129
|
+
* @throws on an owner key, an owner grant or a join request, a string without the prefix or
|
|
130
|
+
* that is not base64url JSON after it, or one that decodes to another shape or version.
|
|
136
131
|
*/
|
|
137
132
|
export function decodeCadreInvitation(encoded: string): CadreInvitation {
|
|
138
133
|
const trimmed = encoded.trim();
|
|
134
|
+
if (trimmed.startsWith(OWNER_KEY_EXPORT_STEM)) {
|
|
135
|
+
throw new Error('This is an owner key, not a cadre invitation; it is private, so import it only where the app asks for an owner key');
|
|
136
|
+
}
|
|
137
|
+
if (trimmed.startsWith(OWNER_GRANT_PREFIX)) {
|
|
138
|
+
throw new Error('This is an owner grant, not a cadre invitation; paste it where the app asks for an owner grant');
|
|
139
|
+
}
|
|
139
140
|
if (trimmed.startsWith(CADRE_JOIN_REQUEST_PREFIX)) {
|
|
140
141
|
throw new Error('This is a join request, not a cadre invitation; paste it into the app that manages your cadre');
|
|
141
142
|
}
|
|
@@ -207,7 +208,7 @@ export type CadreInviteRejectionCode =
|
|
|
207
208
|
| 'party-mismatch'
|
|
208
209
|
/** The member is at its cap on concurrent redemptions. */
|
|
209
210
|
| 'busy'
|
|
210
|
-
/** The write failed transiently (the write retry gave up); nothing was recorded. */
|
|
211
|
+
/** The write failed transiently (the write retry gave up), or the member has not synced the invitation row the cadre holds; nothing was recorded. */
|
|
211
212
|
| 'conflict'
|
|
212
213
|
/**
|
|
213
214
|
* An unexpected member-side failure, or a request frame the member could not read (timed
|
|
@@ -245,8 +246,9 @@ export interface CadreInviteRedeemReply {
|
|
|
245
246
|
invite?: CadreInviteRow;
|
|
246
247
|
/**
|
|
247
248
|
* Dial hints for the rest of the cadre, unsigned: the member's own projection of its
|
|
248
|
-
* `CadrePeer` rows (`ControlDatabase.querySeedPeers`)
|
|
249
|
-
*
|
|
249
|
+
* `CadrePeer` rows (`ControlDatabase.querySeedPeers`), the answering member and every
|
|
250
|
+
* always-on machine marked `preferred`. The device derives trust from its anchor, never
|
|
251
|
+
* from this.
|
|
250
252
|
*/
|
|
251
253
|
peers?: SeedPeer[];
|
|
252
254
|
}
|
|
@@ -260,27 +262,7 @@ export interface CadreInviteAccepted extends CadreInviteRedeemReply {
|
|
|
260
262
|
}
|
|
261
263
|
|
|
262
264
|
/** A refusal as the member sends it: a code, a reason, nothing else. */
|
|
263
|
-
|
|
264
|
-
accepted: false;
|
|
265
|
-
code: CadreInviteRejectionCode;
|
|
266
|
-
reason: string;
|
|
267
|
-
}
|
|
268
|
-
|
|
269
|
-
/**
|
|
270
|
-
* A refusal as the device reads it: the code and reason verbatim off the wire, untyped, so
|
|
271
|
-
* {@link CadreInviteRejectedError} is the one place that classifies a code this build does
|
|
272
|
-
* not know (as `'unrecognized'`, retryable) rather than the decoder folding it into a code
|
|
273
|
-
* it does.
|
|
274
|
-
*/
|
|
275
|
-
interface CadreInviteRefusalFrame {
|
|
276
|
-
accepted: false;
|
|
277
|
-
code: unknown;
|
|
278
|
-
reason: unknown;
|
|
279
|
-
}
|
|
280
|
-
|
|
281
|
-
function rejection(code: CadreInviteRejectionCode, reason: string): CadreInviteRejection {
|
|
282
|
-
return { accepted: false, code, reason };
|
|
283
|
-
}
|
|
265
|
+
type CadreInviteRejection = RedemptionRejection<CadreInviteRejectionCode>;
|
|
284
266
|
|
|
285
267
|
// ── Errors the device sees ───────────────────────────────────────────────────
|
|
286
268
|
|
|
@@ -305,12 +287,8 @@ export class CadreInviteRejectedError extends Error {
|
|
|
305
287
|
}
|
|
306
288
|
}
|
|
307
289
|
|
|
308
|
-
/** What one address the bundle named came to. */
|
|
309
|
-
export
|
|
310
|
-
addr: string;
|
|
311
|
-
/** A dial or exchange failure, or a retryable {@link CadreInviteRejectedError}. */
|
|
312
|
-
error: Error;
|
|
313
|
-
}
|
|
290
|
+
/** What one address the bundle named came to; the owner-grant redemption reports the same shape. */
|
|
291
|
+
export type CadreInviteAddressOutcome = RedemptionAddressOutcome;
|
|
314
292
|
|
|
315
293
|
/**
|
|
316
294
|
* Every address the bundle names was tried and none accepted: each failed to dial, answered
|
|
@@ -345,29 +323,14 @@ export class CadreInviteReplyInvalidError extends Error {
|
|
|
345
323
|
|
|
346
324
|
// ── Shape checks (both directions) ───────────────────────────────────────────
|
|
347
325
|
|
|
348
|
-
function isBoundedString(value: unknown, max = MAX_WIRE_FIELD_LENGTH): value is string {
|
|
349
|
-
return typeof value === 'string' && value.length > 0 && value.length <= max;
|
|
350
|
-
}
|
|
351
|
-
|
|
352
|
-
function isEd25519KeyB64(value: unknown): value is string {
|
|
353
|
-
if (!isBoundedString(value) || !BASE64URL.test(value)) return false;
|
|
354
|
-
try {
|
|
355
|
-
requireEd25519PublicKeyB64(value, 'key');
|
|
356
|
-
return true;
|
|
357
|
-
} catch {
|
|
358
|
-
return false;
|
|
359
|
-
}
|
|
360
|
-
}
|
|
361
|
-
|
|
362
326
|
/** Is `value` a {@link CadreInviteRow} with every column in its stored form and bounded? */
|
|
363
327
|
export function isWellFormedCadreInviteRow(value: unknown): value is CadreInviteRow {
|
|
364
328
|
if (typeof value !== 'object' || value === null) return false;
|
|
365
329
|
const row = value as Record<string, unknown>;
|
|
366
330
|
return isEd25519KeyB64(row.key)
|
|
367
|
-
&& (row.peerId
|
|
368
|
-
&&
|
|
369
|
-
&& (row.
|
|
370
|
-
&& (row.totalUses === null || (Number.isInteger(row.totalUses) && (row.totalUses as number) >= 1))
|
|
331
|
+
&& isNullableBoundedString(row.peerId)
|
|
332
|
+
&& isNullableBoundedString(row.expiresAt)
|
|
333
|
+
&& isNullableUseCap(row.totalUses)
|
|
371
334
|
&& isBoundedString(row.stampId)
|
|
372
335
|
&& isEd25519KeyB64(row.issuerKey)
|
|
373
336
|
&& isBoundedString(row.issuerSig);
|
|
@@ -417,31 +380,15 @@ function sanitizePeers(peers: unknown): SeedPeer[] {
|
|
|
417
380
|
for (const entry of peers) {
|
|
418
381
|
if (out.length >= MAX_REPLY_PEERS) break;
|
|
419
382
|
if (typeof entry !== 'object' || entry === null) continue;
|
|
420
|
-
const { peerId, multiaddrs,
|
|
383
|
+
const { peerId, multiaddrs, preferred } = entry as Record<string, unknown>;
|
|
421
384
|
if (!isBoundedString(peerId)) continue;
|
|
422
|
-
out.push({
|
|
423
|
-
peerId,
|
|
424
|
-
multiaddrs: sanitizeAddrs(multiaddrs),
|
|
425
|
-
isOwner: isOwner === true,
|
|
426
|
-
...(isEd25519KeyB64(publicKey) ? { publicKey } : {})
|
|
427
|
-
});
|
|
385
|
+
out.push({ peerId, multiaddrs: sanitizeAddrs(multiaddrs), preferred: preferred === true });
|
|
428
386
|
}
|
|
429
387
|
return out;
|
|
430
388
|
}
|
|
431
389
|
|
|
432
|
-
/**
|
|
433
|
-
|
|
434
|
-
* acceptance. A refusal keeps only its code and reason, as sent. Throws on a frame that is
|
|
435
|
-
* neither.
|
|
436
|
-
*/
|
|
437
|
-
function decodeReply(value: unknown): CadreInviteAccepted | CadreInviteRefusalFrame {
|
|
438
|
-
if (typeof value !== 'object' || value === null) {
|
|
439
|
-
throw new Error('Cadre invitation reply is not an object');
|
|
440
|
-
}
|
|
441
|
-
const reply = value as Record<string, unknown>;
|
|
442
|
-
if (reply.accepted !== true) {
|
|
443
|
-
return { accepted: false, code: reply.code, reason: reply.reason };
|
|
444
|
-
}
|
|
390
|
+
/** An acceptance frame's fields, the attacker-influenced ones sanitized. Throws on a malformed one. */
|
|
391
|
+
function decodeAccepted(reply: Record<string, unknown>): CadreInviteAccepted {
|
|
445
392
|
if (!isBoundedString(reply.partyId) || !isWellFormedCadreInviteRow(reply.invite)) {
|
|
446
393
|
throw new Error('Cadre invitation acceptance is missing its party id or a well-formed row');
|
|
447
394
|
}
|
|
@@ -521,160 +468,53 @@ export function verifyRedeemReply(
|
|
|
521
468
|
/** The control-database surface a redemption needs; a `ControlDatabase` satisfies it. */
|
|
522
469
|
export type CadreInviteStore = Pick<ControlDatabase, 'seatCadreInvite' | 'isCadreInviteLive' | 'redeemCadreInvite' | 'querySeedPeers'>;
|
|
523
470
|
|
|
524
|
-
export interface CadreInviteHandlerOptions {
|
|
525
|
-
/** This member's
|
|
526
|
-
|
|
471
|
+
export interface CadreInviteHandlerOptions extends MemberRedemptionHandlerOptions {
|
|
472
|
+
/** This member's peer id: the reply's peer list marks it preferred, since the device just reached it. */
|
|
473
|
+
peerId: string;
|
|
527
474
|
store: CadreInviteStore;
|
|
528
|
-
/**
|
|
529
|
-
* Push this member's control store to the device, resolving once the push has finished, and
|
|
530
|
-
* keep the device counted in this member's control write cohort until the returned release
|
|
531
|
-
* is called. Called after the request verified and before the row is seated and the
|
|
532
|
-
* admission is written; the handler releases once both have settled. Absent, the writes go
|
|
533
|
-
* ahead with the device neither caught up nor in the cohort, so it does not hold them; see
|
|
534
|
-
* `admitDeviceIfLive`.
|
|
535
|
-
*/
|
|
536
|
-
admitDevice?: (peerId: string) => Promise<() => void>;
|
|
537
|
-
/** Time to wait for the request frame; default {@link DEFAULT_REDEEM_READ_TIMEOUT_MS}. */
|
|
538
|
-
redeemReadTimeoutMs?: number;
|
|
539
|
-
/** Cap on concurrent inbound redemptions; default {@link DEFAULT_MAX_CONCURRENT_REDEMPTIONS}. */
|
|
540
|
-
maxConcurrentRedemptions?: number;
|
|
541
475
|
}
|
|
542
476
|
|
|
543
477
|
/** Constraint names the schema refuses a redemption with, and the code each maps to. */
|
|
544
|
-
const REDEEM_CONSTRAINT_CODES: ReadonlyArray<
|
|
478
|
+
const REDEEM_CONSTRAINT_CODES: ReadonlyArray<ConstraintRefusalRow<CadreInviteRejectionCode>> = [
|
|
545
479
|
[/CHECK constraint failed: Authorized\b/, 'invite-spent', 'The invitation is expired, withdrawn, exhausted, or its issuer is no longer an owner'],
|
|
546
|
-
[/CHECK constraint failed: (InvitePossessed|PeerConsented|PeerExists
|
|
480
|
+
[/CHECK constraint failed: (InvitePossessed|PeerConsented|PeerExists)\b/, 'invite-invalid', 'The redemption does not satisfy the invitation'],
|
|
547
481
|
[/CHECK constraint failed: NotRevoked\b/, 'invite-spent', 'The invitation was withdrawn'],
|
|
548
482
|
];
|
|
549
483
|
|
|
550
|
-
/** Which named constraint, if any, refused the write — read off the error's cause chain. */
|
|
551
|
-
function constraintRejection(error: unknown): CadreInviteRejection | null {
|
|
552
|
-
if (!(error instanceof Error)) return null;
|
|
553
|
-
const messages = chainMessages(error);
|
|
554
|
-
for (const [pattern, code, reason] of REDEEM_CONSTRAINT_CODES) {
|
|
555
|
-
if (messages.some((message) => pattern.test(message))) return rejection(code, reason);
|
|
556
|
-
}
|
|
557
|
-
return null;
|
|
558
|
-
}
|
|
559
|
-
|
|
560
|
-
/** A write failure that is not a refusal: the retry gave up (`conflict`), or something else (`internal`). */
|
|
561
|
-
function writeFailureRejection(error: unknown): CadreInviteRejection {
|
|
562
|
-
// NOTE: a seat torn on a member alone with an uncaught-up device was measured answering
|
|
563
|
-
// `internal`, not `conflict` (cadre-invite-redeemed-at-a-member-without-the-row). Both are
|
|
564
|
-
// retryable, so the device is unaffected; if the two codes ever diverge in retryability,
|
|
565
|
-
// find which tear `isRetriableControlWriteFailure` does not recognise.
|
|
566
|
-
return isRetriableControlWriteFailure(error)
|
|
567
|
-
? rejection('conflict', 'The redemption write failed transiently; nothing was recorded')
|
|
568
|
-
: rejection('internal', 'Internal redemption error');
|
|
569
|
-
}
|
|
570
|
-
|
|
571
484
|
/**
|
|
572
|
-
* Member side
|
|
573
|
-
*
|
|
574
|
-
*
|
|
575
|
-
*
|
|
576
|
-
*
|
|
577
|
-
*
|
|
578
|
-
* Registered only by a started `CadreNode`, after its control database is up, like the
|
|
579
|
-
* formation responder; nothing here is reachable on a node without one.
|
|
485
|
+
* Member side of the invitation protocol (`MemberRedemptionHandler` has the shape): after the
|
|
486
|
+
* request's shape, it checks the party, the connecting identity (an ed25519 peer id is an
|
|
487
|
+
* identity multihash of the very key, and this is the only layer that can check the pair),
|
|
488
|
+
* both signatures and a targeted invitation's device, then seats the row from the bundle and
|
|
489
|
+
* redeems. An issuer this member does not know as an owner is the one retryable refusal left:
|
|
490
|
+
* the device tries the next address, which may hold the issuer's row.
|
|
580
491
|
*/
|
|
581
|
-
export class CadreInviteHandler {
|
|
582
|
-
private readonly
|
|
583
|
-
private readonly
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
492
|
+
export class CadreInviteHandler extends MemberRedemptionHandler<CadreInviteRedeemRequest, CadreInviteRedeemReply> {
|
|
493
|
+
private readonly peerId: string;
|
|
494
|
+
private readonly store: CadreInviteStore;
|
|
495
|
+
protected readonly busyRefusal = rejection('busy', 'Too many concurrent redemptions');
|
|
496
|
+
protected readonly internalRefusal = rejection('internal', 'Internal redemption error');
|
|
497
|
+
protected readonly malformedRefusal = rejection('invite-invalid', 'Malformed redemption request');
|
|
587
498
|
|
|
588
499
|
constructor(options: CadreInviteHandlerOptions) {
|
|
589
|
-
|
|
590
|
-
this.
|
|
591
|
-
this.
|
|
592
|
-
}
|
|
593
|
-
|
|
594
|
-
/** In-flight inbound redemptions. */
|
|
595
|
-
get activeCount(): number {
|
|
596
|
-
return this.active;
|
|
597
|
-
}
|
|
598
|
-
|
|
599
|
-
/**
|
|
600
|
-
* Register on `node`; rejects when libp2p refuses the registration. `runOnLimitedConnection`
|
|
601
|
-
* because a phone redeems through a relay, whose connection libp2p marks limited; the
|
|
602
|
-
* exchange is two small frames, well inside any relay's cap.
|
|
603
|
-
*/
|
|
604
|
-
async register(node: Libp2p, protocolId: string = CADRE_INVITE_PROTOCOL): Promise<void> {
|
|
605
|
-
if (this.registered.has(node)) return;
|
|
606
|
-
await node.handle(protocolId, async (rawStream: unknown, rawConnection: unknown) => {
|
|
607
|
-
await this.handleStream(rawStream as ControlStream, (rawConnection as Connection).remotePeer.toString());
|
|
608
|
-
}, { runOnLimitedConnection: true });
|
|
609
|
-
this.registered.add(node);
|
|
610
|
-
log('cadre invitation handler registered (%s)', protocolId);
|
|
611
|
-
}
|
|
612
|
-
|
|
613
|
-
async unregister(node: Libp2p, protocolId: string = CADRE_INVITE_PROTOCOL): Promise<void> {
|
|
614
|
-
if (!this.registered.has(node)) return;
|
|
615
|
-
await node.unhandle(protocolId);
|
|
616
|
-
this.registered.delete(node);
|
|
617
|
-
log('cadre invitation handler unregistered (%s)', protocolId);
|
|
618
|
-
}
|
|
619
|
-
|
|
620
|
-
/**
|
|
621
|
-
* One inbound redemption: read the frame, decide, reply, close. Public as the seam the
|
|
622
|
-
* protocol spec drives directly; `remotePeerId` is the connection's authenticated peer.
|
|
623
|
-
*/
|
|
624
|
-
async handleStream(stream: ControlStream, remotePeerId: string): Promise<void> {
|
|
625
|
-
if (this.active >= this.maxConcurrent) {
|
|
626
|
-
log('refusing redemption from %s: %d in flight at cap %d', remotePeerId, this.active, this.maxConcurrent);
|
|
627
|
-
await replyAndClose(stream, rejection('busy', 'Too many concurrent redemptions'), 'Cadre invite');
|
|
628
|
-
return;
|
|
629
|
-
}
|
|
630
|
-
this.active++;
|
|
631
|
-
try {
|
|
632
|
-
const reply = await this.decide(stream, remotePeerId);
|
|
633
|
-
log('redemption from %s: %s', remotePeerId, reply.accepted ? 'accepted' : `refused (${reply.code})`);
|
|
634
|
-
await replyAndClose(stream, reply, 'Cadre invite');
|
|
635
|
-
} catch (error) {
|
|
636
|
-
log('redemption from %s failed: %o', remotePeerId, error);
|
|
637
|
-
await replyAndClose(stream, rejection('internal', 'Internal redemption error'), 'Cadre invite');
|
|
638
|
-
} finally {
|
|
639
|
-
this.active--;
|
|
640
|
-
}
|
|
641
|
-
}
|
|
642
|
-
|
|
643
|
-
/**
|
|
644
|
-
* Read the request frame to EOF, bounded and size-capped; JSON only, shape is `decide`'s.
|
|
645
|
-
* Throws on a frame that cannot be read, which {@link handleStream} answers `internal`:
|
|
646
|
-
* a read timeout or a cut frame proves nothing about the invitation, so the device must
|
|
647
|
-
* stay free to try the next address.
|
|
648
|
-
*/
|
|
649
|
-
private async readRequest(stream: ControlStream): Promise<unknown> {
|
|
650
|
-
const data = await readStreamToEnd(stream, { maxBytes: MAX_CADRE_INVITE_MSG_SIZE, timeoutMs: this.readTimeoutMs, label: 'Cadre invite' });
|
|
651
|
-
return JSON.parse(new TextDecoder().decode(decodeLengthPrefixedFrame(data, MAX_CADRE_INVITE_MSG_SIZE)));
|
|
500
|
+
super('Cadre invite', CADRE_INVITE_PROTOCOL, options);
|
|
501
|
+
this.peerId = options.peerId;
|
|
502
|
+
this.store = options.store;
|
|
652
503
|
}
|
|
653
504
|
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
const refused = this.checkRequest(request, remotePeerId);
|
|
657
|
-
if (refused) return refused;
|
|
658
|
-
return await this.seatAndRedeem(request as CadreInviteRedeemRequest, remotePeerId);
|
|
505
|
+
protected isWellFormedRequest(value: unknown): value is CadreInviteRedeemRequest {
|
|
506
|
+
return isWellFormedRedeemRequest(value);
|
|
659
507
|
}
|
|
660
508
|
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
* ed25519 peer id is an identity multihash of the very key, and this is the only layer that
|
|
664
|
-
* can check the pair), both signatures, and a targeted invitation's device.
|
|
665
|
-
*/
|
|
666
|
-
private checkRequest(request: unknown, remotePeerId: string): CadreInviteRejection | null {
|
|
667
|
-
if (!isWellFormedRedeemRequest(request)) {
|
|
668
|
-
return rejection('invite-invalid', 'Malformed redemption request');
|
|
669
|
-
}
|
|
670
|
-
if (request.partyId !== this.options.partyId) {
|
|
509
|
+
protected checkRequest(request: CadreInviteRedeemRequest, remotePeerId: string): CadreInviteRejection | null {
|
|
510
|
+
if (request.partyId !== this.partyId) {
|
|
671
511
|
return rejection('party-mismatch', 'This machine serves another cadre');
|
|
672
512
|
}
|
|
673
513
|
if (ed25519PublicKeyB64FromPeerId(remotePeerId) !== request.peerKey) {
|
|
674
514
|
return rejection('invite-invalid', 'The peer key is not the connecting identity');
|
|
675
515
|
}
|
|
676
516
|
const fields = { inviteKey: request.invite.key, usageStampId: request.usageStampId, peerKey: request.peerKey };
|
|
677
|
-
if (!verifyCadreInviteRedemption(this.
|
|
517
|
+
if (!verifyCadreInviteRedemption(this.partyId, fields, request.inviteSig, request.peerSig)) {
|
|
678
518
|
return rejection('invite-invalid', 'The redemption signatures do not verify');
|
|
679
519
|
}
|
|
680
520
|
if (request.invite.peerId !== null && request.invite.peerId !== remotePeerId) {
|
|
@@ -683,36 +523,23 @@ export class CadreInviteHandler {
|
|
|
683
523
|
return null;
|
|
684
524
|
}
|
|
685
525
|
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
* seat the row from the bundle (its own transaction; a no-op when held), then redeem. The
|
|
689
|
-
* admission comes first because the seat is already a write the device takes part in. An
|
|
690
|
-
* issuer this member does not know as an owner is the one retryable refusal left: the device
|
|
691
|
-
* tries the next address, which may hold the issuer's row.
|
|
692
|
-
*/
|
|
693
|
-
private async seatAndRedeem(request: CadreInviteRedeemRequest, remotePeerId: string): Promise<CadreInviteRedeemReply> {
|
|
694
|
-
const release = await this.admitDeviceIfLive(request.invite, remotePeerId);
|
|
695
|
-
try {
|
|
696
|
-
return await this.seatAndRedeemAdmitted(request, remotePeerId);
|
|
697
|
-
} finally {
|
|
698
|
-
release();
|
|
699
|
-
}
|
|
526
|
+
protected isLive(request: CadreInviteRedeemRequest): Promise<boolean> {
|
|
527
|
+
return this.store.isCadreInviteLive(request.invite);
|
|
700
528
|
}
|
|
701
529
|
|
|
702
|
-
/**
|
|
703
|
-
|
|
704
|
-
const { store } = this.options;
|
|
530
|
+
/** The two writes — the seat (its own transaction; a no-op when held) and the redemption — each answered by name when it fails. */
|
|
531
|
+
protected async redeemAdmitted(request: CadreInviteRedeemRequest, remotePeerId: string): Promise<CadreInviteRedeemReply> {
|
|
705
532
|
try {
|
|
706
|
-
await store.seatCadreInvite(request.invite);
|
|
533
|
+
await this.store.seatCadreInvite(request.invite);
|
|
707
534
|
} catch (error) {
|
|
708
535
|
if (error instanceof CadreInviteIssuerUnknownError) {
|
|
709
536
|
return rejection('issuer-unknown', 'The invitation\'s issuer is not known as an owner here');
|
|
710
537
|
}
|
|
711
538
|
log('seating invitation %s from %s failed: %o', request.invite.key, remotePeerId, error);
|
|
712
|
-
return
|
|
539
|
+
return constraintRefusal(error, REDEEM_CONSTRAINT_CODES) ?? writeFailureRefusal(error);
|
|
713
540
|
}
|
|
714
541
|
try {
|
|
715
|
-
const result = await store.redeemCadreInvite({
|
|
542
|
+
const result = await this.store.redeemCadreInvite({
|
|
716
543
|
inviteKey: request.invite.key,
|
|
717
544
|
peerId: remotePeerId,
|
|
718
545
|
peerKey: request.peerKey,
|
|
@@ -727,57 +554,17 @@ export class CadreInviteHandler {
|
|
|
727
554
|
return rejection('invite-spent', 'The invitation has no uses left');
|
|
728
555
|
}
|
|
729
556
|
log('redeeming invitation %s for %s failed: %o', request.invite.key, remotePeerId, error);
|
|
730
|
-
return
|
|
557
|
+
return constraintRefusal(error, REDEEM_CONSTRAINT_CODES) ?? writeFailureRefusal(error);
|
|
731
558
|
}
|
|
732
559
|
// The row as held: the seat inserted this copy verbatim, or found the same row (its key
|
|
733
560
|
// is the primary key and every column is under the issuer's signature).
|
|
734
|
-
return { accepted: true, partyId: this.
|
|
735
|
-
}
|
|
736
|
-
|
|
737
|
-
/**
|
|
738
|
-
* Admit the device to this member's control replication before the first write of the
|
|
739
|
-
* exchange (the seat, when this member does not hold the row; otherwise the admission), and
|
|
740
|
-
* resolve to the release that ends the admission. The device is not a member until the
|
|
741
|
-
* admission is written, so only this puts it in this member's control write cohort, and it
|
|
742
|
-
* must be there to hold the rows that admit it. It is pushed the control store first,
|
|
743
|
-
* because a cohort node with no base revision of a block refuses a commit to it; a member
|
|
744
|
-
* alone with the device would then have half its cohort refusing, and the write would tear
|
|
745
|
-
* instead of committing.
|
|
746
|
-
*
|
|
747
|
-
* Only for an invitation still live here, judged on the row this member holds or, before
|
|
748
|
-
* the seat, on the bundle's copy once its issuer signature verifies
|
|
749
|
-
* (`ControlDatabase.isCadreInviteLive`): the holder of a withdrawn, expired or spent one,
|
|
750
|
-
* or of a forged copy naming a real owner, is refused without being sent anything. The
|
|
751
|
-
* retry of an admission already written can read as spent too and is sent nothing; it
|
|
752
|
-
* needs nothing, since the device is a member and the ordinary catch-up reaches it.
|
|
753
|
-
* Best-effort: a failed check or push is logged and the writes go ahead without the device
|
|
754
|
-
* admitted, then commit or are answered retryably.
|
|
755
|
-
*/
|
|
756
|
-
private async admitDeviceIfLive(invite: CadreInviteRow, remotePeerId: string): Promise<() => void> {
|
|
757
|
-
const { admitDevice, store } = this.options;
|
|
758
|
-
const notAdmitted = (): void => undefined;
|
|
759
|
-
if (!admitDevice) return notAdmitted;
|
|
760
|
-
try {
|
|
761
|
-
if (!await store.isCadreInviteLive(invite)) {
|
|
762
|
-
log('invitation %s is not live here; %s is not admitted before the write', invite.key, remotePeerId);
|
|
763
|
-
return notAdmitted;
|
|
764
|
-
}
|
|
765
|
-
// NOTE: accepted tradeoff — when a write then fails (the seat refused, the last use
|
|
766
|
-
// taken by a race, the row expiring between this check and the write), the device keeps
|
|
767
|
-
// a copy of a control store it was not admitted to. It holds a live owner-signed
|
|
768
|
-
// invitation and proved possession and consent, and a seed hands an un-enrolled machine
|
|
769
|
-
// the same information. Revisit if a control table ever carries data a non-member must not see.
|
|
770
|
-
return await admitDevice(remotePeerId);
|
|
771
|
-
} catch (error) {
|
|
772
|
-
log('admitting %s before its admission write failed (writing anyway): %o', remotePeerId, error);
|
|
773
|
-
return notAdmitted;
|
|
774
|
-
}
|
|
561
|
+
return { accepted: true, partyId: this.partyId, invite: request.invite, peers: await this.peersBestEffort() };
|
|
775
562
|
}
|
|
776
563
|
|
|
777
564
|
/** Dial hints are an optimization; a failed read costs the device its hints, not its membership. */
|
|
778
565
|
private async peersBestEffort(): Promise<SeedPeer[]> {
|
|
779
566
|
try {
|
|
780
|
-
return await this.
|
|
567
|
+
return await this.store.querySeedPeers(this.peerId);
|
|
781
568
|
} catch (error) {
|
|
782
569
|
log('peer projection for the reply failed (continuing without hints): %o', error);
|
|
783
570
|
return [];
|
|
@@ -794,11 +581,7 @@ export interface RedeemAtMembersOptions {
|
|
|
794
581
|
request: CadreInviteRedeemRequest;
|
|
795
582
|
/** Is this owner key one the device pinned from the bundle? The reply check's input. */
|
|
796
583
|
isTrustedIssuer: (ownerKey: string) => boolean;
|
|
797
|
-
/**
|
|
798
|
-
* This machine's declared link round trip (`NetworkConfig.linkRoundTripMs`), from which
|
|
799
|
-
* the per-address deadline derives: a dial that may need a relay plus one request
|
|
800
|
-
* (`relayedRequestBudgetMs`, 28.5 s at the default declaration), as seed delivery's is.
|
|
801
|
-
*/
|
|
584
|
+
/** See `MemberRedemptionOptions.linkRoundTripMs`. */
|
|
802
585
|
linkRoundTripMs?: number;
|
|
803
586
|
/** Per-address deadline override; default derived from `linkRoundTripMs`. */
|
|
804
587
|
addressBudgetMs?: number;
|
|
@@ -808,135 +591,42 @@ export interface RedeemAtMembersOptions {
|
|
|
808
591
|
}
|
|
809
592
|
|
|
810
593
|
/** What a successful redemption yields the device. */
|
|
811
|
-
export
|
|
812
|
-
reply: CadreInviteAccepted;
|
|
813
|
-
/** The answering member's peer id, from the address's trailing `/p2p/`; null when it named none. */
|
|
814
|
-
memberPeerId: string | null;
|
|
815
|
-
/** The address that answered. */
|
|
816
|
-
memberAddr: string;
|
|
817
|
-
}
|
|
818
|
-
|
|
819
|
-
/**
|
|
820
|
-
* Dial `addr`, open one stream to it, send the request, half-close, read the one reply frame.
|
|
821
|
-
* `signal` is the per-address deadline: it cancels the dial and resets a live stream.
|
|
822
|
-
*
|
|
823
|
-
* The address is dialed outside libp2p's dial queue (`dialAddr`), so another dial of the
|
|
824
|
-
* member in flight cannot capture this attempt; an open connection is still reused.
|
|
825
|
-
*/
|
|
826
|
-
async function exchangeRedemption(
|
|
827
|
-
node: Libp2p,
|
|
828
|
-
addr: Multiaddr,
|
|
829
|
-
request: CadreInviteRedeemRequest,
|
|
830
|
-
signal: AbortSignal,
|
|
831
|
-
budgetMs: number,
|
|
832
|
-
protocolId: string
|
|
833
|
-
): Promise<CadreInviteAccepted | CadreInviteRefusalFrame> {
|
|
834
|
-
const connection = await dialAddr(node, addr, signal, 'Cadre invitation redemption');
|
|
835
|
-
// `runOnLimitedConnection`: a member may be reachable only through a relay (see `register`).
|
|
836
|
-
const rawStream = await connection.newStream(protocolId, { runOnLimitedConnection: true, signal });
|
|
837
|
-
return await exchangeFrame(
|
|
838
|
-
rawStream as unknown as ControlStream,
|
|
839
|
-
signal,
|
|
840
|
-
request,
|
|
841
|
-
async (stream) => {
|
|
842
|
-
const data = await readStreamToEnd(stream, { maxBytes: MAX_CADRE_INVITE_MSG_SIZE, timeoutMs: budgetMs, label: 'Cadre invite reply' });
|
|
843
|
-
return decodeReply(JSON.parse(new TextDecoder().decode(decodeLengthPrefixedFrame(data, MAX_CADRE_INVITE_MSG_SIZE))));
|
|
844
|
-
},
|
|
845
|
-
'Cadre invitation redemption aborted by its deadline'
|
|
846
|
-
);
|
|
847
|
-
}
|
|
594
|
+
export type CadreInviteRedemption = MemberRedemption<CadreInviteAccepted>;
|
|
848
595
|
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
): Promise<T> {
|
|
865
|
-
if (!caller) return await op(deadline);
|
|
866
|
-
const either = new AbortController();
|
|
867
|
-
const abort = (): void => either.abort();
|
|
868
|
-
deadline.addEventListener('abort', abort, { once: true });
|
|
869
|
-
caller.addEventListener('abort', abort, { once: true });
|
|
870
|
-
// An abort between the caller's last check and the listener above fired no event.
|
|
871
|
-
if (caller.aborted) abort();
|
|
872
|
-
try {
|
|
873
|
-
return await op(either.signal);
|
|
874
|
-
} finally {
|
|
875
|
-
deadline.removeEventListener('abort', abort);
|
|
876
|
-
caller.removeEventListener('abort', abort);
|
|
877
|
-
}
|
|
596
|
+
/** The invitation's half of the shared dial loop: its label, protocol, bundle, codec, checks and errors. */
|
|
597
|
+
function inviteRedemption(options: RedeemAtMembersOptions): MemberRedemptionOptions<CadreInviteAccepted> {
|
|
598
|
+
return {
|
|
599
|
+
label: LABEL,
|
|
600
|
+
protocolId: options.protocolId ?? CADRE_INVITE_PROTOCOL,
|
|
601
|
+
members: options.invitation.members,
|
|
602
|
+
request: options.request,
|
|
603
|
+
decodeAccepted,
|
|
604
|
+
refusal: (code, reason) => new CadreInviteRejectedError(code, reason),
|
|
605
|
+
verifyAccepted: (reply) => verifyRedeemReply(reply, options.invitation, options.isTrustedIssuer),
|
|
606
|
+
unreachable: (outcomes) => new CadreInviteUnreachableError(outcomes),
|
|
607
|
+
...(options.linkRoundTripMs !== undefined ? { linkRoundTripMs: options.linkRoundTripMs } : {}),
|
|
608
|
+
...(options.addressBudgetMs !== undefined ? { addressBudgetMs: options.addressBudgetMs } : {}),
|
|
609
|
+
...(options.signal ? { signal: options.signal } : {}),
|
|
610
|
+
};
|
|
878
611
|
}
|
|
879
612
|
|
|
880
613
|
/**
|
|
881
614
|
* Device side: try the bundle's addresses one at a time, each under its own deadline, and
|
|
882
|
-
* stop at the first acceptance or the first final refusal
|
|
883
|
-
*
|
|
884
|
-
*
|
|
885
|
-
*
|
|
886
|
-
*
|
|
887
|
-
* and a retry at the next member after a dropped reply is answered as already a member.
|
|
615
|
+
* stop at the first acceptance or the first final refusal (`dialRedemption` in
|
|
616
|
+
* `member-redemption.ts`). Every address gets the SAME request, with one `usageStampId`:
|
|
617
|
+
* `CadreInviteUsage` is keyed by it, so a member the device gave up on that was still
|
|
618
|
+
* committing cannot admit it twice, and a retry at the next member after a dropped reply is
|
|
619
|
+
* answered as already a member.
|
|
888
620
|
*
|
|
889
621
|
* @throws {CadreInviteRejectedError} on a final refusal (its `retryable` is false).
|
|
890
622
|
* @throws {CadreInviteReplyInvalidError} on an acceptance that fails {@link verifyRedeemReply}.
|
|
891
623
|
* @throws {CadreInviteUnreachableError} when every address was tried and none accepted.
|
|
892
624
|
* @throws the reason of `options.signal` once it aborts.
|
|
893
625
|
*/
|
|
894
|
-
export
|
|
895
|
-
|
|
896
|
-
const budgetMs = options.addressBudgetMs ?? relayedRequestBudgetMs(options.linkRoundTripMs);
|
|
897
|
-
const outcomes: CadreInviteAddressOutcome[] = [];
|
|
898
|
-
for (const addr of options.invitation.members) {
|
|
899
|
-
options.signal?.throwIfAborted();
|
|
900
|
-
// Between attempts: a relayed address's relay leg goes through libp2p's dial queue, which
|
|
901
|
-
// needs a macrotask to drop an aborted job (see `tryAddrsInTurn`).
|
|
902
|
-
if (outcomes.length > 0) await nextMacrotask();
|
|
903
|
-
let parsed: Multiaddr;
|
|
904
|
-
try {
|
|
905
|
-
parsed = multiaddr(addr);
|
|
906
|
-
} catch (error) {
|
|
907
|
-
outcomes.push({ addr, error: new Error('not a multiaddr', { cause: error }) });
|
|
908
|
-
continue;
|
|
909
|
-
}
|
|
910
|
-
try {
|
|
911
|
-
const reply = await withDeadline(budgetMs, `Cadre invitation redemption via ${addr}`,
|
|
912
|
-
(deadline) => underDeadlineAndCaller(deadline, options.signal,
|
|
913
|
-
(signal) => exchangeRedemption(node, parsed, options.request, signal, budgetMs, protocolId)));
|
|
914
|
-
if (!reply.accepted) {
|
|
915
|
-
const refusal = new CadreInviteRejectedError(reply.code, reply.reason);
|
|
916
|
-
if (!refusal.retryable) throw refusal;
|
|
917
|
-
log('redemption via %s refused retryably (%s); trying the next address', addr, refusal.code);
|
|
918
|
-
outcomes.push({ addr, error: refusal });
|
|
919
|
-
continue;
|
|
920
|
-
}
|
|
921
|
-
verifyRedeemReply(reply, options.invitation, options.isTrustedIssuer);
|
|
922
|
-
return { reply, memberPeerId: trailingPeerId(parsed), memberAddr: addr };
|
|
923
|
-
} catch (error) {
|
|
924
|
-
if (error instanceof CadreInviteRejectedError || error instanceof CadreInviteReplyInvalidError) throw error;
|
|
925
|
-
// The caller's abort cut this attempt short; its reason, not the cut, is the answer.
|
|
926
|
-
options.signal?.throwIfAborted();
|
|
927
|
-
log('redemption via %s failed before an answer (%s); trying the next address', addr, asError(error).message);
|
|
928
|
-
outcomes.push({ addr, error: asError(error) });
|
|
929
|
-
}
|
|
930
|
-
}
|
|
931
|
-
throw new CadreInviteUnreachableError(outcomes);
|
|
626
|
+
export function redeemAtMembers(node: Libp2p, options: RedeemAtMembersOptions): Promise<CadreInviteRedemption> {
|
|
627
|
+
return dialRedemption(node, inviteRedemption(options));
|
|
932
628
|
}
|
|
933
629
|
|
|
934
|
-
/** Pause after the first pass in which no member accepted; doubled after each further one. */
|
|
935
|
-
const REDEEM_PASS_FIRST_PAUSE_MS = 5_000;
|
|
936
|
-
|
|
937
|
-
/** Longest pause between passes. */
|
|
938
|
-
const REDEEM_PASS_MAX_PAUSE_MS = 60_000;
|
|
939
|
-
|
|
940
630
|
export interface RedeemInPassesOptions extends RedeemAtMembersOptions {
|
|
941
631
|
/** Keep making passes while every answer is retryable; false makes exactly one. */
|
|
942
632
|
waitForMember: boolean;
|
|
@@ -945,108 +635,18 @@ export interface RedeemInPassesOptions extends RedeemAtMembersOptions {
|
|
|
945
635
|
}
|
|
946
636
|
|
|
947
637
|
/**
|
|
948
|
-
* Device side, in passes
|
|
949
|
-
* with `waitForMember`, as many as it takes, pausing between
|
|
950
|
-
*
|
|
951
|
-
*
|
|
952
|
-
* pass as already a member, with no second usage row.
|
|
953
|
-
*
|
|
954
|
-
* A wait ends at the first acceptance, at a final refusal or an invalid reply (thrown as
|
|
955
|
-
* {@link redeemAtMembers} throws them), when the invitation expires by this device's clock
|
|
956
|
-
* (thrown as the `invite-spent` refusal a member would give), when `signal` aborts (its reason
|
|
957
|
-
* thrown), and when `node` stops. A bundle that names no address has nothing to wait for and
|
|
958
|
-
* throws at once.
|
|
959
|
-
*/
|
|
960
|
-
export async function redeemInPasses(node: Libp2p, options: RedeemInPassesOptions): Promise<CadreInviteRedemption> {
|
|
961
|
-
for (let pass = 1; ; pass++) {
|
|
962
|
-
if (options.waitForMember) {
|
|
963
|
-
// One pass leaves expiry to the member, whose clock decides; a wait has nothing else to end it.
|
|
964
|
-
refuseIfExpired(options.invitation.invite);
|
|
965
|
-
refuseIfStopped(node);
|
|
966
|
-
}
|
|
967
|
-
reportProgress(options.onProgress, { state: 'redeeming', pass });
|
|
968
|
-
try {
|
|
969
|
-
return await redeemAtMembers(node, options);
|
|
970
|
-
} catch (error) {
|
|
971
|
-
if (!options.waitForMember || !(error instanceof CadreInviteUnreachableError) || error.outcomes.length === 0) throw error;
|
|
972
|
-
const pauseMs = pauseAfterPass(pass, options.invitation.invite);
|
|
973
|
-
reportProgress(options.onProgress, {
|
|
974
|
-
state: 'waiting', pass, retryAtMs: Date.now() + pauseMs, reason: waitReason(error.outcomes), outcomes: error.outcomes
|
|
975
|
-
});
|
|
976
|
-
log('redemption pass %d found no member ready; the next pass in %d ms', pass, pauseMs);
|
|
977
|
-
await pause(pauseMs, options.signal, node);
|
|
978
|
-
}
|
|
979
|
-
}
|
|
980
|
-
}
|
|
981
|
-
|
|
982
|
-
// NOTE: judged by this device's clock, so one running ahead ends a wait before the members
|
|
983
|
-
// would refuse it. If waits are seen ending early on skewed phones, allow a grace margin here.
|
|
984
|
-
function refuseIfExpired(invite: CadreInviteRow): void {
|
|
985
|
-
const expiresAtMs = parseNullableStoredDatetimeMs(invite.expiresAt);
|
|
986
|
-
if (expiresAtMs !== null && expiresAtMs <= Date.now()) {
|
|
987
|
-
throw new CadreInviteRejectedError('invite-spent', 'the invitation expired before a member accepted it');
|
|
988
|
-
}
|
|
989
|
-
}
|
|
990
|
-
|
|
991
|
-
/** A stopped node's every dial fails at once, which would otherwise read as "no member answered" until expiry. */
|
|
992
|
-
function refuseIfStopped(node: Libp2p): void {
|
|
993
|
-
if (node.status !== 'started') {
|
|
994
|
-
throw new Error(`Cadre invitation redemption stopped waiting: the node is ${node.status}`);
|
|
995
|
-
}
|
|
996
|
-
}
|
|
997
|
-
|
|
998
|
-
/** The pause after pass `pass`, cut short at the invitation's expiry so the next check ends the wait then. */
|
|
999
|
-
function pauseAfterPass(pass: number, invite: CadreInviteRow): number {
|
|
1000
|
-
const backoffMs = Math.min(REDEEM_PASS_FIRST_PAUSE_MS * 2 ** (pass - 1), REDEEM_PASS_MAX_PAUSE_MS);
|
|
1001
|
-
const expiresAtMs = parseNullableStoredDatetimeMs(invite.expiresAt);
|
|
1002
|
-
return expiresAtMs === null ? backoffMs : Math.max(0, Math.min(backoffMs, expiresAtMs - Date.now()));
|
|
1003
|
-
}
|
|
1004
|
-
|
|
1005
|
-
/** Any refusal means a member answered and is not ready yet; otherwise none answered at all. */
|
|
1006
|
-
function waitReason(outcomes: readonly CadreInviteAddressOutcome[]): 'no-member-answered' | 'member-not-ready' {
|
|
1007
|
-
return outcomes.some((outcome) => outcome.error instanceof CadreInviteRejectedError) ? 'member-not-ready' : 'no-member-answered';
|
|
1008
|
-
}
|
|
1009
|
-
|
|
1010
|
-
/** A UI callback must not break the join. */
|
|
1011
|
-
function reportProgress(onProgress: ((progress: CadreJoinProgress) => void) | undefined, progress: CadreJoinProgress): void {
|
|
1012
|
-
if (!onProgress) return;
|
|
1013
|
-
try {
|
|
1014
|
-
onProgress(progress);
|
|
1015
|
-
} catch (error) {
|
|
1016
|
-
log('redemption progress callback threw (ignored): %o', error);
|
|
1017
|
-
}
|
|
1018
|
-
}
|
|
1019
|
-
|
|
1020
|
-
/**
|
|
1021
|
-
* Wait `ms`, or reject with `signal`'s reason as soon as it aborts. Ends early, resolving, when
|
|
1022
|
-
* `node` stops (or is already stopped), so the next pass's stop check ends the wait without a
|
|
1023
|
-
* timer holding the process open. Listeners and timer are cleared however it ends.
|
|
638
|
+
* Device side, in passes (`dialRedemptionInPasses` in `member-redemption.ts`): one
|
|
639
|
+
* {@link redeemAtMembers} pass, or, with `waitForMember`, as many as it takes, pausing between
|
|
640
|
+
* them and never past the invitation's expiry, which by this device's clock is thrown as the
|
|
641
|
+
* `invite-spent` refusal a member would give.
|
|
1024
642
|
*/
|
|
1025
|
-
function
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
}
|
|
1035
|
-
const settle = (): void => {
|
|
1036
|
-
clearTimeout(timer);
|
|
1037
|
-
signal?.removeEventListener('abort', onAbort);
|
|
1038
|
-
node.removeEventListener('stop', onStop);
|
|
1039
|
-
};
|
|
1040
|
-
const onAbort = (): void => {
|
|
1041
|
-
settle();
|
|
1042
|
-
reject(signal!.reason);
|
|
1043
|
-
};
|
|
1044
|
-
const onStop = (): void => {
|
|
1045
|
-
settle();
|
|
1046
|
-
resolve();
|
|
1047
|
-
};
|
|
1048
|
-
const timer = setTimeout(onStop, ms);
|
|
1049
|
-
signal?.addEventListener('abort', onAbort, { once: true });
|
|
1050
|
-
node.addEventListener('stop', onStop, { once: true });
|
|
1051
|
-
});
|
|
643
|
+
export function redeemInPasses(node: Libp2p, options: RedeemInPassesOptions): Promise<CadreInviteRedemption> {
|
|
644
|
+
const passes: MemberRedemptionPassOptions<CadreInviteAccepted> = {
|
|
645
|
+
...inviteRedemption(options),
|
|
646
|
+
waitForMember: options.waitForMember,
|
|
647
|
+
expiresAt: options.invitation.invite.expiresAt,
|
|
648
|
+
expired: () => new CadreInviteRejectedError('invite-spent', 'the invitation expired before a member accepted it'),
|
|
649
|
+
...(options.onProgress ? { onProgress: options.onProgress } : {}),
|
|
650
|
+
};
|
|
651
|
+
return dialRedemptionInPasses(node, passes);
|
|
1052
652
|
}
|