@serfab/cadre-core 0.6.0 → 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 +777 -11
  6. package/dist/cadre-node.js +1912 -82
  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 +210 -7
  78. package/dist/seed-bootstrap.js +429 -131
  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 -89
  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 +413 -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 -929
  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 +504 -171
  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 -126
  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 -575
@@ -0,0 +1,449 @@
1
+ import debug from 'debug';
2
+ import { digest, sign, verify, generatePrivateKey, getPublicKey } from '@optimystic/quereus-plugin-crypto';
3
+ import { canonicalDatetime } from './canonical-datetime.js';
4
+ const log = debug('sereus:cadre:strand-membership');
5
+ /**
6
+ * The engine (rules-logic system) assumed to manage every strand. Persisted in
7
+ * `Strand.Header.Engine`.
8
+ *
9
+ * PLACEHOLDER SEAM: there is no engine-selection mechanism yet — Quereus is the
10
+ * only engine. When a real engine-config seam lands, the founder bootstrap should
11
+ * read the strand's chosen engine from there instead of this constant. Documented
12
+ * deliberately so the column is non-null and self-describing in the meantime.
13
+ */
14
+ export const STRAND_ENGINE = 'quereus';
15
+ /**
16
+ * The pinned engine version recorded in `Strand.Header.EngineVersion`. A pinned
17
+ * string for the same reason as {@link STRAND_ENGINE}: it is a documented
18
+ * placeholder until an engine-selection seam supplies the real running version.
19
+ */
20
+ export const STRAND_ENGINE_VERSION = '0.1.0';
21
+ /**
22
+ * The single-digest ed25519 signer the `Strand.*` membership/RBAC constraints
23
+ * verify against.
24
+ *
25
+ * `schemas/strand.qsql` signs a single SHA-256 digest over a `'|'`-joined payload
26
+ * (e.g. `Member.Authorized` verifies
27
+ * `verify(digest(new.Key), context.AuthoritySignature, A.MemberKey, 'ed25519')`).
28
+ * So the signer hashes the payload to raw bytes and ed25519-signs *those bytes*:
29
+ * SQL `digest(...)`'s default output is base64url and `verify(...)`'s default
30
+ * `inputEncoding` is base64url, so signer and verifier operate on identical bytes.
31
+ * This differs from the control-layer's multi-field `buildAuthorizationMessage`
32
+ * (a single digest over MANY fields) — the strand layer is a single digest over ONE
33
+ * joined payload field.
34
+ *
35
+ * All strand keys are ed25519, so the explicit `'ed25519'` curve arg is mandatory
36
+ * (the crypto plugin otherwise defaults to secp256k1). Mirrors the proven
37
+ * `signItem` helper in the `rbac-signed-write` integration scenario.
38
+ *
39
+ * @param payload - The `'|'`-joined payload the matching constraint hashes.
40
+ * @param privateKeyB64 - The signer's base64url ed25519 private seed.
41
+ * @returns The base64url ed25519 signature over the payload's SHA-256 digest.
42
+ */
43
+ export function signStrandPayload(payload, privateKeyB64) {
44
+ const hashBytes = digest([payload], 'sha256', 'bytes');
45
+ return sign(hashBytes, privateKeyB64, 'ed25519', 'bytes', 'base64url', 'base64url');
46
+ }
47
+ /**
48
+ * The verifier counterpart to {@link signStrandPayload}.
49
+ *
50
+ * Mirrors what every `Strand.*` constraint computes:
51
+ * `verify(digest(payload), signature, publicKey, 'ed25519')`.
52
+ * SQL `digest`'s default output and `verify`'s default `inputEncoding`/`sigEncoding`/
53
+ * `keyEncoding` are all base64url, so the off-engine check operates on the exact
54
+ * same bytes the in-engine CHECK does. Used by the off-engine `MemberVerifier`
55
+ * (pre-flight registration checks) so the on-engine constraint is not the only
56
+ * place a member self-proof is validated.
57
+ *
58
+ * @param payload - The `'|'`-joined payload the matching constraint hashes.
59
+ * @param signatureB64 - The base64url ed25519 signature to check.
60
+ * @param publicKeyB64 - The base64url ed25519 public key to verify against.
61
+ * @returns `true` iff the signature is valid for the payload under that key.
62
+ */
63
+ export function verifyStrandPayload(payload, signatureB64, publicKeyB64) {
64
+ const payloadDigest = digest([payload], 'sha256', 'base64url');
65
+ return verify(payloadDigest, signatureB64, publicKeyB64, 'ed25519', 'base64url', 'base64url', 'base64url');
66
+ }
67
+ /**
68
+ * Count rows in a `Strand.*` table as seen by this database instance.
69
+ *
70
+ * The table name is a fixed literal supplied by this module (never user input),
71
+ * so the interpolation is not an injection surface — it just keeps the singleton
72
+ * insert-if-absent guards terse.
73
+ */
74
+ async function strandTableCount(db, table) {
75
+ for await (const row of db.eval(`select count(1) as Count from Strand.${table}`)) {
76
+ return row.Count ?? 0;
77
+ }
78
+ return 0;
79
+ }
80
+ /**
81
+ * Insert the singleton `Strand.Header` if absent.
82
+ *
83
+ * Every `Header` column is NOT NULL (Quereus defaults unqualified columns to NOT
84
+ * NULL; the table declares no `null` columns), so all eight are supplied non-null.
85
+ * A missing `sApp.signature` (dev `requireSignedSchemas:false`) coalesces to `''`
86
+ * rather than inserting null. `Header` carries only `InsertOnly` + a singleton PK,
87
+ * so the insert needs no `with context`.
88
+ */
89
+ async function insertHeaderIfAbsent(db, params) {
90
+ if (await strandTableCount(db, 'Header') > 0) {
91
+ log('Header already present for strand %s; skipping', params.strandId);
92
+ return;
93
+ }
94
+ await db.exec(`insert into Strand.Header
95
+ (Id, Type, sAppId, sAppVersion, sAppSchema, sAppSignature, Engine, EngineVersion)
96
+ values (?, ?, ?, ?, ?, ?, ?, ?)`, [
97
+ params.strandId,
98
+ params.type,
99
+ params.sApp.id,
100
+ params.sApp.version,
101
+ params.sApp.schema,
102
+ params.sApp.signature ?? '',
103
+ STRAND_ENGINE,
104
+ STRAND_ENGINE_VERSION,
105
+ ]);
106
+ log('Inserted Header for strand %s (type %s)', params.strandId, params.type);
107
+ }
108
+ /**
109
+ * Insert the founding `Strand.Member` if no member exists yet.
110
+ *
111
+ * The empty-table state satisfies the `count(1) from Member <= 1` bootstrap branch
112
+ * of `Member.Authorized`, so no authority signature is needed — the context fields
113
+ * are explicit nulls. Guarding on the count makes this idempotent: a re-run (the
114
+ * founder re-`addStrand`/`resumeStrand`) finds the member present and skips.
115
+ */
116
+ async function insertFounderMemberIfAbsent(db, memberKey, strandId) {
117
+ if (await strandTableCount(db, 'Member') > 0) {
118
+ log('Member already present for strand %s; skipping founder Member', strandId);
119
+ return;
120
+ }
121
+ await db.exec(`insert into Strand.Member (Key)
122
+ with context AuthorityKey = null, AuthoritySignature = null
123
+ values (?)`, [memberKey]);
124
+ log('Inserted founding Member for strand %s', strandId);
125
+ }
126
+ /**
127
+ * Insert the founding `Strand.Authority` if no authority exists yet.
128
+ *
129
+ * The empty-table state satisfies the `count(1) from Authority <= 1` bootstrap
130
+ * branch of `Authority.Authorized`, so no signature is needed. Idempotent via the
131
+ * same count guard. Must run AFTER the Header insert: `Authority.OnlyClosed` is a
132
+ * deferred (subquery) check evaluated at commit, and the sequential auto-commit
133
+ * Header→Member→Authority order ensures the closed `Header` is committed first.
134
+ */
135
+ async function insertFounderAuthorityIfAbsent(db, memberKey, strandId) {
136
+ if (await strandTableCount(db, 'Authority') > 0) {
137
+ log('Authority already present for strand %s; skipping founder Authority', strandId);
138
+ return;
139
+ }
140
+ await db.exec(`insert into Strand.Authority (MemberKey)
141
+ with context AuthorityKey = null, Signature = null
142
+ values (?)`, [memberKey]);
143
+ log('Inserted founding Authority for strand %s', strandId);
144
+ }
145
+ /**
146
+ * Founder-only one-time bootstrap of a strand's `Strand.*` membership/RBAC rows.
147
+ *
148
+ * Runs once at bring-up on the strand's FOUNDER (the party that provisioned and
149
+ * published the strand). A joiner never calls this — it receives these rows via
150
+ * Optimystic sync. Every write is insert-if-absent (guarded by a row count) so a
151
+ * restart / founder re-`addStrand` is a no-op and never double-inserts.
152
+ *
153
+ * Behavior by strand type:
154
+ * - **Open (`'o'`)**: insert `Header` only. `Member`/`Authority`/`Invite` are
155
+ * `OnlyClosed` and would trip that constraint — they are skipped entirely.
156
+ * - **Closed (`'c'`)**: insert `Header(Type='c')`, then the founding `Member`
157
+ * (`Key = founderKeyPair.publicKeyB64`), then the founding `Authority`
158
+ * (`MemberKey = founderKeyPair.publicKeyB64`). Insert order matters: the
159
+ * Header must commit before the deferred `OnlyClosed` checks on Member/Authority
160
+ * evaluate at commit.
161
+ *
162
+ * A closed strand with no `founderKeyPair` throws: a closed strand with no founding
163
+ * Authority could never admit anyone, so failing loudly here (which propagates out
164
+ * of `StrandDatabase.initialize()` and triggers the runtime's rollback) is correct.
165
+ *
166
+ * @param db - The strand's Quereus database (schema already applied).
167
+ * @param params - Strand id/type, the sApp config for the Header, and (closed only)
168
+ * the derived founder keypair.
169
+ * @throws If `type === 'c'` and no `founderKeyPair` is supplied.
170
+ */
171
+ export async function bootstrapFounderMembership(db, params) {
172
+ const { strandId, type, founderKeyPair } = params;
173
+ log('Founder bootstrap for strand %s (type %s)', strandId, type);
174
+ // Validate BEFORE writing anything: a closed strand with no founder key must
175
+ // fail without leaving a closed `Header` that has no founding Member/Authority
176
+ // (such a strand could never admit anyone).
177
+ if (type === 'c' && !founderKeyPair) {
178
+ throw new Error(`Cannot bootstrap closed strand ${strandId}: no founder key pair derived from MemberPrivateKey. ` +
179
+ 'A closed strand needs a founding Member + Authority or it can never admit members.');
180
+ }
181
+ // Header is written for every strand; Member/Authority are closed-only.
182
+ await insertHeaderIfAbsent(db, params);
183
+ if (type === 'o') {
184
+ // Open strand: Member/Authority/Invite are OnlyClosed — nothing else to write.
185
+ return;
186
+ }
187
+ const memberKey = founderKeyPair.publicKeyB64;
188
+ await insertFounderMemberIfAbsent(db, memberKey, strandId);
189
+ await insertFounderAuthorityIfAbsent(db, memberKey, strandId);
190
+ log('Founder bootstrap complete for closed strand %s', strandId);
191
+ }
192
+ /**
193
+ * Issue a single-use invitation to join a closed strand.
194
+ *
195
+ * Generates a fresh invite ed25519 keypair (the public key becomes `Invite.Key`),
196
+ * builds the constraint's payload (`Key || '|' || coalesce(Expiration, '')`), and
197
+ * signs it TWICE: with the authority private key (→ `AuthoritySignature`, proving
198
+ * an authority issued it) and with the invite private key (→ `InviteSignature`,
199
+ * proving the issuer actually holds the invite secret). Both signatures plus the
200
+ * authority public key are bound as constraint context for the `Invite` insert.
201
+ *
202
+ * The returned `invitePrivateKey` is the only secret the invitee needs to redeem
203
+ * the invite — it is NOT stored in the strand (only `Invite.Key`, the public half,
204
+ * is). Single-use is enforced at consumption time: `ConsumedInvite`'s primary key
205
+ * is `InviteKey`, so a given invite can be consumed at most once.
206
+ *
207
+ * @param db - The closed strand's database (founder already bootstrapped).
208
+ * @param params - The issuing authority keypair and optional expiry.
209
+ * @returns The invite public key and the out-of-band private seed.
210
+ */
211
+ export async function issueInvite(db, params) {
212
+ const { authorityKeyPair, expiration } = params;
213
+ const invitePrivateKey = generatePrivateKey('ed25519', 'base64url');
214
+ const inviteKey = getPublicKey(invitePrivateKey, 'ed25519', 'base64url', 'base64url');
215
+ // Canonicalise the expiry to the engine's stored `datetime` form so the signed
216
+ // segment matches `new.Expiration` post-coercion; null expiry signs as ''.
217
+ const expirationSegment = expiration == null ? '' : await canonicalDatetime(db, expiration);
218
+ const expirationColumn = expiration == null ? null : expirationSegment;
219
+ const payload = `${inviteKey}|${expirationSegment}`;
220
+ const authoritySignature = signStrandPayload(payload, authorityKeyPair.privateKeyB64);
221
+ const inviteSignature = signStrandPayload(payload, invitePrivateKey);
222
+ await db.exec(`insert into Strand.Invite (Key, Expiration)
223
+ with context AuthorityKey = ?, AuthoritySignature = ?, InviteSignature = ?
224
+ values (?, ?)`, [authorityKeyPair.publicKeyB64, authoritySignature, inviteSignature, inviteKey, expirationColumn]);
225
+ log('Issued invite %s (expires=%s)', inviteKey, expirationColumn ?? 'never');
226
+ return { inviteKey, invitePrivateKey };
227
+ }
228
+ /**
229
+ * Redeem an invite to admit a new `Member`, atomically.
230
+ *
231
+ * `Member.Authorized`'s invite branch needs a `ConsumedInvite` row, while
232
+ * `ConsumedInvite`'s `MemberExists`/`MemberValid` need the `Member` row — a
233
+ * circular dependency. Both are deferred (subquery-bearing) checks that evaluate
234
+ * at COMMIT, so inserting `Member` then `ConsumedInvite` inside one explicit
235
+ * transaction lets both rows exist when the deferred checks fire. This mirrors
236
+ * `ControlDatabase.redeemInvitation` (Strand + FormationUsage in one txn).
237
+ *
238
+ * The `ConsumedInvite` insert carries an `InviteSignature` over
239
+ * `InviteKey || '|' || MemberKey`, proving the consumer holds the invite private
240
+ * key (the on-engine `ValidUsage` gate). The `Member` insert needs no member
241
+ * signature — its admission is the existence of the matching `ConsumedInvite`.
242
+ *
243
+ * Single-use: `ConsumedInvite`'s primary key is `InviteKey`, so a second consume
244
+ * of the same invite is rejected by the PK (a distinct layer from the control
245
+ * network's `FormationUsage` single-use, which gates strand FORMATION, not the
246
+ * per-strand member join enforced here).
247
+ *
248
+ * Expiry: the `ConsumedInvite.NotExpired` deferred check rejects redeeming an invite
249
+ * whose `Expiration` is at or before `context.Now`. `Now` is supplied here as a
250
+ * canonical-datetime string from `canonicalDatetime(db, nowMs)` — the SAME transform
251
+ * `issueInvite` uses to store `Invite.Expiration` — so both sides of the schema's
252
+ * `I.Expiration > context.Now` comparison are byte-identical canonical strings and
253
+ * the lexical `>` orders chronologically at any granularity. (This intentionally
254
+ * diverges from the control layer, which passes `Now` as a JS ISO string; Quereus
255
+ * does not coerce context params, so an ISO `Now` would be compared lexically against
256
+ * the canonical, T-separated `Expiration` and could mis-order near-same-instant
257
+ * timestamps (due to a trailing `.000Z` suffix). The control tests only use far-future/far-past expiries, so that latent
258
+ * skew never bites there — the strand layer avoids it outright.) A null `Expiration`
259
+ * never expires. Like `ValidUsage`, `NotExpired` defers to commit, so an expired
260
+ * invite rolls back the whole txn — neither the `Member` nor the `ConsumedInvite`
261
+ * row survives.
262
+ *
263
+ * @param db - The closed strand's database.
264
+ * @param params - The invite key/secret, the joining member's public key, and an
265
+ * optional `nowMs` instant for the expiry comparison (default `Date.now()`).
266
+ * @throws If any constraint rejects (bad signature, missing invite, or an expired
267
+ * invite); the whole transaction rolls back (neither the `Member` nor the
268
+ * `ConsumedInvite` row survives).
269
+ */
270
+ export async function consumeInvite(db, params) {
271
+ const { inviteKey, invitePrivateKey, memberKey, nowMs } = params;
272
+ const usagePayload = `${inviteKey}|${memberKey}`;
273
+ const inviteSignature = signStrandPayload(usagePayload, invitePrivateKey);
274
+ // Canonicalise "now" the same way issueInvite canonicalises Expiration, so the
275
+ // schema's `I.Expiration > context.Now` compares like-for-like canonical strings.
276
+ // Plain runtime Date.now() — the tess Workflow restriction is on scripts, not libs.
277
+ const nowCanonical = await canonicalDatetime(db, nowMs ?? Date.now());
278
+ await db.beginTransaction();
279
+ try {
280
+ // 1. Member — admitted by the deferred invite branch (the matching
281
+ // ConsumedInvite below), so no authority signature is supplied.
282
+ await db.exec(`insert into Strand.Member (Key)
283
+ with context AuthorityKey = null, AuthoritySignature = null
284
+ values (?)`, [memberKey]);
285
+ // 2. ConsumedInvite — proves possession of the invite private key and that the
286
+ // invite has not expired (NotExpired gate against the canonical Now).
287
+ await db.exec(`insert into Strand.ConsumedInvite (InviteKey, MemberKey)
288
+ with context InviteSignature = ?, Now = ?
289
+ values (?, ?)`, [inviteSignature, nowCanonical, inviteKey, memberKey]);
290
+ await db.commit();
291
+ log('Consumed invite %s -> member %s', inviteKey, memberKey);
292
+ }
293
+ catch (error) {
294
+ // A failed commit() already tears down the transaction, so rollback() would
295
+ // throw "No transaction active" and mask the real cause — swallow only that.
296
+ try {
297
+ await db.rollback();
298
+ }
299
+ catch (rollbackError) {
300
+ log('Rollback after consumeInvite failure was a no-op: %s', rollbackError);
301
+ }
302
+ throw error;
303
+ }
304
+ }
305
+ /**
306
+ * Admit a `Member` directly by authority signature — the sibling of the invite
307
+ * path on `Member.Authorized`'s direct-authority branch.
308
+ *
309
+ * The constraint verifies `digest(new.Key)` against an `Authority` row matching
310
+ * `context.AuthorityKey`, so the payload is just the member key (no `'|'` join).
311
+ * No `ConsumedInvite` is involved: this is the path an authority uses to seat a
312
+ * member it already trusts (e.g. an authority-side enrollment that admits a
313
+ * party already authorised out-of-band).
314
+ *
315
+ * @param db - The closed strand's database.
316
+ * @param params - The admitting authority keypair and the new member key.
317
+ * @throws If `Member.Authorized` rejects (e.g. a non-authority key); the insert
318
+ * rolls back, leaving no `Member` row.
319
+ */
320
+ export async function addMemberByAuthority(db, params) {
321
+ const { authorityKeyPair, memberKey } = params;
322
+ const signature = signStrandPayload(memberKey, authorityKeyPair.privateKeyB64);
323
+ await db.exec(`insert into Strand.Member (Key)
324
+ with context AuthorityKey = ?, AuthoritySignature = ?
325
+ values (?)`, [authorityKeyPair.publicKeyB64, signature, memberKey]);
326
+ log('Admitted member %s by authority %s', memberKey, authorityKeyPair.publicKeyB64);
327
+ }
328
+ /**
329
+ * Register a network node (`PeerId`) as acting on behalf of a member.
330
+ *
331
+ * The member self-signs `MemberKey || '|' || PeerId` with its OWN key; the schema's
332
+ * `MemberPeer.Authorized` verifies that signature against `coalesce(new.MemberKey,
333
+ * old.MemberKey)` — i.e. the member key itself — so only the member that owns the
334
+ * key can register peers for it. A deferred `MemberExists` additionally requires the
335
+ * `Member` row to already exist, so a peer for a non-member is rejected at commit.
336
+ *
337
+ * Insert-if-absent on the composite PK `(MemberKey, PeerId)`: a re-register on
338
+ * restart (or a redundant call) is a no-op rather than relying on the platform's
339
+ * PK-uniqueness rejection (not enforced in bootstrap mode — see
340
+ * `optimystic-insert-pk-uniqueness-not-enforced`). A member may register multiple
341
+ * DISTINCT `PeerId`s (multi-device); each is its own row under the same `MemberKey`.
342
+ *
343
+ * Out of scope: peer DELETION. The schema's `MemberExists` reads `new.MemberKey`,
344
+ * which is null on delete, so a `MemberPeer` delete is currently rejected by the
345
+ * schema (noted in the `apply-strand-membership-schema` review). Removing a peer
346
+ * would need a schema tweak (e.g. `coalesce(new.MemberKey, old.MemberKey)` in
347
+ * `MemberExists`), so no `removeMemberPeer` is provided here.
348
+ *
349
+ * @param db - The closed strand's database (the member already exists).
350
+ * @param params - The member's own keypair and the peer id to bind.
351
+ * @throws If `MemberPeer.Authorized`/`MemberExists` rejects (wrong signer, or no
352
+ * matching `Member` row); the insert rolls back, leaving no `MemberPeer` row.
353
+ */
354
+ export async function registerMemberPeer(db, params) {
355
+ const { memberKeyPair, peerId } = params;
356
+ const memberKey = memberKeyPair.publicKeyB64;
357
+ if (await memberPeerExists(db, memberKey, peerId)) {
358
+ log('MemberPeer (%s, %s) already present; skipping', memberKey, peerId);
359
+ return;
360
+ }
361
+ const payload = `${memberKey}|${peerId}`;
362
+ const signature = signStrandPayload(payload, memberKeyPair.privateKeyB64);
363
+ await db.exec(`insert into Strand.MemberPeer (MemberKey, PeerId)
364
+ with context Signature = ?
365
+ values (?, ?)`, [signature, memberKey, peerId]);
366
+ log('Registered MemberPeer (%s, %s)', memberKey, peerId);
367
+ }
368
+ /** True iff a `MemberPeer` row already exists for this `(MemberKey, PeerId)`. */
369
+ async function memberPeerExists(db, memberKey, peerId) {
370
+ for await (const row of db.eval('select count(1) as Count from Strand.MemberPeer where MemberKey = ? and PeerId = ?', [memberKey, peerId])) {
371
+ return (row.Count ?? 0) > 0;
372
+ }
373
+ return false;
374
+ }
375
+ /**
376
+ * Promote a member to `Authority` on the signature of an existing authority.
377
+ *
378
+ * The existing-authority branch of `Authority.Authorized` verifies
379
+ * `digest(coalesce(new.MemberKey, old.MemberKey))` — `new.MemberKey` =
380
+ * `newAuthorityKey` on insert — against an `Authority` row matching
381
+ * `context.AuthorityKey`. So the signer signs the new authority key directly (no
382
+ * `'|'` join) and binds itself as `context.AuthorityKey`.
383
+ *
384
+ * Once the founder authority exists, the schema's `(select count(1) from Authority)
385
+ * <= 1` bootstrap shortcut no longer applies to a second add (at commit the count
386
+ * includes the new row, so it is ≥ 2), so this genuinely exercises signature
387
+ * verification — a non-authority signer, or a signature over the wrong key, is
388
+ * rejected.
389
+ *
390
+ * @param db - The closed strand's database (founder authority already seated).
391
+ * @param params - The authorizing authority's keypair and the new authority key.
392
+ * @throws If `Authority.Authorized` rejects; the insert rolls back.
393
+ */
394
+ export async function addAuthority(db, params) {
395
+ const { byAuthorityKeyPair, newAuthorityKey } = params;
396
+ const signature = signStrandPayload(newAuthorityKey, byAuthorityKeyPair.privateKeyB64);
397
+ await db.exec(`insert into Strand.Authority (MemberKey)
398
+ with context AuthorityKey = ?, Signature = ?
399
+ values (?)`, [byAuthorityKeyPair.publicKeyB64, signature, newAuthorityKey]);
400
+ log('Added authority %s by %s', newAuthorityKey, byAuthorityKeyPair.publicKeyB64);
401
+ }
402
+ /**
403
+ * Remove an `Authority` row — either an admin removing a different authority or an
404
+ * authority resigning itself.
405
+ *
406
+ * On a DELETE, `coalesce(new.MemberKey, old.MemberKey)` binds `old.MemberKey =
407
+ * targetAuthorityKey` (there is no `new` row), so the signed payload is the target
408
+ * key for BOTH accepting branches of `Authority.Authorized`, and a single context
409
+ * construction serves both:
410
+ * - **Admin removal**: `byAuthorityKeyPair` is a different existing authority; the
411
+ * existing-authority branch verifies the signature against `A.MemberKey =
412
+ * context.AuthorityKey`.
413
+ * - **Self-resignation**: `byAuthorityKeyPair` IS the target (its `publicKeyB64`
414
+ * equals `targetAuthorityKey`); the former-authority self branch verifies
415
+ * `old.MemberKey = context.AuthorityKey` and the self-signature over `old.MemberKey`.
416
+ *
417
+ * The caller selects the case purely by which keypair it passes — no branching here.
418
+ *
419
+ * KNOWN PLATFORM GAP (not fixable here): the optimystic bootstrap-mode transactor
420
+ * evaluates deferred (subquery-bearing) CHECK constraints only on INSERT, not on
421
+ * DELETE. `Authority.Authorized` is deferred, so the platform currently accepts ANY
422
+ * `Authority` delete regardless of signature — `removeAuthority`'s authorization is
423
+ * effectively unenforced at runtime until that gap is closed (filed as
424
+ * `optimystic-deferred-check-not-enforced-on-delete`). This writer still builds the
425
+ * correct, signed delete so it works unchanged once enforcement lands — exactly as
426
+ * the invite path issues correct inserts despite the open PK-uniqueness gap.
427
+ *
428
+ * KNOWN SCHEMA HAZARD (not guarded here): even with enforcement, `Authority.
429
+ * Authorized`'s `(select count(1) from Authority) <= 1` bootstrap branch is true at
430
+ * commit whenever a delete drops the count to ≤ 1, so removing the strand's LAST (or
431
+ * second-to-last) authority would be accepted regardless of signature — and removing
432
+ * the last one orphans the strand (no one can ever add another). The schema does not
433
+ * prevent this; a "min-one-authority" invariant is deferred to a future schema change
434
+ * rather than grown into this writer.
435
+ *
436
+ * @param db - The closed strand's database.
437
+ * @param params - The authorizing keypair and the target authority key.
438
+ * @throws If `Authority.Authorized` rejects (e.g. a non-authority admin removal
439
+ * while > 2 authorities remain); the delete rolls back.
440
+ */
441
+ export async function removeAuthority(db, params) {
442
+ const { byAuthorityKeyPair, targetAuthorityKey } = params;
443
+ const signature = signStrandPayload(targetAuthorityKey, byAuthorityKeyPair.privateKeyB64);
444
+ await db.exec(`delete from Strand.Authority
445
+ with context AuthorityKey = ?, Signature = ?
446
+ where MemberKey = ?`, [byAuthorityKeyPair.publicKeyB64, signature, targetAuthorityKey]);
447
+ log('Removed authority %s by %s', targetAuthorityKey, byAuthorityKeyPair.publicKeyB64);
448
+ }
449
+ //# sourceMappingURL=strand-membership-writer.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"strand-membership-writer.js","sourceRoot":"","sources":["../src/strand-membership-writer.ts"],"names":[],"mappings":"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"}
@@ -1,6 +1,7 @@
1
1
  import type { Libp2p } from '@libp2p/interface';
