@serfab/cadre-core 0.9.0 → 0.10.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 +4 -2
- package/dist/bootstrap-peer-store-file.d.ts +13 -0
- package/dist/bootstrap-peer-store-file.js +42 -0
- package/dist/bootstrap-peer-store-file.js.map +1 -0
- package/dist/bootstrap-peer-store.d.ts +73 -0
- package/dist/bootstrap-peer-store.js +128 -0
- package/dist/bootstrap-peer-store.js.map +1 -0
- package/dist/cadre-node.d.ts +918 -53
- package/dist/cadre-node.js +1843 -190
- package/dist/cadre-node.js.map +1 -1
- package/dist/control-authorization.d.ts +93 -0
- package/dist/control-authorization.js +57 -0
- package/dist/control-authorization.js.map +1 -0
- package/dist/control-database.d.ts +990 -50
- package/dist/control-database.js +1459 -108
- package/dist/control-database.js.map +1 -1
- package/dist/control-formation-recorder.d.ts +89 -10
- package/dist/control-formation-recorder.js +129 -14
- package/dist/control-formation-recorder.js.map +1 -1
- package/dist/control-schema.d.ts +1 -1
- package/dist/control-schema.js +774 -210
- package/dist/control-schema.js.map +1 -1
- package/dist/control-stream.d.ts +38 -4
- package/dist/control-stream.js +70 -4
- package/dist/control-stream.js.map +1 -1
- package/dist/control-write-retry.d.ts +151 -0
- package/dist/control-write-retry.js +411 -0
- package/dist/control-write-retry.js.map +1 -0
- package/dist/delegate-admission.d.ts +134 -0
- package/dist/delegate-admission.js +244 -0
- package/dist/delegate-admission.js.map +1 -0
- package/dist/device-token.d.ts +11 -11
- package/dist/device-token.js +14 -13
- package/dist/device-token.js.map +1 -1
- package/dist/ed25519-key.d.ts +26 -0
- package/dist/ed25519-key.js +56 -1
- package/dist/ed25519-key.js.map +1 -1
- package/dist/file-durable-slot.d.ts +20 -0
- package/dist/file-durable-slot.js +53 -0
- package/dist/file-durable-slot.js.map +1 -0
- package/dist/formation-approval.d.ts +157 -0
- package/dist/formation-approval.js +375 -0
- package/dist/formation-approval.js.map +1 -0
- package/dist/fs-atomic.d.ts +38 -0
- package/dist/fs-atomic.js +97 -0
- package/dist/fs-atomic.js.map +1 -0
- package/dist/identity-key.d.ts +49 -0
- package/dist/identity-key.js +81 -0
- package/dist/identity-key.js.map +1 -0
- package/dist/index.d.ts +16 -6
- package/dist/index.js +39 -5
- package/dist/index.js.map +1 -1
- package/dist/key-store-file.d.ts +2 -24
- package/dist/key-store-file.js +10 -77
- package/dist/key-store-file.js.map +1 -1
- package/dist/membership-connection-gater.d.ts +146 -0
- package/dist/membership-connection-gater.js +180 -0
- package/dist/membership-connection-gater.js.map +1 -0
- package/dist/node-local-snapshot.d.ts +109 -0
- package/dist/node-local-snapshot.js +182 -0
- package/dist/node-local-snapshot.js.map +1 -0
- package/dist/peer-authorization.d.ts +135 -18
- package/dist/peer-authorization.js +152 -21
- package/dist/peer-authorization.js.map +1 -1
- package/dist/peer-record.d.ts +21 -8
- package/dist/peer-record.js +26 -10
- package/dist/peer-record.js.map +1 -1
- package/dist/relay-addrs.d.ts +45 -0
- package/dist/relay-addrs.js +103 -0
- package/dist/relay-addrs.js.map +1 -0
- package/dist/relay-reservation.d.ts +266 -0
- package/dist/relay-reservation.js +613 -0
- package/dist/relay-reservation.js.map +1 -0
- package/dist/seed-bootstrap.d.ts +221 -56
- package/dist/seed-bootstrap.js +328 -188
- package/dist/seed-bootstrap.js.map +1 -1
- package/dist/seed-trust-policy.d.ts +47 -21
- package/dist/seed-trust-policy.js +24 -15
- package/dist/seed-trust-policy.js.map +1 -1
- package/dist/strand-addr-protocol.d.ts +42 -13
- package/dist/strand-addr-protocol.js +58 -52
- package/dist/strand-addr-protocol.js.map +1 -1
- package/dist/strand-backfill.d.ts +109 -0
- package/dist/strand-backfill.js +279 -0
- package/dist/strand-backfill.js.map +1 -0
- package/dist/strand-formation-manager.d.ts +38 -9
- package/dist/strand-formation-manager.js +120 -17
- package/dist/strand-formation-manager.js.map +1 -1
- package/dist/strand-formation-protocol.d.ts +103 -4
- package/dist/strand-formation-protocol.js +232 -4
- package/dist/strand-formation-protocol.js.map +1 -1
- package/dist/strand-instance-manager.d.ts +39 -1
- package/dist/strand-instance-manager.js +92 -14
- package/dist/strand-instance-manager.js.map +1 -1
- package/dist/strand-member-registry.d.ts +5 -5
- package/dist/strand-member-registry.js +18 -10
- package/dist/strand-member-registry.js.map +1 -1
- package/dist/strand-membership-writer.d.ts +482 -88
- package/dist/strand-membership-writer.js +757 -136
- package/dist/strand-membership-writer.js.map +1 -1
- package/dist/strand-solicitation.d.ts +99 -33
- package/dist/strand-solicitation.js +100 -52
- package/dist/strand-solicitation.js.map +1 -1
- package/dist/strand-transport-key.d.ts +40 -0
- package/dist/strand-transport-key.js +56 -0
- package/dist/strand-transport-key.js.map +1 -0
- package/dist/strand-wake-protocol.d.ts +9 -7
- package/dist/strand-wake-protocol.js +22 -46
- package/dist/strand-wake-protocol.js.map +1 -1
- package/dist/strand-watcher.d.ts +17 -2
- package/dist/strand-watcher.js +47 -3
- package/dist/strand-watcher.js.map +1 -1
- package/dist/trusted-owner-store-file.d.ts +12 -0
- package/dist/trusted-owner-store-file.js +39 -0
- package/dist/trusted-owner-store-file.js.map +1 -0
- package/dist/trusted-owner-store.d.ts +64 -0
- package/dist/trusted-owner-store.js +126 -0
- package/dist/trusted-owner-store.js.map +1 -0
- package/dist/types.d.ts +275 -20
- package/dist/types.js +13 -0
- package/dist/types.js.map +1 -1
- package/package.json +19 -8
- package/src/bootstrap-peer-store-file.ts +44 -0
- package/src/bootstrap-peer-store.ts +172 -0
- package/src/cadre-node.ts +1992 -202
- package/src/control-authorization.ts +114 -0
- package/src/control-database.ts +1704 -146
- package/src/control-formation-recorder.ts +172 -19
- package/src/control-schema.ts +786 -222
- package/src/control-stream.ts +81 -4
- package/src/control-write-retry.ts +473 -0
- package/src/delegate-admission.ts +302 -0
- package/src/device-token.ts +18 -13
- package/src/ed25519-key.ts +134 -69
- package/src/file-durable-slot.ts +61 -0
- package/src/formation-approval.ts +604 -0
- package/src/fs-atomic.ts +107 -0
- package/src/identity-key.ts +109 -0
- package/src/index.ts +136 -5
- package/src/key-store-file.ts +10 -81
- package/src/membership-connection-gater.ts +204 -0
- package/src/node-local-snapshot.ts +260 -0
- package/src/peer-authorization.ts +199 -21
- package/src/peer-record.ts +28 -10
- package/src/relay-addrs.ts +111 -0
- package/src/relay-reservation.ts +823 -0
- package/src/seed-bootstrap.ts +397 -197
- package/src/seed-trust-policy.ts +58 -25
- package/src/strand-addr-protocol.ts +84 -53
- package/src/strand-backfill.ts +366 -0
- package/src/strand-formation-manager.ts +145 -19
- package/src/strand-formation-protocol.ts +309 -8
- package/src/strand-instance-manager.ts +111 -14
- package/src/strand-member-registry.ts +18 -13
- package/src/strand-membership-writer.ts +1430 -640
- package/src/strand-solicitation.ts +155 -90
- package/src/strand-transport-key.ts +58 -0
- package/src/strand-wake-protocol.ts +29 -46
- package/src/strand-watcher.ts +66 -3
- package/src/trusted-owner-store-file.ts +41 -0
- package/src/trusted-owner-store.ts +171 -0
- package/src/types.ts +292 -21
|
@@ -1,6 +1,14 @@
|
|
|
1
1
|
import debug from 'debug';
|
|
2
2
|
import { randomBytes } from '@optimystic/quereus-plugin-crypto';
|
|
3
|
-
import type
|
|
3
|
+
import { FormationAbortedError, type ControlDatabase } from './control-database.js';
|
|
4
|
+
import {
|
|
5
|
+
createHttpFormationApprover,
|
|
6
|
+
verifyFormationApproval,
|
|
7
|
+
FormationApprovalError,
|
|
8
|
+
type FormationApprover,
|
|
9
|
+
type FormationApproval,
|
|
10
|
+
type FormationApprovalRequest
|
|
11
|
+
} from './formation-approval.js';
|
|
4
12
|
import type { FormationUsageRecorder, ResolvedHostStrand } from './strand-solicitation.js';
|
|
5
13
|
import type { OpenInvitation } from './types.js';
|
|
6
14
|
|
|
@@ -31,9 +39,28 @@ const NEVER_EXPIRES = new Date(8640000000000000);
|
|
|
31
39
|
* Usage accounting follows the schema's `FormationUsage.Authorized` semantics:
|
|
32
40
|
* a null `TotalUses` means unlimited uses; otherwise the invite is "used up"
|
|
33
41
|
* once the recorded usage count reaches `TotalUses`.
|
|
42
|
+
*
|
|
43
|
+
* When the invite carries a `ValidationUrl`, both write paths first obtain an outside
|
|
44
|
+
* approval ({@link obtainApproval}) — the recorder is the one place where the nonce that is
|
|
45
|
+
* SIGNED and the nonce that is INSERTED are trivially the same value, since it also performs
|
|
46
|
+
* the write. That makes this class do network I/O; failures surface as
|
|
47
|
+
* {@link FormationApprovalError}s the manager maps to protocol rejection reasons.
|
|
34
48
|
*/
|
|
35
49
|
export class ControlFormationUsageRecorder implements FormationUsageRecorder {
|
|
36
|
-
|
|
50
|
+
private readonly approver: FormationApprover;
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* `approver` defaults to the real HTTP hook client — deliberately ON: this recorder is
|
|
54
|
+
* constructed by the reference apps and the integration harness, not by `CadreNode`, so an
|
|
55
|
+
* opt-in approver would leave every real deployment unable to redeem a
|
|
56
|
+
* `ValidationUrl`-bearing invite. Tests inject a fake.
|
|
57
|
+
*/
|
|
58
|
+
constructor(
|
|
59
|
+
private readonly controlDatabase: ControlDatabase,
|
|
60
|
+
options?: { approver?: FormationApprover }
|
|
61
|
+
) {
|
|
62
|
+
this.approver = options?.approver ?? createHttpFormationApprover();
|
|
63
|
+
}
|
|
37
64
|
|
|
38
65
|
/** A token is valid when a matching, unexpired `FormationInvite` exists. */
|
|
39
66
|
async isTokenValid(token: string): Promise<{ valid: boolean; invitation?: OpenInvitation }> {
|
|
@@ -70,17 +97,126 @@ export class ControlFormationUsageRecorder implements FormationUsageRecorder {
|
|
|
70
97
|
return uses >= invite.totalUses;
|
|
71
98
|
}
|
|
72
99
|
|
|
100
|
+
/**
|
|
101
|
+
* Any `FormationInvite` row still unexpired and not fully consumed — the
|
|
102
|
+
* durable half of the control-network connection gate's "does this node
|
|
103
|
+
* expect a stranger?" question. Survives a restart and sees invites
|
|
104
|
+
* replicated in from sibling nodes of the same cadre.
|
|
105
|
+
*/
|
|
106
|
+
async hasOutstandingInvitation(): Promise<boolean> {
|
|
107
|
+
return await this.controlDatabase.hasOutstandingFormationInvite();
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Obtain the approval material for ONE redemption, when the invite demands it.
|
|
112
|
+
*
|
|
113
|
+
* `validationUrl === null` → `{}`: no approval required. The `usageStampId` nonce is
|
|
114
|
+
* ALWAYS supplied by the caller — the JOINER mints it, signs its own consent over it, and
|
|
115
|
+
* sends it in the contact message — so the approver signs over the identical nonce in both
|
|
116
|
+
* paths. Two local pre-checks run before anything is written:
|
|
117
|
+
*
|
|
118
|
+
* - {@link verifyFormationApproval} — did the hook sign the exact fields we hold?
|
|
119
|
+
* - {@link ControlDatabase.queryValidationKeyStampId} — is the approval's key enrolled?
|
|
120
|
+
*
|
|
121
|
+
* Both pre-checks exist purely for legibility — they turn a bad approval into a named
|
|
122
|
+
* {@link FormationApprovalError} instead of an opaque `CHECK constraint failed: Authorized`
|
|
123
|
+
* at commit. The database re-verifies against the STORED `ValidationKey` row and remains
|
|
124
|
+
* the security authority; do not "simplify" the database check away in favour of these.
|
|
125
|
+
*/
|
|
126
|
+
private async obtainApproval(
|
|
127
|
+
request: Omit<FormationApprovalRequest, 'validationUrl'> & { validationUrl: string | null },
|
|
128
|
+
signal?: AbortSignal
|
|
129
|
+
): Promise<{ validationKey?: string; validationSignature?: string }> {
|
|
130
|
+
const { validationUrl, ...fields } = request;
|
|
131
|
+
if (validationUrl === null) {
|
|
132
|
+
return {};
|
|
133
|
+
}
|
|
134
|
+
if (signal?.aborted) {
|
|
135
|
+
throw new FormationAbortedError(fields.token, 'approval');
|
|
136
|
+
}
|
|
137
|
+
const fullRequest: FormationApprovalRequest = { ...fields, validationUrl };
|
|
138
|
+
const approval = await this.askApprover(fullRequest, signal);
|
|
139
|
+
if (!verifyFormationApproval(fullRequest, approval)) {
|
|
140
|
+
throw new FormationApprovalError(
|
|
141
|
+
'malformed',
|
|
142
|
+
`Approval signature does not verify over this redemption (token ${fields.token})`
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
if (await this.controlDatabase.queryValidationKeyStampId(approval.validationKey) === null) {
|
|
146
|
+
throw new FormationApprovalError(
|
|
147
|
+
'unenrolled',
|
|
148
|
+
'Approval is signed by a key that is not an enrolled ValidationKey'
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
return approval;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Ask the approver, re-labelling a caller-abort as a {@link FormationAbortedError}.
|
|
156
|
+
*
|
|
157
|
+
* The HTTP client relays a caller-abort onto its own request and reports it as an
|
|
158
|
+
* `unavailable` {@link FormationApprovalError} — which the manager cannot tell from a
|
|
159
|
+
* genuinely dead hook, and so maps to a rejection reason and RETURNS. Every other abort on
|
|
160
|
+
* this path THROWS, which is what makes the manager rethrow and leave the reply to the
|
|
161
|
+
* listener's timeout path; a mid-hook abort belongs in the same class. The approval error
|
|
162
|
+
* is kept as `cause` so the transport-level detail is not lost.
|
|
163
|
+
*/
|
|
164
|
+
private async askApprover(
|
|
165
|
+
request: FormationApprovalRequest,
|
|
166
|
+
signal?: AbortSignal
|
|
167
|
+
): Promise<FormationApproval> {
|
|
168
|
+
try {
|
|
169
|
+
return await this.approver.requestApproval(request, signal);
|
|
170
|
+
} catch (err) {
|
|
171
|
+
if (signal?.aborted) {
|
|
172
|
+
throw new FormationAbortedError(request.token, 'approval', { cause: err });
|
|
173
|
+
}
|
|
174
|
+
throw err;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
73
178
|
/**
|
|
74
179
|
* Record consent against an **already-existing** host strand (record-only): the
|
|
75
180
|
* single `FormationUsage` insert auto-commits and the deferred `StrandExists`
|
|
76
181
|
* CHECK is satisfied by the pre-existing strand. This is the provision-then-record
|
|
77
182
|
* commitment — the strand was minted owner-signed up front, so we do NOT
|
|
78
|
-
* re-insert it (which would double-insert the same PK). `
|
|
79
|
-
*
|
|
80
|
-
*
|
|
183
|
+
* re-insert it (which would double-insert the same PK). `peerKey` is written as the
|
|
184
|
+
* usage `PeerKey`, which BOTH signed digests cover: the joiner's own consent signature
|
|
185
|
+
* (`peerSignature`, verified by the schema's `PeerConsented` CHECK) and — when the invite
|
|
186
|
+
* carries a `ValidationUrl` — the approver's sign-off, so it is the joiner an approval is
|
|
187
|
+
* spent on AND provably the joiner that agreed. Use
|
|
188
|
+
* {@link ControlDatabase.redeemInvitation} for the consent-creates-strand path instead.
|
|
189
|
+
*
|
|
190
|
+
* Reads the invite first (one extra read per redemption) to learn its `ValidationUrl`, and
|
|
191
|
+
* obtains the approval through {@link obtainApproval} when one is demanded.
|
|
81
192
|
*/
|
|
82
|
-
async recordUsage(
|
|
83
|
-
|
|
193
|
+
async recordUsage(params: {
|
|
194
|
+
token: string;
|
|
195
|
+
peerKey: string;
|
|
196
|
+
peerSignature: string;
|
|
197
|
+
usageStampId: string;
|
|
198
|
+
strandId: string;
|
|
199
|
+
disclosure: string;
|
|
200
|
+
signal?: AbortSignal;
|
|
201
|
+
}): Promise<void> {
|
|
202
|
+
const { token, peerKey, peerSignature, usageStampId, strandId, disclosure, signal } = params;
|
|
203
|
+
if (signal?.aborted) {
|
|
204
|
+
throw new FormationAbortedError(token, 'usage recording');
|
|
205
|
+
}
|
|
206
|
+
const invite = await this.controlDatabase.queryFormationInvite(token);
|
|
207
|
+
// NOTE: a missing invite row reads as "no approval required" here and in
|
|
208
|
+
// `provisionAndRecord`, so an invite that vanished between `resolveStrand` and this read
|
|
209
|
+
// would be written unapproved. Safe only because the database's `FormationUsage.Authorized`
|
|
210
|
+
// CHECK demands a matching `FormationInvite` and rolls the write back regardless; if that
|
|
211
|
+
// CHECK ever loosens, reject on `invite === null` instead of defaulting to null.
|
|
212
|
+
const approval = await this.obtainApproval({
|
|
213
|
+
token, usageStampId, strandId, peerKey, disclosure,
|
|
214
|
+
validationUrl: invite?.validationUrl ?? null,
|
|
215
|
+
}, signal);
|
|
216
|
+
await this.controlDatabase.recordFormationUsage({
|
|
217
|
+
token, strandId, peerKey, peerSignature, usageStampId, disclosure, signal,
|
|
218
|
+
totalUses: invite?.totalUses ?? null, ...approval,
|
|
219
|
+
});
|
|
84
220
|
log('Recorded formation usage: token=%s strand=%s', token, strandId);
|
|
85
221
|
}
|
|
86
222
|
|
|
@@ -129,21 +265,38 @@ export class ControlFormationUsageRecorder implements FormationUsageRecorder {
|
|
|
129
265
|
* key — so the returned `memberPrivateKey` is null. `sAppId` is accepted for parity/future
|
|
130
266
|
* use; `redeemInvitation` does not currently thread it into the `Strand` row.
|
|
131
267
|
*
|
|
132
|
-
* A concurrent redemption of the same single-use invite
|
|
133
|
-
*
|
|
134
|
-
*
|
|
268
|
+
* A concurrent redemption of the same single-use invite still THROWS for the loser — the
|
|
269
|
+
* manager maps that to a clean protocol rejection (it never lets the dropped insert close
|
|
270
|
+
* the stream silently) — but no longer via a `(Token, UseNumber)` primary-key collision on
|
|
271
|
+
* this node: `redeemInvitation` reads the use number INSIDE the write lock, so two local
|
|
272
|
+
* redemptions serialize and the second one reads the number the first already committed.
|
|
273
|
+
* It is then refused because that number is past the invite's seat budget, reported as a
|
|
274
|
+
* named `InvitationExhaustedError` on every attempt (including the first, which is what a
|
|
275
|
+
* same-node race hits). A PK collision is still what a cross-node race surfaces as, and that
|
|
276
|
+
* one IS retried under a fresh use number rather than thrown.
|
|
135
277
|
*/
|
|
136
|
-
async provisionAndRecord(
|
|
137
|
-
token: string
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
278
|
+
async provisionAndRecord(params: {
|
|
279
|
+
token: string;
|
|
280
|
+
peerKey: string;
|
|
281
|
+
peerSignature: string;
|
|
282
|
+
usageStampId: string;
|
|
283
|
+
sAppId: string;
|
|
284
|
+
disclosure: string;
|
|
285
|
+
signal?: AbortSignal;
|
|
286
|
+
}): Promise<{ strandId: string; memberPrivateKey: string | null }> {
|
|
287
|
+
const { token, peerKey, peerSignature, usageStampId, disclosure, signal } = params;
|
|
288
|
+
if (signal?.aborted) {
|
|
289
|
+
throw new FormationAbortedError(token, 'redemption');
|
|
290
|
+
}
|
|
141
291
|
const strandId = `strand-${randomBytes(128, 'hex') as string}`;
|
|
292
|
+
const invite = await this.controlDatabase.queryFormationInvite(token);
|
|
293
|
+
const approval = await this.obtainApproval({
|
|
294
|
+
token, usageStampId, strandId, peerKey, disclosure,
|
|
295
|
+
validationUrl: invite?.validationUrl ?? null,
|
|
296
|
+
}, signal);
|
|
142
297
|
await this.controlDatabase.redeemInvitation({
|
|
143
|
-
token,
|
|
144
|
-
|
|
145
|
-
type: 'o',
|
|
146
|
-
peerId: initiatorKey,
|
|
298
|
+
token, strandId, peerKey, peerSignature, usageStampId, disclosure, signal,
|
|
299
|
+
totalUses: invite?.totalUses ?? null, ...approval,
|
|
147
300
|
});
|
|
148
301
|
log('Provisioned + recorded unbound strand: token=%s strand=%s', token, strandId);
|
|
149
302
|
return { strandId, memberPrivateKey: null };
|