@serfab/cadre-core 0.10.0 → 0.12.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 +255 -255
- package/dist/announce-addrs.d.ts +49 -0
- package/dist/announce-addrs.js +78 -0
- package/dist/announce-addrs.js.map +1 -0
- package/dist/cadre-node.d.ts +484 -58
- package/dist/cadre-node.js +953 -182
- package/dist/cadre-node.js.map +1 -1
- package/dist/control-cohort.d.ts +26 -0
- package/dist/control-cohort.js +26 -0
- package/dist/control-cohort.js.map +1 -1
- package/dist/control-database.d.ts +222 -125
- package/dist/control-database.js +356 -266
- package/dist/control-database.js.map +1 -1
- package/dist/control-formation-recorder.d.ts +8 -7
- package/dist/control-formation-recorder.js +8 -7
- package/dist/control-formation-recorder.js.map +1 -1
- package/dist/control-read-retry.d.ts +103 -0
- package/dist/control-read-retry.js +145 -0
- package/dist/control-read-retry.js.map +1 -0
- package/dist/control-retry.d.ts +101 -0
- package/dist/control-retry.js +108 -0
- package/dist/control-retry.js.map +1 -0
- package/dist/control-schema.d.ts +1 -1
- package/dist/control-schema.js +805 -774
- package/dist/control-schema.js.map +1 -1
- package/dist/control-write-retry.d.ts +65 -31
- package/dist/control-write-retry.js +33 -103
- package/dist/control-write-retry.js.map +1 -1
- package/dist/delegate-admission.d.ts +17 -15
- package/dist/delegate-admission.js +17 -15
- package/dist/delegate-admission.js.map +1 -1
- package/dist/index.d.ts +7 -5
- package/dist/index.js +18 -9
- package/dist/index.js.map +1 -1
- package/dist/membership-connection-gater.d.ts +211 -25
- package/dist/membership-connection-gater.js +309 -29
- package/dist/membership-connection-gater.js.map +1 -1
- package/dist/peer-addr-book.d.ts +92 -0
- package/dist/peer-addr-book.js +220 -0
- package/dist/peer-addr-book.js.map +1 -0
- package/dist/peer-record.d.ts +26 -0
- package/dist/peer-record.js +32 -0
- package/dist/peer-record.js.map +1 -1
- package/dist/relay-addrs.d.ts +76 -26
- package/dist/relay-addrs.js +115 -27
- package/dist/relay-addrs.js.map +1 -1
- package/dist/relay-reservation.d.ts +20 -15
- package/dist/relay-reservation.js +18 -14
- package/dist/relay-reservation.js.map +1 -1
- package/dist/seed-bootstrap.d.ts +7 -0
- package/dist/seed-bootstrap.js +15 -13
- package/dist/seed-bootstrap.js.map +1 -1
- package/dist/strand-addr-protocol.js +16 -0
- package/dist/strand-addr-protocol.js.map +1 -1
- package/dist/strand-backfill.js +13 -4
- package/dist/strand-backfill.js.map +1 -1
- package/dist/strand-cohort.d.ts +4 -37
- package/dist/strand-cohort.js +4 -15
- package/dist/strand-cohort.js.map +1 -1
- package/dist/strand-database.d.ts +11 -16
- package/dist/strand-database.js +19 -7
- package/dist/strand-database.js.map +1 -1
- package/dist/strand-formation-manager.d.ts +18 -17
- package/dist/strand-formation-manager.js +25 -27
- package/dist/strand-formation-manager.js.map +1 -1
- package/dist/strand-formation-protocol.d.ts +7 -7
- package/dist/strand-formation-protocol.js +8 -8
- package/dist/strand-formation-protocol.js.map +1 -1
- package/dist/strand-instance-manager.d.ts +53 -21
- package/dist/strand-instance-manager.js +128 -37
- package/dist/strand-instance-manager.js.map +1 -1
- package/dist/strand-membership-writer.d.ts +113 -19
- package/dist/strand-membership-writer.js +219 -51
- package/dist/strand-membership-writer.js.map +1 -1
- package/dist/strand-solicitation.d.ts +3 -3
- package/dist/strand-solicitation.js +4 -5
- package/dist/strand-solicitation.js.map +1 -1
- package/dist/strand-wake-protocol.d.ts +47 -2
- package/dist/strand-wake-protocol.js +76 -6
- package/dist/strand-wake-protocol.js.map +1 -1
- package/dist/types.d.ts +107 -54
- package/dist/types.js.map +1 -1
- package/package.json +8 -7
- package/src/announce-addrs.ts +93 -0
- package/src/arachnode-stub.ts +159 -159
- package/src/bootstrap-peer-store-file.ts +44 -44
- package/src/bootstrap-peer-store.ts +172 -172
- package/src/cadre-node.ts +1026 -194
- package/src/canonical-json.ts +30 -30
- package/src/control-authorization.ts +114 -114
- package/src/control-cohort.ts +130 -103
- package/src/control-database.ts +376 -298
- package/src/control-formation-recorder.ts +305 -304
- package/src/control-read-retry.ts +167 -0
- package/src/control-retry.ts +190 -0
- package/src/control-schema.ts +817 -786
- package/src/control-stream.ts +205 -205
- package/src/control-write-retry.ts +383 -473
- package/src/delegate-admission.ts +304 -302
- package/src/device-token.ts +94 -94
- package/src/diagnostics/connection-path.ts +277 -277
- package/src/diagnostics/webrtc-turn-tracker.ts +222 -222
- package/src/ed25519-key.ts +134 -134
- package/src/enrollment.ts +199 -199
- package/src/file-durable-slot.ts +61 -61
- package/src/formation-approval.ts +604 -604
- package/src/fs-atomic.ts +107 -107
- package/src/hibernation-manager.ts +437 -437
- package/src/identity-key.ts +109 -109
- package/src/index.ts +435 -406
- package/src/key-store-file.ts +132 -132
- package/src/key-store.ts +99 -99
- package/src/membership-connection-gater.ts +368 -39
- package/src/node-local-snapshot.ts +260 -260
- package/src/peer-addr-book.ts +250 -0
- package/src/peer-authorization.ts +259 -259
- package/src/peer-record.ts +197 -164
- package/src/push-credentials.ts +84 -84
- package/src/push-node.ts +64 -64
- package/src/push-notifier-apns.ts +247 -247
- package/src/push-notifier-fcm.ts +220 -220
- package/src/push-notifier-shared.ts +28 -28
- package/src/push-notifier.ts +52 -52
- package/src/relay-addrs.ts +134 -27
- package/src/relay-reservation.ts +22 -17
- package/src/seed-bootstrap.ts +15 -15
- package/src/seed-trust-policy.ts +140 -140
- package/src/strand-addr-protocol.ts +458 -441
- package/src/strand-backfill.ts +28 -5
- package/src/strand-cohort.ts +38 -77
- package/src/strand-database.ts +202 -204
- package/src/strand-formation-manager.ts +26 -28
- package/src/strand-formation-protocol.ts +692 -692
- package/src/strand-instance-manager.ts +636 -553
- package/src/strand-member-key.ts +39 -39
- package/src/strand-member-registry.ts +182 -182
- package/src/strand-membership-writer.ts +1627 -1430
- package/src/strand-solicitation.ts +471 -472
- package/src/strand-transport-key.ts +58 -58
- package/src/strand-wake-payload.ts +32 -32
- package/src/strand-wake-protocol.ts +400 -314
- package/src/strand-watcher.ts +314 -314
- package/src/trusted-owner-store-file.ts +41 -41
- package/src/trusted-owner-store.ts +171 -171
- package/src/types.ts +1354 -1303
package/src/peer-record.ts
CHANGED
|
@@ -1,164 +1,197 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Peer-address record helpers: the single source of truth for the bytes a node
|
|
3
|
-
* signs when publishing its own `CadrePeer` row and re-verifies when resolving
|
|
4
|
-
* another peer's row.
|
|
5
|
-
*
|
|
6
|
-
* The signed payload is a single SHA-256 digest over the shared domain-tagged
|
|
7
|
-
* field vector (see control-authorization.ts) — the crypto plugin's injective
|
|
8
|
-
* multi-field encoding, so no field split is ambiguous. It is intentionally NOT
|
|
9
|
-
* JSON-canonicalized (deterministic across node/browser/RN, no key ordering)
|
|
10
|
-
* and — crucially — is reconstructable inside the `CadrePeer.AuthorizedUpdate`
|
|
11
|
-
* SQL constraint from the row's own columns:
|
|
12
|
-
*
|
|
13
|
-
* digest('CadreControl.CadrePeer', 'publish',
|
|
14
|
-
* new.PeerId, new.Multiaddr, cast(new.UpdatedAt as text))
|
|
15
|
-
*
|
|
16
|
-
* Keep {@link peerRecordSignedPayload} and that constraint byte-for-byte in
|
|
17
|
-
* sync. The `'publish'` action tag marks this as a peer's SELF-signed record
|
|
18
|
-
* (signed with the peer's own key, not an owner key) and keeps it disjoint from
|
|
19
|
-
* every owner-signed digest.
|
|
20
|
-
*
|
|
21
|
-
* `publicKey` is deliberately excluded from the payload — it is the key the
|
|
22
|
-
* signature is verified *with* (so a signature already commits to exactly one
|
|
23
|
-
* key), and its binding to `peerId` is checked separately at resolve time.
|
|
24
|
-
*/
|
|
25
|
-
|
|
26
|
-
import type { Multiaddr } from '@multiformats/multiaddr';
|
|
27
|
-
import { digest, sign, verify } from '@optimystic/quereus-plugin-crypto';
|
|
28
|
-
import { controlAuthorizationFields } from './control-authorization.js';
|
|
29
|
-
import type { PeerAddressRecord, PeerResolveTrustPolicy } from './types.js';
|
|
30
|
-
|
|
31
|
-
/**
|
|
32
|
-
* Default freshness ceiling for resolved peer-address records (15 minutes).
|
|
33
|
-
* A record older than this is treated as stale and filtered out, so a resolver
|
|
34
|
-
* never hands back a dead relay reservation. The publish path re-stamps at
|
|
35
|
-
* roughly half this interval (see {@link DEFAULT_PEER_RECORD_HEARTBEAT_MS}).
|
|
36
|
-
*/
|
|
37
|
-
export const DEFAULT_PEER_RECORD_MAX_AGE_MS = 15 * 60 * 1000;
|
|
38
|
-
|
|
39
|
-
/**
|
|
40
|
-
* TTL heartbeat interval for re-publishing the self record — half the freshness
|
|
41
|
-
* ceiling, so a record is refreshed well before it can go stale.
|
|
42
|
-
*/
|
|
43
|
-
export const DEFAULT_PEER_RECORD_HEARTBEAT_MS = DEFAULT_PEER_RECORD_MAX_AGE_MS / 2;
|
|
44
|
-
|
|
45
|
-
/** The signaling/relay prefix a WebRTC (or any relayed) dial path consumes. */
|
|
46
|
-
const SIGNALING_PREFIX = '/p2p-circuit';
|
|
47
|
-
|
|
48
|
-
/**
|
|
49
|
-
* Build the base64url SHA-256 digest that the self-signature covers. Mirrors the
|
|
50
|
-
* `CadrePeer.AuthorizedUpdate` constraint exactly; both sides take the default
|
|
51
|
-
* base64url output of a single `digest(...)`, which round-trips cleanly (unlike
|
|
52
|
-
* concatenating several base64url digests).
|
|
53
|
-
*
|
|
54
|
-
* @param peerId - base58btc libp2p peer id (the row key)
|
|
55
|
-
* @param multiaddr - the comma-joined `Multiaddr` string EXACTLY as stored
|
|
56
|
-
* @param updatedAt - epoch-ms freshness stamp
|
|
57
|
-
*/
|
|
58
|
-
export function peerRecordSignedPayload(peerId: string, multiaddr: string, updatedAt: number): string {
|
|
59
|
-
const fields = controlAuthorizationFields('CadreControl.CadrePeer', 'publish', [peerId, multiaddr, String(updatedAt)]);
|
|
60
|
-
return digest(fields, 'sha256', 'base64url') as string;
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
/**
|
|
64
|
-
* Sign a peer-address record with the ed25519 private key behind its `peerId`.
|
|
65
|
-
* Returns a fully-populated {@link PeerAddressRecord} (the `addrs` order is
|
|
66
|
-
* preserved and is the order signed over).
|
|
67
|
-
*
|
|
68
|
-
* @param fields - the record fields to sign (signaling addr first by convention)
|
|
69
|
-
* @param privateKeyB64 - base64url ed25519 seed (see `ed25519KeyPairFromLibp2p`)
|
|
70
|
-
*/
|
|
71
|
-
export function signPeerRecord(
|
|
72
|
-
fields: { peerId: string; publicKey: string; addrs: string[]; updatedAt: number },
|
|
73
|
-
privateKeyB64: string
|
|
74
|
-
): PeerAddressRecord {
|
|
75
|
-
const payloadDigest = peerRecordSignedPayload(fields.peerId, fields.addrs.join(','), fields.updatedAt);
|
|
76
|
-
const sig = sign(
|
|
77
|
-
payloadDigest,
|
|
78
|
-
privateKeyB64,
|
|
79
|
-
'ed25519',
|
|
80
|
-
'base64url',
|
|
81
|
-
'base64url',
|
|
82
|
-
'base64url'
|
|
83
|
-
) as string;
|
|
84
|
-
return { peerId: fields.peerId, publicKey: fields.publicKey, addrs: [...fields.addrs], updatedAt: fields.updatedAt, sig };
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
/**
|
|
88
|
-
* Verify a record's self-signature against its own `publicKey`. Reconstructs the
|
|
89
|
-
* signed bytes from the record exactly as {@link signPeerRecord} produced them.
|
|
90
|
-
* Returns false on a missing key/sig or any verification failure.
|
|
91
|
-
*/
|
|
92
|
-
export function verifyPeerRecordSignature(record: PeerAddressRecord): boolean {
|
|
93
|
-
if (!record.publicKey || !record.sig) {
|
|
94
|
-
return false;
|
|
95
|
-
}
|
|
96
|
-
const payloadDigest = peerRecordSignedPayload(record.peerId, record.addrs.join(','), record.updatedAt);
|
|
97
|
-
return verify(
|
|
98
|
-
payloadDigest,
|
|
99
|
-
record.sig,
|
|
100
|
-
record.publicKey,
|
|
101
|
-
'ed25519',
|
|
102
|
-
'base64url',
|
|
103
|
-
'base64url',
|
|
104
|
-
'base64url'
|
|
105
|
-
);
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
/**
|
|
109
|
-
* Freshness predicate: true when `updatedAt` is a positive stamp within
|
|
110
|
-
* `maxAgeMs` of `now`. A non-positive `updatedAt` (never self-published) is
|
|
111
|
-
* never fresh.
|
|
112
|
-
*/
|
|
113
|
-
export function isPeerRecordFresh(updatedAt: number, maxAgeMs: number, now: number): boolean {
|
|
114
|
-
if (!(updatedAt > 0)) {
|
|
115
|
-
return false;
|
|
116
|
-
}
|
|
117
|
-
return now - updatedAt <= maxAgeMs;
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
/** True if `addr` is a `/p2p-circuit` signaling/relay multiaddr. */
|
|
121
|
-
export function isSignalingAddr(addr: string): boolean {
|
|
122
|
-
return addr.includes(SIGNALING_PREFIX);
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
/**
|
|
126
|
-
* The peer id a multiaddr terminates in, or `null` when it names none.
|
|
127
|
-
*
|
|
128
|
-
* The LAST `/p2p/` component is the dial target: a circuit address
|
|
129
|
-
* (`…/p2p/<relay>/p2p-circuit/p2p/<target>`) names the relay first and the peer
|
|
130
|
-
* being reached last. Replaces the deprecated `Multiaddr.getPeerId()`, which
|
|
131
|
-
* cannot express that distinction.
|
|
132
|
-
*/
|
|
133
|
-
export function trailingPeerId(addr: Multiaddr): string | null {
|
|
134
|
-
const p2p = addr.getComponents().filter((c) => c.name === 'p2p');
|
|
135
|
-
return p2p.length > 0 ? (p2p[p2p.length - 1].value ?? null) : null;
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
/**
|
|
139
|
-
*
|
|
140
|
-
*
|
|
141
|
-
*
|
|
142
|
-
*
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
*
|
|
152
|
-
*
|
|
153
|
-
*
|
|
154
|
-
*
|
|
155
|
-
*
|
|
156
|
-
*
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
1
|
+
/**
|
|
2
|
+
* Peer-address record helpers: the single source of truth for the bytes a node
|
|
3
|
+
* signs when publishing its own `CadrePeer` row and re-verifies when resolving
|
|
4
|
+
* another peer's row.
|
|
5
|
+
*
|
|
6
|
+
* The signed payload is a single SHA-256 digest over the shared domain-tagged
|
|
7
|
+
* field vector (see control-authorization.ts) — the crypto plugin's injective
|
|
8
|
+
* multi-field encoding, so no field split is ambiguous. It is intentionally NOT
|
|
9
|
+
* JSON-canonicalized (deterministic across node/browser/RN, no key ordering)
|
|
10
|
+
* and — crucially — is reconstructable inside the `CadrePeer.AuthorizedUpdate`
|
|
11
|
+
* SQL constraint from the row's own columns:
|
|
12
|
+
*
|
|
13
|
+
* digest('CadreControl.CadrePeer', 'publish',
|
|
14
|
+
* new.PeerId, new.Multiaddr, cast(new.UpdatedAt as text))
|
|
15
|
+
*
|
|
16
|
+
* Keep {@link peerRecordSignedPayload} and that constraint byte-for-byte in
|
|
17
|
+
* sync. The `'publish'` action tag marks this as a peer's SELF-signed record
|
|
18
|
+
* (signed with the peer's own key, not an owner key) and keeps it disjoint from
|
|
19
|
+
* every owner-signed digest.
|
|
20
|
+
*
|
|
21
|
+
* `publicKey` is deliberately excluded from the payload — it is the key the
|
|
22
|
+
* signature is verified *with* (so a signature already commits to exactly one
|
|
23
|
+
* key), and its binding to `peerId` is checked separately at resolve time.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import type { Multiaddr } from '@multiformats/multiaddr';
|
|
27
|
+
import { digest, sign, verify } from '@optimystic/quereus-plugin-crypto';
|
|
28
|
+
import { controlAuthorizationFields } from './control-authorization.js';
|
|
29
|
+
import type { PeerAddressRecord, PeerResolveTrustPolicy } from './types.js';
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Default freshness ceiling for resolved peer-address records (15 minutes).
|
|
33
|
+
* A record older than this is treated as stale and filtered out, so a resolver
|
|
34
|
+
* never hands back a dead relay reservation. The publish path re-stamps at
|
|
35
|
+
* roughly half this interval (see {@link DEFAULT_PEER_RECORD_HEARTBEAT_MS}).
|
|
36
|
+
*/
|
|
37
|
+
export const DEFAULT_PEER_RECORD_MAX_AGE_MS = 15 * 60 * 1000;
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* TTL heartbeat interval for re-publishing the self record — half the freshness
|
|
41
|
+
* ceiling, so a record is refreshed well before it can go stale.
|
|
42
|
+
*/
|
|
43
|
+
export const DEFAULT_PEER_RECORD_HEARTBEAT_MS = DEFAULT_PEER_RECORD_MAX_AGE_MS / 2;
|
|
44
|
+
|
|
45
|
+
/** The signaling/relay prefix a WebRTC (or any relayed) dial path consumes. */
|
|
46
|
+
const SIGNALING_PREFIX = '/p2p-circuit';
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Build the base64url SHA-256 digest that the self-signature covers. Mirrors the
|
|
50
|
+
* `CadrePeer.AuthorizedUpdate` constraint exactly; both sides take the default
|
|
51
|
+
* base64url output of a single `digest(...)`, which round-trips cleanly (unlike
|
|
52
|
+
* concatenating several base64url digests).
|
|
53
|
+
*
|
|
54
|
+
* @param peerId - base58btc libp2p peer id (the row key)
|
|
55
|
+
* @param multiaddr - the comma-joined `Multiaddr` string EXACTLY as stored
|
|
56
|
+
* @param updatedAt - epoch-ms freshness stamp
|
|
57
|
+
*/
|
|
58
|
+
export function peerRecordSignedPayload(peerId: string, multiaddr: string, updatedAt: number): string {
|
|
59
|
+
const fields = controlAuthorizationFields('CadreControl.CadrePeer', 'publish', [peerId, multiaddr, String(updatedAt)]);
|
|
60
|
+
return digest(fields, 'sha256', 'base64url') as string;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Sign a peer-address record with the ed25519 private key behind its `peerId`.
|
|
65
|
+
* Returns a fully-populated {@link PeerAddressRecord} (the `addrs` order is
|
|
66
|
+
* preserved and is the order signed over).
|
|
67
|
+
*
|
|
68
|
+
* @param fields - the record fields to sign (signaling addr first by convention)
|
|
69
|
+
* @param privateKeyB64 - base64url ed25519 seed (see `ed25519KeyPairFromLibp2p`)
|
|
70
|
+
*/
|
|
71
|
+
export function signPeerRecord(
|
|
72
|
+
fields: { peerId: string; publicKey: string; addrs: string[]; updatedAt: number },
|
|
73
|
+
privateKeyB64: string
|
|
74
|
+
): PeerAddressRecord {
|
|
75
|
+
const payloadDigest = peerRecordSignedPayload(fields.peerId, fields.addrs.join(','), fields.updatedAt);
|
|
76
|
+
const sig = sign(
|
|
77
|
+
payloadDigest,
|
|
78
|
+
privateKeyB64,
|
|
79
|
+
'ed25519',
|
|
80
|
+
'base64url',
|
|
81
|
+
'base64url',
|
|
82
|
+
'base64url'
|
|
83
|
+
) as string;
|
|
84
|
+
return { peerId: fields.peerId, publicKey: fields.publicKey, addrs: [...fields.addrs], updatedAt: fields.updatedAt, sig };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Verify a record's self-signature against its own `publicKey`. Reconstructs the
|
|
89
|
+
* signed bytes from the record exactly as {@link signPeerRecord} produced them.
|
|
90
|
+
* Returns false on a missing key/sig or any verification failure.
|
|
91
|
+
*/
|
|
92
|
+
export function verifyPeerRecordSignature(record: PeerAddressRecord): boolean {
|
|
93
|
+
if (!record.publicKey || !record.sig) {
|
|
94
|
+
return false;
|
|
95
|
+
}
|
|
96
|
+
const payloadDigest = peerRecordSignedPayload(record.peerId, record.addrs.join(','), record.updatedAt);
|
|
97
|
+
return verify(
|
|
98
|
+
payloadDigest,
|
|
99
|
+
record.sig,
|
|
100
|
+
record.publicKey,
|
|
101
|
+
'ed25519',
|
|
102
|
+
'base64url',
|
|
103
|
+
'base64url',
|
|
104
|
+
'base64url'
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Freshness predicate: true when `updatedAt` is a positive stamp within
|
|
110
|
+
* `maxAgeMs` of `now`. A non-positive `updatedAt` (never self-published) is
|
|
111
|
+
* never fresh.
|
|
112
|
+
*/
|
|
113
|
+
export function isPeerRecordFresh(updatedAt: number, maxAgeMs: number, now: number): boolean {
|
|
114
|
+
if (!(updatedAt > 0)) {
|
|
115
|
+
return false;
|
|
116
|
+
}
|
|
117
|
+
return now - updatedAt <= maxAgeMs;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** True if `addr` is a `/p2p-circuit` signaling/relay multiaddr. */
|
|
121
|
+
export function isSignalingAddr(addr: string): boolean {
|
|
122
|
+
return addr.includes(SIGNALING_PREFIX);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* The peer id a multiaddr terminates in, or `null` when it names none.
|
|
127
|
+
*
|
|
128
|
+
* The LAST `/p2p/` component is the dial target: a circuit address
|
|
129
|
+
* (`…/p2p/<relay>/p2p-circuit/p2p/<target>`) names the relay first and the peer
|
|
130
|
+
* being reached last. Replaces the deprecated `Multiaddr.getPeerId()`, which
|
|
131
|
+
* cannot express that distinction.
|
|
132
|
+
*/
|
|
133
|
+
export function trailingPeerId(addr: Multiaddr): string | null {
|
|
134
|
+
const p2p = addr.getComponents().filter((c) => c.name === 'p2p');
|
|
135
|
+
return p2p.length > 0 ? (p2p[p2p.length - 1].value ?? null) : null;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* `addr` guaranteed to terminate in `/p2p/<peerId>` — the shape every dial path
|
|
140
|
+
* needs — or `null` when it cannot, because the address already terminates in a
|
|
141
|
+
* DIFFERENT peer id and therefore does not reach `peerId` at all.
|
|
142
|
+
*
|
|
143
|
+
* Three input shapes, matching what a `CadrePeer` record can legitimately carry:
|
|
144
|
+
*
|
|
145
|
+
* - no `/p2p/` component (`/ip4/1.2.3.4/tcp/4001`) — the suffix is appended;
|
|
146
|
+
* - a relay hop with the destination missing (`…/p2p/<relay>/p2p-circuit`) — the
|
|
147
|
+
* last component is the circuit, not a peer id, so the suffix is appended and
|
|
148
|
+
* the address becomes the full `…/p2p/<relay>/p2p-circuit/p2p/<peerId>`;
|
|
149
|
+
* - already terminating in `/p2p/<X>` — returned untouched when `X` is `peerId`,
|
|
150
|
+
* dropped otherwise.
|
|
151
|
+
*
|
|
152
|
+
* This is deliberately the same rule libp2p applies internally in
|
|
153
|
+
* `calculateMultiaddrs` (append when the LAST component is not `p2p`) followed by
|
|
154
|
+
* its wrong-peer-id filter, so a normalized list matches what libp2p would have
|
|
155
|
+
* built anyway. Normalizing before the dial is what keeps a list from mixing
|
|
156
|
+
* suffixed and unsuffixed entries, which `getPeerAddress` rejects outright
|
|
157
|
+
* ("Multiaddrs must all have the same peer id or have no peer id") — taking the
|
|
158
|
+
* whole peer down with it, not just the odd address.
|
|
159
|
+
*
|
|
160
|
+
* Never use before signature verification: verification runs against the
|
|
161
|
+
* original on-record strings.
|
|
162
|
+
*/
|
|
163
|
+
export function withTrailingPeerId(addr: Multiaddr, peerId: string): Multiaddr | null {
|
|
164
|
+
const components = addr.getComponents();
|
|
165
|
+
if (components[components.length - 1]?.name !== 'p2p') {
|
|
166
|
+
return addr.encapsulate(`/p2p/${peerId}`);
|
|
167
|
+
}
|
|
168
|
+
return trailingPeerId(addr) === peerId ? addr : null;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Return `addrs` with signaling (`/p2p-circuit`) addrs first, otherwise stable.
|
|
173
|
+
* Used to present the WebRTC dial input ahead of direct addrs. Does NOT mutate
|
|
174
|
+
* the input and must not be used before signature verification (verification
|
|
175
|
+
* uses the original on-record order).
|
|
176
|
+
*/
|
|
177
|
+
export function orderSignalingFirst(addrs: string[]): string[] {
|
|
178
|
+
const signaling = addrs.filter(isSignalingAddr);
|
|
179
|
+
const rest = addrs.filter(a => !isSignalingAddr(a));
|
|
180
|
+
return [...signaling, ...rest];
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Default resolve trust gate: trust any peer that has a `CadrePeer` row.
|
|
185
|
+
*
|
|
186
|
+
* A row only exists because an owner signed its `AuthorizedInsert`, so row
|
|
187
|
+
* presence already means "owner-vouched member". This is the seam where a
|
|
188
|
+
* stricter policy (trust circle, pinned keys) plugs in — see
|
|
189
|
+
* {@link PeerResolveTrustPolicy}.
|
|
190
|
+
*/
|
|
191
|
+
export function currentMemberTrustPolicy(): PeerResolveTrustPolicy {
|
|
192
|
+
return {
|
|
193
|
+
evaluate() {
|
|
194
|
+
return true;
|
|
195
|
+
},
|
|
196
|
+
};
|
|
197
|
+
}
|
package/src/push-credentials.ts
CHANGED
|
@@ -1,84 +1,84 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* push-credentials.ts — pure validation + log-redaction for {@link PushCredentials}.
|
|
3
|
-
*
|
|
4
|
-
* The platform-push credentials a deployment provisions (`cadre-host` from its
|
|
5
|
-
* secret store, `cadre-provider` from per-tenant config) are written into a
|
|
6
|
-
* spawned node's config under `CadreNodeConfig.push`. This module is the
|
|
7
|
-
* dependency-free seam those provisioners share to (a) reject a *partial* set
|
|
8
|
-
* before it reaches a node — a present platform block must carry all its required
|
|
9
|
-
* fields, or it fails fast at provisioning rather than at first push — and (b)
|
|
10
|
-
* produce a log-safe view that never leaks a private key.
|
|
11
|
-
*
|
|
12
|
-
* It imports only the *types* (erased at emit), so it pulls no node:crypto /
|
|
13
|
-
* node:http2 edge — a provisioner can import these functions without dragging in
|
|
14
|
-
* the FCM/APNs senders.
|
|
15
|
-
*/
|
|
16
|
-
|
|
17
|
-
import type { PushCredentials, FcmCredentials, ApnsCredentials } from './types.js';
|
|
18
|
-
|
|
19
|
-
/** Marker substituted for every secret field by {@link redactPushCredentials}. */
|
|
20
|
-
export const REDACTED = '[redacted]';
|
|
21
|
-
|
|
22
|
-
/**
|
|
23
|
-
* Validate a resolved push-credential bundle, returning human-readable errors
|
|
24
|
-
* (empty ⇒ valid). Each platform block is optional, but a *present* block must
|
|
25
|
-
* carry every required field — a partial set (e.g. APNs `keyId` without
|
|
26
|
-
* `privateKey`) is a misconfiguration that must surface at provisioning time, not
|
|
27
|
-
* silently at the first wake. A bundle with neither platform is valid (push is
|
|
28
|
-
* opt-in); callers that require at least one platform check that separately.
|
|
29
|
-
*/
|
|
30
|
-
export function validatePushCredentials(push: PushCredentials): string[] {
|
|
31
|
-
const errors: string[] = [];
|
|
32
|
-
if (push.fcm) errors.push(...validateFcm(push.fcm));
|
|
33
|
-
if (push.apns) errors.push(...validateApns(push.apns));
|
|
34
|
-
return errors;
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
function validateFcm(fcm: FcmCredentials): string[] {
|
|
38
|
-
return missingFields('fcm', fcm as unknown as Record<string, unknown>, [
|
|
39
|
-
'projectId',
|
|
40
|
-
'clientEmail',
|
|
41
|
-
'privateKey',
|
|
42
|
-
]);
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
function validateApns(apns: ApnsCredentials): string[] {
|
|
46
|
-
return missingFields('apns', apns as unknown as Record<string, unknown>, [
|
|
47
|
-
'keyId',
|
|
48
|
-
'teamId',
|
|
49
|
-
'bundleId',
|
|
50
|
-
'privateKey',
|
|
51
|
-
]);
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
/** Names the required fields of `platform` that are missing or blank. */
|
|
55
|
-
function missingFields(platform: string, obj: Record<string, unknown>, required: string[]): string[] {
|
|
56
|
-
return required
|
|
57
|
-
.filter((f) => typeof obj[f] !== 'string' || (obj[f] as string).trim().length === 0)
|
|
58
|
-
.map((f) => `push.${platform}.${f} is required when a ${platform} credential block is present`);
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
/**
|
|
62
|
-
* Return a log-safe shallow copy of a push bundle with every private key replaced
|
|
63
|
-
* by {@link REDACTED}. Use this for ANY debug/log line that touches push config —
|
|
64
|
-
* `privateKey` fields are secrets and must never reach a log sink (the same rule
|
|
65
|
-
* the startup/seed tokens follow).
|
|
66
|
-
*/
|
|
67
|
-
export function redactPushCredentials(push: PushCredentials): Record<string, unknown> {
|
|
68
|
-
const out: Record<string, unknown> = {};
|
|
69
|
-
if (push.fcm) {
|
|
70
|
-
out.fcm = { projectId: push.fcm.projectId, clientEmail: push.fcm.clientEmail, privateKey: REDACTED };
|
|
71
|
-
}
|
|
72
|
-
if (push.apns) {
|
|
73
|
-
out.apns = {
|
|
74
|
-
keyId: push.apns.keyId,
|
|
75
|
-
teamId: push.apns.teamId,
|
|
76
|
-
bundleId: push.apns.bundleId,
|
|
77
|
-
...(push.apns.production !== undefined ? { production: push.apns.production } : {}),
|
|
78
|
-
privateKey: REDACTED,
|
|
79
|
-
};
|
|
80
|
-
}
|
|
81
|
-
if (push.cooldownMs !== undefined) out.cooldownMs = push.cooldownMs;
|
|
82
|
-
if (push.debounceMs !== undefined) out.debounceMs = push.debounceMs;
|
|
83
|
-
return out;
|
|
84
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* push-credentials.ts — pure validation + log-redaction for {@link PushCredentials}.
|
|
3
|
+
*
|
|
4
|
+
* The platform-push credentials a deployment provisions (`cadre-host` from its
|
|
5
|
+
* secret store, `cadre-provider` from per-tenant config) are written into a
|
|
6
|
+
* spawned node's config under `CadreNodeConfig.push`. This module is the
|
|
7
|
+
* dependency-free seam those provisioners share to (a) reject a *partial* set
|
|
8
|
+
* before it reaches a node — a present platform block must carry all its required
|
|
9
|
+
* fields, or it fails fast at provisioning rather than at first push — and (b)
|
|
10
|
+
* produce a log-safe view that never leaks a private key.
|
|
11
|
+
*
|
|
12
|
+
* It imports only the *types* (erased at emit), so it pulls no node:crypto /
|
|
13
|
+
* node:http2 edge — a provisioner can import these functions without dragging in
|
|
14
|
+
* the FCM/APNs senders.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import type { PushCredentials, FcmCredentials, ApnsCredentials } from './types.js';
|
|
18
|
+
|
|
19
|
+
/** Marker substituted for every secret field by {@link redactPushCredentials}. */
|
|
20
|
+
export const REDACTED = '[redacted]';
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Validate a resolved push-credential bundle, returning human-readable errors
|
|
24
|
+
* (empty ⇒ valid). Each platform block is optional, but a *present* block must
|
|
25
|
+
* carry every required field — a partial set (e.g. APNs `keyId` without
|
|
26
|
+
* `privateKey`) is a misconfiguration that must surface at provisioning time, not
|
|
27
|
+
* silently at the first wake. A bundle with neither platform is valid (push is
|
|
28
|
+
* opt-in); callers that require at least one platform check that separately.
|
|
29
|
+
*/
|
|
30
|
+
export function validatePushCredentials(push: PushCredentials): string[] {
|
|
31
|
+
const errors: string[] = [];
|
|
32
|
+
if (push.fcm) errors.push(...validateFcm(push.fcm));
|
|
33
|
+
if (push.apns) errors.push(...validateApns(push.apns));
|
|
34
|
+
return errors;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function validateFcm(fcm: FcmCredentials): string[] {
|
|
38
|
+
return missingFields('fcm', fcm as unknown as Record<string, unknown>, [
|
|
39
|
+
'projectId',
|
|
40
|
+
'clientEmail',
|
|
41
|
+
'privateKey',
|
|
42
|
+
]);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function validateApns(apns: ApnsCredentials): string[] {
|
|
46
|
+
return missingFields('apns', apns as unknown as Record<string, unknown>, [
|
|
47
|
+
'keyId',
|
|
48
|
+
'teamId',
|
|
49
|
+
'bundleId',
|
|
50
|
+
'privateKey',
|
|
51
|
+
]);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Names the required fields of `platform` that are missing or blank. */
|
|
55
|
+
function missingFields(platform: string, obj: Record<string, unknown>, required: string[]): string[] {
|
|
56
|
+
return required
|
|
57
|
+
.filter((f) => typeof obj[f] !== 'string' || (obj[f] as string).trim().length === 0)
|
|
58
|
+
.map((f) => `push.${platform}.${f} is required when a ${platform} credential block is present`);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Return a log-safe shallow copy of a push bundle with every private key replaced
|
|
63
|
+
* by {@link REDACTED}. Use this for ANY debug/log line that touches push config —
|
|
64
|
+
* `privateKey` fields are secrets and must never reach a log sink (the same rule
|
|
65
|
+
* the startup/seed tokens follow).
|
|
66
|
+
*/
|
|
67
|
+
export function redactPushCredentials(push: PushCredentials): Record<string, unknown> {
|
|
68
|
+
const out: Record<string, unknown> = {};
|
|
69
|
+
if (push.fcm) {
|
|
70
|
+
out.fcm = { projectId: push.fcm.projectId, clientEmail: push.fcm.clientEmail, privateKey: REDACTED };
|
|
71
|
+
}
|
|
72
|
+
if (push.apns) {
|
|
73
|
+
out.apns = {
|
|
74
|
+
keyId: push.apns.keyId,
|
|
75
|
+
teamId: push.apns.teamId,
|
|
76
|
+
bundleId: push.apns.bundleId,
|
|
77
|
+
...(push.apns.production !== undefined ? { production: push.apns.production } : {}),
|
|
78
|
+
privateKey: REDACTED,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
if (push.cooldownMs !== undefined) out.cooldownMs = push.cooldownMs;
|
|
82
|
+
if (push.debounceMs !== undefined) out.debounceMs = push.debounceMs;
|
|
83
|
+
return out;
|
|
84
|
+
}
|