2
2
  import type { OpenInvitation, FormStrandResult, ValidateFormationResult, StrandFormationDisclosure } from './types.js';
3
3
  import { type StrandFormationManagerConfig } from './strand-formation-manager.js';
4
+ import { type FormationResultMessage } from './strand-formation-protocol.js';
4
5
  /**
5
6
  * Interface for validating formation disclosures
6
7
  */
@@ -13,6 +14,31 @@ export interface DisclosureValidator {
13
14
  */
14
15
  validateDisclosure(token: string, disclosure: StrandFormationDisclosure): Promise<boolean>;
15
16
  }
17
+ /**
18
+ * Discriminated result of resolving the host strand an invite binds to (responder side):
19
+ *
20
+ * - `unbound`: the invite carries no `StrandId` → the responder-provisions path (provision
21
+ * a NEW strand; legacy/open).
22
+ * - `bound`: the invite names a host strand AND that strand row is present on this responder
23
+ * → provision-then-record (record consent against it, return it + its membership key).
24
+ * - `missing`: the invite names a host strand but the row is ABSENT on this responder (e.g.
25
+ * not yet converged) → the manager rejects cleanly rather than recording usage against a
26
+ * non-existent strand, which would fail the deferred `FormationUsage.StrandExists` CHECK at
27
+ * commit and drop the result frame.
28
+ *
29
+ * Replaces the older `{ strandId, memberPrivateKey } | null` shape, which conflated `bound`
30
+ * and `missing` (both produced a non-null result whenever the invite had a `StrandId`).
31
+ */
32
+ export type ResolvedHostStrand = {
33
+ kind: 'unbound';
34
+ } | {
35
+ kind: 'bound';
36
+ strandId: string;
37
+ memberPrivateKey: string | null;
38
+ } | {
39
+ kind: 'missing';
40
+ strandId: string;
41
+ };
16
42
  /**
17
43
  * Interface for recording formation usage
18
44
  */
