@serfab/cadre-core 0.10.0 → 0.12.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 (145) hide show
  1. package/README.md +255 -255
  2. package/dist/announce-addrs.d.ts +49 -0
  3. package/dist/announce-addrs.js +78 -0
  4. package/dist/announce-addrs.js.map +1 -0
  5. package/dist/cadre-node.d.ts +484 -58
  6. package/dist/cadre-node.js +953 -182
  7. package/dist/cadre-node.js.map +1 -1
  8. package/dist/control-cohort.d.ts +26 -0
  9. package/dist/control-cohort.js +26 -0
  10. package/dist/control-cohort.js.map +1 -1
  11. package/dist/control-database.d.ts +222 -125
  12. package/dist/control-database.js +356 -266
  13. package/dist/control-database.js.map +1 -1
  14. package/dist/control-formation-recorder.d.ts +8 -7
  15. package/dist/control-formation-recorder.js +8 -7
  16. package/dist/control-formation-recorder.js.map +1 -1
  17. package/dist/control-read-retry.d.ts +103 -0
  18. package/dist/control-read-retry.js +145 -0
  19. package/dist/control-read-retry.js.map +1 -0
  20. package/dist/control-retry.d.ts +101 -0
  21. package/dist/control-retry.js +108 -0
  22. package/dist/control-retry.js.map +1 -0
  23. package/dist/control-schema.d.ts +1 -1
  24. package/dist/control-schema.js +805 -774
  25. package/dist/control-schema.js.map +1 -1
  26. package/dist/control-write-retry.d.ts +65 -31
  27. package/dist/control-write-retry.js +33 -103
  28. package/dist/control-write-retry.js.map +1 -1
  29. package/dist/delegate-admission.d.ts +17 -15
  30. package/dist/delegate-admission.js +17 -15
  31. package/dist/delegate-admission.js.map +1 -1
  32. package/dist/index.d.ts +7 -5
  33. package/dist/index.js +18 -9
  34. package/dist/index.js.map +1 -1
  35. package/dist/membership-connection-gater.d.ts +211 -25
  36. package/dist/membership-connection-gater.js +309 -29
  37. package/dist/membership-connection-gater.js.map +1 -1
  38. package/dist/peer-addr-book.d.ts +92 -0
  39. package/dist/peer-addr-book.js +220 -0
  40. package/dist/peer-addr-book.js.map +1 -0
  41. package/dist/peer-record.d.ts +26 -0
  42. package/dist/peer-record.js +32 -0
  43. package/dist/peer-record.js.map +1 -1
  44. package/dist/relay-addrs.d.ts +76 -26
  45. package/dist/relay-addrs.js +115 -27
  46. package/dist/relay-addrs.js.map +1 -1
  47. package/dist/relay-reservation.d.ts +20 -15
  48. package/dist/relay-reservation.js +18 -14
  49. package/dist/relay-reservation.js.map +1 -1
  50. package/dist/seed-bootstrap.d.ts +7 -0
  51. package/dist/seed-bootstrap.js +15 -13
  52. package/dist/seed-bootstrap.js.map +1 -1
  53. package/dist/strand-addr-protocol.js +16 -0
  54. package/dist/strand-addr-protocol.js.map +1 -1
  55. package/dist/strand-backfill.js +13 -4
  56. package/dist/strand-backfill.js.map +1 -1
  57. package/dist/strand-cohort.d.ts +4 -37
  58. package/dist/strand-cohort.js +4 -15
  59. package/dist/strand-cohort.js.map +1 -1
  60. package/dist/strand-database.d.ts +11 -16
  61. package/dist/strand-database.js +19 -7
  62. package/dist/strand-database.js.map +1 -1
  63. package/dist/strand-formation-manager.d.ts +18 -17
  64. package/dist/strand-formation-manager.js +25 -27
  65. package/dist/strand-formation-manager.js.map +1 -1
  66. package/dist/strand-formation-protocol.d.ts +7 -7
  67. package/dist/strand-formation-protocol.js +8 -8
  68. package/dist/strand-formation-protocol.js.map +1 -1
  69. package/dist/strand-instance-manager.d.ts +53 -21
  70. package/dist/strand-instance-manager.js +128 -37
  71. package/dist/strand-instance-manager.js.map +1 -1
  72. package/dist/strand-membership-writer.d.ts +113 -19
  73. package/dist/strand-membership-writer.js +219 -51
  74. package/dist/strand-membership-writer.js.map +1 -1
  75. package/dist/strand-solicitation.d.ts +3 -3
  76. package/dist/strand-solicitation.js +4 -5
  77. package/dist/strand-solicitation.js.map +1 -1
  78. package/dist/strand-wake-protocol.d.ts +47 -2
  79. package/dist/strand-wake-protocol.js +76 -6
  80. package/dist/strand-wake-protocol.js.map +1 -1
  81. package/dist/types.d.ts +107 -54
  82. package/dist/types.js.map +1 -1
  83. package/package.json +8 -7
  84. package/src/announce-addrs.ts +93 -0
  85. package/src/arachnode-stub.ts +159 -159
  86. package/src/bootstrap-peer-store-file.ts +44 -44
  87. package/src/bootstrap-peer-store.ts +172 -172
  88. package/src/cadre-node.ts +1026 -194
  89. package/src/canonical-json.ts +30 -30
  90. package/src/control-authorization.ts +114 -114
  91. package/src/control-cohort.ts +130 -103
  92. package/src/control-database.ts +376 -298
  93. package/src/control-formation-recorder.ts +305 -304
  94. package/src/control-read-retry.ts +167 -0
  95. package/src/control-retry.ts +190 -0
  96. package/src/control-schema.ts +817 -786
  97. package/src/control-stream.ts +205 -205
  98. package/src/control-write-retry.ts +383 -473
  99. package/src/delegate-admission.ts +304 -302
  100. package/src/device-token.ts +94 -94
  101. package/src/diagnostics/connection-path.ts +277 -277
  102. package/src/diagnostics/webrtc-turn-tracker.ts +222 -222
  103. package/src/ed25519-key.ts +134 -134
  104. package/src/enrollment.ts +199 -199
  105. package/src/file-durable-slot.ts +61 -61
  106. package/src/formation-approval.ts +604 -604
  107. package/src/fs-atomic.ts +107 -107
  108. package/src/hibernation-manager.ts +437 -437
  109. package/src/identity-key.ts +109 -109
  110. package/src/index.ts +435 -406
  111. package/src/key-store-file.ts +132 -132
  112. package/src/key-store.ts +99 -99
  113. package/src/membership-connection-gater.ts +368 -39
  114. package/src/node-local-snapshot.ts +260 -260
  115. package/src/peer-addr-book.ts +250 -0
  116. package/src/peer-authorization.ts +259 -259
  117. package/src/peer-record.ts +197 -164
  118. package/src/push-credentials.ts +84 -84
  119. package/src/push-node.ts +64 -64
  120. package/src/push-notifier-apns.ts +247 -247
  121. package/src/push-notifier-fcm.ts +220 -220
  122. package/src/push-notifier-shared.ts +28 -28
  123. package/src/push-notifier.ts +52 -52
  124. package/src/relay-addrs.ts +134 -27
  125. package/src/relay-reservation.ts +22 -17
  126. package/src/seed-bootstrap.ts +15 -15
  127. package/src/seed-trust-policy.ts +140 -140
  128. package/src/strand-addr-protocol.ts +458 -441
  129. package/src/strand-backfill.ts +28 -5
  130. package/src/strand-cohort.ts +38 -77
  131. package/src/strand-database.ts +202 -204
  132. package/src/strand-formation-manager.ts +26 -28
  133. package/src/strand-formation-protocol.ts +692 -692
  134. package/src/strand-instance-manager.ts +636 -553
  135. package/src/strand-member-key.ts +39 -39
  136. package/src/strand-member-registry.ts +182 -182
  137. package/src/strand-membership-writer.ts +1627 -1430
  138. package/src/strand-solicitation.ts +471 -472
  139. package/src/strand-transport-key.ts +58 -58
  140. package/src/strand-wake-payload.ts +32 -32
  141. package/src/strand-wake-protocol.ts +400 -314
  142. package/src/strand-watcher.ts +314 -314
  143. package/src/trusted-owner-store-file.ts +41 -41
  144. package/src/trusted-owner-store.ts +171 -171
  145. package/src/types.ts +1354 -1303
