@serfab/cadre-core 0.7.1 → 0.8.1

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.
Files changed (166) hide show
  1. package/README.md +9 -8
  2. package/dist/authority-key.d.ts +41 -0
  3. package/dist/authority-key.js +52 -0
  4. package/dist/authority-key.js.map +1 -0
  5. package/dist/cadre-node.d.ts +770 -11
  6. package/dist/cadre-node.js +1900 -88
  7. package/dist/cadre-node.js.map +1 -1
  8. package/dist/canonical-datetime.d.ts +33 -0
  9. package/dist/canonical-datetime.js +38 -0
  10. package/dist/canonical-datetime.js.map +1 -0
  11. package/dist/canonical-json.d.ts +12 -0
  12. package/dist/canonical-json.js +33 -0
  13. package/dist/canonical-json.js.map +1 -0
  14. package/dist/control-cohort.d.ts +63 -0
  15. package/dist/control-cohort.js +80 -0
  16. package/dist/control-cohort.js.map +1 -0
  17. package/dist/control-database.d.ts +259 -5
  18. package/dist/control-database.js +541 -128
  19. package/dist/control-database.js.map +1 -1
  20. package/dist/control-formation-recorder.d.ts +89 -0
  21. package/dist/control-formation-recorder.js +139 -0
  22. package/dist/control-formation-recorder.js.map +1 -0
  23. package/dist/control-schema.d.ts +12 -0
  24. package/dist/control-schema.js +199 -0
  25. package/dist/control-schema.js.map +1 -0
  26. package/dist/control-stream.d.ts +54 -0
  27. package/dist/control-stream.js +104 -0
  28. package/dist/control-stream.js.map +1 -0
  29. package/dist/device-token.d.ts +50 -0
  30. package/dist/device-token.js +66 -0
  31. package/dist/device-token.js.map +1 -0
  32. package/dist/diagnostics/connection-path.d.ts +118 -0
  33. package/dist/diagnostics/connection-path.js +204 -0
  34. package/dist/diagnostics/connection-path.js.map +1 -0
  35. package/dist/diagnostics/webrtc-turn-tracker.d.ts +50 -0
  36. package/dist/diagnostics/webrtc-turn-tracker.js +196 -0
  37. package/dist/diagnostics/webrtc-turn-tracker.js.map +1 -0
  38. package/dist/hibernation-manager.d.ts +79 -0
  39. package/dist/hibernation-manager.js +173 -23
  40. package/dist/hibernation-manager.js.map +1 -1
  41. package/dist/index.d.ts +25 -4
  42. package/dist/index.js +59 -4
  43. package/dist/index.js.map +1 -1
  44. package/dist/key-store-file.d.ts +52 -0
  45. package/dist/key-store-file.js +194 -0
  46. package/dist/key-store-file.js.map +1 -0
  47. package/dist/key-store.d.ts +75 -0
  48. package/dist/key-store.js +66 -0
  49. package/dist/key-store.js.map +1 -0
  50. package/dist/peer-authorization.d.ts +26 -0
  51. package/dist/peer-authorization.js +40 -0
  52. package/dist/peer-authorization.js.map +1 -0
  53. package/dist/peer-record.d.ts +89 -0
  54. package/dist/peer-record.js +117 -0
  55. package/dist/peer-record.js.map +1 -0
  56. package/dist/push-credentials.d.ts +34 -0
  57. package/dist/push-credentials.js +81 -0
  58. package/dist/push-credentials.js.map +1 -0
  59. package/dist/push-fanout.d.ts +137 -0
  60. package/dist/push-fanout.js +197 -0
  61. package/dist/push-fanout.js.map +1 -0
  62. package/dist/push-notifier-apns.d.ts +53 -0
  63. package/dist/push-notifier-apns.js +195 -0
  64. package/dist/push-notifier-apns.js.map +1 -0
  65. package/dist/push-notifier-fcm.d.ts +38 -0
  66. package/dist/push-notifier-fcm.js +183 -0
  67. package/dist/push-notifier-fcm.js.map +1 -0
  68. package/dist/push-notifier-shared.d.ts +18 -0
  69. package/dist/push-notifier-shared.js +25 -0
  70. package/dist/push-notifier-shared.js.map +1 -0
  71. package/dist/push-notifier.d.ts +71 -0
  72. package/dist/push-notifier.js +50 -0
  73. package/dist/push-notifier.js.map +1 -0
  74. package/dist/schema-verification.d.ts +11 -2
  75. package/dist/schema-verification.js +15 -4
  76. package/dist/schema-verification.js.map +1 -1
  77. package/dist/seed-bootstrap.d.ts +197 -9
  78. package/dist/seed-bootstrap.js +396 -137
  79. package/dist/seed-bootstrap.js.map +1 -1
  80. package/dist/seed-trust-policy.d.ts +63 -0
  81. package/dist/seed-trust-policy.js +56 -0
  82. package/dist/seed-trust-policy.js.map +1 -0
  83. package/dist/strand-addr-protocol.d.ts +132 -0
  84. package/dist/strand-addr-protocol.js +313 -0
  85. package/dist/strand-addr-protocol.js.map +1 -0
  86. package/dist/strand-cohort.d.ts +54 -0
  87. package/dist/strand-cohort.js +38 -0
  88. package/dist/strand-cohort.js.map +1 -0
  89. package/dist/strand-database.d.ts +54 -6
  90. package/dist/strand-database.js +88 -100
  91. package/dist/strand-database.js.map +1 -1
  92. package/dist/strand-formation-manager.d.ts +79 -19
  93. package/dist/strand-formation-manager.js +196 -104
  94. package/dist/strand-formation-manager.js.map +1 -1
  95. package/dist/strand-formation-protocol.d.ts +162 -0
  96. package/dist/strand-formation-protocol.js +279 -0
  97. package/dist/strand-formation-protocol.js.map +1 -0
  98. package/dist/strand-instance-manager.d.ts +73 -4
  99. package/dist/strand-instance-manager.js +167 -41
  100. package/dist/strand-instance-manager.js.map +1 -1
  101. package/dist/strand-member-key.d.ts +29 -0
  102. package/dist/strand-member-key.js +38 -0
  103. package/dist/strand-member-key.js.map +1 -0
  104. package/dist/strand-member-registry.d.ts +98 -0
  105. package/dist/strand-member-registry.js +129 -0
  106. package/dist/strand-member-registry.js.map +1 -0
  107. package/dist/strand-membership-writer.d.ts +361 -0
  108. package/dist/strand-membership-writer.js +449 -0
  109. package/dist/strand-membership-writer.js.map +1 -0
  110. package/dist/strand-solicitation.d.ts +77 -2
  111. package/dist/strand-solicitation.js +25 -4
  112. package/dist/strand-solicitation.js.map +1 -1
  113. package/dist/strand-wake-payload.d.ts +30 -0
  114. package/dist/strand-wake-payload.js +20 -0
  115. package/dist/strand-wake-payload.js.map +1 -0
  116. package/dist/strand-wake-protocol.d.ts +114 -0
  117. package/dist/strand-wake-protocol.js +257 -0
  118. package/dist/strand-wake-protocol.js.map +1 -0
  119. package/dist/strand-watcher.d.ts +6 -2
  120. package/dist/strand-watcher.js +57 -17
  121. package/dist/strand-watcher.js.map +1 -1
  122. package/dist/types.d.ts +406 -23
  123. package/dist/types.js +22 -5
  124. package/dist/types.js.map +1 -1
  125. package/package.json +14 -8
  126. package/src/authority-key.ts +68 -0
  127. package/src/cadre-node.ts +2937 -948
  128. package/src/canonical-datetime.ts +39 -0
  129. package/src/canonical-json.ts +30 -0
  130. package/src/control-cohort.ts +103 -0
  131. package/src/control-database.ts +931 -383
  132. package/src/control-formation-recorder.ts +151 -0
  133. package/src/control-schema.ts +198 -0
  134. package/src/control-stream.ts +128 -0
  135. package/src/device-token.ts +89 -0
  136. package/src/diagnostics/connection-path.ts +277 -0
  137. package/src/diagnostics/webrtc-turn-tracker.ts +222 -0
  138. package/src/hibernation-manager.ts +197 -28
  139. package/src/index.ts +273 -79
  140. package/src/key-store-file.ts +203 -0
  141. package/src/key-store.ts +99 -0
  142. package/src/peer-authorization.ts +53 -0
  143. package/src/peer-record.ts +146 -0
  144. package/src/push-credentials.ts +84 -0
  145. package/src/push-fanout.ts +272 -0
  146. package/src/push-notifier-apns.ts +243 -0
  147. package/src/push-notifier-fcm.ts +221 -0
  148. package/src/push-notifier-shared.ts +28 -0
  149. package/src/push-notifier.ts +93 -0
  150. package/src/schema-verification.ts +18 -4
  151. package/src/seed-bootstrap.ts +464 -188
  152. package/src/seed-trust-policy.ts +107 -0
  153. package/src/strand-addr-protocol.ts +410 -0
  154. package/src/strand-cohort.ts +77 -0
  155. package/src/strand-database.ts +113 -141
  156. package/src/strand-formation-manager.ts +233 -142
  157. package/src/strand-formation-protocol.ts +391 -0
  158. package/src/strand-instance-manager.ts +207 -56
  159. package/src/strand-member-key.ts +39 -0
  160. package/src/strand-member-registry.ts +177 -0
  161. package/src/strand-membership-writer.ts +640 -0
  162. package/src/strand-solicitation.ts +93 -4
  163. package/src/strand-wake-payload.ts +32 -0
  164. package/src/strand-wake-protocol.ts +331 -0
  165. package/src/strand-watcher.ts +61 -17
  166. package/src/types.ts +1012 -582
