@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
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The edge node's `CadreNodeConfig` and how a start settles its owner key. Nothing here needs a
|
|
3
|
+
* node started, so a test can build the node shape an edge device runs.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { DEFAULT_IDENTITY_KEY_ID, type KeyStore } from '../key-store.js';
|
|
7
|
+
import { createOwnerKey, loadOwnerSigner } from '../owner-key-store.js';
|
|
8
|
+
import { OWNER_KEY_MAX_LABEL } from '../owner-key-export.js';
|
|
9
|
+
import type { OwnerSigner } from '../owner-signer.js';
|
|
10
|
+
import type { CadreNode } from '../cadre-node.js';
|
|
11
|
+
import type { CadreNodeConfig } from '../types.js';
|
|
12
|
+
import type { BootstrapPeerStore } from '../bootstrap-peer-store.js';
|
|
13
|
+
import type { EnrolledMachineStore } from '../enrolled-machine-store.js';
|
|
14
|
+
import type { StrandNetworkStateStore } from '../strand-network-state.js';
|
|
15
|
+
import type { TrustedOwnerStore } from '../trusted-owner-store.js';
|
|
16
|
+
import type { IRawStorage, Libp2pTransports, NoiseCryptoInterface } from '@optimystic/db-p2p';
|
|
17
|
+
import type { EdgeNodeOptions } from './options.js';
|
|
18
|
+
|
|
19
|
+
/** What {@link buildEdgeNodeConfig} takes: one start's options plus what the platform built. */
|
|
20
|
+
export interface EdgeNodeConfigInputs extends EdgeNodeOptions {
|
|
21
|
+
/** `undefined` keeps libp2p-noise's own crypto. */
|
|
22
|
+
noiseCrypto?: NoiseCryptoInterface;
|
|
23
|
+
/**
|
|
24
|
+
* Holds the node identity, which the node loads on start, generating it on first run, and the
|
|
25
|
+
* owner key in its own slot (`DEFAULT_OWNER_KEY_ID`), which only {@link runOwnerGenesis} creates.
|
|
26
|
+
*/
|
|
27
|
+
keyStore: KeyStore;
|
|
28
|
+
/**
|
|
29
|
+
* Raw block storage per cadre-core storage scope: `controlStorageScope(partyId)` for the
|
|
30
|
+
* control network, the strand id for each strand. The key is already safe as a database-name
|
|
31
|
+
* segment — use it verbatim, do not parse it.
|
|
32
|
+
*/
|
|
33
|
+
storageProvider: (scope: string) => IRawStorage;
|
|
34
|
+
/** libp2p transport factories: what the node dials with, and what listens on `listenAddrs`. */
|
|
35
|
+
transports: Libp2pTransports;
|
|
36
|
+
/**
|
|
37
|
+
* Listen addresses besides the bare `/p2p-circuit` entry cadre-core adds for a named relay.
|
|
38
|
+
* Default `[]`: an edge device accepts no inbound connection. See `EdgeNodePlatform.listenAddrs`.
|
|
39
|
+
*/
|
|
40
|
+
listenAddrs?: string[];
|
|
41
|
+
/** See `EdgeNodePlatform.allowPrivateDial`. */
|
|
42
|
+
allowPrivateDial: boolean;
|
|
43
|
+
trustedOwnerStore: TrustedOwnerStore;
|
|
44
|
+
bootstrapPeerStore: BootstrapPeerStore;
|
|
45
|
+
enrolledMachineStore: EnrolledMachineStore;
|
|
46
|
+
strandNetworkStateStore: StrandNetworkStateStore;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* The edge node's config: transaction profile (Ring Zulu only, intermittent connectivity), no
|
|
51
|
+
* listen address unless the platform names one, reachability through the configured relays (if
|
|
52
|
+
* any) but never a hard requirement for one, every strand the control network lists, and no
|
|
53
|
+
* automatic hibernation (the app's lifecycle runner hibernates on background). Schema signing is
|
|
54
|
+
* left at cadre-core's fail-closed default; an app with unsigned schemas relaxes it in
|
|
55
|
+
* `EdgeNodePlatform.configure`.
|
|
56
|
+
*/
|
|
57
|
+
export function buildEdgeNodeConfig(inputs: EdgeNodeConfigInputs): CadreNodeConfig {
|
|
58
|
+
return {
|
|
59
|
+
// Identity comes from the key store (loaded when present, generated and persisted on first
|
|
60
|
+
// run). Mutually exclusive with `privateKey`, which would take it out of the key store.
|
|
61
|
+
keyStore: inputs.keyStore,
|
|
62
|
+
identityKeyId: DEFAULT_IDENTITY_KEY_ID,
|
|
63
|
+
controlNetwork: {
|
|
64
|
+
partyId: inputs.partyId,
|
|
65
|
+
bootstrapNodes: inputs.bootstrapAddrs,
|
|
66
|
+
},
|
|
67
|
+
profile: 'transaction',
|
|
68
|
+
storage: {
|
|
69
|
+
provider: inputs.storageProvider,
|
|
70
|
+
},
|
|
71
|
+
network: {
|
|
72
|
+
transports: inputs.transports,
|
|
73
|
+
// An edge device accepts no inbound connection, so by default it listens on nothing:
|
|
74
|
+
// the only address it ever has is the `/p2p-circuit` address a relay reservation earns
|
|
75
|
+
// it. An explicitly empty `listenAddrs` stays empty through cadre-core's derivation, and
|
|
76
|
+
// naming a relay adds the bare `/p2p-circuit` search entry to it
|
|
77
|
+
// (`cadre-core/src/relay-addrs.ts` → `resolveListenAddrs`). A browser adds `/webrtc`.
|
|
78
|
+
listenAddrs: inputs.listenAddrs ?? [],
|
|
79
|
+
// Through this config field rather than `CadreNode.reserveRelays()`, which reaches the
|
|
80
|
+
// control node only: formation has the invitee dial the control node and then the strand
|
|
81
|
+
// nodes, so both need a circuit address.
|
|
82
|
+
//
|
|
83
|
+
// NOTE: naming a relay starts a reservation supervisor that runs for as long as the
|
|
84
|
+
// control node does, including while backgrounded (hibernation stops strands, not the
|
|
85
|
+
// control node): a 5 s local check while the reservation holds, a dial every backoff
|
|
86
|
+
// interval (2 s → 60 s) while it does not. If background battery use becomes a complaint,
|
|
87
|
+
// raise `checkMs`/`maxBackoffMs` in cadre-core rather than stopping the supervisor here,
|
|
88
|
+
// which would leave a device that silently stopped being invitable.
|
|
89
|
+
relayAddrs: inputs.relayAddrs,
|
|
90
|
+
// An edge device must come up with its relay down — on a plane, on dead Wi-Fi, or with
|
|
91
|
+
// none configured. A first reservation attempt that lands nothing is logged and retried in
|
|
92
|
+
// the background instead of failing `start()`. A malformed entry still throws.
|
|
93
|
+
requireRelay: false,
|
|
94
|
+
// The platform's Noise primitives (native SHA-256 / ChaCha20-Poly1305, and X25519 in
|
|
95
|
+
// `full` mode, on a phone); only local primitives change, not the wire protocol.
|
|
96
|
+
noiseCrypto: inputs.noiseCrypto,
|
|
97
|
+
// libp2p's `connection-gater` points its `react-native` and `browser` fields at a build
|
|
98
|
+
// that refuses to dial plain `ws://`, loopback and private addresses. A cadre-host node
|
|
99
|
+
// claimed by its code on the same Wi-Fi, an emulator reaching `10.0.2.2` and a self-run
|
|
100
|
+
// relay without TLS are all of those. Only the dial is permitted: the connection is still
|
|
101
|
+
// Noise, the pinned `/p2p/<id>` decides which machine answers, and membership is still
|
|
102
|
+
// gated by `denyDialPeer` and the inbound hooks. cadre-core hands this gater to strand nodes too.
|
|
103
|
+
...(inputs.allowPrivateDial ? { connectionGater: { denyDialMultiaddr: () => false } } : {}),
|
|
104
|
+
},
|
|
105
|
+
strandFilter: { mode: 'all' },
|
|
106
|
+
hibernation: { enabled: false },
|
|
107
|
+
trustedOwners: { store: inputs.trustedOwnerStore },
|
|
108
|
+
bootstrapPeers: { store: inputs.bootstrapPeerStore },
|
|
109
|
+
enrolledMachines: { store: inputs.enrolledMachineStore },
|
|
110
|
+
strandNetworkState: { store: inputs.strandNetworkStateStore },
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* How the start settled this node's owner key: `EdgeNodeStatus.owner`. `'enrolled'`: a key is
|
|
116
|
+
* an owner and wired for signing. `'member'`: the party lists no key this app holds (a party it
|
|
117
|
+
* joined, or one it founded whose owner slot was lost). `'failed'` and `'timed-out'`: see
|
|
118
|
+
* {@link runOwnerGenesis}.
|
|
119
|
+
*/
|
|
120
|
+
export type OwnerGenesisResult = 'enrolled' | 'member' | 'failed' | 'timed-out';
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Default bound on {@link runOwnerGenesis} and {@link wireJoinedOwnerKey}: what a start can wait
|
|
124
|
+
* for its owner step, which runs control reads and one control write, before it goes on without
|
|
125
|
+
* it. Not derived from the link, since a start that waited out a stalled write (about 84 s, see
|
|
126
|
+
* `docs/cadre-consistency.md`) would be a start the user gave up on.
|
|
127
|
+
*/
|
|
128
|
+
// eslint-disable-next-line no-restricted-syntax -- cuts off by design: a start goes on without its owner step rather than wait out a stalled control write; see docs/cadre-consistency.md → "Deadlines Over Optimystic's Reads and Commits"
|
|
129
|
+
export const DEFAULT_OWNER_GENESIS_TIMEOUT_MS = 60_000;
|
|
130
|
+
|
|
131
|
+
/** Default `EdgeNodeOptions.ownerKeyLabel`: the founding owner key's name on its `OwnerKey` row. */
|
|
132
|
+
export const DEFAULT_OWNER_KEY_LABEL = 'phone';
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Refuses an `ownerKeyLabel` the founding `OwnerKey` row would refuse, so a start naming one
|
|
136
|
+
* fails outright: fail-soft genesis would only log it, on that start and every later one.
|
|
137
|
+
*/
|
|
138
|
+
export function requireOwnerKeyLabel(label: string | undefined): void {
|
|
139
|
+
const length = label?.trim().length ?? 0;
|
|
140
|
+
if (length > OWNER_KEY_MAX_LABEL) {
|
|
141
|
+
throw new Error(`ownerKeyLabel must be at most ${OWNER_KEY_MAX_LABEL} characters (this one has ${length})`);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** What {@link runOwnerGenesis} takes. */
|
|
146
|
+
export interface OwnerGenesisOptions {
|
|
147
|
+
/** The key store whose owner slot holds this app's owner key: the one the identity is in. */
|
|
148
|
+
keyStore: KeyStore;
|
|
149
|
+
/** The founding row's label; default {@link DEFAULT_OWNER_KEY_LABEL}. */
|
|
150
|
+
label?: string;
|
|
151
|
+
/** Default {@link DEFAULT_OWNER_GENESIS_TIMEOUT_MS}. */
|
|
152
|
+
timeoutMs?: number;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** What {@link wireJoinedOwnerKey} takes. */
|
|
156
|
+
export interface JoinedOwnerKeyOptions {
|
|
157
|
+
/** The key store whose owner slot holds this app's owner key, if it holds one. */
|
|
158
|
+
keyStore: KeyStore;
|
|
159
|
+
/** Default {@link DEFAULT_OWNER_GENESIS_TIMEOUT_MS}. */
|
|
160
|
+
timeoutMs?: number;
|
|
161
|
+
/** An owner grant has just seated the slot's key: keep asking until the row arrives. */
|
|
162
|
+
granted?: boolean;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** How often {@link wireJoinedOwnerKey} asks again for an owner key an owner grant just seated. */
|
|
166
|
+
const GRANTED_OWNER_RECHECK_MS = 2_000;
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* The owner step of a party this app founded. On a party with no `OwnerKey` row yet, the owner
|
|
170
|
+
* slot's key (created there now unless the slot already holds one) becomes the founding owner
|
|
171
|
+
* under `label` and is wired for signing, which also lets the node author its own `CadrePeer` row.
|
|
172
|
+
* On a party that has owners, the slot's key is wired only when it is a live owner, and no key is
|
|
173
|
+
* created: a new one could not be seated without another owner's signature. A node must be an
|
|
174
|
+
* enrolled owner to publish strands, and `CadreNode.requestJoin` works only on one. A joined
|
|
175
|
+
* party's start runs {@link wireJoinedOwnerKey} instead.
|
|
176
|
+
*
|
|
177
|
+
* Fail-soft and bounded: a failure or a genesis that has not settled within `timeoutMs` is logged
|
|
178
|
+
* and returned, and the start stands — the app can still join discovered strands and sync. A
|
|
179
|
+
* genesis that times out keeps running; its later failure is logged.
|
|
180
|
+
*
|
|
181
|
+
* NOTE: the bound is here, in cadre-core, so every edge node gets it; a Node host runs genesis
|
|
182
|
+
* through the CLI's own flow instead. Since v1.10 a control write against an unresponsive member
|
|
183
|
+
* can hold the write lock for about 84 s, so an unbounded wait here would be a real stall. A bound
|
|
184
|
+
* on that write itself, in `ControlDatabase`, would make this one redundant; drop `timeoutMs` then.
|
|
185
|
+
*/
|
|
186
|
+
export function runOwnerGenesis(node: CadreNode, options: OwnerGenesisOptions): Promise<OwnerGenesisResult> {
|
|
187
|
+
const label = options.label ?? DEFAULT_OWNER_KEY_LABEL;
|
|
188
|
+
return withinBound('owner self-genesis', enrollFoundedOwner(node, options.keyStore, label), options.timeoutMs ?? DEFAULT_OWNER_GENESIS_TIMEOUT_MS);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* The owner step of a party this app joined: wire a key only when the party lists it as a live
|
|
193
|
+
* owner, never insert an `OwnerKey` row, so a joined app can neither seat itself as a founder nor
|
|
194
|
+
* anchor a key that is no owner's. Only the owner slot's key is tried: the key an owner grant
|
|
195
|
+
* seated, one imported here, or one an owner of the party added. The identity key is never
|
|
196
|
+
* tried, since a machine's network identity is never an owner key. Fail-soft and bounded as
|
|
197
|
+
* {@link runOwnerGenesis} is.
|
|
198
|
+
*
|
|
199
|
+
* `granted`: an owner grant has just made this app an owner, so a key not listed yet means the
|
|
200
|
+
* row has not reached this node, not that it is no owner. It is asked again every 2 s until it
|
|
201
|
+
* is wired or the bound passes; a start that timed out wires it on the next start.
|
|
202
|
+
*/
|
|
203
|
+
export function wireJoinedOwnerKey(node: CadreNode, options: JoinedOwnerKeyOptions): Promise<OwnerGenesisResult> {
|
|
204
|
+
let expired = false;
|
|
205
|
+
const wiring = async (): Promise<OwnerGenesisResult> => {
|
|
206
|
+
const held = await loadOwnerSigner(options.keyStore);
|
|
207
|
+
while (!(await wireJoinedOwner(node, held))) {
|
|
208
|
+
if (!options.granted) return 'member';
|
|
209
|
+
await new Promise((resolve) => setTimeout(resolve, GRANTED_OWNER_RECHECK_MS));
|
|
210
|
+
// Past the bound the start has gone on as `'timed-out'`, and may have stopped the node.
|
|
211
|
+
if (expired) return 'member';
|
|
212
|
+
}
|
|
213
|
+
return 'enrolled';
|
|
214
|
+
};
|
|
215
|
+
return withinBound('wiring the joined owner key', wiring(), options.timeoutMs ?? DEFAULT_OWNER_GENESIS_TIMEOUT_MS)
|
|
216
|
+
.finally(() => {
|
|
217
|
+
expired = true;
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** `settling`, or `'failed'` when it rejects (logged), or `'timed-out'` when it has not settled within `timeoutMs` (logged). */
|
|
222
|
+
async function withinBound(
|
|
223
|
+
label: string,
|
|
224
|
+
settling: Promise<OwnerGenesisResult>,
|
|
225
|
+
timeoutMs: number,
|
|
226
|
+
): Promise<OwnerGenesisResult> {
|
|
227
|
+
const settled = settling.catch((err: unknown): OwnerGenesisResult => {
|
|
228
|
+
console.warn(`[cadre-core/edge-node] ${label} failed:`, err);
|
|
229
|
+
return 'failed';
|
|
230
|
+
});
|
|
231
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
232
|
+
const deadline = new Promise<OwnerGenesisResult>((resolve) => {
|
|
233
|
+
timer = setTimeout(() => resolve('timed-out'), timeoutMs);
|
|
234
|
+
});
|
|
235
|
+
try {
|
|
236
|
+
const result = await Promise.race([settled, deadline]);
|
|
237
|
+
if (result === 'timed-out') {
|
|
238
|
+
console.warn(`[cadre-core/edge-node] ${label} has not settled after ${timeoutMs} ms; starting without it`);
|
|
239
|
+
}
|
|
240
|
+
return result;
|
|
241
|
+
} finally {
|
|
242
|
+
clearTimeout(timer);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
async function enrollFoundedOwner(node: CadreNode, keyStore: KeyStore, label: string): Promise<OwnerGenesisResult> {
|
|
247
|
+
const controlDb = node.getControlDatabase();
|
|
248
|
+
if (!controlDb) {
|
|
249
|
+
throw new Error('control database unavailable after start; cannot run owner genesis');
|
|
250
|
+
}
|
|
251
|
+
const held = await loadOwnerSigner(keyStore);
|
|
252
|
+
if (await controlDb.hasOwnerKey()) {
|
|
253
|
+
return held ? wiredIfOwner(node, held) : 'member';
|
|
254
|
+
}
|
|
255
|
+
// Loaded rather than created when the slot already holds a key: one imported before founding,
|
|
256
|
+
// or one an iOS reinstall kept in the Keychain while the app's data went.
|
|
257
|
+
const founding = held ?? (await createOwnerKey(keyStore));
|
|
258
|
+
if (!(await controlDb.ensureOwnerKey(founding.publicKey, label))) {
|
|
259
|
+
// A row arrived between the two reads: the party has an owner now, so judge the key as above.
|
|
260
|
+
return wiredIfOwner(node, founding);
|
|
261
|
+
}
|
|
262
|
+
await node.useOwnerSigner(founding);
|
|
263
|
+
return 'enrolled';
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
async function wiredIfOwner(node: CadreNode, signer: OwnerSigner): Promise<OwnerGenesisResult> {
|
|
267
|
+
return (await node.wireOwnKeyIfOwner(signer)) ? 'enrolled' : 'member';
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* Wire `held`, the owner slot's key, when the party lists it. A node takes one owner signer, so
|
|
272
|
+
* a signer already wired is asked about instead (is it still a live owner?) and never replaced.
|
|
273
|
+
*/
|
|
274
|
+
async function wireJoinedOwner(node: CadreNode, held: OwnerSigner | undefined): Promise<boolean> {
|
|
275
|
+
if (node.getOwnerSigner() !== null) return await node.wireOwnKeyIfOwner();
|
|
276
|
+
return held !== undefined && (await node.wireOwnKeyIfOwner(held));
|
|
277
|
+
}
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dissolving the party this app founded and left (docs/architecture.md → "An app joins an
|
|
3
|
+
* existing cadre" → "Dissolving the party it left"): the check that the running party holds
|
|
4
|
+
* everything the left one holds, and the removal of the left party's local storage. The same
|
|
5
|
+
* removal deletes a party whose owner removed this app ("Removed from A"), with no check.
|
|
6
|
+
*
|
|
7
|
+
* The removal is local: it writes nothing to either party's control database. A marker in the
|
|
8
|
+
* node-local database names the party from before the first deletion until after the saved
|
|
9
|
+
* start stops naming it, so a removal that a crash or a failed step interrupts is finished by
|
|
10
|
+
* the next start or the next dissolve without checking again: the decision to remove was made
|
|
11
|
+
* before the marker was written. Every step deletes what may already be gone.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { controlStorageScope } from '../storage-scope.js';
|
|
15
|
+
import { exportStrandBundle, unheldBundleContent } from '../strand-move.js';
|
|
16
|
+
import type { StrandBundle, StrandHoldings } from '../strand-move.js';
|
|
17
|
+
import { KeyStoreJoinedStrandStore } from '../joined-strand-store.js';
|
|
18
|
+
import type { CadreNode } from '../cadre-node.js';
|
|
19
|
+
import type { KeyStore } from '../key-store.js';
|
|
20
|
+
import { bootstrapPeersKvKey, enrolledMachinesKvKey, strandNetworkKvKey, type NodeLocalKv } from './node-local.js';
|
|
21
|
+
import type { RemovableSlot } from './platform.js';
|
|
22
|
+
import { parseSavedStart, serializeSavedStart, type EdgeNodeOptions } from './options.js';
|
|
23
|
+
import { toError, type PendingStrandMove, type StrandMoveRecords } from './strand-move.js';
|
|
24
|
+
|
|
25
|
+
/** How `EdgeNode.dissolveFormerParty` runs. */
|
|
26
|
+
export interface DissolveFormerPartyOptions {
|
|
27
|
+
/** Strand and pending-join ids of the former party to give up although the running party does not hold them; from a `FormerPartyNotEmptyError`. */
|
|
28
|
+
discard?: readonly string[];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* `EdgeNode.dissolveFormerParty` refused, nothing stopped: moving the former party's strands has
|
|
33
|
+
* not finished. A move into the running party finishes with `finishStrandMove()`; one into
|
|
34
|
+
* another party is adopted only there.
|
|
35
|
+
*/
|
|
36
|
+
export class StrandMovePendingError extends Error {
|
|
37
|
+
constructor(readonly pending: PendingStrandMove) {
|
|
38
|
+
super(`Cannot dissolve party ${pending.fromPartyId}: moving its strands into party ${pending.toPartyId} has not finished`);
|
|
39
|
+
this.name = 'StrandMovePendingError';
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** What keeps a former party from being dissolved: `FormerPartyNotEmptyError`'s lists. */
|
|
44
|
+
interface FormerPartyRemains {
|
|
45
|
+
otherMachines: string[];
|
|
46
|
+
strandIds: string[];
|
|
47
|
+
pendingJoinIds: string[];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** `EdgeNode.dissolveFormerParty` refused, nothing deleted: the former party holds what the running party lacks, or has other machines. */
|
|
51
|
+
export class FormerPartyNotEmptyError extends Error {
|
|
52
|
+
readonly partyId: string;
|
|
53
|
+
/** Peer ids of `CadrePeer` rows other than this node's. Not discardable: removing them is the user's choice, made in that party. */
|
|
54
|
+
readonly otherMachines: string[];
|
|
55
|
+
/** Not held by the running party and not in `discard`. */
|
|
56
|
+
readonly strandIds: string[];
|
|
57
|
+
/** Open pending joins the running party does not hold, and not in `discard`. */
|
|
58
|
+
readonly pendingJoinIds: string[];
|
|
59
|
+
|
|
60
|
+
constructor(partyId: string, remains: FormerPartyRemains) {
|
|
61
|
+
super(`Cannot dissolve party ${partyId}: ${describeRemains(remains)}`);
|
|
62
|
+
this.name = 'FormerPartyNotEmptyError';
|
|
63
|
+
this.partyId = partyId;
|
|
64
|
+
this.otherMachines = remains.otherMachines;
|
|
65
|
+
this.strandIds = remains.strandIds;
|
|
66
|
+
this.pendingJoinIds = remains.pendingJoinIds;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** What the former party holds, read from a node started for it alone. */
|
|
71
|
+
export interface FormerPartyContent {
|
|
72
|
+
bundle: StrandBundle;
|
|
73
|
+
/** Peer ids of its `CadrePeer` rows other than the reading node's. */
|
|
74
|
+
otherMachines: string[];
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export async function readFormerParty(node: CadreNode): Promise<FormerPartyContent> {
|
|
78
|
+
const self = node.peerId?.toString();
|
|
79
|
+
const machines = new Set((await node.listMembers()).map((member) => member.peerId));
|
|
80
|
+
return { bundle: await exportStrandBundle(node), otherMachines: [...machines].filter((peerId) => peerId !== self) };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** The refusal `content` earns against what the running party holds, or null when it may be dissolved. */
|
|
84
|
+
export function formerPartyRefusal(
|
|
85
|
+
content: FormerPartyContent,
|
|
86
|
+
holdings: StrandHoldings,
|
|
87
|
+
discard: readonly string[],
|
|
88
|
+
nowMs: number,
|
|
89
|
+
): FormerPartyNotEmptyError | null {
|
|
90
|
+
const discarded = new Set(discard);
|
|
91
|
+
const unheld = unheldBundleContent(content.bundle, holdings, nowMs);
|
|
92
|
+
const remains: FormerPartyRemains = {
|
|
93
|
+
otherMachines: content.otherMachines,
|
|
94
|
+
strandIds: unheld.strandIds.filter((strandId) => !discarded.has(strandId)),
|
|
95
|
+
pendingJoinIds: unheld.pendingJoinIds.filter((joinId) => !discarded.has(joinId)),
|
|
96
|
+
};
|
|
97
|
+
const empty = remains.otherMachines.length + remains.strandIds.length + remains.pendingJoinIds.length === 0;
|
|
98
|
+
return empty ? null : new FormerPartyNotEmptyError(content.bundle.partyId, remains);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** The former party's start for {@link readFormerParty}: nothing to dial or reserve on, the running Noise mode. */
|
|
102
|
+
export function formerPartyRead(partyId: string, running: EdgeNodeOptions): EdgeNodeOptions {
|
|
103
|
+
return {
|
|
104
|
+
partyId,
|
|
105
|
+
bootstrapAddrs: [],
|
|
106
|
+
relayAddrs: [],
|
|
107
|
+
...(running.noiseCryptoMode !== undefined && { noiseCryptoMode: running.noiseCryptoMode }),
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** `options` without `formerPartyId` when it names `partyId`. */
|
|
112
|
+
export function withoutFormerParty(options: EdgeNodeOptions, partyId: string | undefined): EdgeNodeOptions {
|
|
113
|
+
if (partyId === undefined || options.formerPartyId !== partyId) return options;
|
|
114
|
+
const { formerPartyId: _dissolved, ...rest } = options;
|
|
115
|
+
return rest;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** The edge node's storage a removal reaches. No handle on the party removed may be open. */
|
|
119
|
+
export interface PartyStorage {
|
|
120
|
+
/** The node-local key-value store (`EdgeNodeStorage.nodeLocal`). */
|
|
121
|
+
nodeLocal: NodeLocalKv;
|
|
122
|
+
/** `EdgeNodeNames.dissolveMarkerKey`. */
|
|
123
|
+
markerKey: string;
|
|
124
|
+
/** `EdgeNodeNames.savedStartKey`. */
|
|
125
|
+
savedStartKey: string;
|
|
126
|
+
/** `EdgeNodeStorage.destroyScope`. */
|
|
127
|
+
destroyScope: (scope: string) => Promise<void>;
|
|
128
|
+
/** The party's trusted-owner anchor slot, the platform's own or the node-local default, for its removal. */
|
|
129
|
+
trustedOwnerSlot: (partyId: string) => RemovableSlot;
|
|
130
|
+
keyStore: KeyStore;
|
|
131
|
+
moves: StrandMoveRecords;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const MARKER_VERSION = 1;
|
|
135
|
+
|
|
136
|
+
/** Mark `partyId` as being removed, then remove its local storage. Only once removing it is decided: a dissolve's check passed, or an owner removed this app. */
|
|
137
|
+
export async function removeParty(storage: PartyStorage, partyId: string): Promise<void> {
|
|
138
|
+
await storage.nodeLocal.set(storage.markerKey, JSON.stringify({ v: MARKER_VERSION, partyId }));
|
|
139
|
+
// On disk before the first deletion, so a crash during the removal leaves the marker that finishes it.
|
|
140
|
+
await storage.nodeLocal.flush();
|
|
141
|
+
await finishRemoving(storage, partyId);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* The party a begun dissolve names, or `undefined`. A marker that does not parse is deleted with
|
|
146
|
+
* a warning: nothing can finish it, and the party it named keeps what is left of its storage.
|
|
147
|
+
*/
|
|
148
|
+
async function markedParty(storage: PartyStorage): Promise<string | undefined> {
|
|
149
|
+
const text = await storage.nodeLocal.get(storage.markerKey);
|
|
150
|
+
if (text === undefined) return undefined;
|
|
151
|
+
const partyId = parseMarker(text);
|
|
152
|
+
if (partyId === undefined) {
|
|
153
|
+
console.warn(`[cadre-core/edge-node] deleting the dissolve marker ${storage.markerKey}: it is not one this kit wrote`);
|
|
154
|
+
await storage.nodeLocal.delete(storage.markerKey);
|
|
155
|
+
}
|
|
156
|
+
return partyId;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** Finish the removal a marker names, if any. */
|
|
160
|
+
export async function finishMarkedRemoval(storage: PartyStorage): Promise<void> {
|
|
161
|
+
const partyId = await markedParty(storage);
|
|
162
|
+
if (partyId !== undefined) await finishRemoving(storage, partyId);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** What {@link finishRemovalAtStart} found: the party the marker named, if any, and why finishing its removal failed, if it did. */
|
|
166
|
+
export interface RemovalAtStart {
|
|
167
|
+
/** Absent when there was no marker, or when it could not be read (`error` says so). */
|
|
168
|
+
partyId?: string;
|
|
169
|
+
error?: Error;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* {@link finishMarkedRemoval} before a start, which must then not keep the party it names as
|
|
174
|
+
* `formerPartyId`, finished or not. A failure is logged and reported, and the start goes on; the
|
|
175
|
+
* marker stays for the next.
|
|
176
|
+
*/
|
|
177
|
+
export async function finishRemovalAtStart(storage: PartyStorage): Promise<RemovalAtStart> {
|
|
178
|
+
let partyId: string | undefined;
|
|
179
|
+
try {
|
|
180
|
+
partyId = await markedParty(storage);
|
|
181
|
+
if (partyId !== undefined) await finishRemoving(storage, partyId);
|
|
182
|
+
return { partyId };
|
|
183
|
+
} catch (err) {
|
|
184
|
+
console.warn('[cadre-core/edge-node] finishing the removal of a dissolved party failed; the next start retries:', err);
|
|
185
|
+
return { partyId, error: toError(err) };
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Delete what is left of `partyId`'s local storage, then the marker: its control database, its
|
|
191
|
+
* node-local records, its trusted-owner anchor, its unpublished joined-strand records and its move
|
|
192
|
+
* record; then the saved start stops naming it as `formerPartyId`.
|
|
193
|
+
*
|
|
194
|
+
* NOTE: strand databases stay. A strand's storage scope is its strand id, not the party, so a
|
|
195
|
+
* moved strand's database is the running party's copy; a discarded strand's is left too, for the
|
|
196
|
+
* running party to reuse if it joins that strand again. If discarded strands' storage ever needs
|
|
197
|
+
* reclaiming, delete it here only for a strand no party on this device holds.
|
|
198
|
+
*/
|
|
199
|
+
async function finishRemoving(storage: PartyStorage, partyId: string): Promise<void> {
|
|
200
|
+
await storage.destroyScope(controlStorageScope(partyId));
|
|
201
|
+
for (const key of [bootstrapPeersKvKey(partyId), enrolledMachinesKvKey(partyId), strandNetworkKvKey(partyId)]) {
|
|
202
|
+
await storage.nodeLocal.delete(key);
|
|
203
|
+
}
|
|
204
|
+
await storage.trustedOwnerSlot(partyId).remove();
|
|
205
|
+
await forgetJoinedStrands(storage.keyStore, partyId);
|
|
206
|
+
await storage.moves.remove(partyId);
|
|
207
|
+
await dropFromSavedStart(storage, partyId);
|
|
208
|
+
await storage.nodeLocal.delete(storage.markerKey);
|
|
209
|
+
// One batch: the saved start stops naming the party in the same write that deletes the marker.
|
|
210
|
+
await storage.nodeLocal.flush();
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
async function forgetJoinedStrands(keyStore: KeyStore, partyId: string): Promise<void> {
|
|
214
|
+
const joins = new KeyStoreJoinedStrandStore(keyStore, partyId);
|
|
215
|
+
for (const record of await joins.list()) await joins.forget(record.Id);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** A write that throws, unlike the runtime's best-effort save, so a failure keeps the marker for the next start. */
|
|
219
|
+
async function dropFromSavedStart(storage: PartyStorage, partyId: string): Promise<void> {
|
|
220
|
+
const saved = parseSavedStart(await storage.nodeLocal.get(storage.savedStartKey));
|
|
221
|
+
if (saved?.options.formerPartyId !== partyId) return;
|
|
222
|
+
await storage.nodeLocal.set(storage.savedStartKey, serializeSavedStart({ ...saved, options: withoutFormerParty(saved.options, partyId) }));
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** The party id in a marker's `text`, or `undefined` (logged) when it is not a marker. */
|
|
226
|
+
function parseMarker(text: string): string | undefined {
|
|
227
|
+
let raw: unknown;
|
|
228
|
+
try {
|
|
229
|
+
raw = JSON.parse(text);
|
|
230
|
+
} catch (err) {
|
|
231
|
+
console.warn('[cadre-core/edge-node] the dissolve marker is not JSON:', err);
|
|
232
|
+
return undefined;
|
|
233
|
+
}
|
|
234
|
+
if (typeof raw !== 'object' || raw === null) return undefined;
|
|
235
|
+
const { v, partyId } = raw as Record<string, unknown>;
|
|
236
|
+
return v === MARKER_VERSION && typeof partyId === 'string' && partyId.length > 0 ? partyId : undefined;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/** "other machines are in it (…); the running party does not hold strands …": the non-empty lists. */
|
|
240
|
+
function describeRemains(remains: FormerPartyRemains): string {
|
|
241
|
+
const parts = [
|
|
242
|
+
listed('other machines are in it', remains.otherMachines),
|
|
243
|
+
listed('the running party does not hold its strands', remains.strandIds),
|
|
244
|
+
listed('the running party does not hold its open pending joins', remains.pendingJoinIds),
|
|
245
|
+
].filter((part): part is string => part !== null);
|
|
246
|
+
return parts.join('; ');
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function listed(what: string, ids: readonly string[]): string | null {
|
|
250
|
+
return ids.length === 0 ? null : `${what} (${ids.join(', ')})`;
|
|
251
|
+
}
|