@serfab/cadre-core 1.17.0 → 1.19.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 +3 -2
- package/dist/cadre-invite-protocol.d.ts +35 -13
- package/dist/cadre-invite-protocol.js +90 -25
- package/dist/cadre-invite-protocol.js.map +1 -1
- package/dist/cadre-node.d.ts +586 -255
- package/dist/cadre-node.js +1182 -566
- package/dist/cadre-node.js.map +1 -1
- package/dist/control-cohort.d.ts +66 -9
- package/dist/control-cohort.js +67 -9
- package/dist/control-cohort.js.map +1 -1
- package/dist/control-database.d.ts +451 -193
- package/dist/control-database.js +701 -359
- 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 +6 -0
- package/dist/control-protocol-guard.js +14 -2
- package/dist/control-protocol-guard.js.map +1 -1
- package/dist/control-schema-version.d.ts +48 -31
- package/dist/control-schema-version.js +67 -45
- package/dist/control-schema-version.js.map +1 -1
- package/dist/control-schema.d.ts +8 -8
- package/dist/control-schema.js +16 -16
- package/dist/control-schema.js.map +1 -1
- package/dist/control-write-retry.d.ts +42 -7
- package/dist/control-write-retry.js +87 -19
- package/dist/control-write-retry.js.map +1 -1
- 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 +15 -14
- package/dist/index.js +17 -14
- package/dist/index.js.map +1 -1
- package/dist/joined-strand-store.d.ts +26 -9
- package/dist/joined-strand-store.js +63 -24
- package/dist/joined-strand-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/member-redemption.d.ts +15 -9
- package/dist/member-redemption.js +13 -8
- package/dist/member-redemption.js.map +1 -1
- 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-grant-protocol.d.ts +11 -7
- package/dist/owner-grant-protocol.js +31 -17
- package/dist/owner-grant-protocol.js.map +1 -1
- 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-join-backfill.d.ts +96 -30
- package/dist/peer-join-backfill.js +272 -128
- 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/seed-bootstrap.d.ts +47 -40
- package/dist/seed-bootstrap.js +66 -61
- package/dist/seed-bootstrap.js.map +1 -1
- package/dist/self-address-update.d.ts +13 -0
- package/dist/self-address-update.js +19 -0
- package/dist/self-address-update.js.map +1 -0
- package/dist/strand-database.d.ts +23 -2
- package/dist/strand-database.js +26 -6
- package/dist/strand-database.js.map +1 -1
- package/dist/strand-first-sync-gate.d.ts +176 -89
- package/dist/strand-first-sync-gate.js +288 -122
- 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 +26 -17
- 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 +28 -22
- package/dist/strand-membership-reconciler.js.map +1 -1
- package/dist/strand-membership-writer.d.ts +40 -28
- package/dist/strand-membership-writer.js +212 -153
- package/dist/strand-membership-writer.js.map +1 -1
- package/dist/strand-move.js +5 -2
- package/dist/strand-move.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 +90 -49
- package/dist/types.js +4 -4
- package/dist/types.js.map +1 -1
- package/package.json +8 -8
- package/src/cadre-invite-protocol.ts +119 -31
- package/src/cadre-node.ts +1289 -588
- package/src/control-cohort.ts +86 -9
- package/src/control-database.ts +848 -403
- package/src/control-formation-recorder.ts +359 -353
- package/src/control-protocol-guard.ts +16 -2
- package/src/control-schema-version.ts +101 -51
- package/src/control-schema.ts +16 -16
- package/src/control-write-retry.ts +91 -19
- package/src/delegate-admission.ts +351 -351
- package/src/enrolled-machine-store.ts +3 -3
- package/src/formation-token.ts +63 -0
- package/src/index.ts +30 -18
- package/src/joined-strand-store.ts +69 -24
- package/src/link-budget.ts +9 -7
- package/src/member-redemption.ts +20 -13
- package/src/membership-view.ts +144 -0
- package/src/owner-grant-protocol.ts +33 -18
- package/src/peer-addr-book.ts +4 -3
- package/src/peer-join-backfill.ts +961 -761
- package/src/pending-join-runner.ts +76 -53
- package/src/seed-bootstrap.ts +72 -67
- package/src/self-address-update.ts +19 -0
- package/src/strand-database.ts +352 -322
- package/src/strand-first-sync-gate.ts +334 -134
- 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 +31 -25
- package/src/strand-membership-writer.ts +2093 -2019
- package/src/strand-move.ts +5 -2
- 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 +89 -51
|
@@ -172,9 +172,9 @@ export class PersistentEnrolledMachineStore implements EnrolledMachineStore {
|
|
|
172
172
|
* Record the count: visible via {@link count} synchronously, then persisted.
|
|
173
173
|
*
|
|
174
174
|
* Writes are SKIPPED when the slot already holds this number, because the sole
|
|
175
|
-
* caller runs on every committed membership write
|
|
176
|
-
*
|
|
177
|
-
* integer forever. The skip is keyed off {@link persisted} rather than
|
|
175
|
+
* caller runs on every membership refresh — each committed membership write, each
|
|
176
|
+
* replicated membership change, and each reconcile pass down to its backstop — so a
|
|
177
|
+
* party whose membership is stable would otherwise rewrite the same integer forever. The skip is keyed off {@link persisted} rather than
|
|
178
178
|
* {@link current}, so a write that FAILED is retried by the next refresh instead
|
|
179
179
|
* of being suppressed as "unchanged".
|
|
180
180
|
*/
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import type { FormationRejectionCode } from './strand-formation-rejection.js';
|
|
2
|
+
|
|
3
|
+
/** The `FormationInvite` columns a formation session acts on. */
|
|
4
|
+
export interface FormationInviteFacts {
|
|
5
|
+
sAppId: string;
|
|
6
|
+
/** Epoch ms of the stored `ExpiresAt`; null when the invitation never expires. */
|
|
7
|
+
expiresAtMs: number | null;
|
|
8
|
+
/** Null is unlimited. */
|
|
9
|
+
totalUses: number | null;
|
|
10
|
+
/** A bound invitation's host strand; null on the responder-provisions path. */
|
|
11
|
+
strandId: string | null;
|
|
12
|
+
/** The outside approval hook; null when no approval is required. */
|
|
13
|
+
validationUrl: string | null;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* One formation token as read once at the start of a responder session. Every later step of
|
|
18
|
+
* the session takes this value instead of reading the invite again.
|
|
19
|
+
*/
|
|
20
|
+
export interface FormationTokenRead {
|
|
21
|
+
token: string;
|
|
22
|
+
/** Null: no invite row here (it may not have replicated to this machine yet). */
|
|
23
|
+
invite: FormationInviteFacts | null;
|
|
24
|
+
/** Uses recorded so far; counted only for a metered, unexpired invite, else null. */
|
|
25
|
+
usesRecorded: number | null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* What the responder's token check yields; an invalid token names which way it failed. The
|
|
30
|
+
* valid arm carries the session's {@link FormationTokenRead}, absent only when the manager has
|
|
31
|
+
* no recorder and so accepts every token with nothing read.
|
|
32
|
+
*/
|
|
33
|
+
export type FormationTokenCheck =
|
|
34
|
+
| { valid: true; read?: FormationTokenRead }
|
|
35
|
+
| { valid: false; code: Extract<FormationRejectionCode, 'token-unknown' | 'token-spent'> };
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* `expiresAtMs <= nowMs` is expired: the same boundary `hasOutstandingFormationInvite` applies,
|
|
39
|
+
* so an invite the formation handler refuses can never hold the connection gate open.
|
|
40
|
+
*/
|
|
41
|
+
export function isInviteExpired(invite: FormationInviteFacts, nowMs: number): boolean {
|
|
42
|
+
return invite.expiresAtMs !== null && invite.expiresAtMs <= nowMs;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Used up: a metered invite whose recorded uses reached `totalUses`. An unknown token is not used up. */
|
|
46
|
+
export function isTokenReadSpent(read: FormationTokenRead): boolean {
|
|
47
|
+
const totalUses = read.invite?.totalUses ?? null;
|
|
48
|
+
return totalUses !== null && read.usesRecorded !== null && read.usesRecorded >= totalUses;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* No invite row is `'token-unknown'` (retryable: the row may not have replicated to this
|
|
53
|
+
* machine yet); an expired or used-up invite is `'token-spent'` (final).
|
|
54
|
+
*/
|
|
55
|
+
export function classifyTokenRead(read: FormationTokenRead, nowMs: number): FormationTokenCheck {
|
|
56
|
+
if (read.invite === null) {
|
|
57
|
+
return { valid: false, code: 'token-unknown' };
|
|
58
|
+
}
|
|
59
|
+
if (isInviteExpired(read.invite, nowMs) || isTokenReadSpent(read)) {
|
|
60
|
+
return { valid: false, code: 'token-spent' };
|
|
61
|
+
}
|
|
62
|
+
return { valid: true, read };
|
|
63
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -16,12 +16,13 @@ export { MachineRemovalRefusedError } from './machine-removal.js';
|
|
|
16
16
|
export { OwnerKeyAddRefusedError, OwnerKeyRemovalRefusedError } from './owner-keys.js';
|
|
17
17
|
|
|
18
18
|
// Control database
|
|
19
|
-
export { ControlDatabase, parseNullableStoredDatetimeMs, MissingHostStrandError, InvitationExhaustedError, CadreInviteIssuerUnknownError, OwnerGrantIssuerUnknownError, IssuedRowNotHereYetError, buildAuthorizationMessage, registerPartyIdFunction, formationVouchMessage, formationConsentMessage, cadreInviteAddMessage, cadreInviteRedeemMessage, cadreInviteConsentMessage, ownerGrantAddMessage, ownerGrantRedeemMessage, ownerGrantConsentMessage, projectSeedPeers, REAPABLE_TABLES, type ReapableTable, type ControlDatabaseConfig, type ControlTable, type RevocableTable, type FormationUsageResult, type FormationInviteRow, type MembershipChangeListener, type GuardedDeleteListener, type ControlWriteAbandonedListener, type RevokedRowRef, type ControlSign, type OwnerGrantRedemptionFields } from './control-database.js';
|
|
19
|
+
export { ControlDatabase, parseNullableStoredDatetimeMs, MissingHostStrandError, InvitationExhaustedError, CadreInviteIssuerUnknownError, OwnerGrantIssuerUnknownError, IssuedRowNotHereYetError, buildAuthorizationMessage, registerPartyIdFunction, formationVouchMessage, formationConsentMessage, cadreInviteAddMessage, cadreInviteRedeemMessage, cadreInviteConsentMessage, ownerGrantAddMessage, ownerGrantRedeemMessage, ownerGrantConsentMessage, projectSeedPeers, REAPABLE_TABLES, type ReapableTable, type ControlDatabaseConfig, type ControlTable, type RevocableTable, type FormationUsageResult, type FormationInviteRow, type FoundingRows, type FoundingInvite, type MembershipChangeListener, type GuardedDeleteListener, type ControlWriteAbandonedListener, type RevokedRowRef, type ControlSign, type SelfRecordSigner, type CadrePeerRetouchMode, type CadrePeerRetouchOutcome, type OwnerGrantRedemptionFields } from './control-database.js';
|
|
20
20
|
|
|
21
|
-
//
|
|
22
|
-
//
|
|
23
|
-
|
|
24
|
-
export {
|
|
21
|
+
// The control schema version a store is migrated to on start, and what `start()` throws, with a
|
|
22
|
+
// reason, when the store must not be migrated (docs/architecture.md → "Control schema versions and
|
|
23
|
+
// migrations")
|
|
24
|
+
export { CONTROL_SCHEMA_VERSION, CONTROL_SCHEMA_BASELINE_VERSION, CONTROL_SCHEMA_VERSION_TAG } from './control-schema.js';
|
|
25
|
+
export { ControlStoreIncompatibleError, type ControlStoreIncompatibility } from './control-schema-version.js';
|
|
25
26
|
|
|
26
27
|
// Bounded retry for transient control-write failures (classifier + loop behind
|
|
27
28
|
// ControlDatabase.lockedWithRetry; exported so the integration package can drive the
|
|
@@ -29,9 +30,11 @@ export { ControlStoreIncompatibleError } from './control-schema-version.js';
|
|
|
29
30
|
export {
|
|
30
31
|
CONTROL_WRITE_ATTEMPTS,
|
|
31
32
|
CONTROL_WRITE_RETRY_BUDGET_MS,
|
|
33
|
+
LOST_UPDATE_RETRY_POLICY,
|
|
32
34
|
SCHEMA_INIT_ATTEMPTS,
|
|
33
35
|
SCHEMA_INIT_RETRY_POLICY,
|
|
34
36
|
isRetriableControlWriteFailure,
|
|
37
|
+
isRetriableLostUpdateFailure,
|
|
35
38
|
isRetriableSchemaInitFailure,
|
|
36
39
|
retryControlWrite,
|
|
37
40
|
type ControlWriteRetryOptions
|
|
@@ -236,10 +239,7 @@ export {
|
|
|
236
239
|
export {
|
|
237
240
|
StrandAwaitingFirstSyncError,
|
|
238
241
|
DEFAULT_STRAND_FIRST_SYNC_TIMEOUT_MS,
|
|
239
|
-
|
|
240
|
-
strandHeaderHeld,
|
|
241
|
-
appTablesReadable,
|
|
242
|
-
strandFirstSyncComplete,
|
|
242
|
+
DEFAULT_STRAND_FIRST_SYNC_BACKSTOP_MS,
|
|
243
243
|
type StrandFirstSyncConfig
|
|
244
244
|
} from './strand-first-sync-gate.js';
|
|
245
245
|
|
|
@@ -249,7 +249,6 @@ export {
|
|
|
249
249
|
export {
|
|
250
250
|
PeerJoinBackfill,
|
|
251
251
|
DEFAULT_PEER_JOIN_BACKFILL,
|
|
252
|
-
MAX_BLOCK_MESSAGE_BYTES,
|
|
253
252
|
PEER_JOIN_BACKFILL_WARN_AFTER_FAILURES,
|
|
254
253
|
type PeerJoinBackfillConfig,
|
|
255
254
|
type PeerJoinBackfillDeps,
|
|
@@ -269,14 +268,15 @@ export {
|
|
|
269
268
|
StrandRevocationEnforcer,
|
|
270
269
|
createRevocationConnectionGater,
|
|
271
270
|
readStrandRevocationRows,
|
|
272
|
-
|
|
271
|
+
DEFAULT_REVOCATION_BACKSTOP_MS,
|
|
272
|
+
REVOCATION_REFRESH_RETRY_MS,
|
|
273
|
+
REVOCATION_WIDE_STRAND_REFRESH_MS,
|
|
273
274
|
type StrandRevocationEnforcementConfig,
|
|
274
275
|
type StrandRevocationEnforcerDeps,
|
|
275
276
|
type StrandRevocationRows,
|
|
276
277
|
type StrandMemberPeerBinding,
|
|
277
278
|
type StrandRevocationJudge,
|
|
278
|
-
type StrandRevocationNetwork
|
|
279
|
-
type RevocationRefreshScheduler
|
|
279
|
+
type StrandRevocationNetwork
|
|
280
280
|
} from './strand-revocation-enforcer.js';
|
|
281
281
|
|
|
282
282
|
// Closed-strand membership reconciliation: the bring-up loop that finishes a
|
|
@@ -285,6 +285,7 @@ export {
|
|
|
285
285
|
// bring-up (see the module doc for the pass ladder and terminal states).
|
|
286
286
|
export {
|
|
287
287
|
StrandMembershipReconciler,
|
|
288
|
+
DEFAULT_MEMBERSHIP_RECONCILE_INTERVAL_MS,
|
|
288
289
|
IDLE_PASSES_BEFORE_ESCALATION,
|
|
289
290
|
INITIAL_JOIN_RETRY_INTERVAL_MS,
|
|
290
291
|
type StrandMembershipReconcilerDeps,
|
|
@@ -292,8 +293,8 @@ export {
|
|
|
292
293
|
type PendingMembershipInviteSource
|
|
293
294
|
} from './strand-membership-reconciler.js';
|
|
294
295
|
|
|
295
|
-
// The timer seam the self-rescheduling
|
|
296
|
-
//
|
|
296
|
+
// The timer seam the self-rescheduling loops share (membership retry ladder, the table
|
|
297
|
+
// change schedule the first-sync gate probes on) — injected by tests as a hand-cranked clock.
|
|
297
298
|
export { defaultTimeoutScheduler, type TimeoutScheduler } from './timeout-scheduler.js';
|
|
298
299
|
|
|
299
300
|
// Hibernation
|
|
@@ -324,6 +325,7 @@ export {
|
|
|
324
325
|
type FormationUsageRecorder,
|
|
325
326
|
type FormationUsageParams,
|
|
326
327
|
type AuthorizedFormationUsage,
|
|
328
|
+
type BoundHostStrand,
|
|
327
329
|
type ResolvedHostStrand,
|
|
328
330
|
type StrandProvisioner,
|
|
329
331
|
type FormationResponseValidator,
|
|
@@ -420,6 +422,7 @@ export {
|
|
|
420
422
|
SeedBootstrapService,
|
|
421
423
|
SEED_PROTOCOL,
|
|
422
424
|
mergeSeedPeers,
|
|
425
|
+
seedDialTargets,
|
|
423
426
|
seedDigest,
|
|
424
427
|
type SeedBootstrapConfig,
|
|
425
428
|
type SeedEventCallbacks
|
|
@@ -532,11 +535,12 @@ export {
|
|
|
532
535
|
} from './push-fanout.js';
|
|
533
536
|
|
|
534
537
|
// Control-cohort dial selection (backbone-preferential, bounded out-degree) +
|
|
535
|
-
// the
|
|
538
|
+
// the backstop, redial and degree defaults the proactive reconcile routine reads.
|
|
536
539
|
export {
|
|
537
540
|
selectControlCohortDials,
|
|
538
541
|
DEFAULT_CONTROL_COHORT_RECONCILE_MS,
|
|
539
542
|
DEFAULT_CONTROL_COHORT_TARGET_DEGREE,
|
|
543
|
+
CONTROL_COHORT_REDIAL_MS,
|
|
540
544
|
type ControlCohortSelection,
|
|
541
545
|
type ControlCohortReconcileResult
|
|
542
546
|
} from './control-cohort.js';
|
|
@@ -694,9 +698,16 @@ export {
|
|
|
694
698
|
type FormationDialOptions,
|
|
695
699
|
type FormationDialResult,
|
|
696
700
|
type FormationRejection,
|
|
697
|
-
type FormationTokenCheck,
|
|
698
701
|
type ResponderProvisionOutcome
|
|
699
702
|
} from './strand-formation-protocol.js';
|
|
703
|
+
// A responder session's one read of its formation token, and the check made from it
|
|
704
|
+
export {
|
|
705
|
+
classifyTokenRead,
|
|
706
|
+
isTokenReadSpent,
|
|
707
|
+
type FormationInviteFacts,
|
|
708
|
+
type FormationTokenRead,
|
|
709
|
+
type FormationTokenCheck
|
|
710
|
+
} from './formation-token.js';
|
|
700
711
|
// How a formation dial fails: a typed refusal (with a fixed code) or no answer at all
|
|
701
712
|
export {
|
|
702
713
|
FormationRejectedError,
|
|
@@ -736,6 +747,7 @@ export {
|
|
|
736
747
|
type RedeemAtMembersOptions,
|
|
737
748
|
type RedeemInPassesOptions,
|
|
738
749
|
type RedeemSigner,
|
|
750
|
+
type RedeemSelfRecord,
|
|
739
751
|
type CadreInviteRedemption
|
|
740
752
|
} from './cadre-invite-protocol.js';
|
|
741
753
|
// Owner grant redemption transport: the bundle an owner hands out, the wire messages, the
|
|
@@ -788,7 +800,7 @@ export {
|
|
|
788
800
|
CADRE_INVITATION_ADDRS_PER_SIBLING
|
|
789
801
|
} from './invitation-bootstrap.js';
|
|
790
802
|
// The pending-join retry loop behind CadreNode.requestJoin (PendingJoinStatus is in types.ts)
|
|
791
|
-
export {
|
|
803
|
+
export { PENDING_JOIN_BACKSTOP_MS, PENDING_JOIN_MAX_BACKOFF_MS, MAX_PENDING_JOIN_MS } from './pending-join-runner.js';
|
|
792
804
|
|
|
793
805
|
// Strand Formation manager (drives the native transport)
|
|
794
806
|
export {
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* The strands this party joined from ANOTHER party, held in two records.
|
|
3
3
|
*
|
|
4
4
|
* A party's own strands come back after a restart through its control database: the
|
|
5
|
-
* `Strand` table holds their rows, the strand watcher
|
|
5
|
+
* `Strand` table holds their rows, the strand watcher reads it, and each row no sApp
|
|
6
6
|
* config claims is offered as `strand:discovered`. A strand joined through formation with
|
|
7
7
|
* another party (`CadreNode.formStrand`) has its `Strand` row in the OTHER party's control
|
|
8
8
|
* database, so it needs a record of its own (gotchoices/sereus#18):
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
* published". A machine that is not an owner never publishes; its joins stay here.
|
|
18
18
|
*
|
|
19
19
|
* {@link JoinedStrandSession} moves joins between the two and is what the strand watcher
|
|
20
|
-
*
|
|
20
|
+
* reads beside the control rows.
|
|
21
21
|
*
|
|
22
22
|
* Two store implementations:
|
|
23
23
|
* - {@link MemoryJoinedStrandStore} — joins die with the process. The default only for a
|
|
@@ -76,6 +76,10 @@ export function joinedStrandRow(record: JoinedStrandRecord): StrandRow {
|
|
|
76
76
|
return { Id: record.Id, Type: record.Type, MemberPrivateKey: record.MemberPrivateKey, FounderOwnerKey: null, FounderPeerId: null };
|
|
77
77
|
}
|
|
78
78
|
|
|
79
|
+
function isSameJoin(record: JoinedStrandRecord, row: StrandRow): boolean {
|
|
80
|
+
return record.Type === row.Type && record.MemberPrivateKey === row.MemberPrivateKey;
|
|
81
|
+
}
|
|
82
|
+
|
|
79
83
|
/**
|
|
80
84
|
* Ephemeral store for a node without a `keyStore`. Warns once, at the first join it is
|
|
81
85
|
* asked to remember, because a join held only here is not re-offered after a restart —
|
|
@@ -124,9 +128,9 @@ const SLOT_PREFIX = 'cadre/joined-strand/';
|
|
|
124
128
|
* arbitrary text, and base64url never contains the `/` that ends the segment.
|
|
125
129
|
*
|
|
126
130
|
* Loads every slot once, at the first {@link list}, then answers from memory and writes
|
|
127
|
-
* through, so
|
|
128
|
-
*
|
|
129
|
-
*
|
|
131
|
+
* through, so a strand watcher run does not read the enclave each time. A load that
|
|
132
|
+
* fails — `KeyStoreAccessError` from a refused unlock prompt, say — is rethrown rather
|
|
133
|
+
* than read as "no joins" and retried at the next call, exactly as
|
|
130
134
|
* `loadOrCreateIdentityKey` treats the identity slot. A slot that does not parse as a
|
|
131
135
|
* record for its own id is dropped with a log and its siblings kept: refusing to start
|
|
132
136
|
* repairs nothing, and the other joins are still good.
|
|
@@ -176,9 +180,10 @@ export class KeyStoreJoinedStrandStore implements JoinedStrandStore {
|
|
|
176
180
|
return result;
|
|
177
181
|
}
|
|
178
182
|
|
|
179
|
-
// NOTE: a failed load is retried at the next list(), which is every strand-watcher
|
|
180
|
-
//
|
|
181
|
-
//
|
|
183
|
+
// NOTE: a failed load is retried at the next list(), which is every strand-watcher run (a
|
|
184
|
+
// change to `Strand` or `JoinedStrand`, a local join, a retry, the backstop); over a backend
|
|
185
|
+
// that gates reads behind an unlock prompt the user keeps declining, that is a prompt per
|
|
186
|
+
// run. Every KeyStore shipped today is ungated for this reason
|
|
182
187
|
// (see `SecureStoreKeyStoreOptions.requireAuthentication`); if one is ever gated, back
|
|
183
188
|
// off the retry here.
|
|
184
189
|
private async loaded(): Promise<Map<string, JoinedStrandRecord>> {
|
|
@@ -251,7 +256,7 @@ export interface PartyJoinedStrandLedger {
|
|
|
251
256
|
* the moves between them. Rebuilt at every start, which is what ends the per-session state
|
|
252
257
|
* below.
|
|
253
258
|
*
|
|
254
|
-
* The watcher reads a row missing from a
|
|
259
|
+
* The watcher reads a row missing from a run's read as a removal and detaches the strand, so this
|
|
255
260
|
* holds the state a plain union would get wrong:
|
|
256
261
|
* - the last list each record answered, reused when a later read fails, so a failed read
|
|
257
262
|
* is not "every join was forgotten";
|
|
@@ -262,6 +267,8 @@ export interface PartyJoinedStrandLedger {
|
|
|
262
267
|
export class JoinedStrandSession {
|
|
263
268
|
private lastUnpublished: JoinedStrandRecord[] = [];
|
|
264
269
|
private lastPartyWide: StrandRow[] = [];
|
|
270
|
+
/** Whether the last party-wide read in {@link withControlRows} succeeded; see {@link lastReadPartial}. */
|
|
271
|
+
private lastPartyWideFresh = true;
|
|
265
272
|
private readonly keptForSession = new Map<string, StrandRow>();
|
|
266
273
|
/** Strands whose party-wide row {@link syncWithParty} removes, queued by a self-revocation. */
|
|
267
274
|
private readonly pendingRemovals = new Set<string>();
|
|
@@ -272,13 +279,23 @@ export class JoinedStrandSession {
|
|
|
272
279
|
*/
|
|
273
280
|
private exclusive: Promise<unknown> = Promise.resolve();
|
|
274
281
|
|
|
275
|
-
|
|
282
|
+
/**
|
|
283
|
+
* @param onLocalChange - Called after a move changes what {@link withControlRows} merges in
|
|
284
|
+
* from outside the control database (this machine's unpublished joins, the joins kept for
|
|
285
|
+
* this session), which no control-table change event reports. `CadreNode` wakes its strand
|
|
286
|
+
* watcher with it.
|
|
287
|
+
*/
|
|
288
|
+
constructor(
|
|
289
|
+
private readonly store: JoinedStrandStore,
|
|
290
|
+
private readonly ledger: PartyJoinedStrandLedger,
|
|
291
|
+
private readonly onLocalChange: () => void = () => {}
|
|
292
|
+
) {}
|
|
276
293
|
|
|
277
294
|
/**
|
|
278
295
|
* `control` plus every join, one row per id, in this precedence: the control rows (the
|
|
279
296
|
* party's own strands), then the party-wide joins, then this machine's unpublished joins
|
|
280
297
|
* and the joins kept for this session. A local record named by either of the first two in
|
|
281
|
-
* a read that succeeded this
|
|
298
|
+
* a read that succeeded this run is stale — published, or the strand is the party's own
|
|
282
299
|
* now — so it is forgotten here; this also cleans up after a crash between a publish and
|
|
283
300
|
* its local forget. One named only by the last-good party-wide list is kept: that list may
|
|
284
301
|
* predate a leave the record re-joined after.
|
|
@@ -289,7 +306,7 @@ export class JoinedStrandSession {
|
|
|
289
306
|
const offered = new Map<string, StrandRow>(control.map((row) => [row.Id, row]));
|
|
290
307
|
const partyWide = await this.partyWide();
|
|
291
308
|
// NOTE: a party-wide join whose id the party's own `Strand` table also holds (the party
|
|
292
|
-
// founded AND joined it — contrived) is shadowed here, not deleted: a watcher
|
|
309
|
+
// founded AND joined it — contrived) is shadowed here, not deleted: a watcher run must
|
|
293
310
|
// not make owner-signed writes. If the party later unpublishes its own row the join row
|
|
294
311
|
// resurfaces; if that is ever seen, remove the join row inside `unpublishStrand`.
|
|
295
312
|
for (const row of partyWide.rows) {
|
|
@@ -317,10 +334,18 @@ export class JoinedStrandSession {
|
|
|
317
334
|
return [...offered.values()];
|
|
318
335
|
}
|
|
319
336
|
|
|
337
|
+
/**
|
|
338
|
+
* True when the last {@link withControlRows} offered the party-wide joins from the last
|
|
339
|
+
* list that answered, because its read of `JoinedStrand` failed.
|
|
340
|
+
*/
|
|
341
|
+
lastReadPartial(): boolean {
|
|
342
|
+
return !this.lastPartyWideFresh;
|
|
343
|
+
}
|
|
344
|
+
|
|
320
345
|
/**
|
|
321
346
|
* This machine's unpublished joins, straight from the store (never the last-good list a
|
|
322
347
|
* failed read stands in with). What a strand move exports beside the `JoinedStrand`
|
|
323
|
-
* table: a machine publishes only while
|
|
348
|
+
* table: a machine publishes only while a sibling is reachable, so until then its
|
|
324
349
|
* joins live only here.
|
|
325
350
|
*/
|
|
326
351
|
unpublishedRecords(): Promise<JoinedStrandRecord[]> {
|
|
@@ -331,21 +356,32 @@ export class JoinedStrandSession {
|
|
|
331
356
|
async remember(record: JoinedStrandRecord): Promise<void> {
|
|
332
357
|
this.pendingRemovals.delete(record.Id);
|
|
333
358
|
await this.store.record(record);
|
|
359
|
+
this.onLocalChange();
|
|
334
360
|
}
|
|
335
361
|
|
|
336
362
|
/**
|
|
337
363
|
* Record `row`, joined by `addStrand`, unless this party's control database already names
|
|
338
364
|
* it (its own strand, or a join already published) or an identical record is already here
|
|
339
|
-
* (an app re-claiming a remembered join on every start
|
|
365
|
+
* (an app re-claiming a remembered join on every start, or `addStrand` right after
|
|
366
|
+
* `formStrand`). The store is asked first: it answers from memory once loaded, so the
|
|
367
|
+
* common re-claim reads no control row. Keeps the first `joinedAt`. Like
|
|
340
368
|
* {@link remember}, cancels a queued removal.
|
|
341
369
|
*/
|
|
342
370
|
async rememberForeign(row: StrandRow): Promise<void> {
|
|
343
371
|
this.pendingRemovals.delete(row.Id);
|
|
372
|
+
const early = await this.storedRecord(row.Id).catch((error: unknown) => {
|
|
373
|
+
// Asked again below only when the party does not name the strand; a second failure throws.
|
|
374
|
+
log('joined strand %s: store lookup failed; asking the control database first: %o', row.Id, error);
|
|
375
|
+
return null;
|
|
376
|
+
});
|
|
377
|
+
if (early && isSameJoin(early, row)) {
|
|
378
|
+
return;
|
|
379
|
+
}
|
|
344
380
|
if (await this.namedByParty(row.Id)) {
|
|
345
381
|
return;
|
|
346
382
|
}
|
|
347
|
-
const existing =
|
|
348
|
-
if (existing
|
|
383
|
+
const existing = early === null ? await this.storedRecord(row.Id) : early;
|
|
384
|
+
if (existing && isSameJoin(existing, row)) {
|
|
349
385
|
return;
|
|
350
386
|
}
|
|
351
387
|
await this.store.record({
|
|
@@ -354,6 +390,7 @@ export class JoinedStrandSession {
|
|
|
354
390
|
MemberPrivateKey: row.MemberPrivateKey,
|
|
355
391
|
joinedAt: existing?.joinedAt ?? Date.now()
|
|
356
392
|
});
|
|
393
|
+
this.onLocalChange();
|
|
357
394
|
}
|
|
358
395
|
|
|
359
396
|
/**
|
|
@@ -367,6 +404,7 @@ export class JoinedStrandSession {
|
|
|
367
404
|
this.keptForSession.delete(strandId);
|
|
368
405
|
this.lastPartyWide = this.lastPartyWide.filter((row) => row.Id !== strandId);
|
|
369
406
|
await this.store.forget(strandId);
|
|
407
|
+
this.onLocalChange();
|
|
370
408
|
});
|
|
371
409
|
}
|
|
372
410
|
|
|
@@ -385,13 +423,14 @@ export class JoinedStrandSession {
|
|
|
385
423
|
if (!row) {
|
|
386
424
|
return;
|
|
387
425
|
}
|
|
388
|
-
// Kept BEFORE the store forgets, so no
|
|
426
|
+
// Kept BEFORE the store forgets, so no watcher run can union without it. Queued even for a
|
|
389
427
|
// local-only join, which a sibling may have published already.
|
|
390
428
|
this.keptForSession.set(strandId, row);
|
|
391
429
|
this.pendingRemovals.add(strandId);
|
|
392
430
|
if (local) {
|
|
393
431
|
await this.store.forget(strandId);
|
|
394
432
|
}
|
|
433
|
+
this.onLocalChange();
|
|
395
434
|
});
|
|
396
435
|
}
|
|
397
436
|
|
|
@@ -423,15 +462,16 @@ export class JoinedStrandSession {
|
|
|
423
462
|
}
|
|
424
463
|
|
|
425
464
|
// NOTE: a sibling that joined the same strand and has not yet published publishes it after
|
|
426
|
-
// a leave, bringing the strand back party-wide. Needs
|
|
427
|
-
// a reconcile
|
|
465
|
+
// a leave, bringing the strand back party-wide. Needs a second device to have joined the
|
|
466
|
+
// strand and not yet run a connected reconcile pass when the leave lands (a join starts no
|
|
467
|
+
// pass, so up to the 2-minute backstop); if it is ever seen, check the strand's `Revocation`
|
|
428
468
|
// tombstone before publishing.
|
|
429
469
|
private async publish(record: JoinedStrandRecord): Promise<void> {
|
|
430
470
|
try {
|
|
431
471
|
if (!(await this.ledger.names(record.Id))) {
|
|
432
472
|
await this.ledger.publish(record);
|
|
433
473
|
// Into the last-good list too: its local record goes next, so a party-wide read that
|
|
434
|
-
// fails before one succeeds would otherwise drop the strand from the
|
|
474
|
+
// fails before one succeeds would otherwise drop the strand from the watcher's read and hide it
|
|
435
475
|
// from a self-revocation.
|
|
436
476
|
this.lastPartyWide = [...this.lastPartyWide.filter((row) => row.Id !== record.Id), joinedStrandRow(record)];
|
|
437
477
|
log('joined strand %s published party-wide (party=%s)', record.Id, this.store.partyId);
|
|
@@ -457,19 +497,20 @@ export class JoinedStrandSession {
|
|
|
457
497
|
private async partyWide(): Promise<{ rows: StrandRow[]; fresh: boolean }> {
|
|
458
498
|
try {
|
|
459
499
|
this.lastPartyWide = await this.ledger.list();
|
|
460
|
-
|
|
500
|
+
this.lastPartyWideFresh = true;
|
|
461
501
|
} catch (error) {
|
|
462
502
|
log('party-wide joined-strand read failed; offering the last list it answered (%d join(s)): %o',
|
|
463
503
|
this.lastPartyWide.length, error);
|
|
464
|
-
|
|
504
|
+
this.lastPartyWideFresh = false;
|
|
465
505
|
}
|
|
506
|
+
return { rows: this.lastPartyWide, fresh: this.lastPartyWideFresh };
|
|
466
507
|
}
|
|
467
508
|
|
|
468
509
|
/**
|
|
469
510
|
* {@link PartyJoinedStrandLedger.names}, answering false when the control database cannot
|
|
470
511
|
* be read (a machine cut off from its party before it received the `JoinedStrand` block).
|
|
471
512
|
* Recording is the safe side: a local record the party turns out to name is forgotten by
|
|
472
|
-
* the next
|
|
513
|
+
* the next watcher run that reads it, while a join with no record is not offered after a restart.
|
|
473
514
|
*/
|
|
474
515
|
private async namedByParty(strandId: string): Promise<boolean> {
|
|
475
516
|
try {
|
|
@@ -480,6 +521,10 @@ export class JoinedStrandSession {
|
|
|
480
521
|
}
|
|
481
522
|
}
|
|
482
523
|
|
|
524
|
+
private async storedRecord(strandId: string): Promise<JoinedStrandRecord | undefined> {
|
|
525
|
+
return (await this.store.list()).find((record) => record.Id === strandId);
|
|
526
|
+
}
|
|
527
|
+
|
|
483
528
|
/** Run `work` after every earlier serialized move settles; see {@link exclusive}. */
|
|
484
529
|
private serialized<T>(work: () => Promise<T>): Promise<T> {
|
|
485
530
|
const result = this.exclusive.then(work);
|
|
@@ -502,7 +547,7 @@ export class JoinedStrandSession {
|
|
|
502
547
|
await this.store.forget(strandId);
|
|
503
548
|
log('joined strand %s is named by this party\'s control database — local record forgotten', strandId);
|
|
504
549
|
} catch (error) {
|
|
505
|
-
log('forgetting superseded joined strand %s failed; the next
|
|
550
|
+
log('forgetting superseded joined strand %s failed; the next watcher run retries: %o', strandId, error);
|
|
506
551
|
}
|
|
507
552
|
}
|
|
508
553
|
}
|
package/src/link-budget.ts
CHANGED
|
@@ -392,13 +392,15 @@ export const PUSH_TRANSFER_ALLOWANCE_MS = 6000;
|
|
|
392
392
|
* every members-only protocol from the materialized snapshot, and unplaced
|
|
393
393
|
* relay reservations stay capped (`MAX_UNAUTHORIZED_RELAY_RESERVATIONS`).
|
|
394
394
|
* - **The slow case is bring-up, not steady state.** A membership read consults
|
|
395
|
-
* the cohort only for a block this node does not hold
|
|
396
|
-
*
|
|
397
|
-
*
|
|
398
|
-
*
|
|
399
|
-
*
|
|
400
|
-
*
|
|
401
|
-
*
|
|
395
|
+
* the cohort only for a block this node does not hold: before a joining
|
|
396
|
+
* machine has read it from the cohort, or for a table nobody has written on a
|
|
397
|
+
* party founded before genesis created every control collection. Otherwise
|
|
398
|
+
* every block the decision reads is held
|
|
399
|
+
* (`control-founding-consult-budget.spec.ts` pins that). So the gate always
|
|
400
|
+
* fails open only on a node whose membership reads still consult: one consult
|
|
401
|
+
* costs about two link round trips, and asking a silent peer costs the
|
|
402
|
+
* per-peer read deadline ({@link cohortReadDeadlineMs}, 7 s at the default
|
|
403
|
+
* declaration).
|
|
402
404
|
* - **The decision is spent on the dialing machine's clock.** libp2p's listener
|
|
403
405
|
* runs this gate before it answers the multiplexer negotiation the dialer is
|
|
404
406
|
* waiting on (`libp2p/dist/src/upgrader.js`), and the listener's own
|
package/src/member-redemption.ts
CHANGED
|
@@ -170,12 +170,16 @@ export interface MemberRedemptionHandlerOptions {
|
|
|
170
170
|
* Registered only by a started `CadreNode`, after its control database is up, like the
|
|
171
171
|
* formation responder; nothing here is reachable on a node without one.
|
|
172
172
|
*
|
|
173
|
-
* A subclass supplies the
|
|
174
|
-
* it, the
|
|
175
|
-
* on its own (`busy`, and `internal` for a
|
|
176
|
-
* expect).
|
|
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.
|
|
177
181
|
*/
|
|
178
|
-
export abstract class MemberRedemptionHandler<Request, Reply extends { accepted: boolean }> {
|
|
182
|
+
export abstract class MemberRedemptionHandler<Request, Reply extends { accepted: boolean }, Held> {
|
|
179
183
|
protected readonly partyId: string;
|
|
180
184
|
private readonly admitDevice: MemberRedemptionHandlerOptions['admitDevice'];
|
|
181
185
|
private readonly readTimeoutMs: number;
|
|
@@ -204,10 +208,12 @@ export abstract class MemberRedemptionHandler<Request, Reply extends { accepted:
|
|
|
204
208
|
protected abstract isWellFormedRequest(value: unknown): value is Request;
|
|
205
209
|
/** The checks after the shape that cost no database work; null to go on to the writes. */
|
|
206
210
|
protected abstract checkRequest(request: Request, remotePeerId: string): Reply | null;
|
|
207
|
-
/**
|
|
208
|
-
protected abstract
|
|
209
|
-
/**
|
|
210
|
-
protected abstract
|
|
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>;
|
|
211
217
|
|
|
212
218
|
/** In-flight inbound redemptions. */
|
|
213
219
|
get activeCount(): number {
|
|
@@ -274,9 +280,10 @@ export abstract class MemberRedemptionHandler<Request, Reply extends { accepted:
|
|
|
274
280
|
if (!this.isWellFormedRequest(request)) return this.malformedRefusal;
|
|
275
281
|
const refused = this.checkRequest(request, remotePeerId);
|
|
276
282
|
if (refused) return refused;
|
|
277
|
-
const
|
|
283
|
+
const held = await this.readHeld(request);
|
|
284
|
+
const release = await this.admitIfLive(request, remotePeerId, held);
|
|
278
285
|
try {
|
|
279
|
-
return await this.redeemAdmitted(request, remotePeerId);
|
|
286
|
+
return await this.redeemAdmitted(request, remotePeerId, held);
|
|
280
287
|
} finally {
|
|
281
288
|
release();
|
|
282
289
|
}
|
|
@@ -299,11 +306,11 @@ export abstract class MemberRedemptionHandler<Request, Reply extends { accepted:
|
|
|
299
306
|
* the ordinary catch-up reaches a member. Best-effort: a failed check or push is logged and
|
|
300
307
|
* the writes go ahead without the holder admitted, then commit or are answered retryably.
|
|
301
308
|
*/
|
|
302
|
-
private async admitIfLive(request: Request, remotePeerId: string): Promise<() => void> {
|
|
309
|
+
private async admitIfLive(request: Request, remotePeerId: string, held: Held | null): Promise<() => void> {
|
|
303
310
|
const notAdmitted = (): void => undefined;
|
|
304
311
|
if (!this.admitDevice) return notAdmitted;
|
|
305
312
|
try {
|
|
306
|
-
if (!await this.isLive(request)) {
|
|
313
|
+
if (!await this.isLive(request, held)) {
|
|
307
314
|
log('%s: the credential is not live here; %s is not admitted before the write', this.label, remotePeerId);
|
|
308
315
|
return notAdmitted;
|
|
309
316
|
}
|