@serfab/cadre-core 0.12.0 → 0.13.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-node.d.ts +328 -25
- package/dist/cadre-node.js +760 -59
- package/dist/cadre-node.js.map +1 -1
- package/dist/control-database.d.ts +19 -0
- package/dist/control-database.js +51 -9
- package/dist/control-database.js.map +1 -1
- package/dist/control-schema.d.ts +1 -1
- package/dist/control-schema.js +30 -2
- package/dist/control-schema.js.map +1 -1
- package/dist/enrolled-machine-store-file.d.ts +13 -0
- package/dist/enrolled-machine-store-file.js +41 -0
- package/dist/enrolled-machine-store-file.js.map +1 -0
- package/dist/enrolled-machine-store.d.ts +81 -0
- package/dist/enrolled-machine-store.js +225 -0
- package/dist/enrolled-machine-store.js.map +1 -0
- package/dist/file-durable-slot.js +5 -3
- package/dist/file-durable-slot.js.map +1 -1
- package/dist/index.d.ts +5 -3
- package/dist/index.js +23 -5
- package/dist/index.js.map +1 -1
- package/dist/membership-connection-gater.d.ts +15 -1
- package/dist/membership-connection-gater.js +9 -3
- package/dist/membership-connection-gater.js.map +1 -1
- package/dist/node-local-snapshot.d.ts +6 -0
- package/dist/node-local-snapshot.js +7 -1
- package/dist/node-local-snapshot.js.map +1 -1
- package/dist/{strand-backfill.d.ts → peer-join-backfill.d.ts} +49 -20
- package/dist/{strand-backfill.js → peer-join-backfill.js} +133 -52
- package/dist/peer-join-backfill.js.map +1 -0
- package/dist/relay-addrs.d.ts +73 -0
- package/dist/relay-addrs.js +195 -0
- package/dist/relay-addrs.js.map +1 -1
- package/dist/strand-database.d.ts +12 -0
- package/dist/strand-database.js +16 -0
- package/dist/strand-database.js.map +1 -1
- package/dist/strand-formation-manager.d.ts +12 -0
- package/dist/strand-formation-manager.js +10 -3
- package/dist/strand-formation-manager.js.map +1 -1
- package/dist/strand-formation-protocol.d.ts +76 -2
- package/dist/strand-formation-protocol.js +78 -2
- package/dist/strand-formation-protocol.js.map +1 -1
- package/dist/strand-instance-manager.d.ts +147 -14
- package/dist/strand-instance-manager.js +225 -45
- package/dist/strand-instance-manager.js.map +1 -1
- package/dist/strand-membership-writer.d.ts +12 -0
- package/dist/strand-membership-writer.js +23 -1
- package/dist/strand-membership-writer.js.map +1 -1
- package/dist/strand-network-config.d.ts +84 -0
- package/dist/strand-network-config.js +136 -0
- package/dist/strand-network-config.js.map +1 -0
- package/dist/strand-revocation-enforcer.d.ts +392 -0
- package/dist/strand-revocation-enforcer.js +533 -0
- package/dist/strand-revocation-enforcer.js.map +1 -0
- package/dist/strand-solicitation.d.ts +7 -0
- package/dist/strand-solicitation.js +8 -2
- package/dist/strand-solicitation.js.map +1 -1
- package/dist/strand-watcher.js +6 -1
- package/dist/strand-watcher.js.map +1 -1
- package/dist/types.d.ts +210 -16
- package/dist/types.js +7 -1
- package/dist/types.js.map +1 -1
- package/package.json +16 -8
- package/src/cadre-node.ts +6353 -5603
- package/src/control-database.ts +53 -9
- package/src/control-schema.ts +30 -2
- package/src/enrolled-machine-store-file.ts +43 -0
- package/src/enrolled-machine-store.ts +280 -0
- package/src/file-durable-slot.ts +5 -3
- package/src/index.ts +52 -10
- package/src/membership-connection-gater.ts +9 -3
- package/src/node-local-snapshot.ts +13 -1
- package/src/peer-addr-book.ts +251 -250
- package/src/{strand-backfill.ts → peer-join-backfill.ts} +478 -389
- package/src/relay-addrs.ts +218 -0
- package/src/strand-database.ts +17 -0
- package/src/strand-formation-manager.ts +22 -3
- package/src/strand-formation-protocol.ts +808 -692
- package/src/strand-instance-manager.ts +317 -54
- package/src/strand-membership-writer.ts +23 -1
- package/src/strand-network-config.ts +172 -0
- package/src/strand-revocation-enforcer.ts +653 -0
- package/src/strand-solicitation.ts +484 -471
- package/src/strand-watcher.ts +319 -314
- package/src/types.ts +1554 -1354
- package/dist/strand-backfill.js.map +0 -1
package/src/control-database.ts
CHANGED
|
@@ -17,6 +17,7 @@ import { retryControlWrite, SCHEMA_INIT_RETRY_POLICY } from './control-write-ret
|
|
|
17
17
|
import type { ControlWriteRetryOptions } from './control-write-retry.js';
|
|
18
18
|
import { retryControlRead } from './control-read-retry.js';
|
|
19
19
|
import type { ControlReadRetryOptions } from './control-read-retry.js';
|
|
20
|
+
import { chainMessages } from './control-retry.js';
|
|
20
21
|
|
|
21
22
|
export type { ControlTable, RevocableTable, ControlDomain, ControlAction } from './control-authorization.js';
|
|
22
23
|
|
|
@@ -293,6 +294,38 @@ const GUARDED_KEY_COLUMN: Readonly<Record<RevocableTable, GuardedKeyColumn>> = {
|
|
|
293
294
|
DeviceToken: 'PeerId',
|
|
294
295
|
};
|
|
295
296
|
|
|
297
|
+
/**
|
|
298
|
+
* The rejection a second insert of an already-seated strand id produces, as the optimystic
|
|
299
|
+
* vtab words it (`uniqueConstraintMessage`, qualified by table name only — no schema
|
|
300
|
+
* prefix, matching `test/control-constraint-helpers.ts`'s `expectUniqueViolation`).
|
|
301
|
+
*
|
|
302
|
+
* `Strand.StampId` is unique too, but a fresh stamp is minted per insert attempt, so only
|
|
303
|
+
* the primary key can collide on a repeat publish. Matching the column explicitly keeps
|
|
304
|
+
* any OTHER uniqueness failure out of the idempotency branch.
|
|
305
|
+
*/
|
|
306
|
+
const STRAND_ID_CONFLICT = /UNIQUE constraint failed: Strand\.Id\b/i;
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* Did this write fail because the strand id is already seated?
|
|
310
|
+
*
|
|
311
|
+
* {@link CadreNode.publishStrand} uses this to tell "my own earlier publish already
|
|
312
|
+
* landed" (re-read, and no-op when the row matches) apart from every other rejection —
|
|
313
|
+
* an unauthorized signer, a retired stamp — which must keep surfacing.
|
|
314
|
+
*
|
|
315
|
+
* Matched by TEXT, not by type: the typed engine error does not survive the trip out of
|
|
316
|
+
* optimystic (same constraint the retry classifiers in `control-write-retry.ts` document).
|
|
317
|
+
* Fails CLOSED — a rewording upstream turns the idempotent branch back into the raw
|
|
318
|
+
* uniqueness error the caller saw before, never into a silent overwrite. The
|
|
319
|
+
* `publish-strand.spec.ts` repeat-publish cases assert against the live engine error, so a
|
|
320
|
+
* reword reddens there.
|
|
321
|
+
*/
|
|
322
|
+
export function isStrandIdConflict(error: unknown): boolean {
|
|
323
|
+
if (!(error instanceof Error)) {
|
|
324
|
+
return false;
|
|
325
|
+
}
|
|
326
|
+
return chainMessages(error).some(message => STRAND_ID_CONFLICT.test(message));
|
|
327
|
+
}
|
|
328
|
+
|
|
296
329
|
/**
|
|
297
330
|
* Guarded tables a node may reap locally once their tombstone has committed — the
|
|
298
331
|
* tables whose `AuthorizedDelete` carries the REAP branch (see the constraint comment on
|
|
@@ -690,11 +723,12 @@ export class ControlDatabase {
|
|
|
690
723
|
async queryStrands(): Promise<StrandRow[]> {
|
|
691
724
|
this.ensureInitialized();
|
|
692
725
|
const results: StrandRow[] = [];
|
|
693
|
-
for (const row of await this.readRows('select Id, MemberPrivateKey, Type from CadreControl.Strand', undefined, 'strands')) {
|
|
726
|
+
for (const row of await this.readRows('select Id, MemberPrivateKey, Type, FounderOwnerKey from CadreControl.Strand', undefined, 'strands')) {
|
|
694
727
|
results.push({
|
|
695
728
|
Id: row.Id as string,
|
|
696
729
|
MemberPrivateKey: row.MemberPrivateKey as string | null,
|
|
697
730
|
Type: row.Type as 'o' | 'c',
|
|
731
|
+
FounderOwnerKey: row.FounderOwnerKey as string | null,
|
|
698
732
|
});
|
|
699
733
|
}
|
|
700
734
|
return results;
|
|
@@ -709,7 +743,7 @@ export class ControlDatabase {
|
|
|
709
743
|
async queryStrand(strandId: string): Promise<StrandRow | null> {
|
|
710
744
|
this.ensureInitialized();
|
|
711
745
|
for (const row of await this.readRows(
|
|
712
|
-
'select Id, MemberPrivateKey, Type from CadreControl.Strand where Id = ?',
|
|
746
|
+
'select Id, MemberPrivateKey, Type, FounderOwnerKey from CadreControl.Strand where Id = ?',
|
|
713
747
|
[strandId],
|
|
714
748
|
'strand'
|
|
715
749
|
)) {
|
|
@@ -717,6 +751,7 @@ export class ControlDatabase {
|
|
|
717
751
|
Id: row.Id as string,
|
|
718
752
|
MemberPrivateKey: row.MemberPrivateKey as string | null,
|
|
719
753
|
Type: row.Type as 'o' | 'c',
|
|
754
|
+
FounderOwnerKey: row.FounderOwnerKey as string | null,
|
|
720
755
|
};
|
|
721
756
|
}
|
|
722
757
|
return null;
|
|
@@ -1144,6 +1179,10 @@ export class ControlDatabase {
|
|
|
1144
1179
|
/**
|
|
1145
1180
|
* Insert a strand into the control database using an owner signature.
|
|
1146
1181
|
*
|
|
1182
|
+
* Fails with a `Strand.Id` uniqueness violation when the id is already seated — the
|
|
1183
|
+
* caller decides whether that is a duplicate of its own earlier write (idempotent) or a
|
|
1184
|
+
* genuine conflict; see {@link isStrandIdConflict} and `CadreNode.publishStrand`.
|
|
1185
|
+
*
|
|
1147
1186
|
* The owner signs the canonical row-bound authorization message (see
|
|
1148
1187
|
* {@link buildAuthorizationMessage}) — NOT a bare stamp — so the signature is bound to
|
|
1149
1188
|
* this strand's contents and cannot be transplanted onto an attacker-chosen row. The
|
|
@@ -1171,16 +1210,19 @@ export class ControlDatabase {
|
|
|
1171
1210
|
const stampId = generateStampId(peerId);
|
|
1172
1211
|
|
|
1173
1212
|
// Field order MUST match the schema's Strand `AuthorizedInsert` verify:
|
|
1174
|
-
// Id, Type, MemberPrivateKey ('' when null), StampId.
|
|
1213
|
+
// Id, Type, MemberPrivateKey ('' when null), StampId. FounderOwnerKey is
|
|
1214
|
+
// deliberately NOT in the digest — the schema binds it by equality to the
|
|
1215
|
+
// verified context.OwnerKey instead (see the constraint's comment).
|
|
1175
1216
|
const message = buildAuthorizationMessage('CadreControl.Strand', 'add', [strandId, type, memberPrivateKey ?? '', stampId]);
|
|
1176
1217
|
const signature = signMessage(message);
|
|
1177
1218
|
|
|
1178
1219
|
// StampId is a real, unique column (single-use anti-replay), no longer a context value.
|
|
1220
|
+
// FounderOwnerKey must equal the signing owner or the constraint rejects the row.
|
|
1179
1221
|
await this.execWrite(`
|
|
1180
|
-
insert into CadreControl.Strand (Id, Type, MemberPrivateKey, StampId)
|
|
1222
|
+
insert into CadreControl.Strand (Id, Type, MemberPrivateKey, StampId, FounderOwnerKey)
|
|
1181
1223
|
with context OwnerKey = ?, Signature = ?
|
|
1182
|
-
values (?, ?, ?, ?)
|
|
1183
|
-
`, [ownerKey, signature, strandId, type, memberPrivateKey ?? null, stampId], 'strand-insert');
|
|
1224
|
+
values (?, ?, ?, ?, ?)
|
|
1225
|
+
`, [ownerKey, signature, strandId, type, memberPrivateKey ?? null, stampId, ownerKey], 'strand-insert');
|
|
1184
1226
|
|
|
1185
1227
|
log('Strand inserted: %s', strandId);
|
|
1186
1228
|
}
|
|
@@ -2235,11 +2277,13 @@ export class ControlDatabase {
|
|
|
2235
2277
|
await this.inTransaction('redemption', async () => {
|
|
2236
2278
|
// 1. Strand row — authorised by the FormationUsage branch (no owner sig),
|
|
2237
2279
|
// still carrying a fresh unique StampId for the anti-replay column.
|
|
2238
|
-
// Hard-coded open + keyless
|
|
2280
|
+
// Hard-coded open + keyless + explicit null FounderOwnerKey: the
|
|
2281
|
+
// consent branch admits no other shape (no signature means no
|
|
2282
|
+
// trustworthy founder machine to record — see the schema's NOTE).
|
|
2239
2283
|
await this.db!.exec(`
|
|
2240
|
-
insert into CadreControl.Strand (Id, Type, MemberPrivateKey, StampId)
|
|
2284
|
+
insert into CadreControl.Strand (Id, Type, MemberPrivateKey, StampId, FounderOwnerKey)
|
|
2241
2285
|
with context OwnerKey = null, Signature = null
|
|
2242
|
-
values (?, 'o', null,
|
|
2286
|
+
values (?, 'o', null, ?, null)
|
|
2243
2287
|
`, [strandId, strandStampId]);
|
|
2244
2288
|
|
|
2245
2289
|
// 2. FormationUsage row — authorised by the matching FormationInvite, and
|
package/src/control-schema.ts
CHANGED
|
@@ -142,6 +142,17 @@ declare schema CadreControl {
|
|
|
142
142
|
Id text primary key, -- UUID
|
|
143
143
|
MemberPrivateKey text null unique, -- Our private key as a member of this strand
|
|
144
144
|
Type text, -- Types: 'o' = Open, 'c' = Closed -- Open can still control writes in the sApp, but only Closed controls reads
|
|
145
|
+
FounderOwnerKey text null, -- ed25519 (base64url) owner key of the MACHINE that published this row
|
|
146
|
+
-- (== insert context.OwnerKey; in the reference model each machine's
|
|
147
|
+
-- owner key is the key behind its PeerId, so this names the founding
|
|
148
|
+
-- machine — the one machine that runs the strand's one-time founder
|
|
149
|
+
-- bootstrap). cadre-core derives "am I the founder?" at launch by
|
|
150
|
+
-- comparing it to the node's own owner key (cadre-node.ts
|
|
151
|
+
-- launchStrand). Null on a consent-seated strand — the consent branch
|
|
152
|
+
-- of AuthorizedInsert carries no signature, so there is no
|
|
153
|
+
-- trustworthy signer to record (see the NOTE on that branch below).
|
|
154
|
+
-- Provenance, not content: cadre-node.ts strandRowMismatches
|
|
155
|
+
-- deliberately excludes it from the identical-content comparison.
|
|
145
156
|
StampId text not null unique, -- single-use authorization nonce (anti-replay).
|
|
146
157
|
-- \`unique\` holds over LIVE rows only; a removed row's stamp is retired
|
|
147
158
|
-- permanently into Revocation (NotRevoked below).
|
|
@@ -172,8 +183,16 @@ declare schema CadreControl {
|
|
|
172
183
|
-- Mirrors OwnerKey.NoUpdate / FormationInvite.Immutable.
|
|
173
184
|
constraint NoUpdate check on update (false),
|
|
174
185
|
constraint AuthorizedInsert check on insert (
|
|
175
|
-
-- Authorized by an owner signing over THIS row (Id, Type, MemberPrivateKey, StampId); single-use via unique StampId
|
|
176
|
-
|
|
186
|
+
-- Authorized by an owner signing over THIS row (Id, Type, MemberPrivateKey, StampId); single-use via unique StampId.
|
|
187
|
+
-- FounderOwnerKey persists WHO published the row, so the founding machine can later
|
|
188
|
+
-- recognise its own strand (founder derivation, cadre-node.ts launchStrand). The
|
|
189
|
+
-- equality pins the stored column to the VERIFIED signer — same shape as
|
|
190
|
+
-- CadrePeer.VouchOwner — so a writer cannot record a founder it is not. Deliberately
|
|
191
|
+
-- NOT added to the signed digest: the equality already binds it to the signature's
|
|
192
|
+
-- context.OwnerKey, and widening the digest would churn every existing
|
|
193
|
+
-- insertStrand field-order contract for nothing.
|
|
194
|
+
(exists (select 1 from OwnerKey A where A.Key = context.OwnerKey and verify(digest('CadreControl.Strand', 'add', new.Id, new.Type, coalesce(new.MemberPrivateKey, ''), new.StampId), context.Signature, A.Key, 'ed25519'))
|
|
195
|
+
and new.FounderOwnerKey = context.OwnerKey)
|
|
177
196
|
|
|
178
197
|
-- or authorized WITHOUT a signature by a redemption record naming THIS EXACT ROW: a
|
|
179
198
|
-- FormationUsage row carrying both this strand's Id and its one-off StampId
|
|
@@ -240,6 +259,15 @@ declare schema CadreControl {
|
|
|
240
259
|
or (
|
|
241
260
|
new.Type = 'o'
|
|
242
261
|
and new.MemberPrivateKey is null
|
|
262
|
+
-- NOTE: a consent-seated strand records NO founder machine — this branch
|
|
263
|
+
-- carries no signature, so there is no trustworthy signer to persist.
|
|
264
|
+
-- Consequence: the restart-before-founding orphan shape (a relaunch cannot
|
|
265
|
+
-- derive founder-ness from the row) persists for consent-formed strands
|
|
266
|
+
-- only; the responder that provisions one must keep passing an explicit
|
|
267
|
+
-- founder flag at launch. Revisit if consent-formed strands ever hit the
|
|
268
|
+
-- empty-Header symptom in practice: FormationUsage names the redeeming
|
|
269
|
+
-- peer (PeerKey), which is the candidate derivation source.
|
|
270
|
+
and new.FounderOwnerKey is null
|
|
243
271
|
and exists (
|
|
244
272
|
select 1 from FormationUsage FU
|
|
245
273
|
where FU.StrandId = new.Id
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Node-only file-backed {@link EnrolledMachineStore}: one JSON file per party under
|
|
3
|
+
* a configured directory (the node's state directory — `cadre-cli` passes
|
|
4
|
+
* `ResolvedConfig.nodeStateDir`), holding the node-local, non-replicated count of
|
|
5
|
+
* machines this party had enrolled the last time this node looked.
|
|
6
|
+
*
|
|
7
|
+
* Nothing but the file itself lives here: the envelope, the cold-start-on-anything
|
|
8
|
+
* load policy (deliberately NOT the throw-on-unreadable policy its two sibling
|
|
9
|
+
* records use — see the `enrolled-machine-store.ts` module comment) and the
|
|
10
|
+
* serialised writes are cross-platform in `PersistentEnrolledMachineStore`, and the
|
|
11
|
+
* file is a `FileDurableSlot`. This module exists only so the `node:fs` edge stays
|
|
12
|
+
* out of the package's cross-platform default entry (`./index.js`) — import it from
|
|
13
|
+
* the dedicated subpath instead:
|
|
14
|
+
*
|
|
15
|
+
* ```ts
|
|
16
|
+
* import { FileEnrolledMachineStore } from '@serfab/cadre-core/enrolled-machine-store-file';
|
|
17
|
+
* ```
|
|
18
|
+
*
|
|
19
|
+
* A React Native / browser entry graph never resolves this path, so the `node:fs`
|
|
20
|
+
* edge never reaches a bundler that cannot satisfy it (same isolation pattern as
|
|
21
|
+
* `bootstrap-peer-store-file.ts` / `trusted-owner-store-file.ts` / `key-store-file.ts`).
|
|
22
|
+
*/
|
|
23
|
+
import { FileDurableSlot } from './file-durable-slot.js';
|
|
24
|
+
import { PersistentEnrolledMachineStore } from './enrolled-machine-store.js';
|
|
25
|
+
import type { EnrolledMachineStore } from './enrolled-machine-store.js';
|
|
26
|
+
|
|
27
|
+
/** Base name of the store file: `<dir>/enrolled-machines.<encoded partyId>.json`. */
|
|
28
|
+
const SLOT_NAME = 'enrolled-machines';
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* File-backed {@link EnrolledMachineStore}. Open via {@link open}, which loads the
|
|
32
|
+
* existing file and returns the cross-platform store over it. An absent, corrupt,
|
|
33
|
+
* wrong-party, junk-valued **or unreadable** file is a cold start (`count()` is
|
|
34
|
+
* `undefined`, the node declares no repair yardstick and runs at the base control
|
|
35
|
+
* policy) — `open` never rejects, unlike the sibling stores' file backends. That
|
|
36
|
+
* divergence and its reasoning live on `enrolled-machine-store.ts`.
|
|
37
|
+
*/
|
|
38
|
+
export const FileEnrolledMachineStore = {
|
|
39
|
+
/** Load (or cold-start) the party's last recorded machine count from a file in `dir`. */
|
|
40
|
+
async open(dir: string, partyId: string): Promise<EnrolledMachineStore> {
|
|
41
|
+
return PersistentEnrolledMachineStore.open(new FileDurableSlot(dir, SLOT_NAME, partyId), partyId);
|
|
42
|
+
}
|
|
43
|
+
};
|
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Node-local, NON-replicated record of how many machines this party had enrolled
|
|
3
|
+
* the last time this node looked — the block-repair corroboration yardstick the
|
|
4
|
+
* CONTROL network declares when its libp2p node is built.
|
|
5
|
+
*
|
|
6
|
+
* ## Why the number has to be remembered rather than read
|
|
7
|
+
*
|
|
8
|
+
* `resolveRepairYardstick` (in `@serfab/quereus-plugin-sereus`'s `cluster-size.ts`
|
|
9
|
+
* — read it first; the arithmetic and both clamps are explained there and are not
|
|
10
|
+
* repeated here) derives the yardstick from the machines that SERVE the network being
|
|
11
|
+
* configured. For the CONTROL network that is the machines enrolled in the party, and
|
|
12
|
+
* exactly so: every enrolled machine runs the control node by construction. (It is NOT
|
|
13
|
+
* the right number for a strand, which runs on a subset — see
|
|
14
|
+
* `StartStrandConfig.servingMachines`. This store is control-network-only, and that
|
|
15
|
+
* scope is a safety property, not an accident.) Optimystic freezes a network's
|
|
16
|
+
* `clusterPolicy` when its libp2p node is built and offers no runtime setter, so the
|
|
17
|
+
* number has to be known at construction time.
|
|
18
|
+
*
|
|
19
|
+
* The control node cannot read it when it needs it. `CadreNode.buildControlNodeOptions`
|
|
20
|
+
* runs inside `start()` BEFORE `createControlNode()`, which is before the
|
|
21
|
+
* `ControlDatabase` holding the `CadrePeer` rows exists — so at the one moment the
|
|
22
|
+
* number is needed, nothing can answer. That deadlock is why the control network
|
|
23
|
+
* declared no yardstick at all and ran at `CONTROL_CLUSTER_POLICY`'s
|
|
24
|
+
* `assumedClusterSize` of 2.
|
|
25
|
+
*
|
|
26
|
+
* This store breaks it the only way a chicken-and-egg is broken: by remembering
|
|
27
|
+
* across the restart. `CadreNode.refreshAuthorizedControlPeers` records the count
|
|
28
|
+
* every time membership is recomputed; `CadreNode.start()` reads it back before the
|
|
29
|
+
* control node is built. A node therefore declares what it last knew, and a party
|
|
30
|
+
* that grows applies the larger number on each node's NEXT launch. Raising the
|
|
31
|
+
* yardstick is the safe direction and a node still holding the old value is running
|
|
32
|
+
* at exactly today's behaviour, so the next natural restart is soon enough — there
|
|
33
|
+
* is deliberately no forced rebuild.
|
|
34
|
+
*
|
|
35
|
+
* ## Why this is NOT a `NodeLocalSnapshot` like its two siblings
|
|
36
|
+
*
|
|
37
|
+
* The trusted-owner anchor (`trusted-owner-store.ts`) and the cold-start
|
|
38
|
+
* bootstrap-peer store (`bootstrap-peer-store.ts`) are the same `{ version,
|
|
39
|
+
* partyId, ... }` envelope over a {@link DurableSlot} this record is, and this
|
|
40
|
+
* module is deliberately symmetrical with them — same injected-backend shape, same
|
|
41
|
+
* `Memory*` / `Persistent*` / `File*` split, same party scoping. It does NOT share
|
|
42
|
+
* their `NodeLocalSnapshot` machinery, for two reasons:
|
|
43
|
+
*
|
|
44
|
+
* - **Shape.** `NodeLocalSnapshot` is a `key -> entry` map with no delete. This
|
|
45
|
+
* record is one integer that must be able to go DOWN after a machine is removed.
|
|
46
|
+
* - **Load policy — the important one.** `NodeLocalSnapshot.open` THROWS when the
|
|
47
|
+
* slot is present but unreadable. That is right for the anchor (a record that
|
|
48
|
+
* cannot be read in full is not a trust anchor) and right for the peer store (a
|
|
49
|
+
* failed read reported as absent would let the next snapshot write destroy a
|
|
50
|
+
* stranded node's only way home). It is WRONG here. Nothing in this record is
|
|
51
|
+
* trust-bearing, it holds nothing that is not recomputed the moment the control
|
|
52
|
+
* database is up, and refusing to start a node over an unreadable *repair hint*
|
|
53
|
+
* is strictly worse than declaring today's 2. So every unreadable, unparsable,
|
|
54
|
+
* foreign-party or junk slot LOGS and COLD-STARTS, yielding `undefined`, which
|
|
55
|
+
* declares nothing, which is byte-for-byte today's behaviour.
|
|
56
|
+
*
|
|
57
|
+
* Do not "unify" the three modules by routing this one through `NodeLocalSnapshot`:
|
|
58
|
+
* that would quietly import the throw, and turn an unreadable hint into a node that
|
|
59
|
+
* will not start.
|
|
60
|
+
*
|
|
61
|
+
* NOTE: writes are serialised in-process only, the same caveat `NodeLocalSnapshot`
|
|
62
|
+
* carries. Two processes (or two browser tabs) sharing ONE slot for ONE party each
|
|
63
|
+
* write their own view and the loser's count is lost. Two same-origin tabs of
|
|
64
|
+
* `reference-app-web` do exactly that — its slot is one key of a shared IndexedDB
|
|
65
|
+
* database, the same last-writer-wins caveat its `node-local-slots.ts` already
|
|
66
|
+
* records for the other two records. Harmless here, unlike there: this value is one
|
|
67
|
+
* integer both tabs re-derive from the same membership rows on their next refresh,
|
|
68
|
+
* so the views reconverge rather than losing an entry nobody rewrites.
|
|
69
|
+
*/
|
|
70
|
+
import debug from 'debug';
|
|
71
|
+
import type { DurableSlot } from './node-local-snapshot.js';
|
|
72
|
+
|
|
73
|
+
const log = debug('sereus:cadre:enrolled-machine-store');
|
|
74
|
+
|
|
75
|
+
/** Envelope version. Guards future migrations; an unknown version cold-starts. */
|
|
76
|
+
const ENVELOPE_VERSION = 1;
|
|
77
|
+
|
|
78
|
+
/** Envelope property holding the count. */
|
|
79
|
+
const PAYLOAD_KEY = 'enrolledMachines';
|
|
80
|
+
|
|
81
|
+
export interface EnrolledMachineStore {
|
|
82
|
+
/** Party this store is scoped to. */
|
|
83
|
+
readonly partyId: string;
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Machines last recorded for this party, or `undefined` when nothing usable was
|
|
87
|
+
* ever recorded — a brand-new node, or a slot that could not be read. Callers
|
|
88
|
+
* pass this straight to `controlClusterPolicy`, whose `undefined` branch returns
|
|
89
|
+
* the frozen base policy unchanged, so "this node does not know" and "this node
|
|
90
|
+
* has never run" are the same declaration: none.
|
|
91
|
+
*/
|
|
92
|
+
count(): number | undefined;
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Record the current count. Reflected by {@link count} SYNCHRONOUSLY; the
|
|
96
|
+
* returned promise resolves once the persist attempt has settled.
|
|
97
|
+
*
|
|
98
|
+
* **Never rejects.** Its sole caller is
|
|
99
|
+
* `CadreNode.refreshAuthorizedControlPeers`, whose contract is never-rejects and
|
|
100
|
+
* which leaves the promise un-awaited (`void`) — so a rejection here would
|
|
101
|
+
* surface as an unhandled rejection in the embedder's process, over a hint that
|
|
102
|
+
* re-lands on the next refresh anyway. A failed persist is LOGGED, and the
|
|
103
|
+
* in-memory count stays correct for this session.
|
|
104
|
+
*
|
|
105
|
+
* A count that is not a positive integer is a caller bug: it is logged and
|
|
106
|
+
* IGNORED rather than persisted, so a degenerate value can never reach the slot
|
|
107
|
+
* and be read back as a declaration nobody chose.
|
|
108
|
+
*/
|
|
109
|
+
record(count: number): Promise<void>;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Ephemeral in-memory store — the default when no store is injected via
|
|
114
|
+
* `CadreNodeConfig.enrolledMachines`. A node using this cold-starts on every
|
|
115
|
+
* launch: `count()` is `undefined` at `start()`, so its control node declares
|
|
116
|
+
* nothing and runs at today's behaviour, exactly as before this record existed.
|
|
117
|
+
*/
|
|
118
|
+
export class MemoryEnrolledMachineStore implements EnrolledMachineStore {
|
|
119
|
+
private current: number | undefined;
|
|
120
|
+
|
|
121
|
+
constructor(readonly partyId: string) {}
|
|
122
|
+
|
|
123
|
+
count(): number | undefined {
|
|
124
|
+
return this.current;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
async record(count: number): Promise<void> {
|
|
128
|
+
const accepted = asMachineCount(count);
|
|
129
|
+
if (accepted === undefined) {
|
|
130
|
+
log('refusing to record a non-positive-integer count %o for party %s', count, this.partyId);
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
this.current = accepted;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Durable {@link EnrolledMachineStore} over an app-supplied {@link DurableSlot} —
|
|
139
|
+
* the cross-platform half of every persistent backend (the Node
|
|
140
|
+
* `FileEnrolledMachineStore` is this class over a file slot).
|
|
141
|
+
*
|
|
142
|
+
* Construct via {@link open}. Load policy is the module comment's: every failure
|
|
143
|
+
* mode cold-starts, and `open` never rejects.
|
|
144
|
+
*/
|
|
145
|
+
export class PersistentEnrolledMachineStore implements EnrolledMachineStore {
|
|
146
|
+
/** Serialises persists so the last count recorded is the last one written. */
|
|
147
|
+
private writeChain: Promise<void> = Promise.resolve();
|
|
148
|
+
/** Last count accepted, whether or not it has reached the slot yet. */
|
|
149
|
+
private current: number | undefined;
|
|
150
|
+
/** Last count believed to BE in the slot; drives the unchanged-write skip. */
|
|
151
|
+
private persisted: number | undefined;
|
|
152
|
+
|
|
153
|
+
private constructor(
|
|
154
|
+
private readonly slot: DurableSlot,
|
|
155
|
+
readonly partyId: string,
|
|
156
|
+
loaded: number | undefined
|
|
157
|
+
) {
|
|
158
|
+
this.current = loaded;
|
|
159
|
+
this.persisted = loaded;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Load (or cold-start) the party's last recorded machine count from `slot`. */
|
|
163
|
+
static async open(slot: DurableSlot, partyId: string): Promise<PersistentEnrolledMachineStore> {
|
|
164
|
+
return new PersistentEnrolledMachineStore(slot, partyId, await loadCount(slot, partyId));
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
count(): number | undefined {
|
|
168
|
+
return this.current;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Record the count: visible via {@link count} synchronously, then persisted.
|
|
173
|
+
*
|
|
174
|
+
* Writes are SKIPPED when the slot already holds this number, because the sole
|
|
175
|
+
* caller runs on every committed membership write *and* every timed reconcile
|
|
176
|
+
* pass — a party whose membership is stable would otherwise rewrite the same
|
|
177
|
+
* integer forever. The skip is keyed off {@link persisted} rather than
|
|
178
|
+
* {@link current}, so a write that FAILED is retried by the next refresh instead
|
|
179
|
+
* of being suppressed as "unchanged".
|
|
180
|
+
*/
|
|
181
|
+
record(count: number): Promise<void> {
|
|
182
|
+
const accepted = asMachineCount(count);
|
|
183
|
+
if (accepted === undefined) {
|
|
184
|
+
log('refusing to record a non-positive-integer count %o for party %s', count, this.partyId);
|
|
185
|
+
return Promise.resolve();
|
|
186
|
+
}
|
|
187
|
+
this.current = accepted;
|
|
188
|
+
if (this.persisted === accepted) {
|
|
189
|
+
return Promise.resolve();
|
|
190
|
+
}
|
|
191
|
+
this.writeChain = this.writeChain.then(() => this.persistCurrent());
|
|
192
|
+
return this.writeChain;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Write whatever {@link current} holds when this link of the chain runs — so a
|
|
197
|
+
* burst of records collapses into one write of the latest value — and never
|
|
198
|
+
* throw (see {@link EnrolledMachineStore.record}).
|
|
199
|
+
*/
|
|
200
|
+
private async persistCurrent(): Promise<void> {
|
|
201
|
+
const pending = this.current;
|
|
202
|
+
if (pending === undefined || pending === this.persisted) {
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
const envelope = {
|
|
206
|
+
version: ENVELOPE_VERSION,
|
|
207
|
+
partyId: this.partyId,
|
|
208
|
+
[PAYLOAD_KEY]: pending
|
|
209
|
+
};
|
|
210
|
+
try {
|
|
211
|
+
await this.slot.save(JSON.stringify(envelope, null, '\t'));
|
|
212
|
+
this.persisted = pending;
|
|
213
|
+
log('recorded %d enrolled machine(s) for party %s', pending, this.partyId);
|
|
214
|
+
} catch (error) {
|
|
215
|
+
log(
|
|
216
|
+
'persisting %d enrolled machine(s) for party %s failed (in-memory count stands, retried on the next refresh): %o',
|
|
217
|
+
pending, this.partyId, error
|
|
218
|
+
);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* A usable machine count, or `undefined`. Anything that is not a positive integer
|
|
225
|
+
* is unknown rather than coerced — `'3'`, `0`, `-1`, `2.5`, `null` and a missing key
|
|
226
|
+
* all cold-start. Coercing here would be the beginning of a parser, and Optimystic
|
|
227
|
+
* itself treats a degenerate declaration as absent, so a rounded value would be a
|
|
228
|
+
* number nobody chose.
|
|
229
|
+
*/
|
|
230
|
+
function asMachineCount(value: unknown): number | undefined {
|
|
231
|
+
return typeof value === 'number' && Number.isInteger(value) && value >= 1 ? value : undefined;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Read the slot into the initial count. EVERY failure — unreadable slot, unparsable
|
|
236
|
+
* JSON, wrong envelope version, foreign `partyId`, junk payload — logs and returns
|
|
237
|
+
* `undefined` (see the module comment for why this record, unlike its two siblings,
|
|
238
|
+
* must not throw on an unreadable slot).
|
|
239
|
+
*/
|
|
240
|
+
async function loadCount(slot: DurableSlot, partyId: string): Promise<number | undefined> {
|
|
241
|
+
let text: string | undefined;
|
|
242
|
+
try {
|
|
243
|
+
text = await slot.load();
|
|
244
|
+
} catch (error) {
|
|
245
|
+
// The deliberate divergence from `NodeLocalSnapshot.open`, which throws here.
|
|
246
|
+
log('slot for party %s is present but unreadable (cold start — this node declares no yardstick): %o', partyId, error);
|
|
247
|
+
return undefined;
|
|
248
|
+
}
|
|
249
|
+
if (text === undefined) {
|
|
250
|
+
return undefined;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
let parsed: unknown;
|
|
254
|
+
try {
|
|
255
|
+
parsed = JSON.parse(text);
|
|
256
|
+
} catch (error) {
|
|
257
|
+
log('corrupt slot for party %s (cold start): %o', partyId, error);
|
|
258
|
+
return undefined;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
if (!isRecord(parsed) || parsed.version !== ENVELOPE_VERSION || parsed.partyId !== partyId) {
|
|
262
|
+
// A non-string partyId cannot equal the requested one, so this covers both a
|
|
263
|
+
// malformed envelope and a slot reused for another party.
|
|
264
|
+
log('slot for party %s has an unknown shape or a foreign partyId (cold start)', partyId);
|
|
265
|
+
return undefined;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const count = asMachineCount(parsed[PAYLOAD_KEY]);
|
|
269
|
+
if (count === undefined) {
|
|
270
|
+
log('slot for party %s holds an unusable %s value %o (cold start)', partyId, PAYLOAD_KEY, parsed[PAYLOAD_KEY]);
|
|
271
|
+
return undefined;
|
|
272
|
+
}
|
|
273
|
+
log('loaded %d enrolled machine(s) for party %s', count, partyId);
|
|
274
|
+
return count;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/** A plain JSON object. Arrays excluded: an array envelope is not an envelope. */
|
|
278
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
279
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
280
|
+
}
|
package/src/file-durable-slot.ts
CHANGED
|
@@ -5,9 +5,11 @@
|
|
|
5
5
|
* parties without cross-party leakage.
|
|
6
6
|
*
|
|
7
7
|
* This is the whole platform-specific part of the file-backed node-local
|
|
8
|
-
* stores (`trusted-owner-store-file.ts`, `bootstrap-peer-store-file.ts
|
|
9
|
-
* load policy, envelope and write chain
|
|
10
|
-
* `node-local-snapshot.ts
|
|
8
|
+
* stores (`trusted-owner-store-file.ts`, `bootstrap-peer-store-file.ts`,
|
|
9
|
+
* `enrolled-machine-store-file.ts`); the load policy, envelope and write chain
|
|
10
|
+
* are cross-platform — in `node-local-snapshot.ts` for the first two, and in
|
|
11
|
+
* `enrolled-machine-store.ts` for the third, which deliberately does not share
|
|
12
|
+
* that machinery. Like its three importers, this module imports
|
|
11
13
|
* `node:fs/promises`, so it is kept OUT of the package's cross-platform
|
|
12
14
|
* default entry and is not itself an exported subpath.
|
|
13
15
|
*/
|
package/src/index.ts
CHANGED
|
@@ -94,6 +94,20 @@ export {
|
|
|
94
94
|
type BootstrapPeerEntry
|
|
95
95
|
} from './bootstrap-peer-store.js';
|
|
96
96
|
|
|
97
|
+
// Node-local enrolled-machine count: the machines this party had enrolled the
|
|
98
|
+
// last time this node looked, remembered across restarts so the CONTROL node can
|
|
99
|
+
// declare a block-repair yardstick at bring-up — which is before the database
|
|
100
|
+
// holding the membership rows exists. Same cross-platform split as the two
|
|
101
|
+
// records above; Node-only file backend behind the subpath
|
|
102
|
+
// '@serfab/cadre-core/enrolled-machine-store-file'. Its load policy deliberately
|
|
103
|
+
// DIVERGES from theirs (an unreadable slot cold-starts rather than throwing) —
|
|
104
|
+
// see the module comment before unifying the three.
|
|
105
|
+
export {
|
|
106
|
+
MemoryEnrolledMachineStore,
|
|
107
|
+
PersistentEnrolledMachineStore,
|
|
108
|
+
type EnrolledMachineStore
|
|
109
|
+
} from './enrolled-machine-store.js';
|
|
110
|
+
|
|
97
111
|
// Strand database
|
|
98
112
|
export { StrandDatabase, type StrandDatabaseConfig } from './strand-database.js';
|
|
99
113
|
|
|
@@ -112,17 +126,36 @@ export {
|
|
|
112
126
|
getStrandStoragePath
|
|
113
127
|
} from './strand-instance-manager.js';
|
|
114
128
|
|
|
115
|
-
//
|
|
116
|
-
// connected
|
|
129
|
+
// Peer-join block catch-up (push this network's own blocks to each newly
|
|
130
|
+
// connected peer, so a late joiner physically holds pre-join blocks). Shared
|
|
131
|
+
// by the strand networks (ungated) and the control network (membership-gated).
|
|
117
132
|
export {
|
|
118
|
-
|
|
119
|
-
|
|
133
|
+
PeerJoinBackfill,
|
|
134
|
+
DEFAULT_PEER_JOIN_BACKFILL,
|
|
120
135
|
MAX_BLOCK_MESSAGE_BYTES,
|
|
121
|
-
type
|
|
122
|
-
type
|
|
123
|
-
type
|
|
124
|
-
type
|
|
125
|
-
} from './
|
|
136
|
+
type PeerJoinBackfillConfig,
|
|
137
|
+
type PeerJoinBackfillDeps,
|
|
138
|
+
type PeerJoinBackfillResult,
|
|
139
|
+
type PeerJoinBackfillPushClient
|
|
140
|
+
} from './peer-join-backfill.js';
|
|
141
|
+
|
|
142
|
+
// Closed-strand revoked-peer enforcement (deny the network nodes of removed
|
|
143
|
+
// members at the stream and connection layers, and hang up the sessions they
|
|
144
|
+
// already hold — see the module doc for the settled deny-list design, the
|
|
145
|
+
// teardown seam, and the fail directions).
|
|
146
|
+
export {
|
|
147
|
+
StrandRevocationEnforcer,
|
|
148
|
+
createRevocationConnectionGater,
|
|
149
|
+
readStrandRevocationRows,
|
|
150
|
+
DEFAULT_REVOCATION_POLL_INTERVAL_MS,
|
|
151
|
+
type StrandRevocationEnforcementConfig,
|
|
152
|
+
type StrandRevocationEnforcerDeps,
|
|
153
|
+
type StrandRevocationRows,
|
|
154
|
+
type StrandMemberPeerBinding,
|
|
155
|
+
type StrandRevocationJudge,
|
|
156
|
+
type StrandRevocationNetwork,
|
|
157
|
+
type RevocationRefreshScheduler
|
|
158
|
+
} from './strand-revocation-enforcer.js';
|
|
126
159
|
|
|
127
160
|
// Hibernation
|
|
128
161
|
export {
|
|
@@ -363,6 +396,7 @@ export {
|
|
|
363
396
|
FormationListener,
|
|
364
397
|
dialFormation,
|
|
365
398
|
isValidResponderCreatesResult,
|
|
399
|
+
sanitizeStrandAddrs,
|
|
366
400
|
FORMATION_PROTOCOL,
|
|
367
401
|
type FormationParty,
|
|
368
402
|
type FormationContactMessage,
|
|
@@ -372,6 +406,7 @@ export {
|
|
|
372
406
|
type FormationDbConnectionInfo,
|
|
373
407
|
type FormationListenerOptions,
|
|
374
408
|
type FormationDialOptions,
|
|
409
|
+
type FormationDialResult,
|
|
375
410
|
type ResponderProvisionOutcome
|
|
376
411
|
} from './strand-formation-protocol.js';
|
|
377
412
|
|
|
@@ -400,7 +435,14 @@ export {
|
|
|
400
435
|
// Thrown out of `CadreNode.start()` when a `network.relayAddrs` reservation does
|
|
401
436
|
// not land on its first attempt — the fail-fast posture of that config field now
|
|
402
437
|
// that the control node reserves AFTER control-database bring-up (relay-addrs.ts).
|
|
403
|
-
|
|
438
|
+
// `UnbindableListenAddressError` is the same posture for the OTHER half of the listen
|
|
439
|
+
// config: a `network.listenAddrs` entry whose transport the node will not have refuses
|
|
440
|
+
// start instead of being silently dropped by libp2p's transport manager.
|
|
441
|
+
export {
|
|
442
|
+
RelayReservationFailedError,
|
|
443
|
+
UnbindableListenAddressError,
|
|
444
|
+
RELAY_SEARCH_LISTEN_ADDR
|
|
445
|
+
} from './relay-addrs.js';
|
|
404
446
|
|
|
405
447
|
// Relay reservation via the bare `/p2p-circuit` search listener — the one route
|
|
406
448
|
// every control node takes; `network.relayAddrs` is its fail-fast posture
|
|
@@ -367,7 +367,11 @@ export class UnauthorizedReservationBudget {
|
|
|
367
367
|
* dialer sees its "open" connection close moments later.
|
|
368
368
|
*
|
|
369
369
|
* Control node only: strand cohort nodes legitimately connect cross-party
|
|
370
|
-
* peers, so
|
|
370
|
+
* peers, so cadre membership never gates them. An OPEN strand's node receives
|
|
371
|
+
* the raw configured gater unchanged; a CLOSED strand's node composes its own
|
|
372
|
+
* revoked-peer denial onto it instead — a deny-list on positive revocation
|
|
373
|
+
* evidence, not this module's membership admission — see
|
|
374
|
+
* `strand-revocation-enforcer.ts`.
|
|
371
375
|
*/
|
|
372
376
|
export function createMembershipConnectionGater(
|
|
373
377
|
policy: InboundAdmissionPolicy,
|
|
@@ -440,9 +444,11 @@ export function createMembershipConnectionGater(
|
|
|
440
444
|
* fail-open outcome) if it has not settled in time — see
|
|
441
445
|
* {@link ADMISSION_DECISION_TIMEOUT_MS} for why an unbounded await is not safe
|
|
442
446
|
* on the connection hook. The timer is always cleared so a decided call never
|
|
443
|
-
* holds the event loop open.
|
|
447
|
+
* holds the event loop open. Exported for the strand revoked-peer gate
|
|
448
|
+
* (`strand-revocation-enforcer.ts`), which bounds its connection hooks with the
|
|
449
|
+
* same fail-open contract.
|
|
444
450
|
*/
|
|
445
|
-
async function decideWithinDeadline<T>(
|
|
451
|
+
export async function decideWithinDeadline<T>(
|
|
446
452
|
decide: () => Promise<T> | T,
|
|
447
453
|
fallback: T,
|
|
448
454
|
timeoutMs: number,
|