@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.
Files changed (170) hide show
  1. package/README.md +11 -10
  2. package/dist/cadre-node.d.ts +818 -19
  3. package/dist/cadre-node.js +1952 -99
  4. package/dist/cadre-node.js.map +1 -1
  5. package/dist/canonical-datetime.d.ts +33 -0
  6. package/dist/canonical-datetime.js +38 -0
  7. package/dist/canonical-datetime.js.map +1 -0
  8. package/dist/canonical-json.d.ts +12 -0
  9. package/dist/canonical-json.js +33 -0
  10. package/dist/canonical-json.js.map +1 -0
  11. package/dist/control-cohort.d.ts +63 -0
  12. package/dist/control-cohort.js +80 -0
  13. package/dist/control-cohort.js.map +1 -0
  14. package/dist/control-database.d.ts +279 -8
  15. package/dist/control-database.js +563 -134
  16. package/dist/control-database.js.map +1 -1
  17. package/dist/control-formation-recorder.d.ts +89 -0
  18. package/dist/control-formation-recorder.js +139 -0
  19. package/dist/control-formation-recorder.js.map +1 -0
  20. package/dist/control-schema.d.ts +12 -0
  21. package/dist/control-schema.js +223 -0
  22. package/dist/control-schema.js.map +1 -0
  23. package/dist/control-stream.d.ts +54 -0
  24. package/dist/control-stream.js +104 -0
  25. package/dist/control-stream.js.map +1 -0
  26. package/dist/device-token.d.ts +50 -0
  27. package/dist/device-token.js +66 -0
  28. package/dist/device-token.js.map +1 -0
  29. package/dist/diagnostics/connection-path.d.ts +118 -0
  30. package/dist/diagnostics/connection-path.js +204 -0
  31. package/dist/diagnostics/connection-path.js.map +1 -0
  32. package/dist/diagnostics/webrtc-turn-tracker.d.ts +50 -0
  33. package/dist/diagnostics/webrtc-turn-tracker.js +196 -0
  34. package/dist/diagnostics/webrtc-turn-tracker.js.map +1 -0
  35. package/dist/ed25519-key.d.ts +42 -0
  36. package/dist/ed25519-key.js +51 -0
  37. package/dist/ed25519-key.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 +48 -0
  51. package/dist/peer-authorization.js +66 -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-node.d.ts +38 -0
  63. package/dist/push-node.js +48 -0
  64. package/dist/push-node.js.map +1 -0
  65. package/dist/push-notifier-apns.d.ts +61 -0
  66. package/dist/push-notifier-apns.js +199 -0
  67. package/dist/push-notifier-apns.js.map +1 -0
  68. package/dist/push-notifier-fcm.d.ts +44 -0
  69. package/dist/push-notifier-fcm.js +182 -0
  70. package/dist/push-notifier-fcm.js.map +1 -0
  71. package/dist/push-notifier-shared.d.ts +18 -0
  72. package/dist/push-notifier-shared.js +25 -0
  73. package/dist/push-notifier-shared.js.map +1 -0
  74. package/dist/push-notifier.d.ts +52 -0
  75. package/dist/push-notifier.js +22 -0
  76. package/dist/push-notifier.js.map +1 -0
  77. package/dist/schema-verification.d.ts +11 -2
  78. package/dist/schema-verification.js +15 -4
  79. package/dist/schema-verification.js.map +1 -1
  80. package/dist/seed-bootstrap.d.ts +228 -21
  81. package/dist/seed-bootstrap.js +489 -168
  82. package/dist/seed-bootstrap.js.map +1 -1
  83. package/dist/seed-trust-policy.d.ts +63 -0
  84. package/dist/seed-trust-policy.js +56 -0
  85. package/dist/seed-trust-policy.js.map +1 -0
  86. package/dist/strand-addr-protocol.d.ts +132 -0
  87. package/dist/strand-addr-protocol.js +313 -0
  88. package/dist/strand-addr-protocol.js.map +1 -0
  89. package/dist/strand-cohort.d.ts +54 -0
  90. package/dist/strand-cohort.js +38 -0
  91. package/dist/strand-cohort.js.map +1 -0
  92. package/dist/strand-database.d.ts +54 -6
  93. package/dist/strand-database.js +88 -100
  94. package/dist/strand-database.js.map +1 -1
  95. package/dist/strand-formation-manager.d.ts +79 -19
  96. package/dist/strand-formation-manager.js +196 -104
  97. package/dist/strand-formation-manager.js.map +1 -1
  98. package/dist/strand-formation-protocol.d.ts +162 -0
  99. package/dist/strand-formation-protocol.js +279 -0
  100. package/dist/strand-formation-protocol.js.map +1 -0
  101. package/dist/strand-instance-manager.d.ts +73 -4
  102. package/dist/strand-instance-manager.js +167 -41
  103. package/dist/strand-instance-manager.js.map +1 -1
  104. package/dist/strand-member-key.d.ts +29 -0
  105. package/dist/strand-member-key.js +38 -0
  106. package/dist/strand-member-key.js.map +1 -0
  107. package/dist/strand-member-registry.d.ts +98 -0
  108. package/dist/strand-member-registry.js +129 -0
  109. package/dist/strand-member-registry.js.map +1 -0
  110. package/dist/strand-membership-writer.d.ts +361 -0
  111. package/dist/strand-membership-writer.js +449 -0
  112. package/dist/strand-membership-writer.js.map +1 -0
  113. package/dist/strand-solicitation.d.ts +77 -2
  114. package/dist/strand-solicitation.js +25 -4
  115. package/dist/strand-solicitation.js.map +1 -1
  116. package/dist/strand-wake-payload.d.ts +30 -0
  117. package/dist/strand-wake-payload.js +20 -0
  118. package/dist/strand-wake-payload.js.map +1 -0
  119. package/dist/strand-wake-protocol.d.ts +114 -0
  120. package/dist/strand-wake-protocol.js +257 -0
  121. package/dist/strand-wake-protocol.js.map +1 -0
  122. package/dist/strand-watcher.d.ts +6 -2
  123. package/dist/strand-watcher.js +57 -17
  124. package/dist/strand-watcher.js.map +1 -1
  125. package/dist/types.d.ts +436 -33
  126. package/dist/types.js +22 -5
  127. package/dist/types.js.map +1 -1
  128. package/package.json +18 -8
  129. package/src/cadre-node.ts +2981 -948
  130. package/src/canonical-datetime.ts +39 -0
  131. package/src/canonical-json.ts +30 -0
  132. package/src/control-cohort.ts +103 -0
  133. package/src/control-database.ts +948 -383
  134. package/src/control-formation-recorder.ts +151 -0
  135. package/src/control-schema.ts +222 -0
  136. package/src/control-stream.ts +128 -0
  137. package/src/device-token.ts +89 -0
  138. package/src/diagnostics/connection-path.ts +277 -0
  139. package/src/diagnostics/webrtc-turn-tracker.ts +222 -0
  140. package/src/ed25519-key.ts +69 -0
  141. package/src/hibernation-manager.ts +197 -28
  142. package/src/index.ts +275 -79
  143. package/src/key-store-file.ts +203 -0
  144. package/src/key-store.ts +99 -0
  145. package/src/peer-authorization.ts +81 -0
  146. package/src/peer-record.ts +146 -0
  147. package/src/push-credentials.ts +84 -0
  148. package/src/push-fanout.ts +272 -0
  149. package/src/push-node.ts +64 -0
  150. package/src/push-notifier-apns.ts +247 -0
  151. package/src/push-notifier-fcm.ts +220 -0
  152. package/src/push-notifier-shared.ts +28 -0
  153. package/src/push-notifier.ts +52 -0
  154. package/src/schema-verification.ts +18 -4
  155. package/src/seed-bootstrap.ts +563 -223
  156. package/src/seed-trust-policy.ts +107 -0
  157. package/src/strand-addr-protocol.ts +410 -0
  158. package/src/strand-cohort.ts +77 -0
  159. package/src/strand-database.ts +113 -141
  160. package/src/strand-formation-manager.ts +233 -142
  161. package/src/strand-formation-protocol.ts +391 -0
  162. package/src/strand-instance-manager.ts +207 -56
  163. package/src/strand-member-key.ts +39 -0
  164. package/src/strand-member-registry.ts +177 -0
  165. package/src/strand-membership-writer.ts +640 -0
  166. package/src/strand-solicitation.ts +93 -4
  167. package/src/strand-wake-payload.ts +32 -0
  168. package/src/strand-wake-protocol.ts +331 -0
  169. package/src/strand-watcher.ts +61 -17
  170. package/src/types.ts +1032 -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 (owner-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 owner-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,222 @@
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 OwnerKey (
16
+ Key text primary key,
17
+ StampId text not null unique, -- single-use authorization nonce (anti-replay)
18
+ constraint Authorized check (
19
+ -- Bootstrap: first owner key needs no existing authorization
20
+ (select count(1) from OwnerKey) <= 1
21
+
22
+ -- Old owner authorizes by signing over THIS row (Key, StampId); single-use via unique StampId
23
+ or (old.Key is not null and old.Key = context.OwnerKey and verify(digest(new.Key, new.StampId), context.Signature, old.Key, 'ed25519'))
24
+
25
+ -- or other owners authorize by signing over THIS row (Key, StampId); single-use via unique StampId
26
+ or exists (select 1 from OwnerKey A where A.Key = context.OwnerKey and verify(digest(new.Key, new.StampId), context.Signature, A.Key, 'ed25519'))
27
+ )
28
+ ) with context (OwnerKey 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
+ -- Owners authorize by signing over THIS row (Key, StampId); single-use via unique StampId
36
+ exists (select 1 from OwnerKey A where A.Key = context.OwnerKey and verify(digest(new.Key, new.StampId), context.Signature, A.Key, 'ed25519'))
37
+ )
38
+ ) with context (OwnerKey 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 owner signing over THIS row (Id, Type, MemberPrivateKey, StampId); single-use via unique StampId
49
+ exists (select 1 from OwnerKey A where A.Key = context.OwnerKey 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 (OwnerKey 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
+ StampId text not null unique, -- single-use authorization nonce (anti-replay), bound into the voucher/remove digests; rotates on (re)insert
74
+ VouchOwner text null, -- ed25519 (base64url) owner key that vouched this membership (== insert context.OwnerKey)
75
+ VouchSig text null, -- that owner's signature over digest(PeerId, StampId) (== insert context.Signature). A reader checks VouchOwner against its NODE-LOCAL trusted-owner anchor (not this replicated table, which a self-owner can pollute) to decide authorized membership.
76
+ constraint AuthorizedInsert check on insert (
77
+ -- Authorized by an owner key, which vouches both membership and the
78
+ -- PublicKey<->PeerId binding (cadre-core derives PublicKey from PeerId before insert).
79
+ -- The digest binds PeerId + the single-use StampId nonce (two TEXT fields),
80
+ -- matching cadre-core peer-authorization.ts:cadrePeerVoucherDigest.
81
+ exists (select 1 from OwnerKey A where A.Key = context.OwnerKey and verify(digest(new.PeerId, new.StampId), context.Signature, A.Key, 'ed25519'))
82
+ -- Persist the vouching (owner, signature) onto the row so a reader can later
83
+ -- re-check VouchOwner against its node-local anchor. The stored pair MUST equal
84
+ -- the pair the verify above validated, so a writer cannot store a voucher it did
85
+ -- not actually receive a signature for.
86
+ and new.VouchOwner = context.OwnerKey
87
+ and new.VouchSig = context.Signature
88
+ ),
89
+ constraint AuthorizedDelete check on delete (
90
+ -- Delete is authorized by a signature over a DISTINCT 'remove'-scoped digest bound
91
+ -- to the row's StampId (cadre-core peer-authorization.ts:cadrePeerRemoveDigest), so
92
+ -- the stored voucher (a signature over digest(PeerId, StampId)) can NEVER be replayed
93
+ -- to authorize a delete. The remove signature rides in context and is never stored.
94
+ exists (select 1 from OwnerKey A where A.Key = context.OwnerKey and verify(digest(old.PeerId, old.StampId, 'remove'), context.Signature, A.Key, 'ed25519'))
95
+ ),
96
+ constraint AuthorizedUpdate check on update (
97
+ -- Peer self-updates its own addrs + freshness, signing with its OWN ed25519 key
98
+ -- (the key behind PeerId). PeerId/PublicKey/StampId/voucher are immutable on
99
+ -- self-update, UpdatedAt must strictly increase (replay guard), and Sig is verified
100
+ -- against the stored PublicKey over the same payload the publish path signs
101
+ -- (cadre-core peer-record.ts:peerRecordSignedPayload).
102
+ (
103
+ new.PeerId = old.PeerId
104
+ and new.PublicKey = old.PublicKey
105
+ and new.StampId = old.StampId
106
+ and new.VouchOwner = old.VouchOwner
107
+ and new.VouchSig = old.VouchSig
108
+ and new.UpdatedAt > coalesce(old.UpdatedAt, 0)
109
+ and verify(
110
+ digest(new.PeerId || '|' || new.Multiaddr || '|' || cast(new.UpdatedAt as text)),
111
+ new.Sig, new.PublicKey, 'ed25519')
112
+ )
113
+ -- or an owner re-authorizes (rotation / correction): re-vouches the row over
114
+ -- its current StampId, so the stored voucher is re-bound to the re-authorizing owner.
115
+ or (
116
+ exists (select 1 from OwnerKey A where A.Key = context.OwnerKey and verify(digest(new.PeerId, new.StampId), context.Signature, A.Key, 'ed25519'))
117
+ and new.VouchOwner = context.OwnerKey
118
+ and new.VouchSig = context.Signature
119
+ )
120
+ )
121
+ ) with context (OwnerKey text null, Signature text);
122
+
123
+ -- A mobile cadre peer's platform push token (FCM/APNs), self-published so a
124
+ -- server peer can deliver a push-wake to a suspended app over the platform push
125
+ -- channel (a control-network dial cannot reach an OS-suspended process). The row
126
+ -- mirrors CadrePeer: a resolver re-verifies Sig against the CadrePeer.PublicKey for
127
+ -- this PeerId (see device-token.ts:deviceTokenSignedPayload), checks freshness, then
128
+ -- hands the token to the push sender.
129
+ table DeviceToken (
130
+ PeerId text primary key, -- the CadrePeer this token belongs to
131
+ Platform text not null, -- 'fcm' | 'apns'
132
+ Token text not null, -- opaque platform device/registration token
133
+ UpdatedAt int null, -- epoch ms; strictly increases per self-update (replay guard)
134
+ Sig text null, -- ed25519 self-sig over (PeerId|Platform|Token|UpdatedAt)
135
+ constraint AuthorizedInsert check on insert, delete (
136
+ -- Authorized by an owner key (membership vouch), exactly like CadrePeer.
137
+ -- The single-field digest frames the base58btc PeerId as TEXT.
138
+ exists (select 1 from OwnerKey A where A.Key = context.OwnerKey and verify(digest(coalesce(new.PeerId, old.PeerId)), context.Signature, A.Key, 'ed25519'))
139
+ ),
140
+ constraint AuthorizedUpdate check on update (
141
+ -- Peer self-updates its own token, signing with its OWN ed25519 key (the key
142
+ -- behind PeerId). PeerId is immutable, UpdatedAt must strictly increase (replay
143
+ -- guard), and Sig is verified against the stored CadrePeer.PublicKey over the
144
+ -- signed payload (cadre-core device-token.ts:deviceTokenSignedPayload). Platform
145
+ -- and Token may change on self-update (platform switch / reinstall / rotation).
146
+ (
147
+ new.PeerId = old.PeerId
148
+ and new.UpdatedAt > coalesce(old.UpdatedAt, 0)
149
+ and exists (select 1 from CadrePeer P where P.PeerId = new.PeerId and verify(
150
+ digest(new.PeerId || '|' || new.Platform || '|' || new.Token || '|' || cast(new.UpdatedAt as text)),
151
+ new.Sig, P.PublicKey, 'ed25519'))
152
+ )
153
+ -- or an owner re-authorizes (rotation / correction)
154
+ or exists (select 1 from OwnerKey A where A.Key = context.OwnerKey and verify(digest(new.PeerId), context.Signature, A.Key, 'ed25519'))
155
+ )
156
+ ) with context (OwnerKey text null, Signature text);
157
+
158
+ -- An open invitation to form a strand with this party
159
+ table FormationInvite (
160
+ Token text primary key, -- Just a random string
161
+ sAppId text, -- The app for the strand that will be formed
162
+ ExpiresAt datetime null,
163
+ TotalUses int null check (TotalUses >= 0),
164
+ ValidationUrl text null, -- Web hook - send disclosure, IP address...
165
+ 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.
166
+ StampId text not null unique, -- single-use authorization nonce (anti-replay)
167
+ constraint AuthorizedAddOrRemove check on insert, delete (
168
+ -- Authorized by an owner signing over THIS row
169
+ -- (Token, sAppId, ExpiresAt, TotalUses, ValidationUrl, StrandId, StampId); single-use via unique StampId.
170
+ -- coalesce(new.F, old.F) binds the NEW row on insert and the OLD row on delete (cf. CadrePeer).
171
+ -- StrandId is a nullable bound field: it signs as '' when absent (cf. ValidationUrl).
172
+ exists (select 1 from OwnerKey A where A.Key = context.OwnerKey and verify(
173
+ digest(
174
+ coalesce(new.Token, old.Token),
175
+ coalesce(new.sAppId, old.sAppId),
176
+ coalesce(cast(coalesce(new.ExpiresAt, old.ExpiresAt) as text), ''),
177
+ coalesce(cast(coalesce(new.TotalUses, old.TotalUses) as text), ''),
178
+ coalesce(coalesce(new.ValidationUrl, old.ValidationUrl), ''),
179
+ coalesce(coalesce(new.StrandId, old.StrandId), ''),
180
+ coalesce(new.StampId, old.StampId)
181
+ ),
182
+ context.Signature, A.Key, 'ed25519'))
183
+ ),
184
+ -- Invites are insert/delete only: forbid in-place mutation so the row-bound
185
+ -- AuthorizedAddOrRemove signature cannot be sidestepped by updating consent
186
+ -- parameters (TotalUses/ExpiresAt/...) after a legitimate insert. A bare check
187
+ -- defaults to insert+update; the check on insert, delete above excludes update,
188
+ -- so this guard is required. Mirrors FormationUsage.InsertOnly.
189
+ constraint Immutable check on update (false)
190
+ ) with context (OwnerKey text, Signature text);
191
+
192
+ table FormationUsage (
193
+ Token text,
194
+ UseNumber int,
195
+ Disclosure text,
196
+ StrandId text,
197
+ primary key (Token, UseNumber),
198
+ constraint InsertOnly check on update, delete (false),
199
+ constraint Monotonic check (
200
+ -- Read the PRE-transaction snapshot (committed.*): this CHECK auto-defers
201
+ -- to commit (it has a subquery), by which point the row being inserted is
202
+ -- live, so a plain from-FormationUsage would count this row itself and make
203
+ -- UseNumber = max+1 unsatisfiable. committed.* excludes the in-flight row,
204
+ -- giving sequential 1,2,3... per token (cross-transaction; concurrent
205
+ -- redemptions of the same token collide on the (Token, UseNumber) PK).
206
+ new.UseNumber = coalesce((select max(UseNumber) from committed.FormationUsage U where U.Token = new.Token), 0) + 1
207
+ ),
208
+ constraint Authorized check on insert (
209
+ -- Satisfies an invitation
210
+ exists (
211
+ select 1 from FormationInvite FI
212
+ where FI.Token = new.Token
213
+ and (FI.TotalUses is null or FI.TotalUses >= new.UseNumber)
214
+ and (FI.ExpiresAt is null or FI.ExpiresAt > context.Now)
215
+ and (FI.ValidationUrl is null or verify(digest(new.Token || new.Disclosure), context.ValidationSignature, context.ValidationKey, 'ed25519'))
216
+ )
217
+ ),
218
+ constraint StrandExists check (exists (select 1 from Strand S where S.Id = new.StrandId)),
219
+ ) with context (PeerId text, PeerSignature text, Now datetime, ValidationKey text null, ValidationSignature text null);
220
+ }
221
+
222
+ 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 `ed25519KeyPairFromLibp2p`)
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
+ }