@@ -0,0 +1,151 @@
1
+ import debug from 'debug';
2
+ import { randomBytes } from '@optimystic/quereus-plugin-crypto';
3
+ import type { ControlDatabase } from './control-database.js';
4
+ import type { FormationUsageRecorder, ResolvedHostStrand } from './strand-solicitation.js';
5
+ import type { OpenInvitation } from './types.js';
6
+
7
+ const log = debug('sereus:cadre:formation-recorder');
8
+
9
+ /** Far-future sentinel for an invitation that never expires (max valid JS Date). */
10
+ const NEVER_EXPIRES = new Date(8640000000000000);
11
+
12
+ /**
13
+ * {@link FormationUsageRecorder} backed by the real `CadreControl` tables.
14
+ *
15
+ * It reads `FormationInvite` / `FormationUsage` to answer token-validity and
16
+ * usage questions, and writes the consent row that records a redemption. Two
17
+ * provisioning shapes are supported, keyed on whether the invite binds a host strand:
18
+ *
19
+ * - **Bound (provision-then-record):** the host strand already exists (authority-signed
20
+ * up front and named by the invite's `StrandId`), so {@link resolveStrand} reports it
21
+ * and {@link recordUsage} writes the consent row against that pre-existing strand
22
+ * (record-only) rather than inserting a new `Strand`.
23
+ * - **Unbound (responder-provisions):** the invite carries no `StrandId`, so
24
+ * {@link provisionAndRecord} mints a fresh strand and records consent against it
25
+ * ATOMICALLY (one `FormationUsage` row), closing the single-use hole the older
26
+ * never-record fallback left open.
27
+ *
28
+ * This replaces the in-memory stubs used by the formation tests so the consent path
29
+ * is exercised against the persisted control network.
30
+ *
31
+ * Usage accounting follows the schema's `FormationUsage.Authorized` semantics:
32
+ * a null `TotalUses` means unlimited uses; otherwise the invite is "used up"
33
+ * once the recorded usage count reaches `TotalUses`.
34
+ */
35
+ export class ControlFormationUsageRecorder implements FormationUsageRecorder {
36
+ constructor(private readonly controlDatabase: ControlDatabase) {}
37
+
38
+ /** A token is valid when a matching, unexpired `FormationInvite` exists. */
39
+ async isTokenValid(token: string): Promise<{ valid: boolean; invitation?: OpenInvitation }> {
40
+ const invite = await this.controlDatabase.queryFormationInvite(token);
41
+ if (!invite) {
42
+ return { valid: false };
43
+ }
44
+ if (invite.expiresAtMs !== null && invite.expiresAtMs <= Date.now()) {
45
+ log('Token expired: %s', token);
46
+ return { valid: false };
47
+ }
48
+ return {
49
+ valid: true,
50
+ invitation: {
51
+ token: invite.token,
52
+ sAppId: invite.sAppId,
53
+ expiration: invite.expiresAtMs !== null ? new Date(invite.expiresAtMs) : NEVER_EXPIRES,
54
+ bootstrap: [],
55
+ },
56
+ };
57
+ }
58
+
59
+ /**
60
+ * A token is "used" when its recorded usage count has reached the invite's
61
+ * `TotalUses`. A null `TotalUses` (unlimited) is never used up; an unknown
62
+ * token is reported not-used (validity is handled by {@link isTokenValid}).
63
+ */
64
+ async isTokenUsed(token: string): Promise<boolean> {
65
+ const invite = await this.controlDatabase.queryFormationInvite(token);
66
+ if (!invite || invite.totalUses === null) {
67
+ return false;
68
+ }
69
+ const uses = await this.controlDatabase.countFormationUsage(token);
70
+ return uses >= invite.totalUses;
71
+ }
72
+
73
+ /**
74
+ * Record consent against an **already-existing** host strand (record-only): the
75
+ * single `FormationUsage` insert auto-commits and the deferred `StrandExists`
76
+ * CHECK is satisfied by the pre-existing strand. This is the provision-then-record
77
+ * commitment — the strand was minted authority-signed up front, so we do NOT
78
+ * re-insert it (which would double-insert the same PK). `initiatorKey` is carried
79
+ * as the usage `PeerId` (advisory). Use {@link ControlDatabase.redeemInvitation}
80
+ * for the consent-creates-strand path instead.
81
+ */
82
+ async recordUsage(token: string, initiatorKey: string, strandId: string): Promise<void> {
83
+ await this.controlDatabase.recordFormationUsage({ token, strandId, peerId: initiatorKey });
84
+ log('Recorded formation usage: token=%s strand=%s', token, strandId);
85
+ }
86
+
87
+ /**
88
+ * Classify the host strand this invite binds to (see {@link ResolvedHostStrand}):
89
+ *
90
+ * - no invite / no `StrandId` → `unbound` (responder-provisions path).
91
+ * - `StrandId` set AND the strand row is present → `bound`, carrying its
92
+ * `MemberPrivateKey` (the closed-strand read-gating secret) for delivery to a
93
+ * validated invitee.
94
+ * - `StrandId` set but the strand row is absent → `missing` (the host strand has not
95
+ * converged on this responder yet); the manager rejects cleanly instead of recording
96
+ * usage against a non-existent strand, which would fail the deferred `StrandExists`
97
+ * CHECK at commit and drop the result frame.
98
+ */
99
+ async resolveStrand(token: string): Promise<ResolvedHostStrand> {
100
+ const invite = await this.controlDatabase.queryFormationInvite(token);
101
+ if (!invite || !invite.strandId) {
102
+ return { kind: 'unbound' };
103
+ }
104
+ const strand = await this.controlDatabase.queryStrand(invite.strandId);
105
+ if (!strand) {
106
+ return { kind: 'missing', strandId: invite.strandId };
107
+ }
108
+ return {
109
+ kind: 'bound',
110
+ strandId: invite.strandId,
111
+ memberPrivateKey: strand.MemberPrivateKey ?? null,
112
+ };
113
+ }
114
+
115
+ /**
116
+ * Provision a NEW strand for an UNBOUND invite and record consent against it in ONE
117
+ * transaction (the responder-provisions fallback, now single-use-enforced).
118
+ *
119
+ * Mints a fresh, globally-unique strand id from {@link randomBytes} — the same
120
+ * cross-platform CSPRNG `control-database`'s `generateStampId` uses, NOT
121
+ * `crypto.randomUUID` / `Date.now` / `Math.random` (not uniformly available across
122
+ * node/browser/RN) — then delegates to {@link ControlDatabase.redeemInvitation}, whose
123
+ * single `begin … commit` inserts the consent-authorized `Strand` row AND the matching
124
+ * `FormationUsage` row together (both deferred CHECKs see both rows at commit). That one
125
+ * `FormationUsage` row makes the unbound redemption single-use exactly like the bound
126
+ * path: the next redemption of a `TotalUses:1` invite sees `count 1 >= 1` and is rejected.
127
+ *
128
+ * The strand is open (`'o'`) — an unbound responder-provisioned strand has no membership
129
+ * key — so the returned `memberPrivateKey` is null. `sAppId` is accepted for parity/future
130
+ * use; `redeemInvitation` does not currently thread it into the `Strand` row.
131
+ *
132
+ * A concurrent redemption of the same single-use invite collides on the
133
+ * `(Token, UseNumber)` PK and this call THROWS for the loser — the manager maps that to a
134
+ * clean protocol rejection (it never lets the dropped insert close the stream silently).
135
+ */
136
+ async provisionAndRecord(
137
+ token: string,
138
+ initiatorKey: string,
139
+ _sAppId: string
140
+ ): Promise<{ strandId: string; memberPrivateKey: string | null }> {
141
+ const strandId = `strand-${randomBytes(128, 'hex') as string}`;
142
+ await this.controlDatabase.redeemInvitation({
143
+ token,
144
+ strandId,
145
+ type: 'o',
146
+ peerId: initiatorKey,
147
+ });
148
+ log('Provisioned + recorded unbound strand: token=%s strand=%s', token, strandId);
149
+ return { strandId, memberPrivateKey: null };
150
+ }
151
+ }
@@ -0,0 +1,198 @@
1
+ /**
2
+ * Embedded control schema for cross-platform compatibility.
3
+ *
4
+ * This is the authoritative runtime copy of the CadreControl authorization
5
+ * schema. It is duplicated from `schemas/control.qsql` so that React Native and
6
+ * other filesystem-less environments get the schema without a runtime file read.
7
+ *
8
+ * The two copies MUST stay identical — `control-schema-drift.spec.ts` fails the
9
+ * build if they drift. Any edit here must be mirrored in `schemas/control.qsql`
10
+ * and vice versa.
11
+ */
12
+ export const CONTROL_SCHEMA = `-- This manages a Sereus party's cadre, or set of nodes, and their participation in strands (networks)
13
+ declare schema CadreControl {
14
+ -- A key that can authorize various control changes
15
+ table AuthorityKey (
16
+ Key text primary key,
17
+ StampId text not null unique, -- single-use authorization nonce (anti-replay)
18
+ constraint Authorized check (
19
+ -- Bootstrap: first authority key needs no existing authorization
20
+ (select count(1) from AuthorityKey) <= 1
21
+
22
+ -- Old authority authorizes by signing over THIS row (Key, StampId); single-use via unique StampId
23
+ or (old.Key is not null and old.Key = context.AuthorityKey and verify(digest(new.Key, new.StampId), context.Signature, old.Key, 'ed25519'))
24
+
25
+ -- or other authorities authorize by signing over THIS row (Key, StampId); single-use via unique StampId
26
+ or exists (select 1 from AuthorityKey A where A.Key = context.AuthorityKey and verify(digest(new.Key, new.StampId), context.Signature, A.Key, 'ed25519'))
27
+ )
28
+ ) with context (AuthorityKey text null, Signature text null);
29
+
30
+ -- A key that can validate a strand formation disclosure
31
+ table ValidationKey (
32
+ Key text primary key,
33
+ StampId text not null unique, -- single-use authorization nonce (anti-replay)
34
+ constraint Authorized check (
35
+ -- Authorities authorize by signing over THIS row (Key, StampId); single-use via unique StampId
36
+ exists (select 1 from AuthorityKey A where A.Key = context.AuthorityKey and verify(digest(new.Key, new.StampId), context.Signature, A.Key, 'ed25519'))
37
+ )
38
+ ) with context (AuthorityKey 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
+ StampId text not null unique, -- single-use authorization nonce (anti-replay)
47
+ constraint Authorized check (
48
+ -- Authorized by an authority signing over THIS row (Id, Type, MemberPrivateKey, StampId); single-use via unique StampId
49
+ exists (select 1 from AuthorityKey A where A.Key = context.AuthorityKey and verify(digest(new.Id, new.Type, coalesce(new.MemberPrivateKey, ''), new.StampId), context.Signature, A.Key, 'ed25519'))
50
+
51
+ -- or authorized by a cadre peer who has received a valid formation invitation.
52
+ -- That path has no signed writer yet; when one is built it must still supply a
53
+ -- fresh, unique StampId to satisfy the not-null/unique anti-replay column.
54
+ or exists (select 1 from FormationUsage FU where FU.StrandId = new.Id)
55
+ ),
56
+ constraint MemberKeyClosedOnly check (
57
+ -- An open strand ('o') has no membership gate, so it must not carry a
58
+ -- member key. A non-null MemberPrivateKey requires a closed strand ('c').
59
+ new.MemberPrivateKey is null or new.Type = 'c'
60
+ )
61
+ ) with context (AuthorityKey text, Signature text);
62
+
63
+ -- A peer (node) that is part of the cadre, carrying a self-published,
64
+ -- freshness-stamped, self-signed address record (see PeerAddressRecord in cadre-core).
65
+ -- The row IS the peer-address record: a resolver reads it, re-verifies Sig against
66
+ -- PublicKey, checks freshness (UpdatedAt) and trust, then dials the addrs.
67
+ table CadrePeer (
68
+ PeerId text primary key,
69
+ PublicKey text null, -- ed25519 (base64url) whose libp2p identity == PeerId (null if non-Ed25519)
70
+ Multiaddr text, -- comma-joined current addrs (signaling / p2p-circuit first)
71
+ UpdatedAt int null, -- epoch ms; strictly increases per self-update (replay/rollback guard)
72
+ Sig text null, -- ed25519 self-signature over the signed payload (base64url); null until self-published
73
+ constraint AuthorizedInsert check on insert, delete (
74
+ -- Authorized by an authority key, which vouches both membership and the
75
+ -- PublicKey<->PeerId binding (cadre-core derives PublicKey from PeerId before insert).
76
+ -- The single-field digest frames the base58btc PeerId as TEXT, matching
77
+ -- cadre-core peer-authorization.ts:peerAuthorizationDigest.
78
+ exists (select 1 from AuthorityKey A where A.Key = context.AuthorityKey and verify(digest(coalesce(new.PeerId, old.PeerId)), context.Signature, A.Key, 'ed25519'))
79
+ ),
80
+ constraint AuthorizedUpdate check on update (
81
+ -- Peer self-updates its own addrs + freshness, signing with its OWN ed25519 key
82
+ -- (the key behind PeerId). PeerId/PublicKey are immutable on self-update, UpdatedAt
83
+ -- must strictly increase (replay guard), and Sig is verified against the stored
84
+ -- PublicKey over the same payload the publish path signs
85
+ -- (cadre-core peer-record.ts:peerRecordSignedPayload).
86
+ (
87
+ new.PeerId = old.PeerId
88
+ and new.PublicKey = old.PublicKey
89
+ and new.UpdatedAt > coalesce(old.UpdatedAt, 0)
90
+ and verify(
91
+ digest(new.PeerId || '|' || new.Multiaddr || '|' || cast(new.UpdatedAt as text)),
92
+ new.Sig, new.PublicKey, 'ed25519')
93
+ )
94
+ -- or an authority re-authorizes (rotation / correction)
95
+ or exists (select 1 from AuthorityKey A where A.Key = context.AuthorityKey and verify(digest(new.PeerId), context.Signature, A.Key, 'ed25519'))
96
+ )
97
+ ) with context (AuthorityKey text null, Signature text);
98
+
99
+ -- A mobile cadre peer's platform push token (FCM/APNs), self-published so a
100
+ -- server peer can deliver a push-wake to a suspended app over the platform push
101
+ -- channel (a control-network dial cannot reach an OS-suspended process). The row
102
+ -- mirrors CadrePeer: a resolver re-verifies Sig against the CadrePeer.PublicKey for
103
+ -- this PeerId (see device-token.ts:deviceTokenSignedPayload), checks freshness, then
104
+ -- hands the token to the push sender.
105
+ table DeviceToken (
106
+ PeerId text primary key, -- the CadrePeer this token belongs to
107
+ Platform text not null, -- 'fcm' | 'apns'
108
+ Token text not null, -- opaque platform device/registration token
109
+ UpdatedAt int null, -- epoch ms; strictly increases per self-update (replay guard)
110
+ Sig text null, -- ed25519 self-sig over (PeerId|Platform|Token|UpdatedAt)
111
+ constraint AuthorizedInsert check on insert, delete (
112
+ -- Authorized by an authority key (membership vouch), exactly like CadrePeer.
113
+ -- The single-field digest frames the base58btc PeerId as TEXT.
114
+ exists (select 1 from AuthorityKey A where A.Key = context.AuthorityKey and verify(digest(coalesce(new.PeerId, old.PeerId)), context.Signature, A.Key, 'ed25519'))
115
+ ),
116
+ constraint AuthorizedUpdate check on update (
117
+ -- Peer self-updates its own token, signing with its OWN ed25519 key (the key
118
+ -- behind PeerId). PeerId is immutable, UpdatedAt must strictly increase (replay
119
+ -- guard), and Sig is verified against the stored CadrePeer.PublicKey over the
120
+ -- signed payload (cadre-core device-token.ts:deviceTokenSignedPayload). Platform
121
+ -- and Token may change on self-update (platform switch / reinstall / rotation).
122
+ (
123
+ new.PeerId = old.PeerId
124
+ and new.UpdatedAt > coalesce(old.UpdatedAt, 0)
125
+ and exists (select 1 from CadrePeer P where P.PeerId = new.PeerId and verify(
126
+ digest(new.PeerId || '|' || new.Platform || '|' || new.Token || '|' || cast(new.UpdatedAt as text)),
127
+ new.Sig, P.PublicKey, 'ed25519'))
128
+ )
129
+ -- or an authority re-authorizes (rotation / correction)
130
+ or exists (select 1 from AuthorityKey A where A.Key = context.AuthorityKey and verify(digest(new.PeerId), context.Signature, A.Key, 'ed25519'))
131
+ )
132
+ ) with context (AuthorityKey text null, Signature text);
133
+
134
+ -- An open invitation to form a strand with this party
135
+ table FormationInvite (
136
+ Token text primary key, -- Just a random string
137
+ sAppId text, -- The app for the strand that will be formed
138
+ ExpiresAt datetime null,
139
+ TotalUses int null check (TotalUses >= 0),
140
+ ValidationUrl text null, -- Web hook - send disclosure, IP address...
141
+ StrandId text null, -- Host strand this invite binds to (provision-then-record). Non-null => the responder records consent against this pre-existing strand; null => legacy responder-provisions path.
142
+ StampId text not null unique, -- single-use authorization nonce (anti-replay)
143
+ constraint AuthorizedAddOrRemove check on insert, delete (
144
+ -- Authorized by an authority signing over THIS row
145
+ -- (Token, sAppId, ExpiresAt, TotalUses, ValidationUrl, StrandId, StampId); single-use via unique StampId.
146
+ -- coalesce(new.F, old.F) binds the NEW row on insert and the OLD row on delete (cf. CadrePeer).
147
+ -- StrandId is a nullable bound field: it signs as '' when absent (cf. ValidationUrl).
148
+ exists (select 1 from AuthorityKey A where A.Key = context.AuthorityKey and verify(
149
+ digest(
150
+ coalesce(new.Token, old.Token),
151
+ coalesce(new.sAppId, old.sAppId),
152
+ coalesce(cast(coalesce(new.ExpiresAt, old.ExpiresAt) as text), ''),
153
+ coalesce(cast(coalesce(new.TotalUses, old.TotalUses) as text), ''),
154
+ coalesce(coalesce(new.ValidationUrl, old.ValidationUrl), ''),
155
+ coalesce(coalesce(new.StrandId, old.StrandId), ''),
156
+ coalesce(new.StampId, old.StampId)
157
+ ),
158
+ context.Signature, A.Key, 'ed25519'))
159
+ ),
160
+ -- Invites are insert/delete only: forbid in-place mutation so the row-bound
161
+ -- AuthorizedAddOrRemove signature cannot be sidestepped by updating consent
162
+ -- parameters (TotalUses/ExpiresAt/...) after a legitimate insert. A bare check
163
+ -- defaults to insert+update; the check on insert, delete above excludes update,
164
+ -- so this guard is required. Mirrors FormationUsage.InsertOnly.
165
+ constraint Immutable check on update (false)
166
+ ) with context (AuthorityKey text, Signature text);
167
+
168
+ table FormationUsage (
169
+ Token text,
170
+ UseNumber int,
171
+ Disclosure text,
172
+ StrandId text,
173
+ primary key (Token, UseNumber),
174
+ constraint InsertOnly check on update, delete (false),
175
+ constraint Monotonic check (
176
+ -- Read the PRE-transaction snapshot (committed.*): this CHECK auto-defers
177
+ -- to commit (it has a subquery), by which point the row being inserted is
178
+ -- live, so a plain from-FormationUsage would count this row itself and make
179
+ -- UseNumber = max+1 unsatisfiable. committed.* excludes the in-flight row,
180
+ -- giving sequential 1,2,3... per token (cross-transaction; concurrent
181
+ -- redemptions of the same token collide on the (Token, UseNumber) PK).
182
+ new.UseNumber = coalesce((select max(UseNumber) from committed.FormationUsage U where U.Token = new.Token), 0) + 1
183
+ ),
184
+ constraint Authorized check on insert (
185
+ -- Satisfies an invitation
186
+ exists (
187
+ select 1 from FormationInvite FI
188
+ where FI.Token = new.Token
189
+ and (FI.TotalUses is null or FI.TotalUses >= new.UseNumber)
190
+ and (FI.ExpiresAt is null or FI.ExpiresAt > context.Now)
191
+ and (FI.ValidationUrl is null or verify(digest(new.Token || new.Disclosure), context.ValidationSignature, context.ValidationKey, 'ed25519'))
192
+ )
193
+ ),
194
+ constraint StrandExists check (exists (select 1 from Strand S where S.Id = new.StrandId)),
195
+ ) with context (PeerId text, PeerSignature text, Now datetime, ValidationKey text null, ValidationSignature text null);
196
+ }
197
+
198
+ apply schema CadreControl;`;
@@ -0,0 +1,128 @@
1
+ /**
2
+ * Shared control-network stream primitives.
3
+ *
4
+ * The cadre control protocols — seed delivery (`seed-bootstrap.ts`), push-wake
5
+ * (`strand-wake-protocol.ts`), and strand formation (`strand-formation-protocol.ts`)
6
+ * — all speak 4-byte big-endian length-prefixed JSON frames over a libp2p 3.x
7
+ * stream. This module lifts the previously-triplicated stream shape, frame
8
+ * writer, and `withTimeout` wrapper into one place, and adds a timeout-bounded,
9
+ * abort-on-timeout read-to-EOF helper so a misbehaving peer that opens a stream
10
+ * and never half-closes its write end cannot pin a receiver forever.
11
+ *
12
+ * Dependency-free by design: it imports nothing from the protocol modules, so
13
+ * the import graph stays acyclic (`control-stream` ← `seed-bootstrap` ←
14
+ * `strand-wake-protocol`). In particular it returns RAW bytes from
15
+ * {@link readStreamToEnd} rather than decoding a frame — callers own the
16
+ * `decodeLengthPrefixedFrame` guard.
17
+ */
18
+
19
+ /**
20
+ * Minimal libp2p 3.x stream surface: AsyncIterable for reads, `send()` for
21
+ * writes, `close()` to half-close the write end (EOF), `abort()` to reset.
22
+ */
23
+ export interface ControlStream extends AsyncIterable<Uint8Array> {
24
+ send(data: Uint8Array): boolean;
25
+ close(): Promise<void>;
26
+ abort(err: Error): void;
27
+ }
28
+
29
+ /** Write a JSON object as a single 4-byte big-endian length-prefixed frame. */
30
+ export function writeFrame(stream: ControlStream, obj: unknown): void {
31
+ const body = new TextEncoder().encode(JSON.stringify(obj));
32
+ const prefix = new Uint8Array(4);
33
+ new DataView(prefix.buffer).setUint32(0, body.length, false);
34
+ stream.send(prefix);
35
+ stream.send(body);
36
+ }
37
+
38
+ /**
39
+ * Reject if `op` does not settle within `ms`. On timeout, invoke `onTimeout`
40
+ * (used by the sender to abort the in-flight dial/stream so it does not leak)
41
+ * before rejecting. A throwing `onTimeout` is swallowed so it cannot mask the
42
+ * timeout rejection. The rejection message is exactly `<label> timed out after
43
+ * <ms>ms`, so callers fold any domain prefix into `label`.
44
+ */
45
+ export function withTimeout<T>(
46
+ ms: number,
47
+ label: string,
48
+ op: () => Promise<T>,
49
+ onTimeout?: () => void,
50
+ ): Promise<T> {
51
+ return new Promise<T>((resolve, reject) => {
52
+ const timer = setTimeout(() => {
53
+ try {
54
+ onTimeout?.();
55
+ } catch {
56
+ // Best-effort: a throwing onTimeout must not replace the timeout error.
57
+ }
58
+ reject(new Error(`${label} timed out after ${ms}ms`));
59
+ }, ms);
60
+ op().then(resolve, reject).finally(() => clearTimeout(timer));
61
+ });
62
+ }
63
+
64
+ /** Normalize a libp2p chunk (Uint8Array or Uint8ArrayList) to a flat Uint8Array. */
65
+ function toBytes(chunk: Uint8Array | { subarray(): Uint8Array }): Uint8Array {
66
+ return chunk instanceof Uint8Array ? chunk : chunk.subarray();
67
+ }
68
+
69
+ /**
70
+ * Read a stream to EOF, capped at `maxBytes`, bounded by `timeoutMs`, returning
71
+ * the assembled bytes (the caller decodes via `decodeLengthPrefixedFrame`).
72
+ *
73
+ * Implemented as `Promise.race([readLoop, timeout])`:
74
+ * - The size cap throws synchronously inside `readLoop` (a well-behaved peer may
75
+ * have sent a real-but-too-large frame and could still be reading for the ack,
76
+ * so this path does NOT abort the stream — it just rejects).
77
+ * - The timeout path is the true hang: it ALSO calls `stream.abort(err)` to
78
+ * release the stuck read. The race guarantees the helper settles at the
79
+ * deadline even if `abort()` does not immediately unblock the iterator (e.g. a
80
+ * test double whose `abort()` is a no-op).
81
+ */
82
+ export function readStreamToEnd(
83
+ stream: ControlStream,
84
+ opts: { maxBytes: number; timeoutMs: number; label: string },
85
+ ): Promise<Uint8Array> {
86
+ const { maxBytes, timeoutMs, label } = opts;
87
+ let timer: ReturnType<typeof setTimeout> | undefined;
88
+
89
+ const timeout = new Promise<never>((_resolve, reject) => {
90
+ timer = setTimeout(() => {
91
+ const err = new Error(`${label} read timed out after ${timeoutMs}ms`);
92
+ // Release the hung read; the race below settles on `err` regardless of
93
+ // whether abort() promptly unblocks the iterator.
94
+ try {
95
+ stream.abort(err);
96
+ } catch {
97
+ // Best-effort: a broken/closed stream may reject abort.
98
+ }
99
+ reject(err);
100
+ }, timeoutMs);
101
+ });
102
+
103
+ const readLoop = collect(stream, maxBytes, label);
104
+
105
+ return Promise.race([readLoop, timeout]).finally(() => clearTimeout(timer));
106
+ }
107
+
108
+ /** Accumulate every chunk to EOF, rejecting once the running total exceeds `maxBytes`. */
109
+ async function collect(stream: ControlStream, maxBytes: number, label: string): Promise<Uint8Array> {
110
+ const chunks: Uint8Array[] = [];
111
+ let total = 0;
112
+ for await (const chunk of stream) {
113
+ const bytes = toBytes(chunk);
114
+ chunks.push(bytes);
115
+ total += bytes.length;
116
+ if (total > maxBytes) {
117
+ throw new Error(`${label} message too large: ${total} bytes exceeds max ${maxBytes}`);
118
+ }
119
+ }
120
+
121
+ const data = new Uint8Array(total);
122
+ let offset = 0;
123
+ for (const chunk of chunks) {
124
+ data.set(chunk, offset);
125
+ offset += chunk.length;
126
+ }
127
+ return data;
128
+ }
@@ -0,0 +1,89 @@
1
+ /**
2
+ * Device-token record helpers: the single source of truth for the bytes a node
3
+ * signs when publishing its own `DeviceToken` row and re-verifies when resolving
4
+ * another peer's row.
5
+ *
6
+ * Modeled directly on {@link peerRecordSignedPayload} (peer-record.ts): the signed
7
+ * payload is a single SHA-256 digest of a delimited string of the authenticated
8
+ * fields. It is intentionally NOT JSON-canonicalized — a delimited digest is
9
+ * trivially deterministic across node/browser/RN (plain string concat + sha256, no
10
+ * key ordering) and is reconstructable inside the `DeviceToken.AuthorizedUpdate` SQL
11
+ * constraint from the row's own columns:
12
+ *
13
+ * digest(new.PeerId || '|' || new.Platform || '|' || new.Token || '|'
14
+ * || cast(new.UpdatedAt as text))
15
+ *
16
+ * Keep {@link deviceTokenSignedPayload} and that constraint byte-for-byte in sync.
17
+ * The `'|'` delimiter is safe: a base58btc PeerId, a fixed `'fcm'`/`'apns'` platform
18
+ * string, and a base-10 integer never contain it. The opaque platform token is the
19
+ * last variable-length field before the integer stamp, so even a token containing a
20
+ * `'|'` cannot create a collision with a different field split.
21
+ *
22
+ * Unlike a peer-address record, a device-token record carries NO public key: the
23
+ * signature is verified against the `CadrePeer.PublicKey` bound to the same PeerId
24
+ * (the resolver supplies it), so the key binding is checked exactly once, where the
25
+ * membership row already lives.
26
+ */
27
+
28
+ import { digest, sign, verify } from '@optimystic/quereus-plugin-crypto';
29
+ import type { DeviceTokenRecord, PushPlatform } from './types.js';
30
+
31
+ /**
32
+ * Build the base64url SHA-256 digest that the self-signature covers. Mirrors the
33
+ * `DeviceToken.AuthorizedUpdate` constraint exactly; both sides take the default
34
+ * base64url output of a single `digest(...)`, which round-trips cleanly.
35
+ */
36
+ export function deviceTokenSignedPayload(record: Omit<DeviceTokenRecord, 'sig'>): string {
37
+ const joined = `${record.peerId}|${record.platform}|${record.token}|${record.updatedAt}`;
38
+ return digest([joined], 'sha256', 'base64url') as string;
39
+ }
40
+
41
+ /**
42
+ * Sign a device-token record with the ed25519 private key behind its `peerId`.
43
+ * Returns a fully-populated {@link DeviceTokenRecord}.
44
+ *
45
+ * @param fields - the record fields to sign
46
+ * @param privateKeyB64 - base64url ed25519 seed (see `authorityKeyFromLibp2p`)
47
+ */
48
+ export function signDeviceTokenRecord(
49
+ fields: Omit<DeviceTokenRecord, 'sig'>,
50
+ privateKeyB64: string
51
+ ): DeviceTokenRecord {
52
+ const payloadDigest = deviceTokenSignedPayload(fields);
53
+ const sig = sign(
54
+ payloadDigest,
55
+ privateKeyB64,
56
+ 'ed25519',
57
+ 'base64url',
58
+ 'base64url',
59
+ 'base64url'
60
+ ) as string;
61
+ return { peerId: fields.peerId, platform: fields.platform, token: fields.token, updatedAt: fields.updatedAt, sig };
62
+ }
63
+
64
+ /**
65
+ * Verify a record's self-signature against the `CadrePeer.PublicKey` (base64url)
66
+ * bound to its `peerId`. Reconstructs the signed bytes from the record exactly as
67
+ * {@link signDeviceTokenRecord} produced them. Returns false on a missing key/sig or
68
+ * any verification failure.
69
+ */
70
+ export function verifyDeviceTokenSignature(record: DeviceTokenRecord, publicKeyB64: string): boolean {
71
+ if (!publicKeyB64 || !record.sig) {
72
+ return false;
73
+ }
74
+ const payloadDigest = deviceTokenSignedPayload(record);
75
+ return verify(
76
+ payloadDigest,
77
+ record.sig,
78
+ publicKeyB64,
79
+ 'ed25519',
80
+ 'base64url',
81
+ 'base64url',
82
+ 'base64url'
83
+ );
84
+ }
85
+
86
+ /** Narrow an arbitrary stored string to a known {@link PushPlatform}. */
87
+ export function isPushPlatform(value: string): value is PushPlatform {
88
+ return value === 'fcm' || value === 'apns';
89
+ }