@@ -32,6 +58,26 @@ export interface FormationUsageRecorder {
32
58
  valid: boolean;
33
59
  invitation?: OpenInvitation;
34
60
  }>;
61
+ /**
62
+ * Resolve the host strand an invite binds to, classifying it as unbound / bound /
63
+ * missing (see {@link ResolvedHostStrand}). Optional: a recorder that does not bind
64
+ * strands omits it, and {@link StrandFormationManager} treats every invite as `unbound`
65
+ * and falls back to its provisioner. Keeping resolution behind this interface keeps the
66
+ * manager DB-agnostic and unit-testable with an in-memory fake.
67
+ */
68
+ resolveStrand?(token: string): Promise<ResolvedHostStrand>;
69
+ /**
70
+ * Provision a NEW strand for an UNBOUND invite and record consent against it
71
+ * ATOMICALLY (single `FormationUsage` row → single-use enforced on the next redemption).
72
+ * Optional: a recorder that only supports provision-then-record omits it, and the
73
+ * manager falls back to its {@link StrandProvisioner}. Mirrors the create-strand-by-consent
74
+ * path the schema's `Strand.Authorized` `FormationUsage` branch authorizes. Returns the
75
+ * minted strand id plus its membership key (null for an open responder-provisioned strand).
76
+ */
77
+ provisionAndRecord?(token: string, initiatorKey: string, sAppId: string): Promise<{
78
+ strandId: string;
79
+ memberPrivateKey: string | null;
80
+ }>;
35
81
  }
