@serfab/cadre-core 0.9.0 → 0.11.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 (193) hide show
  1. package/README.md +255 -253
  2. package/dist/bootstrap-peer-store-file.d.ts +13 -0
  3. package/dist/bootstrap-peer-store-file.js +42 -0
  4. package/dist/bootstrap-peer-store-file.js.map +1 -0
  5. package/dist/bootstrap-peer-store.d.ts +73 -0
  6. package/dist/bootstrap-peer-store.js +128 -0
  7. package/dist/bootstrap-peer-store.js.map +1 -0
  8. package/dist/cadre-node.d.ts +1121 -81
  9. package/dist/cadre-node.js +2276 -262
  10. package/dist/cadre-node.js.map +1 -1
  11. package/dist/control-authorization.d.ts +93 -0
  12. package/dist/control-authorization.js +57 -0
  13. package/dist/control-authorization.js.map +1 -0
  14. package/dist/control-cohort.d.ts +26 -0
  15. package/dist/control-cohort.js +26 -0
  16. package/dist/control-cohort.js.map +1 -1
  17. package/dist/control-database.d.ts +959 -54
  18. package/dist/control-database.js +1421 -148
  19. package/dist/control-database.js.map +1 -1
  20. package/dist/control-formation-recorder.d.ts +90 -10
  21. package/dist/control-formation-recorder.js +130 -14
  22. package/dist/control-formation-recorder.js.map +1 -1
  23. package/dist/control-schema.d.ts +1 -1
  24. package/dist/control-schema.js +651 -72
  25. package/dist/control-schema.js.map +1 -1
  26. package/dist/control-stream.d.ts +38 -4
  27. package/dist/control-stream.js +70 -4
  28. package/dist/control-stream.js.map +1 -1
  29. package/dist/control-write-retry.d.ts +161 -0
  30. package/dist/control-write-retry.js +412 -0
  31. package/dist/control-write-retry.js.map +1 -0
  32. package/dist/delegate-admission.d.ts +134 -0
  33. package/dist/delegate-admission.js +244 -0
  34. package/dist/delegate-admission.js.map +1 -0
  35. package/dist/device-token.d.ts +11 -11
  36. package/dist/device-token.js +14 -13
  37. package/dist/device-token.js.map +1 -1
  38. package/dist/ed25519-key.d.ts +26 -0
  39. package/dist/ed25519-key.js +56 -1
  40. package/dist/ed25519-key.js.map +1 -1
  41. package/dist/file-durable-slot.d.ts +20 -0
  42. package/dist/file-durable-slot.js +53 -0
  43. package/dist/file-durable-slot.js.map +1 -0
  44. package/dist/formation-approval.d.ts +157 -0
  45. package/dist/formation-approval.js +375 -0
  46. package/dist/formation-approval.js.map +1 -0
  47. package/dist/fs-atomic.d.ts +38 -0
  48. package/dist/fs-atomic.js +97 -0
  49. package/dist/fs-atomic.js.map +1 -0
  50. package/dist/identity-key.d.ts +49 -0
  51. package/dist/identity-key.js +81 -0
  52. package/dist/identity-key.js.map +1 -0
  53. package/dist/index.d.ts +17 -7
  54. package/dist/index.js +40 -6
  55. package/dist/index.js.map +1 -1
  56. package/dist/key-store-file.d.ts +2 -24
  57. package/dist/key-store-file.js +10 -77
  58. package/dist/key-store-file.js.map +1 -1
  59. package/dist/membership-connection-gater.d.ts +146 -0
  60. package/dist/membership-connection-gater.js +180 -0
  61. package/dist/membership-connection-gater.js.map +1 -0
  62. package/dist/node-local-snapshot.d.ts +109 -0
  63. package/dist/node-local-snapshot.js +182 -0
  64. package/dist/node-local-snapshot.js.map +1 -0
  65. package/dist/peer-addr-book.d.ts +92 -0
  66. package/dist/peer-addr-book.js +220 -0
  67. package/dist/peer-addr-book.js.map +1 -0
  68. package/dist/peer-authorization.d.ts +135 -18
  69. package/dist/peer-authorization.js +152 -21
  70. package/dist/peer-authorization.js.map +1 -1
  71. package/dist/peer-record.d.ts +21 -8
  72. package/dist/peer-record.js +26 -10
  73. package/dist/peer-record.js.map +1 -1
  74. package/dist/relay-addrs.d.ts +45 -0
  75. package/dist/relay-addrs.js +103 -0
  76. package/dist/relay-addrs.js.map +1 -0
  77. package/dist/relay-reservation.d.ts +266 -0
  78. package/dist/relay-reservation.js +613 -0
  79. package/dist/relay-reservation.js.map +1 -0
  80. package/dist/seed-bootstrap.d.ts +228 -56
  81. package/dist/seed-bootstrap.js +340 -198
  82. package/dist/seed-bootstrap.js.map +1 -1
  83. package/dist/seed-trust-policy.d.ts +47 -21
  84. package/dist/seed-trust-policy.js +24 -15
  85. package/dist/seed-trust-policy.js.map +1 -1
  86. package/dist/strand-addr-protocol.d.ts +42 -13
  87. package/dist/strand-addr-protocol.js +58 -52
  88. package/dist/strand-addr-protocol.js.map +1 -1
  89. package/dist/strand-backfill.d.ts +109 -0
  90. package/dist/strand-backfill.js +279 -0
  91. package/dist/strand-backfill.js.map +1 -0
  92. package/dist/strand-cohort.d.ts +4 -37
  93. package/dist/strand-cohort.js +4 -15
  94. package/dist/strand-cohort.js.map +1 -1
  95. package/dist/strand-database.d.ts +11 -16
  96. package/dist/strand-database.js +19 -7
  97. package/dist/strand-database.js.map +1 -1
  98. package/dist/strand-formation-manager.d.ts +36 -7
  99. package/dist/strand-formation-manager.js +115 -15
  100. package/dist/strand-formation-manager.js.map +1 -1
  101. package/dist/strand-formation-protocol.d.ts +103 -4
  102. package/dist/strand-formation-protocol.js +232 -4
  103. package/dist/strand-formation-protocol.js.map +1 -1
  104. package/dist/strand-instance-manager.d.ts +88 -19
  105. package/dist/strand-instance-manager.js +178 -34
  106. package/dist/strand-instance-manager.js.map +1 -1
  107. package/dist/strand-member-registry.d.ts +5 -5
  108. package/dist/strand-member-registry.js +18 -10
  109. package/dist/strand-member-registry.js.map +1 -1
  110. package/dist/strand-membership-writer.d.ts +484 -88
  111. package/dist/strand-membership-writer.js +751 -128
  112. package/dist/strand-membership-writer.js.map +1 -1
  113. package/dist/strand-solicitation.d.ts +99 -33
  114. package/dist/strand-solicitation.js +100 -52
  115. package/dist/strand-solicitation.js.map +1 -1
  116. package/dist/strand-transport-key.d.ts +40 -0
  117. package/dist/strand-transport-key.js +56 -0
  118. package/dist/strand-transport-key.js.map +1 -0
  119. package/dist/strand-wake-protocol.d.ts +9 -7
  120. package/dist/strand-wake-protocol.js +22 -46
  121. package/dist/strand-wake-protocol.js.map +1 -1
  122. package/dist/strand-watcher.d.ts +17 -2
  123. package/dist/strand-watcher.js +47 -3
  124. package/dist/strand-watcher.js.map +1 -1
  125. package/dist/trusted-owner-store-file.d.ts +12 -0
  126. package/dist/trusted-owner-store-file.js +39 -0
  127. package/dist/trusted-owner-store-file.js.map +1 -0
  128. package/dist/trusted-owner-store.d.ts +64 -0
  129. package/dist/trusted-owner-store.js +126 -0
  130. package/dist/trusted-owner-store.js.map +1 -0
  131. package/dist/types.d.ts +307 -44
  132. package/dist/types.js +13 -0
  133. package/dist/types.js.map +1 -1
  134. package/package.json +20 -8
  135. package/src/arachnode-stub.ts +159 -159
  136. package/src/bootstrap-peer-store-file.ts +44 -0
  137. package/src/bootstrap-peer-store.ts +172 -0
  138. package/src/cadre-node.ts +5158 -2981
  139. package/src/canonical-json.ts +30 -30
  140. package/src/control-authorization.ts +114 -0
  141. package/src/control-cohort.ts +130 -103
  142. package/src/control-database.ts +2406 -948
  143. package/src/control-formation-recorder.ts +305 -151
  144. package/src/control-schema.ts +651 -72
  145. package/src/control-stream.ts +205 -128
  146. package/src/control-write-retry.ts +483 -0
  147. package/src/delegate-admission.ts +302 -0
  148. package/src/device-token.ts +94 -89
  149. package/src/diagnostics/connection-path.ts +277 -277
  150. package/src/diagnostics/webrtc-turn-tracker.ts +222 -222
  151. package/src/ed25519-key.ts +66 -1
  152. package/src/enrollment.ts +199 -199
  153. package/src/file-durable-slot.ts +61 -0
  154. package/src/formation-approval.ts +604 -0
  155. package/src/fs-atomic.ts +107 -0
  156. package/src/hibernation-manager.ts +437 -437
  157. package/src/identity-key.ts +109 -0
  158. package/src/index.ts +137 -5
  159. package/src/key-store-file.ts +132 -203
  160. package/src/key-store.ts +99 -99
  161. package/src/membership-connection-gater.ts +204 -0
  162. package/src/node-local-snapshot.ts +260 -0
  163. package/src/peer-addr-book.ts +250 -0
  164. package/src/peer-authorization.ts +259 -81
  165. package/src/peer-record.ts +164 -146
  166. package/src/push-credentials.ts +84 -84
  167. package/src/push-node.ts +64 -64
  168. package/src/push-notifier-apns.ts +247 -247
  169. package/src/push-notifier-fcm.ts +220 -220
  170. package/src/push-notifier-shared.ts +28 -28
  171. package/src/push-notifier.ts +52 -52
  172. package/src/relay-addrs.ts +111 -0
  173. package/src/relay-reservation.ts +823 -0
  174. package/src/seed-bootstrap.ts +409 -209
  175. package/src/seed-trust-policy.ts +140 -107
  176. package/src/strand-addr-protocol.ts +441 -410
  177. package/src/strand-backfill.ts +366 -0
  178. package/src/strand-cohort.ts +38 -77
  179. package/src/strand-database.ts +202 -204
  180. package/src/strand-formation-manager.ts +491 -368
  181. package/src/strand-formation-protocol.ts +692 -391
  182. package/src/strand-instance-manager.ts +201 -46
  183. package/src/strand-member-key.ts +39 -39
  184. package/src/strand-member-registry.ts +182 -177
  185. package/src/strand-membership-writer.ts +938 -146
  186. package/src/strand-solicitation.ts +472 -407
  187. package/src/strand-transport-key.ts +58 -0
  188. package/src/strand-wake-payload.ts +32 -32
  189. package/src/strand-wake-protocol.ts +314 -331
  190. package/src/strand-watcher.ts +314 -251
  191. package/src/trusted-owner-store-file.ts +41 -0
  192. package/src/trusted-owner-store.ts +171 -0
  193. package/src/types.ts +1309 -1032