@@ -1,39 +1,39 @@
1
- import { generateKeyPair, privateKeyToProtobuf, privateKeyFromProtobuf } from '@libp2p/crypto/keys';
2
- import { toString as uint8ArrayToString, fromString as uint8ArrayFromString } from 'uint8arrays';
3
- import { ed25519KeyPairFromLibp2p, type Ed25519KeyPair } from './ed25519-key.js';
4
-
5
- /**
6
- * Mint a fresh ed25519 strand member private key, encoded as base64 protobuf.
7
- *
8
- * Closed strands (`Type:'c'`) gate membership on a `MemberPrivateKey` column
9
- * (see the `Strand` control-schema table). This produces one in the SAME
10
- * encoding {@link StrandSolicitationService.formStrand} uses for its
11
- * `invitePrivateKey`, so a host-minted key and a formation-issued key are
12
- * interchangeable when attaching a closed strand.
13
- */
14
- export async function generateStrandMemberKey(): Promise<string> {
15
- const privateKey = await generateKeyPair('Ed25519');
16
- return uint8ArrayToString(privateKeyToProtobuf(privateKey), 'base64');
17
- }
18
-
19
- /**
20
- * Bridge a strand's `MemberPrivateKey` (base64 protobuf libp2p ed25519 key, as
21
- * minted by {@link generateStrandMemberKey} or issued by formation) into the
22
- * base64url keypair the strand RBAC constraints consume.
23
- *
24
- * The `Strand` control-layer `MemberPrivateKey` is the closed-strand read-gating
25
- * secret; the founding `Member.Key`/`Manager.MemberKey` (the strand RBAC layer)
26
- * are *derived from it* — they are the `publicKeyB64` of the keypair this returns.
27
- * Decode the protobuf to a libp2p private key, then reuse
28
- * {@link ed25519KeyPairFromLibp2p} so the same seed→public derivation used for node
29
- * owner keys yields a stable `{ privateKeyB64, publicKeyB64 }` whose public
30
- * key a later strand signature verifies against.
31
- *
32
- * @param memberPrivateKey - The strand's `MemberPrivateKey` (base64 protobuf).
33
- * @returns The base64url seed/public-key pair for founding membership/manager.
34
- * @throws If the decoded key is not Ed25519 or the raw bytes are malformed.
35
- */
36
- export function strandMemberKeyPair(memberPrivateKey: string): Ed25519KeyPair {
37
- const privateKey = privateKeyFromProtobuf(uint8ArrayFromString(memberPrivateKey, 'base64'));
38
- return ed25519KeyPairFromLibp2p(privateKey);
39
- }
1
+ import { generateKeyPair, privateKeyToProtobuf, privateKeyFromProtobuf } from '@libp2p/crypto/keys';
2
+ import { toString as uint8ArrayToString, fromString as uint8ArrayFromString } from 'uint8arrays';
3
+ import { ed25519KeyPairFromLibp2p, type Ed25519KeyPair } from './ed25519-key.js';
4
+
5
+ /**
6
+ * Mint a fresh ed25519 strand member private key, encoded as base64 protobuf.
7
+ *
8
+ * Closed strands (`Type:'c'`) gate membership on a `MemberPrivateKey` column
9
+ * (see the `Strand` control-schema table). This produces one in the SAME
10
+ * encoding {@link StrandSolicitationService.formStrand} uses for its
11
+ * `invitePrivateKey`, so a host-minted key and a formation-issued key are
12
+ * interchangeable when attaching a closed strand.
13
+ */
14
+ export async function generateStrandMemberKey(): Promise<string> {
15
+ const privateKey = await generateKeyPair('Ed25519');
16
+ return uint8ArrayToString(privateKeyToProtobuf(privateKey), 'base64');
17
+ }
18
+
19
+ /**
20
+ * Bridge a strand's `MemberPrivateKey` (base64 protobuf libp2p ed25519 key, as
21
+ * minted by {@link generateStrandMemberKey} or issued by formation) into the
22
+ * base64url keypair the strand RBAC constraints consume.
23
+ *
24
+ * The `Strand` control-layer `MemberPrivateKey` is the closed-strand read-gating
25
+ * secret; the founding `Member.Key`/`Manager.MemberKey` (the strand RBAC layer)
26
+ * are *derived from it* — they are the `publicKeyB64` of the keypair this returns.
27
+ * Decode the protobuf to a libp2p private key, then reuse
28
+ * {@link ed25519KeyPairFromLibp2p} so the same seed→public derivation used for node
29
+ * owner keys yields a stable `{ privateKeyB64, publicKeyB64 }` whose public
30
+ * key a later strand signature verifies against.
31
+ *
32
+ * @param memberPrivateKey - The strand's `MemberPrivateKey` (base64 protobuf).
33
+ * @returns The base64url seed/public-key pair for founding membership/manager.
34
+ * @throws If the decoded key is not Ed25519 or the raw bytes are malformed.
35
+ */
36
+ export function strandMemberKeyPair(memberPrivateKey: string): Ed25519KeyPair {
37
+ const privateKey = privateKeyFromProtobuf(uint8ArrayFromString(memberPrivateKey, 'base64'));
38
+ return ed25519KeyPairFromLibp2p(privateKey);
39
+ }
@@ -1,182 +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
- 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
- }
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
+ }