@serfab/cadre-core 1.13.0 → 1.15.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 +293 -274
- package/dist/cadre-invite-protocol.d.ts +383 -0
- package/dist/cadre-invite-protocol.js +774 -0
- package/dist/cadre-invite-protocol.js.map +1 -0
- package/dist/cadre-join-request.d.ts +42 -0
- package/dist/cadre-join-request.js +144 -0
- package/dist/cadre-join-request.js.map +1 -0
- package/dist/cadre-node.d.ts +849 -210
- package/dist/cadre-node.js +1574 -374
- package/dist/cadre-node.js.map +1 -1
- package/dist/claim-proof.d.ts +45 -0
- package/dist/claim-proof.js +132 -0
- package/dist/claim-proof.js.map +1 -0
- package/dist/control-authorization.d.ts +80 -19
- package/dist/control-authorization.js +55 -13
- package/dist/control-authorization.js.map +1 -1
- package/dist/control-cohort.js +1 -1
- package/dist/control-cohort.js.map +1 -1
- package/dist/control-database.d.ts +605 -108
- package/dist/control-database.js +1299 -280
- package/dist/control-database.js.map +1 -1
- package/dist/control-protocol-guard.d.ts +142 -0
- package/dist/control-protocol-guard.js +293 -0
- package/dist/control-protocol-guard.js.map +1 -0
- package/dist/control-schema.d.ts +1 -1
- package/dist/control-schema.js +817 -1067
- package/dist/control-schema.js.map +1 -1
- package/dist/control-stream.d.ts +4 -0
- package/dist/control-stream.js +22 -7
- package/dist/control-stream.js.map +1 -1
- package/dist/delegate-admission.d.ts +1 -1
- package/dist/delegate-admission.js +1 -1
- package/dist/device-token.d.ts +11 -9
- package/dist/device-token.js +14 -12
- package/dist/device-token.js.map +1 -1
- package/dist/ed25519-key.d.ts +22 -9
- package/dist/ed25519-key.js +47 -10
- package/dist/ed25519-key.js.map +1 -1
- package/dist/fetch-impl.d.ts +12 -0
- package/dist/fetch-impl.js +23 -0
- package/dist/fetch-impl.js.map +1 -0
- package/dist/file-durable-slot.js +2 -1
- package/dist/file-durable-slot.js.map +1 -1
- package/dist/file-safe-name.d.ts +25 -0
- package/dist/file-safe-name.js +29 -0
- package/dist/file-safe-name.js.map +1 -0
- package/dist/formation-approval.js +5 -11
- package/dist/formation-approval.js.map +1 -1
- package/dist/fs-atomic.d.ts +0 -19
- package/dist/fs-atomic.js +3 -24
- package/dist/fs-atomic.js.map +1 -1
- package/dist/index.d.ts +22 -10
- package/dist/index.js +54 -11
- package/dist/index.js.map +1 -1
- package/dist/invitation-bootstrap.d.ts +50 -12
- package/dist/invitation-bootstrap.js +76 -13
- package/dist/invitation-bootstrap.js.map +1 -1
- package/dist/joined-strand-store.d.ts +7 -0
- package/dist/joined-strand-store.js +9 -0
- package/dist/joined-strand-store.js.map +1 -1
- package/dist/key-store-file.js +2 -1
- package/dist/key-store-file.js.map +1 -1
- package/dist/link-budget.d.ts +25 -33
- package/dist/link-budget.js +25 -35
- package/dist/link-budget.js.map +1 -1
- package/dist/machine-removal.d.ts +78 -0
- package/dist/machine-removal.js +166 -0
- package/dist/machine-removal.js.map +1 -0
- package/dist/membership-connection-gater.d.ts +191 -151
- package/dist/membership-connection-gater.js +236 -173
- package/dist/membership-connection-gater.js.map +1 -1
- package/dist/node-claim-payload.d.ts +74 -0
- package/dist/node-claim-payload.js +224 -0
- package/dist/node-claim-payload.js.map +1 -0
- package/dist/node-default-transports.d.ts +27 -0
- package/dist/node-default-transports.js +35 -0
- package/dist/node-default-transports.js.map +1 -0
- package/dist/node-transports.d.ts +30 -0
- package/dist/node-transports.js +40 -0
- package/dist/node-transports.js.map +1 -0
- package/dist/owner-anchor-sync.d.ts +22 -0
- package/dist/owner-anchor-sync.js +143 -0
- package/dist/owner-anchor-sync.js.map +1 -0
- package/dist/peer-authorization.d.ts +113 -18
- package/dist/peer-authorization.js +225 -29
- package/dist/peer-authorization.js.map +1 -1
- package/dist/peer-dial.d.ts +143 -27
- package/dist/peer-dial.js +276 -31
- package/dist/peer-dial.js.map +1 -1
- package/dist/peer-join-backfill.d.ts +26 -2
- package/dist/peer-join-backfill.js +60 -12
- package/dist/peer-join-backfill.js.map +1 -1
- package/dist/peer-record.d.ts +17 -52
- package/dist/peer-record.js +20 -58
- package/dist/peer-record.js.map +1 -1
- package/dist/pending-join-runner.d.ts +66 -58
- package/dist/pending-join-runner.js +136 -145
- package/dist/pending-join-runner.js.map +1 -1
- package/dist/primary-lan-address.d.ts +8 -0
- package/dist/primary-lan-address.js +46 -0
- package/dist/primary-lan-address.js.map +1 -0
- package/dist/provider/client.d.ts +131 -0
- package/dist/provider/client.js +226 -0
- package/dist/provider/client.js.map +1 -0
- package/dist/provider/descriptor.d.ts +41 -0
- package/dist/provider/descriptor.js +103 -0
- package/dist/provider/descriptor.js.map +1 -0
- package/dist/provider/index.d.ts +9 -0
- package/dist/provider/index.js +10 -0
- package/dist/provider/index.js.map +1 -0
- package/dist/provider/oauth.d.ts +125 -0
- package/dist/provider/oauth.js +199 -0
- package/dist/provider/oauth.js.map +1 -0
- package/dist/provider/rent-node.d.ts +101 -0
- package/dist/provider/rent-node.js +220 -0
- package/dist/provider/rent-node.js.map +1 -0
- package/dist/push-fanout.d.ts +8 -2
- package/dist/push-fanout.js +8 -2
- package/dist/push-fanout.js.map +1 -1
- package/dist/relay-addrs.d.ts +9 -4
- package/dist/relay-addrs.js +9 -4
- package/dist/relay-addrs.js.map +1 -1
- package/dist/relay-reservation.d.ts +51 -16
- package/dist/relay-reservation.js +107 -24
- package/dist/relay-reservation.js.map +1 -1
- package/dist/seed-bootstrap.d.ts +165 -123
- package/dist/seed-bootstrap.js +313 -275
- package/dist/seed-bootstrap.js.map +1 -1
- package/dist/seed-trust-policy.d.ts +98 -37
- package/dist/seed-trust-policy.js +188 -2
- package/dist/seed-trust-policy.js.map +1 -1
- package/dist/strand-addr-protocol.d.ts +24 -26
- package/dist/strand-addr-protocol.js +19 -26
- package/dist/strand-addr-protocol.js.map +1 -1
- package/dist/strand-commit-durability.d.ts +54 -0
- package/dist/strand-commit-durability.js +74 -0
- package/dist/strand-commit-durability.js.map +1 -0
- package/dist/strand-database.d.ts +23 -8
- package/dist/strand-database.js +41 -13
- package/dist/strand-database.js.map +1 -1
- package/dist/strand-first-sync-gate.d.ts +7 -1
- package/dist/strand-first-sync-gate.js +7 -1
- package/dist/strand-first-sync-gate.js.map +1 -1
- package/dist/strand-formation-protocol.js +5 -2
- package/dist/strand-formation-protocol.js.map +1 -1
- package/dist/strand-fret-addrs.d.ts +19 -1
- package/dist/strand-fret-addrs.js +27 -9
- package/dist/strand-fret-addrs.js.map +1 -1
- package/dist/strand-instance-manager.d.ts +81 -19
- package/dist/strand-instance-manager.js +216 -105
- package/dist/strand-instance-manager.js.map +1 -1
- package/dist/strand-move.d.ts +124 -0
- package/dist/strand-move.js +438 -0
- package/dist/strand-move.js.map +1 -0
- package/dist/strand-network-config.js +7 -6
- package/dist/strand-network-config.js.map +1 -1
- package/dist/strand-peer-redial.d.ts +113 -0
- package/dist/strand-peer-redial.js +236 -0
- package/dist/strand-peer-redial.js.map +1 -0
- package/dist/strand-revocation-enforcer.d.ts +11 -11
- package/dist/strand-revocation-enforcer.js +11 -11
- package/dist/strand-wake-protocol.d.ts +13 -16
- package/dist/strand-wake-protocol.js +23 -27
- package/dist/strand-wake-protocol.js.map +1 -1
- package/dist/trusted-owner-store.d.ts +35 -5
- package/dist/trusted-owner-store.js +50 -8
- package/dist/trusted-owner-store.js.map +1 -1
- package/dist/types.d.ts +512 -89
- package/dist/types.js +10 -0
- package/dist/types.js.map +1 -1
- package/package.json +19 -10
- package/src/cadre-invite-protocol.ts +1052 -0
- package/src/cadre-join-request.ts +167 -0
- package/src/cadre-node.ts +1703 -386
- package/src/claim-proof.ts +147 -0
- package/src/control-authorization.ts +203 -117
- package/src/control-cohort.ts +1 -1
- package/src/control-database.ts +4663 -3453
- package/src/control-protocol-guard.ts +348 -0
- package/src/control-schema.ts +828 -1078
- package/src/control-stream.ts +26 -7
- package/src/delegate-admission.ts +1 -1
- package/src/device-token.ts +14 -10
- package/src/ed25519-key.ts +47 -10
- package/src/fetch-impl.ts +23 -0
- package/src/file-durable-slot.ts +64 -63
- package/src/file-safe-name.ts +32 -0
- package/src/formation-approval.ts +604 -610
- package/src/fs-atomic.ts +92 -117
- package/src/index.ts +789 -648
- package/src/invitation-bootstrap.ts +91 -13
- package/src/joined-strand-store.ts +508 -498
- package/src/key-store-file.ts +142 -141
- package/src/link-budget.ts +25 -36
- package/src/machine-removal.ts +216 -0
- package/src/membership-connection-gater.ts +259 -191
- package/src/node-claim-payload.ts +259 -0
- package/src/node-default-transports.ts +38 -0
- package/src/node-transports.ts +49 -0
- package/src/owner-anchor-sync.ts +171 -0
- package/src/peer-authorization.ts +258 -29
- package/src/peer-dial.ts +604 -315
- package/src/peer-join-backfill.ts +62 -11
- package/src/peer-record.ts +159 -197
- package/src/pending-join-runner.ts +729 -735
- package/src/primary-lan-address.ts +47 -0
- package/src/provider/client.ts +326 -0
- package/src/provider/descriptor.ts +129 -0
- package/src/provider/index.ts +59 -0
- package/src/provider/oauth.ts +285 -0
- package/src/provider/rent-node.ts +275 -0
- package/src/push-fanout.ts +8 -2
- package/src/relay-addrs.ts +9 -4
- package/src/relay-reservation.ts +1211 -1121
- package/src/seed-bootstrap.ts +1502 -1448
- package/src/seed-trust-policy.ts +353 -140
- package/src/strand-addr-protocol.ts +26 -35
- package/src/strand-commit-durability.ts +116 -0
- package/src/strand-database.ts +46 -14
- package/src/strand-first-sync-gate.ts +7 -1
- package/src/strand-formation-protocol.ts +1039 -1036
- package/src/strand-fret-addrs.ts +179 -145
- package/src/strand-instance-manager.ts +2026 -1883
- package/src/strand-move.ts +585 -0
- package/src/strand-network-config.ts +7 -6
- package/src/strand-peer-redial.ts +307 -0
- package/src/strand-revocation-enforcer.ts +11 -11
- package/src/strand-wake-protocol.ts +23 -30
- package/src/trusted-owner-store.ts +242 -171
- package/src/types.ts +2687 -2242
package/src/key-store-file.ts
CHANGED
|
@@ -1,141 +1,142 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Node-only file-backed {@link KeyStore}: one file per slot under a configured
|
|
3
|
-
* directory, holding the raw key material bytes.
|
|
4
|
-
*
|
|
5
|
-
* This module imports `node:fs/promises`, `node:path`, and `node:crypto`, so it
|
|
6
|
-
* is deliberately kept OUT of the package's cross-platform default entry
|
|
7
|
-
* (`./index.js`). Import it from the dedicated subpath instead:
|
|
8
|
-
*
|
|
9
|
-
* ```ts
|
|
10
|
-
* import { FileKeyStore } from '@serfab/cadre-core/key-store-file';
|
|
11
|
-
* ```
|
|
12
|
-
*
|
|
13
|
-
* A React Native / browser entry graph never resolves this path, so the
|
|
14
|
-
* `node:fs` edge never reaches a bundler that cannot satisfy it.
|
|
15
|
-
*/
|
|
16
|
-
import debug from 'debug';
|
|
17
|
-
import { readFile, rm, readdir } from 'node:fs/promises';
|
|
18
|
-
import { join } from 'node:path';
|
|
19
|
-
import { randomBytes } from 'node:crypto';
|
|
20
|
-
import type { KeyId, KeyStore } from './key-store.js';
|
|
21
|
-
import { KeyStoreAccessError } from './key-store.js';
|
|
22
|
-
import {
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
* {@link decodeKeyId}
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
*
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
*
|
|
52
|
-
*
|
|
53
|
-
*
|
|
54
|
-
*
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
*
|
|
75
|
-
*
|
|
76
|
-
*
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
*
|
|
88
|
-
*
|
|
89
|
-
*
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
//
|
|
104
|
-
//
|
|
105
|
-
//
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
*
|
|
114
|
-
*
|
|
115
|
-
*
|
|
116
|
-
*
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
.
|
|
139
|
-
.
|
|
140
|
-
|
|
141
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Node-only file-backed {@link KeyStore}: one file per slot under a configured
|
|
3
|
+
* directory, holding the raw key material bytes.
|
|
4
|
+
*
|
|
5
|
+
* This module imports `node:fs/promises`, `node:path`, and `node:crypto`, so it
|
|
6
|
+
* is deliberately kept OUT of the package's cross-platform default entry
|
|
7
|
+
* (`./index.js`). Import it from the dedicated subpath instead:
|
|
8
|
+
*
|
|
9
|
+
* ```ts
|
|
10
|
+
* import { FileKeyStore } from '@serfab/cadre-core/key-store-file';
|
|
11
|
+
* ```
|
|
12
|
+
*
|
|
13
|
+
* A React Native / browser entry graph never resolves this path, so the
|
|
14
|
+
* `node:fs` edge never reaches a bundler that cannot satisfy it.
|
|
15
|
+
*/
|
|
16
|
+
import debug from 'debug';
|
|
17
|
+
import { readFile, rm, readdir } from 'node:fs/promises';
|
|
18
|
+
import { join } from 'node:path';
|
|
19
|
+
import { randomBytes } from 'node:crypto';
|
|
20
|
+
import type { KeyId, KeyStore } from './key-store.js';
|
|
21
|
+
import { KeyStoreAccessError } from './key-store.js';
|
|
22
|
+
import { isNotFound, writeFileAtomically } from './fs-atomic.js';
|
|
23
|
+
import { encodeFileSafeComponent } from './file-safe-name.js';
|
|
24
|
+
|
|
25
|
+
const log = debug('sereus:cadre:key-store-file');
|
|
26
|
+
|
|
27
|
+
/** Suffix appended to the encoded keyId to form a slot filename. */
|
|
28
|
+
const SLOT_SUFFIX = '.key';
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Suffix for the transient file written then atomically renamed over the slot.
|
|
32
|
+
* Distinct from {@link SLOT_SUFFIX}, so {@link FileKeyStore.list} (which matches
|
|
33
|
+
* only `.key`) never surfaces an in-flight or crash-orphaned temp file.
|
|
34
|
+
*/
|
|
35
|
+
const TEMP_SUFFIX = '.tmp';
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Percent-encode a keyId into a filesystem-safe filename component (see
|
|
39
|
+
* {@link encodeFileSafeComponent} — `.` is escaped so the `.key` suffix is the
|
|
40
|
+
* only literal dot, making suffix-stripping in {@link decodeKeyId} unambiguous;
|
|
41
|
+
* {@link decodeKeyId} reverses every escape via `decodeURIComponent`).
|
|
42
|
+
*/
|
|
43
|
+
function encodeKeyId(keyId: KeyId): string {
|
|
44
|
+
return encodeFileSafeComponent(keyId);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Reverse {@link encodeKeyId}: strip the slot suffix and percent-decode. Returns
|
|
49
|
+
* `undefined` for a `.key` filename this store did not write, so
|
|
50
|
+
* {@link FileKeyStore.list} can skip it rather than letting one undecodable
|
|
51
|
+
* entry throw and break enumeration of every real slot. That includes a name
|
|
52
|
+
* that decodes but is not the one {@link encodeKeyId} produces for the result
|
|
53
|
+
* (e.g. a slot written before uppercase letters were escaped):
|
|
54
|
+
* {@link FileKeyStore.get} looks only at the canonical name, so listing it
|
|
55
|
+
* would name a key id that reads back as absent.
|
|
56
|
+
*/
|
|
57
|
+
function decodeKeyId(fileName: string): KeyId | undefined {
|
|
58
|
+
const encoded = fileName.slice(0, -SLOT_SUFFIX.length);
|
|
59
|
+
let keyId: KeyId;
|
|
60
|
+
try {
|
|
61
|
+
keyId = decodeURIComponent(encoded);
|
|
62
|
+
} catch (error) {
|
|
63
|
+
log('FileKeyStore: skipping undecodable slot filename %s: %o', fileName, error);
|
|
64
|
+
return undefined;
|
|
65
|
+
}
|
|
66
|
+
if (encodeKeyId(keyId) !== encoded) {
|
|
67
|
+
log('FileKeyStore: skipping non-canonical slot filename %s', fileName);
|
|
68
|
+
return undefined;
|
|
69
|
+
}
|
|
70
|
+
return keyId;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* File-backed {@link KeyStore}. Each slot is `<dir>/<encoded keyId>.key`
|
|
75
|
+
* containing the raw material bytes. The directory is created lazily on first
|
|
76
|
+
* {@link set}. Suitable for headless Node cadre nodes and tests; for mobile use
|
|
77
|
+
* a platform secure-enclave backend instead.
|
|
78
|
+
*/
|
|
79
|
+
export class FileKeyStore implements KeyStore {
|
|
80
|
+
constructor(private readonly dir: string) {}
|
|
81
|
+
|
|
82
|
+
private slotPath(keyId: KeyId): string {
|
|
83
|
+
return join(this.dir, `${encodeKeyId(keyId)}${SLOT_SUFFIX}`);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Sibling path for the temp file written before the atomic rename. The random
|
|
88
|
+
* component makes concurrent {@link set}s to the same slot (and crash-orphaned
|
|
89
|
+
* leftovers) collide-free; it lives in the same directory as the slot so the
|
|
90
|
+
* final {@link rename} stays within one filesystem and is therefore atomic.
|
|
91
|
+
*/
|
|
92
|
+
private tempPath(keyId: KeyId): string {
|
|
93
|
+
const unique = randomBytes(6).toString('hex');
|
|
94
|
+
return join(this.dir, `${encodeKeyId(keyId)}.${unique}${TEMP_SUFFIX}`);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async get(keyId: KeyId): Promise<Uint8Array | undefined> {
|
|
98
|
+
try {
|
|
99
|
+
const buf = await readFile(this.slotPath(keyId));
|
|
100
|
+
// Copy into a plain Uint8Array decoupled from Node's Buffer pool.
|
|
101
|
+
return new Uint8Array(buf);
|
|
102
|
+
} catch (error) {
|
|
103
|
+
// A missing slot is "empty" (undefined), NOT an error — so a
|
|
104
|
+
// load-or-create caller generates a key rather than mistaking a
|
|
105
|
+
// permission failure for absence. Any other failure (e.g. EACCES) is
|
|
106
|
+
// surfaced as access-denied so callers do not clobber an existing key.
|
|
107
|
+
if (isNotFound(error)) return undefined;
|
|
108
|
+
throw new KeyStoreAccessError(keyId, `FileKeyStore: failed to read key slot ${keyId}`, { cause: error });
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Crash-atomic write: a concurrent reader sees either the complete previous
|
|
114
|
+
* bytes or the complete new bytes, never a torn slot. The new material is
|
|
115
|
+
* written to a sibling temp file, fsync'd, then atomically renamed over the
|
|
116
|
+
* slot (see {@link writeFileAtomically}). A failure at any point removes the
|
|
117
|
+
* temp file and leaves the previous slot untouched.
|
|
118
|
+
*/
|
|
119
|
+
async set(keyId: KeyId, keyMaterial: Uint8Array): Promise<void> {
|
|
120
|
+
await writeFileAtomically(this.dir, this.tempPath(keyId), this.slotPath(keyId), keyMaterial);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
async delete(keyId: KeyId): Promise<void> {
|
|
124
|
+
// force:true makes removing an absent slot a no-op (idempotent).
|
|
125
|
+
await rm(this.slotPath(keyId), { force: true });
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
async list(): Promise<KeyId[]> {
|
|
129
|
+
let entries: string[];
|
|
130
|
+
try {
|
|
131
|
+
entries = await readdir(this.dir);
|
|
132
|
+
} catch (error) {
|
|
133
|
+
// An absent directory means no slots yet — not a failure.
|
|
134
|
+
if (isNotFound(error)) return [];
|
|
135
|
+
throw new Error('FileKeyStore: failed to list key directory', { cause: error });
|
|
136
|
+
}
|
|
137
|
+
return entries
|
|
138
|
+
.filter((name) => name.endsWith(SLOT_SUFFIX))
|
|
139
|
+
.map(decodeKeyId)
|
|
140
|
+
.filter((id): id is KeyId => id !== undefined);
|
|
141
|
+
}
|
|
142
|
+
}
|
package/src/link-budget.ts
CHANGED
|
@@ -73,9 +73,7 @@
|
|
|
73
73
|
* Every row that opens a relayed connection counts opening the relay connection too
|
|
74
74
|
* ({@link RELAYED_DIAL_ROUND_TRIPS}), because a dialer cannot know beforehand whether it holds
|
|
75
75
|
* one, and a budget that is too short fails as silently as an absent peer. The
|
|
76
|
-
* reservation-request row is {@link RELAY_RESERVE_REQUEST_ROUND_TRIPS},
|
|
77
|
-
* a party-run relay waits for a stranger's reservation ({@link relayAdmissionReserveDeadlineMs}).
|
|
78
|
-
* The commit row is a measurement of a whole write, not a count of exchanges; its doc comment
|
|
76
|
+
* reservation-request row is {@link RELAY_RESERVE_REQUEST_ROUND_TRIPS}. The commit row is a measurement of a whole write, not a count of exchanges; its doc comment
|
|
79
77
|
* ({@link COMMIT_ROUND_TRIPS}) holds the figures and says when to re-measure it.
|
|
80
78
|
*
|
|
81
79
|
* **Measured** 2026-09-26, one Windows machine, loopback dedicated relay: a relayed dial over a
|
|
@@ -198,10 +196,10 @@
|
|
|
198
196
|
* the host derives its provisioning budget from its own, so a joiner declaring a faster
|
|
199
197
|
* link than the host gives up on a reply the host is still entitled to send. Declare the
|
|
200
198
|
* same link on every machine of a party.
|
|
201
|
-
*
|
|
202
|
-
*
|
|
203
|
-
*
|
|
204
|
-
*
|
|
199
|
+
* Provisional admission too: a member derives how long it keeps a connection from a peer it
|
|
200
|
+
* cannot place from ITS declaration ({@link relayedRequestBudgetMs}), so a member declaring a
|
|
201
|
+
* faster link than a redeeming device can close that device's connection while the device is
|
|
202
|
+
* still waiting on its redemption.
|
|
205
203
|
*/
|
|
206
204
|
|
|
207
205
|
import { resolveLinkDeadlines, type Libp2pConnectionTimeouts, type RpcDeadlineDefaults } from '@optimystic/db-p2p';
|
|
@@ -261,20 +259,19 @@ export const RELAY_DIAL_ROUND_TRIPS = 1;
|
|
|
261
259
|
export const RELAYED_DIAL_ROUND_TRIPS = RELAY_DIAL_ROUND_TRIPS + CIRCUIT_DIAL_ROUND_TRIPS;
|
|
262
260
|
|
|
263
261
|
/**
|
|
264
|
-
* Link round trips from the moment a
|
|
265
|
-
*
|
|
266
|
-
*
|
|
267
|
-
*
|
|
262
|
+
* Link round trips from the moment a relay accepts a client's connection to the moment that
|
|
263
|
+
* client's RESERVE request reaches it. libp2p's reservation store (`@libp2p/circuit-relay-v2`'s
|
|
264
|
+
* `addRelay`) opens the hop stream the instant its dial resolves, without waiting for identify,
|
|
265
|
+
* so:
|
|
268
266
|
*
|
|
269
267
|
* 1. The client's last Noise handshake message and its hop-stream open arrive at the relay
|
|
270
|
-
* together.
|
|
271
|
-
*
|
|
268
|
+
* together. A party-run relay's gate runs on the handshake message, then the relay answers
|
|
269
|
+
* the protocol negotiation.
|
|
272
270
|
* 2. The answer reaches the client and the client's RESERVE request reaches the relay: one round
|
|
273
271
|
* trip on the client-to-relay hop, which is the whole link round trip when all of the link's
|
|
274
272
|
* delay is on the client's own hop (a phone on a congested mobile link).
|
|
275
273
|
*
|
|
276
|
-
* The relay's decision on that request is local time, not link time
|
|
277
|
-
* {@link relayAdmissionReserveDeadlineMs} adds it separately.
|
|
274
|
+
* The relay's decision on that request is local time, not link time.
|
|
278
275
|
*/
|
|
279
276
|
export const RELAY_RESERVE_REQUEST_ROUND_TRIPS = 1;
|
|
280
277
|
|
|
@@ -285,10 +282,15 @@ export const RELAY_RESERVE_REQUEST_ROUND_TRIPS = 1;
|
|
|
285
282
|
*
|
|
286
283
|
* The protocol work measured is 2 — dial the relay ({@link RELAY_DIAL_ROUND_TRIPS}) plus request
|
|
287
284
|
* the reservation ({@link RELAY_RESERVE_REQUEST_ROUND_TRIPS}). It is budgeted at twice that,
|
|
288
|
-
* because
|
|
289
|
-
*
|
|
290
|
-
*
|
|
291
|
-
*
|
|
285
|
+
* because on a node that keeps libp2p's own relay discovery (a browser tab driving a bare
|
|
286
|
+
* `/p2p-circuit` through `reserveRelays`) the wait afterwards may instead be satisfied by
|
|
287
|
+
* discovery, which repeats both legs after identify has run; 4 is therefore the largest thing
|
|
288
|
+
* this one deadline can be asked to contain, and the poll interval
|
|
289
|
+
* (`DEFAULT_RELAY_RESERVE_POLL_MS`) rounds up on top of it.
|
|
290
|
+
*
|
|
291
|
+
* NOTE: on a node with configured relays discovery is off (`node-transports.ts`), so there the
|
|
292
|
+
* doubling is only headroom, and it is what an unreachable configured relay costs every launch
|
|
293
|
+
* (the whole deadline is polled out). If that launch cost ever matters, budget those nodes at 2.
|
|
292
294
|
*/
|
|
293
295
|
export const RELAY_RESERVATION_ROUND_TRIPS = 2 * (RELAY_DIAL_ROUND_TRIPS + RELAY_RESERVE_REQUEST_ROUND_TRIPS);
|
|
294
296
|
|
|
@@ -511,23 +513,6 @@ export function relayReservationBudgetMs(linkRoundTripMs?: number): number {
|
|
|
511
513
|
return RELAY_RESERVATION_ROUND_TRIPS * resolveLinkRoundTripMs(linkRoundTripMs) + 2 * ADMISSION_DECISION_TIMEOUT_MS;
|
|
512
514
|
}
|
|
513
515
|
|
|
514
|
-
/**
|
|
515
|
-
* How long a party-run relay keeps a connection it admitted only for relay use
|
|
516
|
-
* (`membership-connection-gater.ts`'s `'admit-for-relay'`) before closing it, unless a
|
|
517
|
-
* reservation is admitted on it first: {@link RELAY_RESERVE_REQUEST_ROUND_TRIPS} at the declared
|
|
518
|
-
* link round trip, plus one {@link ADMISSION_DECISION_TIMEOUT_MS} for the relay's own decision on
|
|
519
|
-
* the reservation (`denyInboundRelayReservation`), which disarms the timer only once it settles.
|
|
520
|
-
* 3 500 + 2 000 = 5 500 ms at the default declaration.
|
|
521
|
-
*
|
|
522
|
-
* The declaration is the RELAY's own, because the relay is the machine that decides.
|
|
523
|
-
*
|
|
524
|
-
* What it costs, against the fixed 5 000 ms it replaced: a stranger that connects and never
|
|
525
|
-
* reserves holds a mute connection 0.5 s longer at the default declaration.
|
|
526
|
-
*/
|
|
527
|
-
export function relayAdmissionReserveDeadlineMs(linkRoundTripMs?: number): number {
|
|
528
|
-
return RELAY_RESERVE_REQUEST_ROUND_TRIPS * resolveLinkRoundTripMs(linkRoundTripMs) + ADMISSION_DECISION_TIMEOUT_MS;
|
|
529
|
-
}
|
|
530
|
-
|
|
531
516
|
/**
|
|
532
517
|
* Deadline for one request and its answer over a circuit that is already open —
|
|
533
518
|
* {@link CIRCUIT_REQUEST_ROUND_TRIPS} at the declared link round trip, plus `transferAllowanceMs`
|
|
@@ -549,6 +534,10 @@ export function circuitRequestBudgetMs(
|
|
|
549
534
|
*
|
|
550
535
|
* No transfer allowance, unlike {@link circuitRequestBudgetMs}: the requests this bounds are a few
|
|
551
536
|
* hundred bytes to a few KB, so their bytes cost nothing a round trip does not already cover.
|
|
537
|
+
*
|
|
538
|
+
* A control node also keeps a connection it admitted provisionally this long, at its own
|
|
539
|
+
* declaration, because a cadre invitation redemption is bounded by this budget on the device's
|
|
540
|
+
* side (`membership-connection-gater.ts`, `PROVISIONAL_ADMISSION_DEADLINE_MS`).
|
|
552
541
|
*/
|
|
553
542
|
export function relayedRequestBudgetMs(linkRoundTripMs?: number): number {
|
|
554
543
|
return relayedDialBudgetMs(linkRoundTripMs) + CIRCUIT_REQUEST_ROUND_TRIPS * resolveLinkRoundTripMs(linkRoundTripMs);
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Removing a machine from a cadre: the listing an app shows (`CadreNode.listMachines`), the
|
|
3
|
+
* read-only plan behind its confirmation step (`CadreNode.planMachineRemoval`), and the
|
|
4
|
+
* check that a requested re-vouch stays inside that plan. Pure projections over rows the
|
|
5
|
+
* node has already read; the reads and writes themselves are `CadreNode.removeMachine`'s and
|
|
6
|
+
* `ControlDatabase.removeMachine`'s.
|
|
7
|
+
*
|
|
8
|
+
* A machine's owner key is its own identity key: an owner-granting invitation seats
|
|
9
|
+
* `OwnerKey.Key = CadrePeer.PublicKey`, and `addOwner` takes the key behind a peer id. So
|
|
10
|
+
* "this machine is an owner" is "the ed25519 key behind its peer id is a live `OwnerKey`",
|
|
11
|
+
* and an owner key with no machine row is still listed, as the machine it names.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { ed25519PublicKeyB64FromPeerId, peerIdFromEd25519PublicKeyB64 } from './ed25519-key.js';
|
|
15
|
+
import { verifyPeerRecordSignature } from './peer-record.js';
|
|
16
|
+
import type {
|
|
17
|
+
CadreInviteStatus,
|
|
18
|
+
CadreInviteUsageRow,
|
|
19
|
+
CadreMachine,
|
|
20
|
+
CadrePeerRow,
|
|
21
|
+
MachineRemovalPlan,
|
|
22
|
+
MachineRemovalRefusal,
|
|
23
|
+
OwnerKeyRow,
|
|
24
|
+
RemoveMachineOptions,
|
|
25
|
+
} from './types.js';
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* `CadreNode.removeMachine` refused by name, before any write. `code` says which rule
|
|
29
|
+
* (`MachineRemovalRefusal`), for an app to switch on; the message is for a log.
|
|
30
|
+
*/
|
|
31
|
+
export class MachineRemovalRefusedError extends Error {
|
|
32
|
+
readonly code: MachineRemovalRefusal;
|
|
33
|
+
readonly peerId: string;
|
|
34
|
+
|
|
35
|
+
constructor(peerId: string, code: MachineRemovalRefusal) {
|
|
36
|
+
super(`Cannot remove machine ${peerId}: ${MACHINE_REMOVAL_REFUSAL_REASON[code]}`);
|
|
37
|
+
this.name = 'MachineRemovalRefusedError';
|
|
38
|
+
this.code = code;
|
|
39
|
+
this.peerId = peerId;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const MACHINE_REMOVAL_REFUSAL_REASON: Record<MachineRemovalRefusal, string> = {
|
|
44
|
+
'not-an-owner': 'this node holds no enrolled owner key',
|
|
45
|
+
'self': 'an owner cannot remove its own machine; ask another owner',
|
|
46
|
+
'last-owner': 'it holds the only live owner key',
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
/** What {@link describeMachines} projects from: one node's live rows and its own predicate. */
|
|
50
|
+
export interface MachineListingInput {
|
|
51
|
+
partyId: string;
|
|
52
|
+
selfPeerId: string | null;
|
|
53
|
+
/** Live `CadrePeer` rows (`ControlDatabase.queryCadrePeers`). */
|
|
54
|
+
rows: readonly CadrePeerRow[];
|
|
55
|
+
/** Live `OwnerKey` rows (`ControlDatabase.queryOwnerKeyRows`). */
|
|
56
|
+
owners: readonly OwnerKeyRow[];
|
|
57
|
+
/** This node's authorized-membership predicate for one row (`CadreNode.listAuthorizedMembers`'s). */
|
|
58
|
+
trusted: (row: CadrePeerRow) => boolean;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* One entry per live `CadrePeer` row, then one per live `OwnerKey` whose key derives a peer
|
|
63
|
+
* id with no row. `profile` and `lastPublishedAt` are reported only when the row's
|
|
64
|
+
* self-signature verifies under the key behind its peer id, as `CadreNode.resolvePeerAddrs`
|
|
65
|
+
* judges the same record: no insert rule constrains `Profile`, and an owner re-vouch may
|
|
66
|
+
* bump `UpdatedAt` without re-signing.
|
|
67
|
+
*/
|
|
68
|
+
export function describeMachines(input: MachineListingInput): CadreMachine[] {
|
|
69
|
+
const ownerKeys = new Set(input.owners.map((owner) => owner.key));
|
|
70
|
+
const machines = input.rows.map((row) => describeMemberRow(input, row, ownerKeys));
|
|
71
|
+
const listed = new Set(machines.map((machine) => machine.peerId));
|
|
72
|
+
for (const owner of input.owners) {
|
|
73
|
+
const peerId = peerIdFromEd25519PublicKeyB64(owner.key);
|
|
74
|
+
if (peerId === null || listed.has(peerId)) {
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
listed.add(peerId);
|
|
78
|
+
machines.push({
|
|
79
|
+
peerId,
|
|
80
|
+
publicKey: owner.key,
|
|
81
|
+
isSelf: peerId === input.selfPeerId,
|
|
82
|
+
isOwner: true,
|
|
83
|
+
isMember: false,
|
|
84
|
+
trusted: false,
|
|
85
|
+
profile: null,
|
|
86
|
+
lastPublishedAt: null,
|
|
87
|
+
vouchedBy: owner.vouchOwner,
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
return machines;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function describeMemberRow(input: MachineListingInput, row: CadrePeerRow, ownerKeys: ReadonlySet<string>): CadreMachine {
|
|
94
|
+
const publicKey = ed25519PublicKeyB64FromPeerId(row.peerId);
|
|
95
|
+
const selfPublished = publicKey !== null && row.publicKey === publicKey && row.sig !== null
|
|
96
|
+
&& verifyPeerRecordSignature(input.partyId, {
|
|
97
|
+
peerId: row.peerId,
|
|
98
|
+
publicKey,
|
|
99
|
+
addrs: row.multiaddr ? row.multiaddr.split(',') : [],
|
|
100
|
+
updatedAt: row.updatedAt,
|
|
101
|
+
profile: row.profile,
|
|
102
|
+
sig: row.sig,
|
|
103
|
+
});
|
|
104
|
+
return {
|
|
105
|
+
peerId: row.peerId,
|
|
106
|
+
publicKey,
|
|
107
|
+
isSelf: row.peerId === input.selfPeerId,
|
|
108
|
+
isOwner: publicKey !== null && ownerKeys.has(publicKey),
|
|
109
|
+
isMember: true,
|
|
110
|
+
trusted: input.trusted(row),
|
|
111
|
+
profile: selfPublished ? row.profile : null,
|
|
112
|
+
lastPublishedAt: selfPublished ? row.updatedAt : null,
|
|
113
|
+
vouchedBy: row.vouchOwner,
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** What {@link planMachineRemoval} judges from. */
|
|
118
|
+
export interface MachineRemovalPlanInput {
|
|
119
|
+
peerId: string;
|
|
120
|
+
machines: readonly CadreMachine[];
|
|
121
|
+
rows: readonly CadrePeerRow[];
|
|
122
|
+
owners: readonly OwnerKeyRow[];
|
|
123
|
+
selfPeerId: string | null;
|
|
124
|
+
/** This node's enrolled owner key (`CadreNode.enrolledOwnerSigningKey`), or null when it cannot sign owner writes. */
|
|
125
|
+
selfOwnerKey: string | null;
|
|
126
|
+
invites: readonly CadreInviteStatus[];
|
|
127
|
+
usages: readonly CadreInviteUsageRow[];
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* The plan for removing `peerId` from these rows. The refusals come first and by name: a
|
|
132
|
+
* node that cannot sign, the node itself (an owner cannot sign its own `OwnerKey` removal;
|
|
133
|
+
* refusing the whole self-removal keeps one rule for owners and non-owners), and a machine
|
|
134
|
+
* holding the only live owner key. The last cannot coexist with this node being a different
|
|
135
|
+
* owner, but the local count can be wrong under concurrency, so the name stays.
|
|
136
|
+
*
|
|
137
|
+
* `dependents` are the rows the machine's owner key vouched, which every reader judges
|
|
138
|
+
* against its anchor through `VouchOwner`: once the key is removed they stop passing the
|
|
139
|
+
* membership predicate on every machine unless re-vouched first. This node's own rows are
|
|
140
|
+
* reported as `ownDependency` instead, since it cannot re-vouch them itself.
|
|
141
|
+
*
|
|
142
|
+
* `invitations` are the live ones the machine could redeem to come back: targeted at it,
|
|
143
|
+
* or already redeemed by it and still redeemable (a multi-use invitation, or an open
|
|
144
|
+
* owner invitation inside its lifetime). The schema's rule: keeping a device out is removing
|
|
145
|
+
* its row and withdrawing the invitation.
|
|
146
|
+
*/
|
|
147
|
+
export function planMachineRemoval(input: MachineRemovalPlanInput): MachineRemovalPlan {
|
|
148
|
+
const machine = input.machines.find((candidate) => candidate.peerId === input.peerId) ?? null;
|
|
149
|
+
const key = machine?.isOwner ? machine.publicKey : null;
|
|
150
|
+
const refusal = refusalFor(input, machine);
|
|
151
|
+
const removesOwnerKey = key !== null;
|
|
152
|
+
return {
|
|
153
|
+
machine,
|
|
154
|
+
refusal,
|
|
155
|
+
removesOwnerKey,
|
|
156
|
+
dependents: removesOwnerKey ? dependentsOf(input, key) : { owners: [], peers: [] },
|
|
157
|
+
invitations: invitationsOpenTo(input),
|
|
158
|
+
ownDependency: removesOwnerKey && isOwnDependency(input, key),
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function refusalFor(input: MachineRemovalPlanInput, machine: CadreMachine | null): MachineRemovalRefusal | null {
|
|
163
|
+
if (input.selfOwnerKey === null) {
|
|
164
|
+
return 'not-an-owner';
|
|
165
|
+
}
|
|
166
|
+
if (input.peerId === input.selfPeerId) {
|
|
167
|
+
return 'self';
|
|
168
|
+
}
|
|
169
|
+
if (machine?.isOwner && input.owners.length <= 1) {
|
|
170
|
+
return 'last-owner';
|
|
171
|
+
}
|
|
172
|
+
return null;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function dependentsOf(input: MachineRemovalPlanInput, key: string): MachineRemovalPlan['dependents'] {
|
|
176
|
+
return {
|
|
177
|
+
owners: input.owners
|
|
178
|
+
.filter((owner) => owner.vouchOwner === key && owner.key !== key && owner.key !== input.selfOwnerKey)
|
|
179
|
+
.map((owner) => owner.key),
|
|
180
|
+
peers: input.rows
|
|
181
|
+
.filter((row) => row.vouchOwner === key && row.peerId !== input.peerId && row.peerId !== input.selfPeerId)
|
|
182
|
+
.map((row) => row.peerId),
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function isOwnDependency(input: MachineRemovalPlanInput, key: string): boolean {
|
|
187
|
+
const ownRow = input.rows.find((row) => row.peerId === input.selfPeerId);
|
|
188
|
+
const ownOwner = input.owners.find((owner) => owner.key === input.selfOwnerKey);
|
|
189
|
+
return ownRow?.vouchOwner === key || ownOwner?.vouchOwner === key;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function invitationsOpenTo(input: MachineRemovalPlanInput): string[] {
|
|
193
|
+
const redeemed = new Set(input.usages.filter((usage) => usage.peerId === input.peerId).map((usage) => usage.inviteKey));
|
|
194
|
+
return input.invites
|
|
195
|
+
.filter((status) => status.live && (status.invite.peerId === input.peerId || redeemed.has(status.invite.key)))
|
|
196
|
+
.map((status) => status.invite.key);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* The re-vouch a caller asked for, checked against the plan: every key and peer must be in
|
|
201
|
+
* `plan.dependents`, so a caller cannot re-vouch a row the removed owner never vouched (nor
|
|
202
|
+
* the machine itself, which `dependents.peers` excludes). Throws naming the offenders, before
|
|
203
|
+
* `CadreNode.removeMachine` writes anything. Duplicates are dropped.
|
|
204
|
+
*/
|
|
205
|
+
export function revouchWithinPlan(plan: MachineRemovalPlan, revouch: RemoveMachineOptions['revouch']): MachineRemovalPlan['dependents'] {
|
|
206
|
+
const owners = [...new Set(revouch?.owners ?? [])];
|
|
207
|
+
const peers = [...new Set(revouch?.peers ?? [])];
|
|
208
|
+
const strayOwners = owners.filter((key) => !plan.dependents.owners.includes(key));
|
|
209
|
+
const strayPeers = peers.filter((peerId) => !plan.dependents.peers.includes(peerId));
|
|
210
|
+
if (strayOwners.length > 0 || strayPeers.length > 0) {
|
|
211
|
+
throw new Error(
|
|
212
|
+
`removeMachine: re-vouch names rows the removed machine did not vouch: ${[...strayOwners, ...strayPeers].join(', ')}`,
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
return { owners, peers };
|
|
216
|
+
}
|