36
82
  /**
37
83
  * Interface for strand provisioning during formation
@@ -56,11 +102,39 @@ export interface FormationSigner {
56
102
  validationSignature: string;
57
103
  }>;
58
104
  }
105
+ /**
106
+ * Interface for validating the responder's formation result on the initiator side.
107
+ *
108
+ * Symmetric to {@link DisclosureValidator} (which runs responder→initiator): it lets
109
+ * the initiator verify the responder's disclosed identity, cadre, and provisioned
110
+ * strand against the invitation/disclosure it used to form the strand. The manager
111
+ * owns one dialer session per `formStrand` call, so the per-session invitation +
112
+ * disclosure context is local — no hook-signature gymnastics needed.
113
+ */
114
+ export interface FormationResponseValidator {
115
+ /** Validate the responder's result against the invitation/disclosure used to form the strand. */
116
+ validateResponse(ctx: {
117
+ invitation: OpenInvitation;
118
+ disclosure: StrandFormationDisclosure;
119
+ response: FormationResultMessage;
120
+ }): Promise<boolean>;
121
+ }
122
+ /**
123
+ * Built-in structural {@link FormationResponseValidator}.
124
+ *
125
+ * `validateResponse` rejects when the responder did not approve, omitted a disclosed
126
+ * identity, returned no/placeholder cadre addresses, or returned a missing/empty or
127
+ * non-responder-created strand (see {@link isValidResponderCreatesResult}).
128
+ * Apps can supply a stricter validator via {@link StrandSolicitationServiceOptions}.
129
+ */
130
+ export declare function createDefaultFormationResponseValidator(): FormationResponseValidator;
59
131
  export interface StrandSolicitationServiceOptions {
60
132
  disclosureValidator?: DisclosureValidator;
61
133
  formationUsageRecorder?: FormationUsageRecorder;
62
134
  strandProvisioner?: StrandProvisioner;
63
135
  formationSigner?: FormationSigner;
136
+ /** Validates the responder's result on the initiator side (defaults to a structural check) */
137
+ formationResponseValidator?: FormationResponseValidator;
64
138
  /** Party ID for this node (used in protocol messages) */
65
139
  partyId?: string;
66
140
  /** Cadre peer addresses for this node */
@@ -75,14 +149,15 @@ export interface StrandSolicitationServiceOptions {
75
149
  * - formStrand(invitation, disclosure, node) - called by initiator
76
150
  * - validateStrandFormation(token, disclosure) - called by responder
77
151
  *
78
- * When a libp2p node is provided, the underlying protocol is handled by
79
- * strand-proto's SessionManager via StrandFormationManager.
152
+ * When a libp2p node is provided, the underlying protocol is handled by the
153
+ * native cadre-core formation transport via StrandFormationManager.
80
154
  */
81
155
  export declare class StrandSolicitationService {
82
156
  private readonly disclosureValidator?;
83
157
  private readonly formationUsageRecorder?;
84
158
  private readonly strandProvisioner?;
85
159
  private readonly formationSigner?;
160
+ private readonly formationResponseValidator?;
86
161
  private readonly partyId;
87
162
  private readonly cadrePeerAddrs;
88
163
  private formationManager?;
@@ -3,7 +3,23 @@ import { toString as uint8ArrayToString } from 'uint8arrays';
3
3
  import { generateKeyPair, privateKeyToProtobuf } from '@libp2p/crypto/keys';
4
4
  import { peerIdFromPrivateKey } from '@libp2p/peer-id';
5
5
  import { StrandFormationManager } from './strand-formation-manager.js';
6
+ import { isValidResponderCreatesResult } from './strand-formation-protocol.js';
6
7
  const log = debug('sereus:cadre:solicitation');
8
+ /**
9
+ * Built-in structural {@link FormationResponseValidator}.
10
+ *
11
+ * `validateResponse` rejects when the responder did not approve, omitted a disclosed
12
+ * identity, returned no/placeholder cadre addresses, or returned a missing/empty or
13
+ * non-responder-created strand (see {@link isValidResponderCreatesResult}).
14
+ * Apps can supply a stricter validator via {@link StrandSolicitationServiceOptions}.
15
+ */
16
+ export function createDefaultFormationResponseValidator() {
17
+ return {
18
+ async validateResponse({ response }) {
19
+ return isValidResponderCreatesResult(response);
20
+ }
21
+ };
22
+ }
7
23
  /**
8
24
  * Strand Solicitation API for forming strands via open invitations.
9
25
  *
@@ -11,8 +27,8 @@ const log = debug('sereus:cadre:solicitation');
11
27
  * - formStrand(invitation, disclosure, node) - called by initiator
12
28
  * - validateStrandFormation(token, disclosure) - called by responder
13
29
  *
14
- * When a libp2p node is provided, the underlying protocol is handled by
15
- * strand-proto's SessionManager via StrandFormationManager.
30
+ * When a libp2p node is provided, the underlying protocol is handled by the
31
+ * native cadre-core formation transport via StrandFormationManager.
16
32
  */
17
33
  export class StrandSolicitationService {
18
34
  constructor(options) {
@@ -20,6 +36,7 @@ export class StrandSolicitationService {
20
36
  this.formationUsageRecorder = options?.formationUsageRecorder;
21
37
  this.strandProvisioner = options?.strandProvisioner;
22
38
  this.formationSigner = options?.formationSigner;
39
+ this.formationResponseValidator = options?.formationResponseValidator;
23
40
  this.partyId = options?.partyId ?? `party-${Date.now()}`;
24
41
  this.cadrePeerAddrs = options?.cadrePeerAddrs ?? [];
25
42
  this.formationConfig = options?.formationConfig;
@@ -35,6 +52,7 @@ export class StrandSolicitationService {
35
52
  disclosureValidator: this.disclosureValidator,
36
53
  formationUsageRecorder: this.formationUsageRecorder,
37
54
  strandProvisioner: this.strandProvisioner,
55
+ formationResponseValidator: this.formationResponseValidator,
38
56
  partyId: this.partyId,
39
57
  cadrePeerAddrs: this.cadrePeerAddrs,
40
58
  config: this.formationConfig
@@ -82,12 +100,15 @@ export class StrandSolicitationService {
82
100
  log('Generated member key: %s', memberKey);
83
101
  // If we have a full invitation and a node, use the real protocol
84
102
  if (typeof invitation !== 'string' && node) {
85
- log('Using strand-proto for real protocol handling');
103
+ log('Using native cadre-core formation transport');
86
104
  const result = await this.getFormationManager().formStrand(invitation, { ...disclosure, partyId: memberKey }, node);
87
105
  return {
88
106
  memberKey,
89
107
  invitePrivateKey,
90
- strandId: result.strandId
108
+ strandId: result.strandId,
109
+ // Carry the host strand's membership key delivered over the protocol (closed-strand
110
+ // provision-then-record). invitePrivateKey stays the initiator's generated signing key.
111
+ memberPrivateKey: result.memberPrivateKey
91
112
  };
92
113
  }
93
114
  // Fallback: placeholder strandId (for testing without network)