@serfab/cadre-core 0.7.1 → 0.9.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 +11 -10
- package/dist/cadre-node.d.ts +818 -19
- package/dist/cadre-node.js +1952 -99
- package/dist/cadre-node.js.map +1 -1
- package/dist/canonical-datetime.d.ts +33 -0
- package/dist/canonical-datetime.js +38 -0
- package/dist/canonical-datetime.js.map +1 -0
- package/dist/canonical-json.d.ts +12 -0
- package/dist/canonical-json.js +33 -0
- package/dist/canonical-json.js.map +1 -0
- package/dist/control-cohort.d.ts +63 -0
- package/dist/control-cohort.js +80 -0
- package/dist/control-cohort.js.map +1 -0
- package/dist/control-database.d.ts +279 -8
- package/dist/control-database.js +563 -134
- package/dist/control-database.js.map +1 -1
- package/dist/control-formation-recorder.d.ts +89 -0
- package/dist/control-formation-recorder.js +139 -0
- package/dist/control-formation-recorder.js.map +1 -0
- package/dist/control-schema.d.ts +12 -0
- package/dist/control-schema.js +223 -0
- package/dist/control-schema.js.map +1 -0
- package/dist/control-stream.d.ts +54 -0
- package/dist/control-stream.js +104 -0
- package/dist/control-stream.js.map +1 -0
- package/dist/device-token.d.ts +50 -0
- package/dist/device-token.js +66 -0
- package/dist/device-token.js.map +1 -0
- package/dist/diagnostics/connection-path.d.ts +118 -0
- package/dist/diagnostics/connection-path.js +204 -0
- package/dist/diagnostics/connection-path.js.map +1 -0
- package/dist/diagnostics/webrtc-turn-tracker.d.ts +50 -0
- package/dist/diagnostics/webrtc-turn-tracker.js +196 -0
- package/dist/diagnostics/webrtc-turn-tracker.js.map +1 -0
- package/dist/ed25519-key.d.ts +42 -0
- package/dist/ed25519-key.js +51 -0
- package/dist/ed25519-key.js.map +1 -0
- package/dist/hibernation-manager.d.ts +79 -0
- package/dist/hibernation-manager.js +173 -23
- package/dist/hibernation-manager.js.map +1 -1
- package/dist/index.d.ts +25 -4
- package/dist/index.js +59 -4
- package/dist/index.js.map +1 -1
- package/dist/key-store-file.d.ts +52 -0
- package/dist/key-store-file.js +194 -0
- package/dist/key-store-file.js.map +1 -0
- package/dist/key-store.d.ts +75 -0
- package/dist/key-store.js +66 -0
- package/dist/key-store.js.map +1 -0
- package/dist/peer-authorization.d.ts +48 -0
- package/dist/peer-authorization.js +66 -0
- package/dist/peer-authorization.js.map +1 -0
- package/dist/peer-record.d.ts +89 -0
- package/dist/peer-record.js +117 -0
- package/dist/peer-record.js.map +1 -0
- package/dist/push-credentials.d.ts +34 -0
- package/dist/push-credentials.js +81 -0
- package/dist/push-credentials.js.map +1 -0
- package/dist/push-fanout.d.ts +137 -0
- package/dist/push-fanout.js +197 -0
- package/dist/push-fanout.js.map +1 -0
- package/dist/push-node.d.ts +38 -0
- package/dist/push-node.js +48 -0
- package/dist/push-node.js.map +1 -0
- package/dist/push-notifier-apns.d.ts +61 -0
- package/dist/push-notifier-apns.js +199 -0
- package/dist/push-notifier-apns.js.map +1 -0
- package/dist/push-notifier-fcm.d.ts +44 -0
- package/dist/push-notifier-fcm.js +182 -0
- package/dist/push-notifier-fcm.js.map +1 -0
- package/dist/push-notifier-shared.d.ts +18 -0
- package/dist/push-notifier-shared.js +25 -0
- package/dist/push-notifier-shared.js.map +1 -0
- package/dist/push-notifier.d.ts +52 -0
- package/dist/push-notifier.js +22 -0
- package/dist/push-notifier.js.map +1 -0
- package/dist/schema-verification.d.ts +11 -2
- package/dist/schema-verification.js +15 -4
- package/dist/schema-verification.js.map +1 -1
- package/dist/seed-bootstrap.d.ts +228 -21
- package/dist/seed-bootstrap.js +489 -168
- package/dist/seed-bootstrap.js.map +1 -1
- package/dist/seed-trust-policy.d.ts +63 -0
- package/dist/seed-trust-policy.js +56 -0
- package/dist/seed-trust-policy.js.map +1 -0
- package/dist/strand-addr-protocol.d.ts +132 -0
- package/dist/strand-addr-protocol.js +313 -0
- package/dist/strand-addr-protocol.js.map +1 -0
- package/dist/strand-cohort.d.ts +54 -0
- package/dist/strand-cohort.js +38 -0
- package/dist/strand-cohort.js.map +1 -0
- package/dist/strand-database.d.ts +54 -6
- package/dist/strand-database.js +88 -100
- package/dist/strand-database.js.map +1 -1
- package/dist/strand-formation-manager.d.ts +79 -19
- package/dist/strand-formation-manager.js +196 -104
- package/dist/strand-formation-manager.js.map +1 -1
- package/dist/strand-formation-protocol.d.ts +162 -0
- package/dist/strand-formation-protocol.js +279 -0
- package/dist/strand-formation-protocol.js.map +1 -0
- package/dist/strand-instance-manager.d.ts +73 -4
- package/dist/strand-instance-manager.js +167 -41
- package/dist/strand-instance-manager.js.map +1 -1
- package/dist/strand-member-key.d.ts +29 -0
- package/dist/strand-member-key.js +38 -0
- package/dist/strand-member-key.js.map +1 -0
- package/dist/strand-member-registry.d.ts +98 -0
- package/dist/strand-member-registry.js +129 -0
- package/dist/strand-member-registry.js.map +1 -0
- package/dist/strand-membership-writer.d.ts +361 -0
- package/dist/strand-membership-writer.js +449 -0
- package/dist/strand-membership-writer.js.map +1 -0
- package/dist/strand-solicitation.d.ts +77 -2
- package/dist/strand-solicitation.js +25 -4
- package/dist/strand-solicitation.js.map +1 -1
- package/dist/strand-wake-payload.d.ts +30 -0
- package/dist/strand-wake-payload.js +20 -0
- package/dist/strand-wake-payload.js.map +1 -0
- package/dist/strand-wake-protocol.d.ts +114 -0
- package/dist/strand-wake-protocol.js +257 -0
- package/dist/strand-wake-protocol.js.map +1 -0
- package/dist/strand-watcher.d.ts +6 -2
- package/dist/strand-watcher.js +57 -17
- package/dist/strand-watcher.js.map +1 -1
- package/dist/types.d.ts +436 -33
- package/dist/types.js +22 -5
- package/dist/types.js.map +1 -1
- package/package.json +18 -8
- package/src/cadre-node.ts +2981 -948
- package/src/canonical-datetime.ts +39 -0
- package/src/canonical-json.ts +30 -0
- package/src/control-cohort.ts +103 -0
- package/src/control-database.ts +948 -383
- package/src/control-formation-recorder.ts +151 -0
- package/src/control-schema.ts +222 -0
- package/src/control-stream.ts +128 -0
- package/src/device-token.ts +89 -0
- package/src/diagnostics/connection-path.ts +277 -0
- package/src/diagnostics/webrtc-turn-tracker.ts +222 -0
- package/src/ed25519-key.ts +69 -0
- package/src/hibernation-manager.ts +197 -28
- package/src/index.ts +275 -79
- package/src/key-store-file.ts +203 -0
- package/src/key-store.ts +99 -0
- package/src/peer-authorization.ts +81 -0
- package/src/peer-record.ts +146 -0
- package/src/push-credentials.ts +84 -0
- package/src/push-fanout.ts +272 -0
- package/src/push-node.ts +64 -0
- package/src/push-notifier-apns.ts +247 -0
- package/src/push-notifier-fcm.ts +220 -0
- package/src/push-notifier-shared.ts +28 -0
- package/src/push-notifier.ts +52 -0
- package/src/schema-verification.ts +18 -4
- package/src/seed-bootstrap.ts +563 -223
- package/src/seed-trust-policy.ts +107 -0
- package/src/strand-addr-protocol.ts +410 -0
- package/src/strand-cohort.ts +77 -0
- package/src/strand-database.ts +113 -141
- package/src/strand-formation-manager.ts +233 -142
- package/src/strand-formation-protocol.ts +391 -0
- package/src/strand-instance-manager.ts +207 -56
- package/src/strand-member-key.ts +39 -0
- package/src/strand-member-registry.ts +177 -0
- package/src/strand-membership-writer.ts +640 -0
- package/src/strand-solicitation.ts +93 -4
- package/src/strand-wake-payload.ts +32 -0
- package/src/strand-wake-protocol.ts +331 -0
- package/src/strand-watcher.ts +61 -17
- package/src/types.ts +1032 -582
package/dist/control-database.js
CHANGED
|
@@ -4,119 +4,21 @@ import { Database, registerPlugin } from '@quereus/quereus';
|
|
|
4
4
|
import cryptoPlugin from '@optimystic/quereus-plugin-crypto/plugin';
|
|
5
5
|
import optimysticPlugin from '@optimystic/quereus-plugin-optimystic/plugin';
|
|
6
6
|
import { digest, randomBytes } from '@optimystic/quereus-plugin-crypto';
|
|
7
|
+
import { CONTROL_SCHEMA } from './control-schema.js';
|
|
8
|
+
import { canonicalDatetime } from './canonical-datetime.js';
|
|
7
9
|
const log = debug('sereus:cadre:control-db');
|
|
8
10
|
const timing = debug('sereus:cadre:timing');
|
|
9
|
-
/**
|
|
10
|
-
* Embedded control schema for cross-platform compatibility.
|
|
11
|
-
* This schema is embedded directly in the code to work in React Native and other
|
|
12
|
-
* environments where file system access is not available.
|
|
13
|
-
*/
|
|
14
|
-
const CONTROL_SCHEMA = `-- This manages a Sereus party's cadre, or set of nodes, and their participation in strands (networks)
|
|
15
|
-
declare schema CadreControl {
|
|
16
|
-
-- A key that can authorize various control changes
|
|
17
|
-
table AuthorityKey (
|
|
18
|
-
Key text primary key,
|
|
19
|
-
constraint Authorized check (
|
|
20
|
-
-- Bootstrap: first authority key needs no existing authorization
|
|
21
|
-
(select count(1) from AuthorityKey) <= 1
|
|
22
|
-
|
|
23
|
-
-- Old authority can authorize by signature and transaction stamp id (not repeatable)
|
|
24
|
-
or (old.Key is not null and old.Key = context.AuthorityKey and verify(digest(context.StampId, 'sha256', 'utf8'), context.Signature, old.Key, 'ed25519'))
|
|
25
|
-
|
|
26
|
-
-- or other authorities can authorize by signature and transaction stamp id (not repeatable)
|
|
27
|
-
or exists (select 1 from AuthorityKey A where A.Key = context.AuthorityKey and verify(digest(context.StampId, 'sha256', 'utf8'), context.Signature, A.Key, 'ed25519'))
|
|
28
|
-
)
|
|
29
|
-
) with context (AuthorityKey text null, Signature text null, StampId text);
|
|
30
|
-
|
|
31
|
-
-- A key that can validate a strand formation disclosure
|
|
32
|
-
table ValidationKey (
|
|
33
|
-
Key text primary key,
|
|
34
|
-
constraint Authorized check (
|
|
35
|
-
-- Authorities can authorize by signature and transaction stamp id (not repeatable)
|
|
36
|
-
exists (select 1 from AuthorityKey A where A.Key = context.AuthorityKey and verify(digest(context.StampId, 'sha256', 'utf8'), context.Signature, A.Key, 'ed25519'))
|
|
37
|
-
)
|
|
38
|
-
) with context (AuthorityKey text, StampId text, Signature text);
|
|
39
|
-
|
|
40
|
-
-- A network of members sharing an sApp database, each contributing peer nodes (their cadre) to the overall cohort
|
|
41
|
-
-- Cadre peers should participate in each of these strands
|
|
42
|
-
table Strand (
|
|
43
|
-
Id text primary key, -- UUID
|
|
44
|
-
MemberPrivateKey text null unique, -- Our private key as a member of this strand
|
|
45
|
-
Type text, -- Types: 'o' = Open, 'c' = Closed -- Open can still control writes in the sApp, but only Closed controls reads
|
|
46
|
-
constraint Authorized check (
|
|
47
|
-
-- Authorized by authority signature and transaction stamp id (not repeatable)
|
|
48
|
-
exists (select 1 from AuthorityKey A where A.Key = context.AuthorityKey and verify(digest(context.StampId, 'sha256', 'utf8'), context.Signature, A.Key, 'ed25519'))
|
|
49
|
-
|
|
50
|
-
-- or authorized by a cadre peer who has received a valid formation invitation
|
|
51
|
-
or exists (select 1 from FormationUsage FU where FU.StrandId = new.Id)
|
|
52
|
-
),
|
|
53
|
-
-- TODO: constraint to ensure member private key only if closed
|
|
54
|
-
) with context (AuthorityKey text, StampId text, Signature text);
|
|
55
|
-
|
|
56
|
-
-- A peer (node) that is part of the cadre
|
|
57
|
-
table CadrePeer (
|
|
58
|
-
PeerId text primary key,
|
|
59
|
-
Multiaddr text,
|
|
60
|
-
constraint AuthorizedInsert check on insert, delete (
|
|
61
|
-
-- Authorized by an authority key
|
|
62
|
-
-- Use utf8 input encoding since peer IDs are base58btc strings, not base64url
|
|
63
|
-
exists (select 1 from AuthorityKey A where A.Key = context.AuthorityKey and verify(digest(coalesce(new.PeerId, old.PeerId), 'sha256', 'utf8'), context.Signature, A.Key, 'ed25519'))
|
|
64
|
-
),
|
|
65
|
-
constraint AuthorizedUpdate check on update (
|
|
66
|
-
-- Peer can change its own multiaddr (using utf8 encoding for both values)
|
|
67
|
-
verify(digest(new.PeerId, 'sha256', 'utf8') || digest(new.Multiaddr, 'sha256', 'utf8'), context.Signature, new.PeerId, 'ed25519')
|
|
68
|
-
-- or authorized by an authority key
|
|
69
|
-
or exists (select 1 from AuthorityKey A where A.Key = context.AuthorityKey and verify(digest(new.PeerId, 'sha256', 'utf8'), context.Signature, A.Key, 'ed25519'))
|
|
70
|
-
)
|
|
71
|
-
) with context (AuthorityKey text null, Signature text);
|
|
72
|
-
|
|
73
|
-
-- An open invitation to form a strand with this party
|
|
74
|
-
table FormationInvite (
|
|
75
|
-
Token text primary key, -- Just a random string
|
|
76
|
-
sAppId text, -- The app for the strand that will be formed
|
|
77
|
-
ExpiresAt datetime null,
|
|
78
|
-
TotalUses int null check (TotalUses >= 0),
|
|
79
|
-
ValidationUrl text null, -- Web hook - send disclosure, IP address...
|
|
80
|
-
constraint AuthorizedAddOrRemove check on insert, delete (
|
|
81
|
-
-- Authorized by an authority key to add or remove this invite
|
|
82
|
-
exists (select 1 from AuthorityKey A where A.Key = context.AuthorityKey and verify(digest(context.StampId), context.Signature, A.Key))
|
|
83
|
-
)
|
|
84
|
-
) with context (AuthorityKey text, StampId text, Signature text);
|
|
85
|
-
|
|
86
|
-
table FormationUsage (
|
|
87
|
-
Token text,
|
|
88
|
-
UseNumber int,
|
|
89
|
-
Disclosure text,
|
|
90
|
-
StrandId text,
|
|
91
|
-
primary key (Token, UseNumber),
|
|
92
|
-
constraint InsertOnly check on update, delete (false),
|
|
93
|
-
constraint Monotonic check (
|
|
94
|
-
new.UseNumber = coalesce((select max(UseNumber) from FormationUsage U where U.Token = new.Token), 0) + 1
|
|
95
|
-
),
|
|
96
|
-
constraint Authorized check on insert (
|
|
97
|
-
-- Satisfies an invitation
|
|
98
|
-
exists (
|
|
99
|
-
select 1 from FormationInvite FI
|
|
100
|
-
where FI.Token = new.Token
|
|
101
|
-
and (FI.TotalUses is null or FI.TotalUses >= new.UseNumber)
|
|
102
|
-
and (FI.ExpiresAt is null or FI.ExpiresAt > context.Now)
|
|
103
|
-
and (FI.ValidationUrl is null or verify(digest(new.Token, new.Disclosure), context.ValidationSignature, context.ValidationKey))
|
|
104
|
-
)
|
|
105
|
-
),
|
|
106
|
-
constraint StrandExists check (exists (select 1 from Strand S where S.Id = new.StrandId)),
|
|
107
|
-
) with context (PeerId text, PeerSignature text, Now datetime, ValidationKey text null, ValidationSignature text null);
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
apply schema CadreControl;`;
|
|
111
11
|
/**
|
|
112
12
|
* Generate a unique stamp ID for transaction authorization.
|
|
113
13
|
* Format: 32 bytes base64url encoded
|
|
114
14
|
* - First 16 bytes: SHA-256 hash of peer ID (for distributed uniqueness)
|
|
115
15
|
* - Last 16 bytes: Random bytes (for collision resistance)
|
|
116
16
|
*/
|
|
117
|
-
function generateStampId(peerId) {
|
|
118
|
-
// Hash the peer ID and get first 16 bytes (128 bits)
|
|
119
|
-
|
|
17
|
+
export function generateStampId(peerId) {
|
|
18
|
+
// Hash the peer ID and get first 16 bytes (128 bits). A purely local ID
|
|
19
|
+
// generator: never signed/verified against SQL, so the framed single-field
|
|
20
|
+
// digest is fine (the changed framing is not cross-checked anywhere).
|
|
21
|
+
const peerIdHash = digest([peerId], 'sha256', 'bytes');
|
|
120
22
|
const peerIdHashPart = peerIdHash.slice(0, 16);
|
|
121
23
|
// Generate 16 random bytes
|
|
122
24
|
const randomPart = randomBytes(128, 'bytes');
|
|
@@ -127,6 +29,54 @@ function generateStampId(peerId) {
|
|
|
127
29
|
// Convert to base64url
|
|
128
30
|
return uint8ArrayToString(combined, 'base64url');
|
|
129
31
|
}
|
|
32
|
+
/**
|
|
33
|
+
* Parse a stored Quereus `datetime` value into epoch milliseconds.
|
|
34
|
+
*
|
|
35
|
+
* Quereus canonicalises a `datetime` column to a bare UTC `PlainDateTime` string
|
|
36
|
+
* (e.g. `2026-06-04T12:34:56`, no `Z`). JS `Date` reads a timezone-less
|
|
37
|
+
* date-time as LOCAL, so we append `Z` when no offset/zone is present to keep the
|
|
38
|
+
* value anchored to UTC. Numbers (already epoch ms) pass through.
|
|
39
|
+
*/
|
|
40
|
+
function parseStoredDatetimeMs(value) {
|
|
41
|
+
if (typeof value === 'number')
|
|
42
|
+
return value;
|
|
43
|
+
const hasZone = /[zZ]$|[+-]\d\d:?\d\d$/.test(value);
|
|
44
|
+
return new Date(hasZone ? value : `${value}Z`).getTime();
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Build the canonical authorization message that owner signatures are bound to.
|
|
48
|
+
*
|
|
49
|
+
* The message is a SINGLE framed SHA-256 digest over the ordered tuple of fields (the
|
|
50
|
+
* crypto plugin's injective multi-field encoding), in a fixed field order, with the
|
|
51
|
+
* single-use StampId as the final field:
|
|
52
|
+
*
|
|
53
|
+
* message = sha256(encodeFields([field_1, field_2, ..., StampId])) // raw digest bytes
|
|
54
|
+
*
|
|
55
|
+
* ed25519 signs these raw digest bytes DIRECTLY (no second hash). The SQL constraints
|
|
56
|
+
* verify the identical bytes with one variadic call
|
|
57
|
+
* (`verify(digest(field_1, ..., StampId), context.Signature, A.Key, 'ed25519')`): SQL
|
|
58
|
+
* `digest(...)` returns the base64url string of the same digest, which `verify`'s default
|
|
59
|
+
* base64url input encoding decodes back to those raw bytes — so signer and verifier
|
|
60
|
+
* operate on the same bytes. Every field is TEXT on both sides (the SQL columns are
|
|
61
|
+
* `cast(... as text)` / `coalesce(...,'')`; the TS args are strings), so the per-field
|
|
62
|
+
* type tags agree. This binds the signature to the row contents (not a bare stamp),
|
|
63
|
+
* closing the captured-stamp replay / privilege-escalation hole. Single source of truth:
|
|
64
|
+
* every signed writer (and every test/harness signer) MUST build the message through this
|
|
65
|
+
* function in the schema's field order, or `verify` will reject the row.
|
|
66
|
+
*/
|
|
67
|
+
export function buildAuthorizationMessage(fields) {
|
|
68
|
+
return digest([...fields], 'sha256', 'bytes');
|
|
69
|
+
}
|
|
70
|
+
/** Runtime guard mirroring {@link ControlTable} for the dynamic-`from` count. */
|
|
71
|
+
const CONTROL_TABLES = new Set([
|
|
72
|
+
'OwnerKey',
|
|
73
|
+
'ValidationKey',
|
|
74
|
+
'Strand',
|
|
75
|
+
'CadrePeer',
|
|
76
|
+
'DeviceToken',
|
|
77
|
+
'FormationInvite',
|
|
78
|
+
'FormationUsage',
|
|
79
|
+
]);
|
|
130
80
|
/**
|
|
131
81
|
* ControlDatabase manages the CadreControl schema using Quereus with Optimystic backend.
|
|
132
82
|
* It provides typed query methods for accessing control network data.
|
|
@@ -177,6 +127,29 @@ export class ControlDatabase {
|
|
|
177
127
|
this.collectionFactory.registerLibp2pNode(networkName, this.config.libp2pNode, this.config.coordinatedRepo);
|
|
178
128
|
timing('[controlDb] registerLibp2pNode: %dms', Math.round(performance.now() - t0));
|
|
179
129
|
log('Registered libp2p node with collection factory');
|
|
130
|
+
// Network-back the control tables. CONTROL_SCHEMA's `declare schema CadreControl
|
|
131
|
+
// { table ... }` tables carry NO per-table `using optimystic(...)`, so storage is
|
|
132
|
+
// chosen by the database's DEFAULT vtab. Routing the default to optimystic (with
|
|
133
|
+
// the network transactor + this party's control network) is what makes a control
|
|
134
|
+
// write replicate peer-to-peer — exactly what connectToStrand does for strand
|
|
135
|
+
// tables (compose-strand.ts). Without these two calls the tables fall back to
|
|
136
|
+
// Quereus's in-memory vtab and never converge across the cadre.
|
|
137
|
+
this.db.setDefaultVtabName('optimystic');
|
|
138
|
+
this.db.setDefaultVtabArgs({
|
|
139
|
+
networkName,
|
|
140
|
+
transactor: 'network',
|
|
141
|
+
keyNetwork: 'libp2p',
|
|
142
|
+
});
|
|
143
|
+
log('Set default vtab to optimystic (networkName=%s, transactor=network)', networkName);
|
|
144
|
+
// Hydrate Quereus's catalog from any persisted optimystic vtab schemas BEFORE
|
|
145
|
+
// applying CONTROL_SCHEMA, so a warm restart with persistent storage diffs the
|
|
146
|
+
// control DDL against the already-present tables and re-emits nothing — the same
|
|
147
|
+
// warm-start regression connectToStrand's hydrate-before-apply guards against.
|
|
148
|
+
// No-op on a cold / in-memory start (`{ tables: 0, indexes: 0 }`).
|
|
149
|
+
t0 = performance.now();
|
|
150
|
+
const hydrated = await pluginResult.hydrate(this.db);
|
|
151
|
+
timing('[controlDb] hydrate: %dms (tables=%d, indexes=%d)', Math.round(performance.now() - t0), hydrated.tables, hydrated.indexes);
|
|
152
|
+
log('Hydrated control catalog (tables=%d, indexes=%d)', hydrated.tables, hydrated.indexes);
|
|
180
153
|
// Load and execute the schema
|
|
181
154
|
t0 = performance.now();
|
|
182
155
|
await this.loadSchema();
|
|
@@ -193,13 +166,16 @@ export class ControlDatabase {
|
|
|
193
166
|
// Check if we're in a Node.js environment
|
|
194
167
|
if (typeof process !== 'undefined' && process.versions?.node) {
|
|
195
168
|
try {
|
|
196
|
-
// Use require to conditionally load fs only in Node.js
|
|
197
|
-
// This won't be bundled by React Native's Metro bundler
|
|
169
|
+
// Use require to conditionally load fs only in Node.js.
|
|
170
|
+
// This won't be bundled by React Native's Metro bundler — a dynamic
|
|
171
|
+
// import() would be statically picked up by Metro, so require is
|
|
172
|
+
// intentional here (cross-platform constraint, not lazy CommonJS).
|
|
173
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
198
174
|
const fs = require('fs/promises');
|
|
199
175
|
schemaContent = await fs.readFile(this.config.schemaPath, 'utf-8');
|
|
200
176
|
}
|
|
201
177
|
catch (error) {
|
|
202
|
-
throw new Error(`Failed to load schema from ${this.config.schemaPath}: ${error instanceof Error ? error.message : String(error)}
|
|
178
|
+
throw new Error(`Failed to load schema from ${this.config.schemaPath}: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
|
|
203
179
|
}
|
|
204
180
|
}
|
|
205
181
|
else {
|
|
@@ -230,6 +206,44 @@ export class ControlDatabase {
|
|
|
230
206
|
}
|
|
231
207
|
return results;
|
|
232
208
|
}
|
|
209
|
+
/**
|
|
210
|
+
* Read a single strand row by id, or null when absent. Single-row sibling of
|
|
211
|
+
* {@link queryStrands}; the responder uses it to read a host strand's
|
|
212
|
+
* `MemberPrivateKey` (the closed-strand read-gating secret) for delivery to a
|
|
213
|
+
* validated invitee during provision-then-record formation.
|
|
214
|
+
*/
|
|
215
|
+
async queryStrand(strandId) {
|
|
216
|
+
this.ensureInitialized();
|
|
217
|
+
for await (const row of this.db.eval('select Id, MemberPrivateKey, Type from CadreControl.Strand where Id = ?', [strandId])) {
|
|
218
|
+
return {
|
|
219
|
+
Id: row.Id,
|
|
220
|
+
MemberPrivateKey: row.MemberPrivateKey,
|
|
221
|
+
Type: row.Type,
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
return null;
|
|
225
|
+
}
|
|
226
|
+
/**
|
|
227
|
+
* Count rows in a CadreControl table as seen by THIS database instance.
|
|
228
|
+
*
|
|
229
|
+
* `table` is validated against {@link CONTROL_TABLES} before it is interpolated
|
|
230
|
+
* into the `from` clause: the names are not user input, but the check keeps the
|
|
231
|
+
* dynamic query off the injection surface and fails loudly on a typo instead of
|
|
232
|
+
* emitting a malformed statement. The count reflects only the rows this node's
|
|
233
|
+
* control DB has converged on — in the integration harness that is the owner
|
|
234
|
+
* node (one ControlDatabase per party), i.e. the authoritative control-network
|
|
235
|
+
* view, not a per-drone convergence guarantee.
|
|
236
|
+
*/
|
|
237
|
+
async countRows(table) {
|
|
238
|
+
this.ensureInitialized();
|
|
239
|
+
if (!CONTROL_TABLES.has(table)) {
|
|
240
|
+
throw new Error(`Unknown CadreControl table: ${table}`);
|
|
241
|
+
}
|
|
242
|
+
for await (const row of this.db.eval(`select count(1) as Count from CadreControl.${table}`)) {
|
|
243
|
+
return row.Count ?? 0;
|
|
244
|
+
}
|
|
245
|
+
return 0;
|
|
246
|
+
}
|
|
233
247
|
/**
|
|
234
248
|
* Get the underlying database for advanced queries
|
|
235
249
|
*/
|
|
@@ -238,47 +252,462 @@ export class ControlDatabase {
|
|
|
238
252
|
return this.db;
|
|
239
253
|
}
|
|
240
254
|
/**
|
|
241
|
-
*
|
|
255
|
+
* Check whether any owner key exists in the control database.
|
|
256
|
+
* Used to decide whether a fresh-party genesis insert is required.
|
|
257
|
+
*/
|
|
258
|
+
async hasOwnerKey() {
|
|
259
|
+
this.ensureInitialized();
|
|
260
|
+
for await (const row of this.db.eval('select count(1) as Count from CadreControl.OwnerKey')) {
|
|
261
|
+
return row.Count > 0;
|
|
262
|
+
}
|
|
263
|
+
return false;
|
|
264
|
+
}
|
|
265
|
+
/**
|
|
266
|
+
* Idempotent genesis: insert `key` as the founding owner key only when
|
|
267
|
+
* the party has none yet. Returns true if it inserted, false if an owner
|
|
268
|
+
* key already existed (so a repeat `--owner` start is a no-op).
|
|
242
269
|
*/
|
|
243
|
-
async
|
|
270
|
+
async ensureOwnerKey(key) {
|
|
244
271
|
this.ensureInitialized();
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
await this.
|
|
250
|
-
|
|
251
|
-
with context AuthorityKey = null, Signature = null, StampId = StampId()
|
|
252
|
-
values (?)
|
|
253
|
-
`, [key]);
|
|
254
|
-
log('Authority key inserted');
|
|
272
|
+
if (await this.hasOwnerKey()) {
|
|
273
|
+
log('Owner key already present; skipping genesis insert');
|
|
274
|
+
return false;
|
|
275
|
+
}
|
|
276
|
+
await this.insertOwnerKey(key);
|
|
277
|
+
return true;
|
|
255
278
|
}
|
|
256
279
|
/**
|
|
257
|
-
*
|
|
258
|
-
*
|
|
280
|
+
* Collect every owner key (`CadreControl.OwnerKey.Key`) as a set.
|
|
281
|
+
*
|
|
282
|
+
* This is the steady-state trust anchor for seeds: a seed's signer key is
|
|
283
|
+
* trusted only if it is already enrolled here (see `SeedTrustPolicy`). It is
|
|
284
|
+
* also the owner-identity source for `queryPeers`, decoupling owner
|
|
285
|
+
* status from the libp2p transport peer ID.
|
|
286
|
+
*/
|
|
287
|
+
async getOwnerKeys() {
|
|
288
|
+
this.ensureInitialized();
|
|
289
|
+
const keys = new Set();
|
|
290
|
+
for await (const row of this.db.eval('select Key from CadreControl.OwnerKey')) {
|
|
291
|
+
keys.add(row.Key);
|
|
292
|
+
}
|
|
293
|
+
return keys;
|
|
294
|
+
}
|
|
295
|
+
/**
|
|
296
|
+
* Enumerate the CadrePeer rows (cadre membership) for admin/membership reads.
|
|
297
|
+
*/
|
|
298
|
+
async queryCadrePeers() {
|
|
299
|
+
this.ensureInitialized();
|
|
300
|
+
const rows = [];
|
|
301
|
+
for await (const row of this.db.eval('select PeerId, Multiaddr, StampId, VouchOwner, VouchSig from CadreControl.CadrePeer')) {
|
|
302
|
+
rows.push({
|
|
303
|
+
peerId: row.PeerId,
|
|
304
|
+
multiaddr: row.Multiaddr ?? null,
|
|
305
|
+
stampId: row.StampId ?? null,
|
|
306
|
+
vouchOwner: row.VouchOwner ?? null,
|
|
307
|
+
vouchSig: row.VouchSig ?? null,
|
|
308
|
+
});
|
|
309
|
+
}
|
|
310
|
+
return rows;
|
|
311
|
+
}
|
|
312
|
+
/**
|
|
313
|
+
* Read a single `CadrePeer` row's single-use `StampId` nonce (null when the row
|
|
314
|
+
* does not exist). Used by the owner delete / re-touch paths, which must bind
|
|
315
|
+
* their signature to the row's CURRENT nonce ({@link cadrePeerRemoveDigest} /
|
|
316
|
+
* {@link cadrePeerVoucherDigest}).
|
|
317
|
+
*/
|
|
318
|
+
async queryCadrePeerStampId(peerId) {
|
|
319
|
+
this.ensureInitialized();
|
|
320
|
+
for await (const row of this.db.eval('select StampId from CadreControl.CadrePeer where PeerId = ?', [peerId])) {
|
|
321
|
+
return row.StampId ?? null;
|
|
322
|
+
}
|
|
323
|
+
return null;
|
|
324
|
+
}
|
|
325
|
+
/**
|
|
326
|
+
* Read a single peer's address record (the full `CadrePeer` row) by PeerId.
|
|
327
|
+
*
|
|
328
|
+
* Returns null when no row exists. Missing/legacy column values are coalesced
|
|
329
|
+
* to their empty form (`''` key/sig, `[]` addrs, `0` stamp) so the caller's
|
|
330
|
+
* verify/freshness gates uniformly reject an unpublished or malformed row.
|
|
331
|
+
* The split `addrs` re-join to the exact stored `Multiaddr` (split-on-`,` is
|
|
332
|
+
* the inverse of join-on-`,`), so the resolver re-verifies over the same bytes
|
|
333
|
+
* the publisher signed.
|
|
334
|
+
*/
|
|
335
|
+
async queryPeerRecord(peerId) {
|
|
336
|
+
this.ensureInitialized();
|
|
337
|
+
for await (const row of this.db.eval('select PeerId, PublicKey, Multiaddr, UpdatedAt, Sig from CadreControl.CadrePeer where PeerId = ?', [peerId])) {
|
|
338
|
+
const multiaddr = row.Multiaddr ?? '';
|
|
339
|
+
return {
|
|
340
|
+
peerId: row.PeerId,
|
|
341
|
+
publicKey: row.PublicKey ?? '',
|
|
342
|
+
addrs: multiaddr.length > 0 ? multiaddr.split(',') : [],
|
|
343
|
+
updatedAt: row.UpdatedAt ?? 0,
|
|
344
|
+
sig: row.Sig ?? '',
|
|
345
|
+
};
|
|
346
|
+
}
|
|
347
|
+
return null;
|
|
348
|
+
}
|
|
349
|
+
/**
|
|
350
|
+
* Apply a peer's own self-signed address-record update to an existing row.
|
|
351
|
+
*
|
|
352
|
+
* Authorization is carried entirely by the record: the `Sig` column (verified
|
|
353
|
+
* by the `AuthorizedUpdate` self-branch against the stored `PublicKey`) plus
|
|
354
|
+
* the strictly-increasing `UpdatedAt`. No owner key is involved, so this
|
|
355
|
+
* is the refresh path for any member — owner or drone — once its row
|
|
356
|
+
* exists. `PublicKey` is intentionally not in the SET list (it is immutable on
|
|
357
|
+
* self-update and the constraint enforces `new.PublicKey = old.PublicKey`).
|
|
358
|
+
*/
|
|
359
|
+
async updateSelfPeerRecord(record) {
|
|
360
|
+
this.ensureInitialized();
|
|
361
|
+
const multiaddr = record.addrs.join(',');
|
|
362
|
+
await this.db.exec(`
|
|
363
|
+
update CadreControl.CadrePeer
|
|
364
|
+
with context OwnerKey = null, Signature = ?
|
|
365
|
+
set Multiaddr = ?, UpdatedAt = ?, Sig = ?
|
|
366
|
+
where PeerId = ?
|
|
367
|
+
`, [record.sig, multiaddr, record.updatedAt, record.sig, record.peerId]);
|
|
368
|
+
log('Self peer record updated: %s (updatedAt=%d)', record.peerId, record.updatedAt);
|
|
369
|
+
}
|
|
370
|
+
/**
|
|
371
|
+
* Read a single peer's device push token (the full `DeviceToken` row) by PeerId.
|
|
372
|
+
*
|
|
373
|
+
* Returns null when no row exists. Missing/legacy column values are coalesced to
|
|
374
|
+
* their empty form (`''` token/sig, `0` stamp) so the caller's verify/freshness
|
|
375
|
+
* gates uniformly reject an unpublished or malformed row. `platform` is returned
|
|
376
|
+
* verbatim (the resolver validates it against {@link PushPlatform} and re-verifies
|
|
377
|
+
* the self-signature, which covers the platform field).
|
|
378
|
+
*/
|
|
379
|
+
async queryDeviceToken(peerId) {
|
|
380
|
+
this.ensureInitialized();
|
|
381
|
+
for await (const row of this.db.eval('select PeerId, Platform, Token, UpdatedAt, Sig from CadreControl.DeviceToken where PeerId = ?', [peerId])) {
|
|
382
|
+
return {
|
|
383
|
+
peerId: row.PeerId,
|
|
384
|
+
platform: (row.Platform ?? ''),
|
|
385
|
+
token: row.Token ?? '',
|
|
386
|
+
updatedAt: row.UpdatedAt ?? 0,
|
|
387
|
+
sig: row.Sig ?? '',
|
|
388
|
+
};
|
|
389
|
+
}
|
|
390
|
+
return null;
|
|
391
|
+
}
|
|
392
|
+
/**
|
|
393
|
+
* Apply a peer's own self-signed device-token update to an existing row.
|
|
394
|
+
*
|
|
395
|
+
* Authorization is carried entirely by the record: the `Sig` column (verified by
|
|
396
|
+
* the `DeviceToken.AuthorizedUpdate` self-branch against the stored
|
|
397
|
+
* `CadrePeer.PublicKey`) plus the strictly-increasing `UpdatedAt`. No owner key
|
|
398
|
+
* is involved, so this is the refresh / rotation path for any member once both its
|
|
399
|
+
* `CadrePeer` row (for the PublicKey) and its `DeviceToken` row exist. `PeerId` is
|
|
400
|
+
* intentionally not in the SET list (immutable; the constraint enforces
|
|
401
|
+
* `new.PeerId = old.PeerId`). Mirrors {@link updateSelfPeerRecord}.
|
|
402
|
+
*/
|
|
403
|
+
async updateSelfDeviceToken(record) {
|
|
404
|
+
this.ensureInitialized();
|
|
405
|
+
await this.db.exec(`
|
|
406
|
+
update CadreControl.DeviceToken
|
|
407
|
+
with context OwnerKey = null, Signature = ?
|
|
408
|
+
set Platform = ?, Token = ?, UpdatedAt = ?, Sig = ?
|
|
409
|
+
where PeerId = ?
|
|
410
|
+
`, [record.sig, record.platform, record.token, record.updatedAt, record.sig, record.peerId]);
|
|
411
|
+
log('Self device token updated: %s (platform=%s, updatedAt=%d)', record.peerId, record.platform, record.updatedAt);
|
|
412
|
+
}
|
|
413
|
+
/**
|
|
414
|
+
* Insert the initial owner key (bootstrap - no existing owners required)
|
|
415
|
+
*/
|
|
416
|
+
async insertOwnerKey(key) {
|
|
417
|
+
this.ensureInitialized();
|
|
418
|
+
log('Inserting owner key: %s', key);
|
|
419
|
+
// Bootstrap is authorized by the schema's `(select count(1) from OwnerKey) <= 1`
|
|
420
|
+
// branch, so no signature is needed. We still persist a fresh, unique StampId in the
|
|
421
|
+
// row's own column to satisfy the not-null/unique anti-replay constraint — the StampId
|
|
422
|
+
// is now a real column value, not the optimystic `StampId()` SQL function.
|
|
423
|
+
const stampId = generateStampId(this.config.libp2pNode.peerId.toString());
|
|
424
|
+
await this.db.exec(`
|
|
425
|
+
insert into CadreControl.OwnerKey (Key, StampId)
|
|
426
|
+
with context OwnerKey = null, Signature = null
|
|
427
|
+
values (?, ?)
|
|
428
|
+
`, [key, stampId]);
|
|
429
|
+
log('Owner key inserted');
|
|
430
|
+
}
|
|
431
|
+
/**
|
|
432
|
+
* Insert a strand into the control database using an owner signature.
|
|
433
|
+
*
|
|
434
|
+
* The owner signs the canonical row-bound authorization message (see
|
|
435
|
+
* {@link buildAuthorizationMessage}) — NOT a bare stamp — so the signature is bound to
|
|
436
|
+
* this strand's contents and cannot be transplanted onto an attacker-chosen row. The
|
|
437
|
+
* StampId is persisted as a unique column for single-use anti-replay.
|
|
259
438
|
*
|
|
260
439
|
* @param strandId - Unique identifier for the strand
|
|
261
440
|
* @param type - Strand type: 'o' for open, 'c' for closed
|
|
262
|
-
* @param
|
|
263
|
-
* @param
|
|
441
|
+
* @param ownerKey - Public key of the authorizing owner
|
|
442
|
+
* @param signMessage - Function that ed25519-signs the raw message bytes (no pre-hash)
|
|
443
|
+
* with the owner's private key, returning a base64url signature
|
|
264
444
|
* @param memberPrivateKey - Optional private key for membership in closed strands
|
|
265
445
|
*/
|
|
266
|
-
async insertStrand(strandId, type,
|
|
446
|
+
async insertStrand(strandId, type, ownerKey, signMessage, memberPrivateKey) {
|
|
267
447
|
this.ensureInitialized();
|
|
268
448
|
log('Inserting strand: %s (type: %s)', strandId, type);
|
|
269
449
|
// Generate a unique stamp ID using the peer ID for distributed uniqueness
|
|
270
450
|
const peerId = this.config.libp2pNode.peerId.toString();
|
|
271
451
|
const stampId = generateStampId(peerId);
|
|
272
|
-
//
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
452
|
+
// Field order MUST match the schema's Strand `Authorized` verify:
|
|
453
|
+
// Id, Type, MemberPrivateKey ('' when null), StampId.
|
|
454
|
+
const message = buildAuthorizationMessage([strandId, type, memberPrivateKey ?? '', stampId]);
|
|
455
|
+
const signature = signMessage(message);
|
|
456
|
+
// StampId is a real, unique column (single-use anti-replay), no longer a context value.
|
|
457
|
+
await this.db.exec(`
|
|
458
|
+
insert into CadreControl.Strand (Id, Type, MemberPrivateKey, StampId)
|
|
459
|
+
with context OwnerKey = ?, Signature = ?
|
|
460
|
+
values (?, ?, ?, ?)
|
|
461
|
+
`, [ownerKey, signature, strandId, type, memberPrivateKey ?? null, stampId]);
|
|
280
462
|
log('Strand inserted: %s', strandId);
|
|
281
463
|
}
|
|
464
|
+
/**
|
|
465
|
+
* Insert a validation key into the control database using an owner signature.
|
|
466
|
+
*
|
|
467
|
+
* Mirrors {@link insertStrand}: the owner signs the canonical row-bound
|
|
468
|
+
* authorization message over (Key, StampId), and the StampId is persisted as a unique
|
|
469
|
+
* column for single-use anti-replay. A `ValidationKey` authorizes verifying strand
|
|
470
|
+
* formation disclosures.
|
|
471
|
+
*
|
|
472
|
+
* @param key - The validation public key to enroll
|
|
473
|
+
* @param ownerKey - Public key of the authorizing owner
|
|
474
|
+
* @param signMessage - Function that ed25519-signs the raw message bytes (no pre-hash)
|
|
475
|
+
* with the owner's private key, returning a base64url signature
|
|
476
|
+
*/
|
|
477
|
+
async insertValidationKey(key, ownerKey, signMessage) {
|
|
478
|
+
this.ensureInitialized();
|
|
479
|
+
log('Inserting validation key: %s', key);
|
|
480
|
+
const peerId = this.config.libp2pNode.peerId.toString();
|
|
481
|
+
const stampId = generateStampId(peerId);
|
|
482
|
+
// Field order MUST match the schema's ValidationKey `Authorized` verify: Key, StampId.
|
|
483
|
+
const message = buildAuthorizationMessage([key, stampId]);
|
|
484
|
+
const signature = signMessage(message);
|
|
485
|
+
await this.db.exec(`
|
|
486
|
+
insert into CadreControl.ValidationKey (Key, StampId)
|
|
487
|
+
with context OwnerKey = ?, Signature = ?
|
|
488
|
+
values (?, ?)
|
|
489
|
+
`, [ownerKey, signature, key, stampId]);
|
|
490
|
+
log('Validation key inserted: %s', key);
|
|
491
|
+
}
|
|
492
|
+
/**
|
|
493
|
+
* Insert an owner-signed `FormationInvite` (open invitation token).
|
|
494
|
+
*
|
|
495
|
+
* The invite is the on-network record that later authorizes an
|
|
496
|
+
* owner-signature-FREE `Strand` creation: an invited cadre peer redeems it
|
|
497
|
+
* by inserting a matching `FormationUsage` row (see {@link redeemInvitation}),
|
|
498
|
+
* which satisfies the consent branch of `Strand.Authorized`.
|
|
499
|
+
*
|
|
500
|
+
* Like {@link insertStrand}/{@link insertValidationKey}, the owner signs the
|
|
501
|
+
* canonical row-bound authorization message (see {@link buildAuthorizationMessage})
|
|
502
|
+
* over (Token, sAppId, ExpiresAt, TotalUses, ValidationUrl, StrandId, StampId) — NOT a
|
|
503
|
+
* bare stamp — so the signature is bound to this invite's contents and cannot be
|
|
504
|
+
* transplanted onto an attacker-chosen row. The StampId is persisted as a unique
|
|
505
|
+
* column for single-use anti-replay. `FormationInvite.AuthorizedAddOrRemove` gates
|
|
506
|
+
* both insert and delete; its `coalesce(new, old)` verify binds the NEW row on
|
|
507
|
+
* insert and the OLD row on delete.
|
|
508
|
+
*
|
|
509
|
+
* `StrandId` binds the invite to a pre-existing host strand (provision-then-record):
|
|
510
|
+
* when set, a responder redeeming this token records a `FormationUsage` against that
|
|
511
|
+
* strand and returns it (see {@link ControlFormationUsageRecorder.resolveStrand}); a
|
|
512
|
+
* null `StrandId` (the default) leaves the legacy responder-provisions path in place.
|
|
513
|
+
* Like ValidationUrl it is a nullable bound field, signed as `''` when absent.
|
|
514
|
+
*
|
|
515
|
+
* The ExpiresAt and TotalUses message fields must byte-match what the (auto-deferred,
|
|
516
|
+
* because it has a subquery) CHECK sees AFTER column coercion: TotalUses becomes a
|
|
517
|
+
* decimal string (`String(totalUses)` ⇔ `cast(new.TotalUses as text)`) and ExpiresAt
|
|
518
|
+
* becomes the engine's canonical `PlainDateTime` string — sourced here from
|
|
519
|
+
* {@link canonicalDatetime} (a `select datetime(?)` round-trip) rather than a hand-rolled
|
|
520
|
+
* ISO formatter, so signer and verifier agree exactly. A null ExpiresAt / TotalUses /
|
|
521
|
+
* ValidationUrl signs as `''`, matching the schema's `coalesce(..., '')`.
|
|
522
|
+
*
|
|
523
|
+
* @param token - Invitation token (the `FormationInvite` primary key)
|
|
524
|
+
* @param sAppId - The sApp a redeemed strand will use
|
|
525
|
+
* @param ownerKey - Public key of the authorizing owner
|
|
526
|
+
* @param signMessage - ed25519-signs the raw message bytes (no pre-hash),
|
|
527
|
+
* returning a base64url signature — the same callback shape {@link insertStrand} uses
|
|
528
|
+
* @param options - Optional `expiresAtMs` (epoch ms), `totalUses`, `validationUrl`,
|
|
529
|
+
* `strandId` (bind to a pre-existing host strand for provision-then-record)
|
|
530
|
+
*/
|
|
531
|
+
async insertFormationInvite(token, sAppId, ownerKey, signMessage, options = {}) {
|
|
532
|
+
this.ensureInitialized();
|
|
533
|
+
log('Inserting formation invite: %s', token);
|
|
534
|
+
const stampId = generateStampId(this.config.libp2pNode.peerId.toString());
|
|
535
|
+
// Build each bound field exactly as the deferred CHECK sees it post-coercion:
|
|
536
|
+
// - ExpiresAt: engine-canonical datetime string (or '' when absent), sourced from
|
|
537
|
+
// the engine so it byte-matches the column's stored/coerced form.
|
|
538
|
+
// - TotalUses: decimal string via String(...) (⇔ cast(new.TotalUses as text)), '' when absent.
|
|
539
|
+
// - ValidationUrl: the url or '' when absent.
|
|
540
|
+
// - StrandId: the host strand id or '' when absent (text column, no coercion).
|
|
541
|
+
const expiresAtCanonical = options.expiresAtMs == null
|
|
542
|
+
? null
|
|
543
|
+
: await canonicalDatetime(this.db, options.expiresAtMs);
|
|
544
|
+
const expiresAtField = expiresAtCanonical ?? '';
|
|
545
|
+
const totalUsesField = options.totalUses == null ? '' : String(options.totalUses);
|
|
546
|
+
const validationUrlField = options.validationUrl ?? '';
|
|
547
|
+
const strandIdField = options.strandId ?? '';
|
|
548
|
+
// Field order MUST match the schema's FormationInvite `AuthorizedAddOrRemove` verify:
|
|
549
|
+
// Token, sAppId, ExpiresAt, TotalUses, ValidationUrl, StrandId, StampId.
|
|
550
|
+
const message = buildAuthorizationMessage([
|
|
551
|
+
token, sAppId, expiresAtField, totalUsesField, validationUrlField, strandIdField, stampId,
|
|
552
|
+
]);
|
|
553
|
+
const signature = signMessage(message);
|
|
554
|
+
// Persist the canonical ExpiresAt string (datetime parse is idempotent on it) so the
|
|
555
|
+
// signed source-of-truth and the stored value are produced once. StampId is a real,
|
|
556
|
+
// unique column (single-use anti-replay), no longer a context value.
|
|
557
|
+
await this.db.exec(`
|
|
558
|
+
insert into CadreControl.FormationInvite (Token, sAppId, ExpiresAt, TotalUses, ValidationUrl, StrandId, StampId)
|
|
559
|
+
with context OwnerKey = ?, Signature = ?
|
|
560
|
+
values (?, ?, ?, ?, ?, ?, ?)
|
|
561
|
+
`, [
|
|
562
|
+
ownerKey, signature,
|
|
563
|
+
token, sAppId,
|
|
564
|
+
expiresAtCanonical,
|
|
565
|
+
options.totalUses ?? null,
|
|
566
|
+
options.validationUrl ?? null,
|
|
567
|
+
options.strandId ?? null,
|
|
568
|
+
stampId,
|
|
569
|
+
]);
|
|
570
|
+
log('Formation invite inserted: %s', token);
|
|
571
|
+
}
|
|
572
|
+
/**
|
|
573
|
+
* Redeem a `FormationInvite` by inserting the `Strand` row and a matching
|
|
574
|
+
* `FormationUsage` row **atomically, in one transaction**.
|
|
575
|
+
*
|
|
576
|
+
* The two CHECK constraints are mutually circular under immediate evaluation:
|
|
577
|
+
* `Strand.Authorized`'s consent branch requires the `FormationUsage` row, while
|
|
578
|
+
* `FormationUsage.StrandExists` requires the `Strand` row. Both CHECKs contain
|
|
579
|
+
* subqueries, so Quereus auto-defers them to transaction commit — wrapping both
|
|
580
|
+
* inserts in a single explicit `begin … commit` lets both deferred CHECKs see
|
|
581
|
+
* both rows at commit. The strand is authorised WITHOUT an owner signature
|
|
582
|
+
* (the `FormationUsage` branch of `Strand.Authorized`) but still gets a fresh,
|
|
583
|
+
* unique `StampId` column to satisfy the not-null/unique anti-replay column.
|
|
584
|
+
*
|
|
585
|
+
* `UseNumber` is computed as `max(UseNumber)+1` for the token (the `Monotonic`
|
|
586
|
+
* constraint); callers redeeming concurrently against the same token must
|
|
587
|
+
* serialise, since the next use number is read before the insert.
|
|
588
|
+
*/
|
|
589
|
+
async redeemInvitation(params) {
|
|
590
|
+
this.ensureInitialized();
|
|
591
|
+
const { token, strandId, type = 'o', memberPrivateKey, disclosure = '', peerId, peerSignature, nowMs, validationKey, validationSignature, } = params;
|
|
592
|
+
log('Redeeming invitation %s -> strand %s', token, strandId);
|
|
593
|
+
const localPeerId = this.config.libp2pNode.peerId.toString();
|
|
594
|
+
const strandStampId = generateStampId(localPeerId);
|
|
595
|
+
const useNumber = await this.nextUseNumber(token);
|
|
596
|
+
await this.db.beginTransaction();
|
|
597
|
+
try {
|
|
598
|
+
// 1. Strand row — authorised by the FormationUsage branch (no owner sig),
|
|
599
|
+
// still carrying a fresh unique StampId for the anti-replay column.
|
|
600
|
+
await this.db.exec(`
|
|
601
|
+
insert into CadreControl.Strand (Id, Type, MemberPrivateKey, StampId)
|
|
602
|
+
with context OwnerKey = null, Signature = null
|
|
603
|
+
values (?, ?, ?, ?)
|
|
604
|
+
`, [strandId, type, memberPrivateKey ?? null, strandStampId]);
|
|
605
|
+
// 2. FormationUsage row — authorised by the matching FormationInvite.
|
|
606
|
+
await this.execFormationUsageInsert({
|
|
607
|
+
token, useNumber, disclosure, strandId,
|
|
608
|
+
peerId: peerId ?? localPeerId, peerSignature: peerSignature ?? null, nowMs: nowMs ?? Date.now(),
|
|
609
|
+
validationKey: validationKey ?? null, validationSignature: validationSignature ?? null,
|
|
610
|
+
});
|
|
611
|
+
await this.db.commit();
|
|
612
|
+
log('Redeemed invitation %s -> strand %s (use #%d)', token, strandId, useNumber);
|
|
613
|
+
}
|
|
614
|
+
catch (error) {
|
|
615
|
+
// A failed commit() already tears down the transaction, so rollback() would
|
|
616
|
+
// throw "No transaction active" and mask the real cause — swallow only that.
|
|
617
|
+
try {
|
|
618
|
+
await this.db.rollback();
|
|
619
|
+
}
|
|
620
|
+
catch (rollbackError) {
|
|
621
|
+
log('Rollback after redemption failure was a no-op: %s', rollbackError);
|
|
622
|
+
}
|
|
623
|
+
throw error;
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
/**
|
|
627
|
+
* Record a `FormationUsage` against an **already-existing** `Strand` (no strand
|
|
628
|
+
* insert). This is the redemption path when the strand was provisioned
|
|
629
|
+
* separately (e.g. owner-signed) and the consent record is added after the
|
|
630
|
+
* fact: the single insert auto-commits, and the deferred `StrandExists` CHECK
|
|
631
|
+
* is satisfied by the pre-existing committed strand row. Returns the assigned
|
|
632
|
+
* `UseNumber`.
|
|
633
|
+
*
|
|
634
|
+
* Use {@link redeemInvitation} instead when the strand must be created by
|
|
635
|
+
* consent atomically with the usage.
|
|
636
|
+
*/
|
|
637
|
+
async recordFormationUsage(params) {
|
|
638
|
+
this.ensureInitialized();
|
|
639
|
+
const { token, strandId, disclosure = '', peerId, peerSignature, nowMs, validationKey, validationSignature, } = params;
|
|
640
|
+
const localPeerId = this.config.libp2pNode.peerId.toString();
|
|
641
|
+
const useNumber = await this.nextUseNumber(token);
|
|
642
|
+
await this.execFormationUsageInsert({
|
|
643
|
+
token, useNumber, disclosure, strandId,
|
|
644
|
+
peerId: peerId ?? localPeerId, peerSignature: peerSignature ?? null, nowMs: nowMs ?? Date.now(),
|
|
645
|
+
validationKey: validationKey ?? null, validationSignature: validationSignature ?? null,
|
|
646
|
+
});
|
|
647
|
+
log('Recorded formation usage: token=%s strand=%s (use #%d)', token, strandId, useNumber);
|
|
648
|
+
return useNumber;
|
|
649
|
+
}
|
|
650
|
+
/** Parameterised `FormationUsage` insert shared by redeem + record paths. */
|
|
651
|
+
async execFormationUsageInsert(opts) {
|
|
652
|
+
// Derive `context.Now` through the same `canonicalDatetime` transform that
|
|
653
|
+
// produced the stored `ExpiresAt`, so the deferred CHECK's `FI.ExpiresAt >
|
|
654
|
+
// context.Now` compares two byte-identical engine-`datetime` strings. The
|
|
655
|
+
// previous `new Date(nowMs).toISOString()` form differed only by a trailing
|
|
656
|
+
// `.000Z` (the engine `datetime()` separator is `T`, not a space), which never
|
|
657
|
+
// flipped the strict `>` against a second-granular `ExpiresAt` — so this is a
|
|
658
|
+
// robustness/consistency change, matching the strand layer's `consumeInvite`,
|
|
659
|
+
// not a fix for an observable mis-ordering.
|
|
660
|
+
const nowCanonical = await canonicalDatetime(this.db, opts.nowMs);
|
|
661
|
+
await this.db.exec(`
|
|
662
|
+
insert into CadreControl.FormationUsage (Token, UseNumber, Disclosure, StrandId)
|
|
663
|
+
with context PeerId = ?, PeerSignature = ?, Now = ?, ValidationKey = ?, ValidationSignature = ?
|
|
664
|
+
values (?, ?, ?, ?)
|
|
665
|
+
`, [
|
|
666
|
+
opts.peerId, opts.peerSignature, nowCanonical,
|
|
667
|
+
opts.validationKey, opts.validationSignature,
|
|
668
|
+
opts.token, opts.useNumber, opts.disclosure, opts.strandId,
|
|
669
|
+
]);
|
|
670
|
+
}
|
|
671
|
+
/**
|
|
672
|
+
* Read a `FormationInvite` row by token, or null when absent. `expiresAtMs` is
|
|
673
|
+
* the parsed epoch-ms of the stored `datetime` (null when the invite never
|
|
674
|
+
* expires); the caller compares it against the wall clock for freshness.
|
|
675
|
+
*/
|
|
676
|
+
async queryFormationInvite(token) {
|
|
677
|
+
this.ensureInitialized();
|
|
678
|
+
for await (const row of this.db.eval('select Token, sAppId, ExpiresAt, TotalUses, ValidationUrl, StrandId from CadreControl.FormationInvite where Token = ?', [token])) {
|
|
679
|
+
const expiresAt = row.ExpiresAt;
|
|
680
|
+
const expiresAtMs = expiresAt === null || expiresAt === undefined
|
|
681
|
+
? null
|
|
682
|
+
: parseStoredDatetimeMs(expiresAt);
|
|
683
|
+
return {
|
|
684
|
+
token: row.Token,
|
|
685
|
+
sAppId: row.sAppId,
|
|
686
|
+
expiresAtMs: expiresAtMs !== null && Number.isNaN(expiresAtMs) ? null : expiresAtMs,
|
|
687
|
+
totalUses: row.TotalUses ?? null,
|
|
688
|
+
validationUrl: row.ValidationUrl ?? null,
|
|
689
|
+
strandId: row.StrandId ?? null,
|
|
690
|
+
};
|
|
691
|
+
}
|
|
692
|
+
return null;
|
|
693
|
+
}
|
|
694
|
+
/**
|
|
695
|
+
* Count `FormationUsage` rows recorded against a token (uses consumed so far).
|
|
696
|
+
*/
|
|
697
|
+
async countFormationUsage(token) {
|
|
698
|
+
this.ensureInitialized();
|
|
699
|
+
for await (const row of this.db.eval('select count(1) as Count from CadreControl.FormationUsage where Token = ?', [token])) {
|
|
700
|
+
return row.Count ?? 0;
|
|
701
|
+
}
|
|
702
|
+
return 0;
|
|
703
|
+
}
|
|
704
|
+
/** Next `UseNumber` for a token = max(existing)+1, per the `Monotonic` constraint. */
|
|
705
|
+
async nextUseNumber(token) {
|
|
706
|
+
for await (const row of this.db.eval('select coalesce(max(UseNumber), 0) as MaxUse from CadreControl.FormationUsage where Token = ?', [token])) {
|
|
707
|
+
return row.MaxUse + 1;
|
|
708
|
+
}
|
|
709
|
+
return 1;
|
|
710
|
+
}
|
|
282
711
|
/**
|
|
283
712
|
* Close the database and cleanup resources
|
|
284
713
|
*/
|
|
@@ -288,7 +717,7 @@ export class ControlDatabase {
|
|
|
288
717
|
this.collectionFactory = null;
|
|
289
718
|
}
|
|
290
719
|
if (this.db) {
|
|
291
|
-
this.db.close();
|
|
720
|
+
void this.db.close();
|
|
292
721
|
this.db = null;
|
|
293
722
|
}
|
|
294
723
|
this.initialized = false;
|