@@ -1,177 +1,182 @@
1
- import debug from 'debug';
2
- import type { Database, SqlValue } from '@quereus/quereus';
3
- import type { MemberRegistration } from './types.js';
4
- import type { MemberRegistry, MemberVerifier } from './enrollment.js';
5
- import type { Ed25519KeyPair } from './ed25519-key.js';
6
- import {
7
- verifyStrandPayload,
8
- consumeInvite,
9
- addMemberByManager,
10
- } from './strand-membership-writer.js';
11
- import { canonicalDatetime } from './canonical-datetime.js';
12
-
13
- const log = debug('sereus:cadre:strand-member-registry');
14
-
15
- /**
16
- * The canonical payload a joining member signs to prove ownership of its strand
17
- * member key, verified by {@link StrandMemberVerifier.verifyMember}.
18
- *
19
- * Binds the member key to the strand id so a registration signature cannot be
20
- * replayed against a different strand. Peer-id binding is intentionally NOT part
21
- * of this payload — `MemberPeer` rows carry their OWN per-peer self-proofs
22
- * (`MemberKey || '|' || PeerId`, signed by the member) and are written by the
23
- * dedicated `registerMemberPeer` writer; see {@link StrandMemberRegistry}.
24
- */
25
- export function memberRegistrationPayload(registration: MemberRegistration): string {
26
- return `${registration.strandId}|${registration.key}`;
27
- }
28
-
29
- /** How a {@link StrandMemberRegistry} admits a member into the strand. */
30
- export type StrandAdmission =
31
- | {
32
- /** Redeem a specific single-use invite (the invitee-side join). */
33
- readonly mode: 'invite';
34
- /** The `Invite.Key` (invite public key, base64url) to redeem. */
35
- readonly inviteKey: string;
36
- /** The matching invite private seed (base64url), received out-of-band. */
37
- readonly invitePrivateKey: string;
38
- }
39
- | {
40
- /** Admit directly by manager signature (the manager-side join). */
41
- readonly mode: 'manager';
42
- /** The admitting manager's strand keypair. */
43
- readonly managerKeyPair: Ed25519KeyPair;
44
- };
45
-
46
- /**
47
- * Strand-`Database`-backed {@link MemberVerifier} — the reconciliation that lets
48
- * {@link EnrollmentService} make real authorization decisions against a strand's
49
- * `Invite`/`ConsumedInvite`/`Member` tables instead of a placeholder.
50
- *
51
- * Each instance is scoped to ONE strand's database (the `Strand.*` tables live
52
- * inside the connected strand DB), so the `strandId` arguments on the interface
53
- * are accepted for signature compatibility but the queries run against this db.
54
- */
55
- export class StrandMemberVerifier implements MemberVerifier {
56
- constructor(private readonly db: Database) {}
57
-
58
- /**
59
- * Verify the member's self-proof over {@link memberRegistrationPayload} against
60
- * the registration's own `key` (the member's ed25519 public key). This is the
61
- * off-engine counterpart to the on-engine signature checks — a pre-flight gate
62
- * before the constraint-enforced write.
63
- */
64
- async verifyMember(registration: MemberRegistration, signature: string): Promise<boolean> {
65
- const ok = verifyStrandPayload(memberRegistrationPayload(registration), signature, registration.key);
66
- if (!ok) {
67
- log('verifyMember: bad self-proof for member %s on strand %s', registration.key, registration.strandId);
68
- }
69
- return ok;
70
- }
71
-
72
- /**
73
- * A member is authorized to join when either it has already consumed an invite
74
- * (a `ConsumedInvite` row bears its key) OR the strand has at least one
75
- * outstanding `Invite` it could redeem.
76
- *
77
- * Strand invites are anonymous — `Invite.Key` is an invite public key, not a
78
- * member key, so an un-consumed invite cannot be bound to a specific member
79
- * here. This check is therefore a "door is open" pre-flight; the binding,
80
- * single-use, and cryptographic gates are all enforced by the deferred
81
- * `Strand.*` constraints when the member is actually written.
82
- *
83
- * Expired invites are filtered out so this pre-flight matches the on-engine
84
- * `ConsumedInvite.NotExpired` gate — `Now` is canonicalised the same way the
85
- * writer canonicalises it, so `Expiration > ?` compares like-for-like canonical
86
- * strings. A member that already holds a `ConsumedInvite` short-circuits above,
87
- * regardless of any invite's expiry.
88
- */
89
- async isAuthorizedToJoin(_strandId: string, memberKey: string): Promise<boolean> {
90
- const consumed = await scalarCount(
91
- this.db,
92
- 'select count(1) as c from Strand.ConsumedInvite where MemberKey = ?',
93
- [memberKey],
94
- );
95
- if (consumed > 0) {
96
- return true;
97
- }
98
- const nowCanonical = await canonicalDatetime(this.db, Date.now());
99
- const invites = await scalarCount(
100
- this.db,
101
- 'select count(1) as c from Strand.Invite where Expiration is null or Expiration > ?',
102
- [nowCanonical],
103
- );
104
- return invites > 0;
105
- }
106
- }
107
-
108
- /**
109
- * Strand-`Database`-backed {@link MemberRegistry} — writes real `Strand.Member`
110
- * (and, in invite mode, `Strand.ConsumedInvite`) rows through the signed writer
111
- * primitives, so {@link EnrollmentService.registerMember} performs the actual
112
- * per-strand join handshake rather than returning "MemberRegistry not configured".
113
- *
114
- * The {@link StrandAdmission} chosen at construction selects which branch of
115
- * `Member.Authorized` is taken:
116
- * - `invite` → {@link consumeInvite} (atomic `Member` + `ConsumedInvite`), the
117
- * invitee-side flow that redeems a single-use invite.
118
- * - `manager` → {@link addMemberByManager}, the manager-side flow that
119
- * seats a member already trusted by a manager.
120
- *
121
- * Scoped to one strand's db; the `strandId` argument is accepted for interface
122
- * compatibility but writes target this db's `Strand.*` tables.
123
- *
124
- * NOTE: `peerIds` are NOT written as `MemberPeer` rows here. The signed peer path
125
- * now exists as the standalone `registerMemberPeer` writer (each peer self-proof is
126
- * signed by the member's own key), but wiring the enrollment `peerIds` through it is
127
- * a deliberate follow-on — the member self-proof available here does not carry the
128
- * per-peer signatures `MemberPeer.Authorized` requires. A non-empty `peerIds` is
129
- * logged and otherwise ignored so the member still lands; peer rows are reconciled
130
- * separately via `registerMemberPeer`.
131
- */
132
- export class StrandMemberRegistry implements MemberRegistry {
133
- constructor(
134
- private readonly db: Database,
135
- private readonly admission: StrandAdmission,
136
- ) {}
137
-
138
- async registerMember(strandId: string, memberKey: string, peerIds: string[]): Promise<void> {
139
- if (peerIds.length > 0) {
140
- log(
141
- 'registerMember: %d peerId(s) for member %s on strand %s deferred to the registerMemberPeer path (needs per-peer self-proofs); registering member only',
142
- peerIds.length, memberKey, strandId,
143
- );
144
- }
145
-
146
- if (this.admission.mode === 'invite') {
147
- await consumeInvite(this.db, {
148
- inviteKey: this.admission.inviteKey,
149
- invitePrivateKey: this.admission.invitePrivateKey,
150
- memberKey,
151
- });
152
- return;
153
- }
154
-
155
- await addMemberByManager(this.db, {
156
- managerKeyPair: this.admission.managerKeyPair,
157
- memberKey,
158
- });
159
- }
160
-
161
- async isMemberRegistered(_strandId: string, memberKey: string): Promise<boolean> {
162
- const count = await scalarCount(
163
- this.db,
164
- 'select count(1) as c from Strand.Member where Key = ?',
165
- [memberKey],
166
- );
167
- return count > 0;
168
- }
169
- }
170
-
171
- /** Run a `select count(1) as c ...` and return the scalar (0 if no row). */
172
- async function scalarCount(db: Database, sql: string, params: SqlValue[]): Promise<number> {
173
- for await (const row of db.eval(sql, params)) {
174
- return (row.c as number) ?? 0;
175
- }
176
- return 0;
177
- }
1
+ import debug from 'debug';
2
+ import type { Database, SqlValue } from '@quereus/quereus';
3
+ import type { MemberRegistration } from './types.js';
4
+ import type { MemberRegistry, MemberVerifier } from './enrollment.js';
5
+ import type { Ed25519KeyPair } from './ed25519-key.js';
6
+ import {
7
+ verifyStrandPayload,
8
+ consumeInvite,
9
+ addMemberByManager,
10
+ listOutstandingInvites,
11
+ } from './strand-membership-writer.js';
12
+
13
+ const log = debug('sereus:cadre:strand-member-registry');
14
+
15
+ /**
16
+ * The canonical payload a joining member signs to prove ownership of its strand
17
+ * member key, verified by {@link StrandMemberVerifier.verifyMember}.
18
+ *
19
+ * Binds the member key to the strand id so a registration signature cannot be
20
+ * replayed against a different strand. Peer-id binding is intentionally NOT part
21
+ * of this payload — `MemberPeer` rows carry their OWN per-peer self-proofs
22
+ * (`MemberKey || '|' || PeerId`, signed by the member) and are written by the
23
+ * dedicated `registerMemberPeer` writer; see {@link StrandMemberRegistry}.
24
+ */
25
+ export function memberRegistrationPayload(registration: MemberRegistration): string {
26
+ return `${registration.strandId}|${registration.key}`;
27
+ }
28
+
29
+ /** How a {@link StrandMemberRegistry} admits a member into the strand. */
30
+ export type StrandAdmission =
31
+ | {
32
+ /** Redeem a specific single-use invite (the invitee-side join). */
33
+ readonly mode: 'invite';
34
+ /** The `Invite.Key` (invite public key, base64url) to redeem. */
35
+ readonly inviteKey: string;
36
+ /** The matching invite private seed (base64url), received out-of-band. */
37
+ readonly invitePrivateKey: string;
38
+ }
39
+ | {
40
+ /** Admit directly by manager signature (the manager-side join). */
41
+ readonly mode: 'manager';
42
+ /** The admitting manager's strand keypair. */
43
+ readonly managerKeyPair: Ed25519KeyPair;
44
+ };
45
+
46
+ /**
47
+ * Strand-`Database`-backed {@link MemberVerifier} — the reconciliation that lets
48
+ * {@link EnrollmentService} make real authorization decisions against a strand's
49
+ * `Invite`/`ConsumedInvite`/`Member` tables instead of a placeholder.
50
+ *
51
+ * Each instance is scoped to ONE strand's database (the `Strand.*` tables live
52
+ * inside the connected strand DB), so the `strandId` arguments on the interface
53
+ * are accepted for signature compatibility but the queries run against this db.
54
+ */
55
+ export class StrandMemberVerifier implements MemberVerifier {
56
+ constructor(private readonly db: Database) {}
57
+
58
+ /**
59
+ * Verify the member's self-proof over {@link memberRegistrationPayload} against
60
+ * the registration's own `key` (the member's ed25519 public key). This is the
61
+ * off-engine counterpart to the on-engine signature checks — a pre-flight gate
62
+ * before the constraint-enforced write.
63
+ */
64
+ async verifyMember(registration: MemberRegistration, signature: string): Promise<boolean> {
65
+ const ok = verifyStrandPayload(memberRegistrationPayload(registration), signature, registration.key);
66
+ if (!ok) {
67
+ log('verifyMember: bad self-proof for member %s on strand %s', registration.key, registration.strandId);
68
+ }
69
+ return ok;
70
+ }
71
+
72
+ /**
73
+ * A member is authorized to join when either it has already consumed an invite
74
+ * (a `ConsumedInvite` row bears its key) OR the strand has at least one
75
+ * outstanding `Invite` it could redeem.
76
+ *
77
+ * Strand invites are anonymous — `Invite.Key` is an invite public key, not a
78
+ * member key, so an un-consumed invite cannot be bound to a specific member
79
+ * here. This check is therefore a "door is open" pre-flight; the binding,
80
+ * single-use, and cryptographic gates are all enforced by the deferred
81
+ * `Strand.*` constraints when the member is actually written.
82
+ *
83
+ * "Outstanding" is {@link listOutstandingInvites}: an `Invite` that is neither
84
+ * already consumed, nor cancelled by a manager, nor expired. All three exclusions
85
+ * mirror gates the actual write would hit (`ConsumedInvite`'s primary key,
86
+ * `NotCancelled`, `NotExpired`), so this pre-flight does not report a door the
87
+ * constraints will slam. A raw `Invite` count would over-report on all three.
88
+ */
89
+ async isAuthorizedToJoin(_strandId: string, memberKey: string): Promise<boolean> {
90
+ const consumed = await scalarCount(
91
+ this.db,
92
+ 'select count(1) as c from Strand.ConsumedInvite where MemberKey = ?',
93
+ [memberKey],
94
+ );
95
+ if (consumed > 0) {
96
+ // NOTE: a REVOKED member still bears its stale ConsumedInvite row (the table is
97
+ // insert-only, and revocation does not delete it), so this short-circuit says
98
+ // "authorized" for a party that can no longer actually join. Harmless because the
99
+ // on-engine constraints are the real gate — Member.Authorized's invite branch
100
+ // requires a FRESH consumption row, which a stale one is not — and this pre-flight
101
+ // only decides whether attempting the write is worthwhile.
102
+ return true;
103
+ }
104
+ // NOTE: materialises the whole outstanding set to answer a non-empty question (the
105
+ // former code was a single `select count(1)`). Deliberate — sharing one definition of
106
+ // "outstanding" with the manager-facing enumeration beats a second, drifting copy of
107
+ // the three exclusions. If this pre-flight ever runs hot on a strand with many
108
+ // invitations, add a short-circuiting variant rather than re-inlining the rules.
109
+ return (await listOutstandingInvites(this.db)).length > 0;
110
+ }
111
+ }
112
+
113
+ /**
114
+ * Strand-`Database`-backed {@link MemberRegistry} — writes real `Strand.Member`
115
+ * (and, in invite mode, `Strand.ConsumedInvite`) rows through the signed writer
116
+ * primitives, so {@link EnrollmentService.registerMember} performs the actual
117
+ * per-strand join handshake rather than returning "MemberRegistry not configured".
118
+ *
119
+ * The {@link StrandAdmission} chosen at construction selects which branch of
120
+ * `Member.Authorized` is taken:
121
+ * - `invite` → {@link consumeInvite} (atomic `Member` + `ConsumedInvite`), the
122
+ * invitee-side flow that redeems a single-use invite.
123
+ * - `manager` → {@link addMemberByManager}, the manager-side flow that
124
+ * seats a member already trusted by a manager.
125
+ *
126
+ * Scoped to one strand's db; the `strandId` argument is accepted for interface
127
+ * compatibility but writes target this db's `Strand.*` tables.
128
+ *
129
+ * NOTE: `peerIds` are NOT written as `MemberPeer` rows here. The signed peer path
130
+ * now exists as the standalone `registerMemberPeer` writer (each peer self-proof is
131
+ * signed by the member's own key), but wiring the enrollment `peerIds` through it is
132
+ * a deliberate follow-on — the member self-proof available here does not carry the
133
+ * per-peer signatures `MemberPeer.Authorized` requires. A non-empty `peerIds` is
134
+ * logged and otherwise ignored so the member still lands; peer rows are reconciled
135
+ * separately via `registerMemberPeer`.
136
+ */
137
+ export class StrandMemberRegistry implements MemberRegistry {
138
+ constructor(
139
+ private readonly db: Database,
140
+ private readonly admission: StrandAdmission,
141
+ ) {}
142
+
143
+ async registerMember(strandId: string, memberKey: string, peerIds: string[]): Promise<void> {
144
+ if (peerIds.length > 0) {
145
+ log(
146
+ 'registerMember: %d peerId(s) for member %s on strand %s deferred to the registerMemberPeer path (needs per-peer self-proofs); registering member only',
147
+ peerIds.length, memberKey, strandId,
148
+ );
149
+ }
150
+
151
+ if (this.admission.mode === 'invite') {
152
+ await consumeInvite(this.db, {
153
+ inviteKey: this.admission.inviteKey,
154
+ invitePrivateKey: this.admission.invitePrivateKey,
155
+ memberKey,
156
+ });
157
+ return;
158
+ }
159
+
160
+ await addMemberByManager(this.db, {
161
+ managerKeyPair: this.admission.managerKeyPair,
162
+ memberKey,
163
+ });
164
+ }
165
+
166
+ async isMemberRegistered(_strandId: string, memberKey: string): Promise<boolean> {
167
+ const count = await scalarCount(
168
+ this.db,
169
+ 'select count(1) as c from Strand.Member where Key = ?',
170
+ [memberKey],
171
+ );
172
+ return count > 0;
173
+ }
174
+ }
175
+
176
+ /** Run a `select count(1) as c ...` and return the scalar (0 if no row). */
177
+ async function scalarCount(db: Database, sql: string, params: SqlValue[]): Promise<number> {
178
+ for await (const row of db.eval(sql, params)) {
179
+ return (row.c as number) ?? 0;
180
+ }
181
+ return 0;
182
+ }