@serfab/cadre-core 0.8.1 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/dist/cadre-node.d.ts +101 -61
- package/dist/cadre-node.js +165 -124
- package/dist/cadre-node.js.map +1 -1
- package/dist/control-cohort.d.ts +15 -15
- package/dist/control-cohort.js +14 -14
- package/dist/control-cohort.js.map +1 -1
- package/dist/control-database.d.ts +51 -34
- package/dist/control-database.js +72 -56
- package/dist/control-database.js.map +1 -1
- package/dist/control-formation-recorder.d.ts +2 -2
- package/dist/control-formation-recorder.js +2 -2
- package/dist/control-schema.d.ts +1 -1
- package/dist/control-schema.js +57 -33
- package/dist/control-schema.js.map +1 -1
- package/dist/device-token.d.ts +1 -1
- package/dist/device-token.js +1 -1
- package/dist/{authority-key.d.ts → ed25519-key.d.ts} +11 -10
- package/dist/{authority-key.js → ed25519-key.js} +9 -10
- package/dist/ed25519-key.js.map +1 -0
- package/dist/index.d.ts +3 -3
- package/dist/index.js +8 -8
- package/dist/index.js.map +1 -1
- package/dist/key-store.d.ts +1 -1
- package/dist/key-store.js +1 -1
- package/dist/peer-authorization.d.ts +28 -6
- package/dist/peer-authorization.js +33 -7
- package/dist/peer-authorization.js.map +1 -1
- package/dist/peer-record.d.ts +3 -3
- package/dist/peer-record.js +3 -3
- package/dist/push-node.d.ts +38 -0
- package/dist/push-node.js +48 -0
- package/dist/push-node.js.map +1 -0
- package/dist/push-notifier-apns.d.ts +10 -2
- package/dist/push-notifier-apns.js +14 -10
- package/dist/push-notifier-apns.js.map +1 -1
- package/dist/push-notifier-fcm.d.ts +7 -1
- package/dist/push-notifier-fcm.js +9 -10
- package/dist/push-notifier-fcm.js.map +1 -1
- package/dist/push-notifier.d.ts +16 -35
- package/dist/push-notifier.js +16 -44
- package/dist/push-notifier.js.map +1 -1
- package/dist/seed-bootstrap.d.ts +70 -51
- package/dist/seed-bootstrap.js +168 -106
- package/dist/seed-bootstrap.js.map +1 -1
- package/dist/seed-trust-policy.d.ts +11 -11
- package/dist/seed-trust-policy.js +11 -11
- package/dist/seed-trust-policy.js.map +1 -1
- package/dist/strand-database.d.ts +5 -5
- package/dist/strand-database.js +4 -4
- package/dist/strand-database.js.map +1 -1
- package/dist/strand-formation-protocol.d.ts +1 -1
- package/dist/strand-formation-protocol.js +1 -1
- package/dist/strand-instance-manager.d.ts +1 -1
- package/dist/strand-instance-manager.js +1 -1
- package/dist/strand-instance-manager.js.map +1 -1
- package/dist/strand-member-key.d.ts +6 -6
- package/dist/strand-member-key.js +6 -6
- package/dist/strand-member-key.js.map +1 -1
- package/dist/strand-member-registry.d.ts +7 -7
- package/dist/strand-member-registry.js +5 -5
- package/dist/strand-member-registry.js.map +1 -1
- package/dist/strand-membership-writer.d.ts +84 -84
- package/dist/strand-membership-writer.js +90 -90
- package/dist/strand-membership-writer.js.map +1 -1
- package/dist/types.d.ts +52 -32
- package/dist/types.js.map +1 -1
- package/package.json +7 -3
- package/src/cadre-node.ts +170 -126
- package/src/control-cohort.ts +17 -17
- package/src/control-database.ts +76 -59
- package/src/control-formation-recorder.ts +2 -2
- package/src/control-schema.ts +57 -33
- package/src/device-token.ts +1 -1
- package/src/{authority-key.ts → ed25519-key.ts} +12 -11
- package/src/index.ts +21 -19
- package/src/key-store.ts +1 -1
- package/src/peer-authorization.ts +35 -7
- package/src/peer-record.ts +3 -3
- package/src/push-node.ts +64 -0
- package/src/push-notifier-apns.ts +16 -12
- package/src/push-notifier-fcm.ts +9 -10
- package/src/push-notifier.ts +16 -57
- package/src/seed-bootstrap.ts +179 -115
- package/src/seed-trust-policy.ts +18 -18
- package/src/strand-database.ts +6 -6
- package/src/strand-formation-protocol.ts +1 -1
- package/src/strand-instance-manager.ts +2 -2
- package/src/strand-member-key.ts +7 -7
- package/src/strand-member-registry.ts +10 -10
- package/src/strand-membership-writer.ts +127 -127
- package/src/types.ts +52 -32
- package/dist/authority-key.js.map +0 -1
|
@@ -2,7 +2,7 @@ import debug from 'debug';
|
|
|
2
2
|
import type { Database } from '@quereus/quereus';
|
|
3
3
|
import { digest, sign, verify, generatePrivateKey, getPublicKey } from '@optimystic/quereus-plugin-crypto';
|
|
4
4
|
import type { SAppConfig } from './types.js';
|
|
5
|
-
import type {
|
|
5
|
+
import type { Ed25519KeyPair } from './ed25519-key.js';
|
|
6
6
|
import { canonicalDatetime } from './canonical-datetime.js';
|
|
7
7
|
|
|
8
8
|
const log = debug('sereus:cadre:strand-membership');
|
|
@@ -31,7 +31,7 @@ export const STRAND_ENGINE_VERSION = '0.1.0';
|
|
|
31
31
|
*
|
|
32
32
|
* `schemas/strand.qsql` signs a single SHA-256 digest over a `'|'`-joined payload
|
|
33
33
|
* (e.g. `Member.Authorized` verifies
|
|
34
|
-
* `verify(digest(new.Key), context.
|
|
34
|
+
* `verify(digest(new.Key), context.ManagerSignature, A.MemberKey, 'ed25519')`).
|
|
35
35
|
* So the signer hashes the payload to raw bytes and ed25519-signs *those bytes*:
|
|
36
36
|
* SQL `digest(...)`'s default output is base64url and `verify(...)`'s default
|
|
37
37
|
* `inputEncoding` is base64url, so signer and verifier operate on identical bytes.
|
|
@@ -77,16 +77,16 @@ export function verifyStrandPayload(payload: string, signatureB64: string, publi
|
|
|
77
77
|
export interface FounderBootstrapParams {
|
|
78
78
|
/** The strand id — written to `Header.Id`. */
|
|
79
79
|
strandId: string;
|
|
80
|
-
/** Strand type: `'o'` (open, Header only) or `'c'` (closed, Header+Member+
|
|
80
|
+
/** Strand type: `'o'` (open, Header only) or `'c'` (closed, Header+Member+Manager). */
|
|
81
81
|
type: 'o' | 'c';
|
|
82
82
|
/** The sApp config whose id/version/schema/signature populate the Header. */
|
|
83
83
|
sApp: SAppConfig;
|
|
84
84
|
/**
|
|
85
85
|
* The founder's derived strand keypair (from {@link strandMemberKeyPair}). Its
|
|
86
|
-
* `publicKeyB64` becomes the founding `Member.Key` and `
|
|
86
|
+
* `publicKeyB64` becomes the founding `Member.Key` and `Manager.MemberKey`.
|
|
87
87
|
* Required for a closed strand; ignored for an open strand.
|
|
88
88
|
*/
|
|
89
|
-
founderKeyPair?:
|
|
89
|
+
founderKeyPair?: Ed25519KeyPair;
|
|
90
90
|
}
|
|
91
91
|
|
|
92
92
|
/**
|
|
@@ -96,7 +96,7 @@ export interface FounderBootstrapParams {
|
|
|
96
96
|
* so the interpolation is not an injection surface — it just keeps the singleton
|
|
97
97
|
* insert-if-absent guards terse.
|
|
98
98
|
*/
|
|
99
|
-
async function strandTableCount(db: Database, table: 'Header' | 'Member' | '
|
|
99
|
+
async function strandTableCount(db: Database, table: 'Header' | 'Member' | 'Manager'): Promise<number> {
|
|
100
100
|
for await (const row of db.eval(`select count(1) as Count from Strand.${table}`)) {
|
|
101
101
|
return (row.Count as number) ?? 0;
|
|
102
102
|
}
|
|
@@ -139,7 +139,7 @@ async function insertHeaderIfAbsent(db: Database, params: FounderBootstrapParams
|
|
|
139
139
|
* Insert the founding `Strand.Member` if no member exists yet.
|
|
140
140
|
*
|
|
141
141
|
* The empty-table state satisfies the `count(1) from Member <= 1` bootstrap branch
|
|
142
|
-
* of `Member.Authorized`, so no
|
|
142
|
+
* of `Member.Authorized`, so no manager signature is needed — the context fields
|
|
143
143
|
* are explicit nulls. Guarding on the count makes this idempotent: a re-run (the
|
|
144
144
|
* founder re-`addStrand`/`resumeStrand`) finds the member present and skips.
|
|
145
145
|
*/
|
|
@@ -150,7 +150,7 @@ async function insertFounderMemberIfAbsent(db: Database, memberKey: string, stra
|
|
|
150
150
|
}
|
|
151
151
|
await db.exec(
|
|
152
152
|
`insert into Strand.Member (Key)
|
|
153
|
-
with context
|
|
153
|
+
with context ManagerKey = null, ManagerSignature = null
|
|
154
154
|
values (?)`,
|
|
155
155
|
[memberKey],
|
|
156
156
|
);
|
|
@@ -158,26 +158,26 @@ async function insertFounderMemberIfAbsent(db: Database, memberKey: string, stra
|
|
|
158
158
|
}
|
|
159
159
|
|
|
160
160
|
/**
|
|
161
|
-
* Insert the founding `Strand.
|
|
161
|
+
* Insert the founding `Strand.Manager` if no manager exists yet.
|
|
162
162
|
*
|
|
163
|
-
* The empty-table state satisfies the `count(1) from
|
|
164
|
-
* branch of `
|
|
165
|
-
* same count guard. Must run AFTER the Header insert: `
|
|
163
|
+
* The empty-table state satisfies the `count(1) from Manager <= 1` bootstrap
|
|
164
|
+
* branch of `Manager.Authorized`, so no signature is needed. Idempotent via the
|
|
165
|
+
* same count guard. Must run AFTER the Header insert: `Manager.OnlyClosed` is a
|
|
166
166
|
* deferred (subquery) check evaluated at commit, and the sequential auto-commit
|
|
167
|
-
* Header→Member→
|
|
167
|
+
* Header→Member→Manager order ensures the closed `Header` is committed first.
|
|
168
168
|
*/
|
|
169
|
-
async function
|
|
170
|
-
if (await strandTableCount(db, '
|
|
171
|
-
log('
|
|
169
|
+
async function insertFounderManagerIfAbsent(db: Database, memberKey: string, strandId: string): Promise<void> {
|
|
170
|
+
if (await strandTableCount(db, 'Manager') > 0) {
|
|
171
|
+
log('Manager already present for strand %s; skipping founder Manager', strandId);
|
|
172
172
|
return;
|
|
173
173
|
}
|
|
174
174
|
await db.exec(
|
|
175
|
-
`insert into Strand.
|
|
176
|
-
with context
|
|
175
|
+
`insert into Strand.Manager (MemberKey)
|
|
176
|
+
with context ManagerKey = null, Signature = null
|
|
177
177
|
values (?)`,
|
|
178
178
|
[memberKey],
|
|
179
179
|
);
|
|
180
|
-
log('Inserted founding
|
|
180
|
+
log('Inserted founding Manager for strand %s', strandId);
|
|
181
181
|
}
|
|
182
182
|
|
|
183
183
|
/**
|
|
@@ -189,16 +189,16 @@ async function insertFounderAuthorityIfAbsent(db: Database, memberKey: string, s
|
|
|
189
189
|
* restart / founder re-`addStrand` is a no-op and never double-inserts.
|
|
190
190
|
*
|
|
191
191
|
* Behavior by strand type:
|
|
192
|
-
* - **Open (`'o'`)**: insert `Header` only. `Member`/`
|
|
192
|
+
* - **Open (`'o'`)**: insert `Header` only. `Member`/`Manager`/`Invite` are
|
|
193
193
|
* `OnlyClosed` and would trip that constraint — they are skipped entirely.
|
|
194
194
|
* - **Closed (`'c'`)**: insert `Header(Type='c')`, then the founding `Member`
|
|
195
|
-
* (`Key = founderKeyPair.publicKeyB64`), then the founding `
|
|
195
|
+
* (`Key = founderKeyPair.publicKeyB64`), then the founding `Manager`
|
|
196
196
|
* (`MemberKey = founderKeyPair.publicKeyB64`). Insert order matters: the
|
|
197
|
-
* Header must commit before the deferred `OnlyClosed` checks on Member/
|
|
197
|
+
* Header must commit before the deferred `OnlyClosed` checks on Member/Manager
|
|
198
198
|
* evaluate at commit.
|
|
199
199
|
*
|
|
200
200
|
* A closed strand with no `founderKeyPair` throws: a closed strand with no founding
|
|
201
|
-
*
|
|
201
|
+
* Manager could never admit anyone, so failing loudly here (which propagates out
|
|
202
202
|
* of `StrandDatabase.initialize()` and triggers the runtime's rollback) is correct.
|
|
203
203
|
*
|
|
204
204
|
* @param db - The strand's Quereus database (schema already applied).
|
|
@@ -211,26 +211,26 @@ export async function bootstrapFounderMembership(db: Database, params: FounderBo
|
|
|
211
211
|
log('Founder bootstrap for strand %s (type %s)', strandId, type);
|
|
212
212
|
|
|
213
213
|
// Validate BEFORE writing anything: a closed strand with no founder key must
|
|
214
|
-
// fail without leaving a closed `Header` that has no founding Member/
|
|
214
|
+
// fail without leaving a closed `Header` that has no founding Member/Manager
|
|
215
215
|
// (such a strand could never admit anyone).
|
|
216
216
|
if (type === 'c' && !founderKeyPair) {
|
|
217
217
|
throw new Error(
|
|
218
218
|
`Cannot bootstrap closed strand ${strandId}: no founder key pair derived from MemberPrivateKey. ` +
|
|
219
|
-
'A closed strand needs a founding Member +
|
|
219
|
+
'A closed strand needs a founding Member + Manager or it can never admit members.',
|
|
220
220
|
);
|
|
221
221
|
}
|
|
222
222
|
|
|
223
|
-
// Header is written for every strand; Member/
|
|
223
|
+
// Header is written for every strand; Member/Manager are closed-only.
|
|
224
224
|
await insertHeaderIfAbsent(db, params);
|
|
225
225
|
|
|
226
226
|
if (type === 'o') {
|
|
227
|
-
// Open strand: Member/
|
|
227
|
+
// Open strand: Member/Manager/Invite are OnlyClosed — nothing else to write.
|
|
228
228
|
return;
|
|
229
229
|
}
|
|
230
230
|
|
|
231
231
|
const memberKey = founderKeyPair!.publicKeyB64;
|
|
232
232
|
await insertFounderMemberIfAbsent(db, memberKey, strandId);
|
|
233
|
-
await
|
|
233
|
+
await insertFounderManagerIfAbsent(db, memberKey, strandId);
|
|
234
234
|
log('Founder bootstrap complete for closed strand %s', strandId);
|
|
235
235
|
}
|
|
236
236
|
|
|
@@ -239,10 +239,10 @@ export async function bootstrapFounderMembership(db: Database, params: FounderBo
|
|
|
239
239
|
/** Parameters for {@link issueInvite}. */
|
|
240
240
|
export interface IssueInviteParams {
|
|
241
241
|
/**
|
|
242
|
-
* The issuing
|
|
243
|
-
* `Strand.
|
|
242
|
+
* The issuing manager's strand keypair. Its `publicKeyB64` must already be a
|
|
243
|
+
* `Strand.Manager` row (the `InviteValid` constraint rejects a non-manager).
|
|
244
244
|
*/
|
|
245
|
-
|
|
245
|
+
managerKeyPair: Ed25519KeyPair;
|
|
246
246
|
/**
|
|
247
247
|
* Optional invite expiry as epoch milliseconds. When set, it is canonicalised
|
|
248
248
|
* via {@link canonicalDatetime} so the signed payload segment byte-matches the
|
|
@@ -270,10 +270,10 @@ export interface IssuedInvite {
|
|
|
270
270
|
*
|
|
271
271
|
* Generates a fresh invite ed25519 keypair (the public key becomes `Invite.Key`),
|
|
272
272
|
* builds the constraint's payload (`Key || '|' || coalesce(Expiration, '')`), and
|
|
273
|
-
* signs it TWICE: with the
|
|
274
|
-
*
|
|
273
|
+
* signs it TWICE: with the manager private key (→ `ManagerSignature`, proving
|
|
274
|
+
* a manager issued it) and with the invite private key (→ `InviteSignature`,
|
|
275
275
|
* proving the issuer actually holds the invite secret). Both signatures plus the
|
|
276
|
-
*
|
|
276
|
+
* manager public key are bound as constraint context for the `Invite` insert.
|
|
277
277
|
*
|
|
278
278
|
* The returned `invitePrivateKey` is the only secret the invitee needs to redeem
|
|
279
279
|
* the invite — it is NOT stored in the strand (only `Invite.Key`, the public half,
|
|
@@ -281,11 +281,11 @@ export interface IssuedInvite {
|
|
|
281
281
|
* is `InviteKey`, so a given invite can be consumed at most once.
|
|
282
282
|
*
|
|
283
283
|
* @param db - The closed strand's database (founder already bootstrapped).
|
|
284
|
-
* @param params - The issuing
|
|
284
|
+
* @param params - The issuing manager keypair and optional expiry.
|
|
285
285
|
* @returns The invite public key and the out-of-band private seed.
|
|
286
286
|
*/
|
|
287
287
|
export async function issueInvite(db: Database, params: IssueInviteParams): Promise<IssuedInvite> {
|
|
288
|
-
const {
|
|
288
|
+
const { managerKeyPair, expiration } = params;
|
|
289
289
|
|
|
290
290
|
const invitePrivateKey = generatePrivateKey('ed25519', 'base64url') as string;
|
|
291
291
|
const inviteKey = getPublicKey(invitePrivateKey, 'ed25519', 'base64url', 'base64url') as string;
|
|
@@ -296,14 +296,14 @@ export async function issueInvite(db: Database, params: IssueInviteParams): Prom
|
|
|
296
296
|
const expirationColumn = expiration == null ? null : expirationSegment;
|
|
297
297
|
|
|
298
298
|
const payload = `${inviteKey}|${expirationSegment}`;
|
|
299
|
-
const
|
|
299
|
+
const managerSignature = signStrandPayload(payload, managerKeyPair.privateKeyB64);
|
|
300
300
|
const inviteSignature = signStrandPayload(payload, invitePrivateKey);
|
|
301
301
|
|
|
302
302
|
await db.exec(
|
|
303
303
|
`insert into Strand.Invite (Key, Expiration)
|
|
304
|
-
with context
|
|
304
|
+
with context ManagerKey = ?, ManagerSignature = ?, InviteSignature = ?
|
|
305
305
|
values (?, ?)`,
|
|
306
|
-
[
|
|
306
|
+
[managerKeyPair.publicKeyB64, managerSignature, inviteSignature, inviteKey, expirationColumn],
|
|
307
307
|
);
|
|
308
308
|
log('Issued invite %s (expires=%s)', inviteKey, expirationColumn ?? 'never');
|
|
309
309
|
|
|
@@ -382,10 +382,10 @@ export async function consumeInvite(db: Database, params: ConsumeInviteParams):
|
|
|
382
382
|
await db.beginTransaction();
|
|
383
383
|
try {
|
|
384
384
|
// 1. Member — admitted by the deferred invite branch (the matching
|
|
385
|
-
// ConsumedInvite below), so no
|
|
385
|
+
// ConsumedInvite below), so no manager signature is supplied.
|
|
386
386
|
await db.exec(
|
|
387
387
|
`insert into Strand.Member (Key)
|
|
388
|
-
with context
|
|
388
|
+
with context ManagerKey = null, ManagerSignature = null
|
|
389
389
|
values (?)`,
|
|
390
390
|
[memberKey],
|
|
391
391
|
);
|
|
@@ -413,42 +413,42 @@ export async function consumeInvite(db: Database, params: ConsumeInviteParams):
|
|
|
413
413
|
}
|
|
414
414
|
}
|
|
415
415
|
|
|
416
|
-
/** Parameters for {@link
|
|
417
|
-
export interface
|
|
416
|
+
/** Parameters for {@link addMemberByManager}. */
|
|
417
|
+
export interface AddMemberByManagerParams {
|
|
418
418
|
/**
|
|
419
|
-
* The admitting
|
|
420
|
-
* `Strand.
|
|
419
|
+
* The admitting manager's strand keypair. Its `publicKeyB64` must be a
|
|
420
|
+
* `Strand.Manager` row; it signs the new member key directly.
|
|
421
421
|
*/
|
|
422
|
-
|
|
422
|
+
managerKeyPair: Ed25519KeyPair;
|
|
423
423
|
/** The joining member's ed25519 PUBLIC key (base64url) — the new `Member.Key`. */
|
|
424
424
|
memberKey: string;
|
|
425
425
|
}
|
|
426
426
|
|
|
427
427
|
/**
|
|
428
|
-
* Admit a `Member` directly by
|
|
429
|
-
* path on `Member.Authorized`'s direct-
|
|
428
|
+
* Admit a `Member` directly by manager signature — the sibling of the invite
|
|
429
|
+
* path on `Member.Authorized`'s direct-manager branch.
|
|
430
430
|
*
|
|
431
|
-
* The constraint verifies `digest(new.Key)` against
|
|
432
|
-
* `context.
|
|
433
|
-
* No `ConsumedInvite` is involved: this is the path
|
|
434
|
-
* member it already trusts (e.g.
|
|
431
|
+
* The constraint verifies `digest(new.Key)` against a `Manager` row matching
|
|
432
|
+
* `context.ManagerKey`, so the payload is just the member key (no `'|'` join).
|
|
433
|
+
* No `ConsumedInvite` is involved: this is the path a manager uses to seat a
|
|
434
|
+
* member it already trusts (e.g. a manager-side enrollment that admits a
|
|
435
435
|
* party already authorised out-of-band).
|
|
436
436
|
*
|
|
437
437
|
* @param db - The closed strand's database.
|
|
438
|
-
* @param params - The admitting
|
|
439
|
-
* @throws If `Member.Authorized` rejects (e.g. a non-
|
|
438
|
+
* @param params - The admitting manager keypair and the new member key.
|
|
439
|
+
* @throws If `Member.Authorized` rejects (e.g. a non-manager key); the insert
|
|
440
440
|
* rolls back, leaving no `Member` row.
|
|
441
441
|
*/
|
|
442
|
-
export async function
|
|
443
|
-
const {
|
|
444
|
-
const signature = signStrandPayload(memberKey,
|
|
442
|
+
export async function addMemberByManager(db: Database, params: AddMemberByManagerParams): Promise<void> {
|
|
443
|
+
const { managerKeyPair, memberKey } = params;
|
|
444
|
+
const signature = signStrandPayload(memberKey, managerKeyPair.privateKeyB64);
|
|
445
445
|
await db.exec(
|
|
446
446
|
`insert into Strand.Member (Key)
|
|
447
|
-
with context
|
|
447
|
+
with context ManagerKey = ?, ManagerSignature = ?
|
|
448
448
|
values (?)`,
|
|
449
|
-
[
|
|
449
|
+
[managerKeyPair.publicKeyB64, signature, memberKey],
|
|
450
450
|
);
|
|
451
|
-
log('Admitted member %s by
|
|
451
|
+
log('Admitted member %s by manager %s', memberKey, managerKeyPair.publicKeyB64);
|
|
452
452
|
}
|
|
453
453
|
|
|
454
454
|
// ── MemberPeer registration (a member binds its own network nodes) ─────────────
|
|
@@ -459,12 +459,12 @@ export interface RegisterMemberPeerParams {
|
|
|
459
459
|
* The member's OWN strand keypair (`{ privateKeyB64, publicKeyB64 }`). Its
|
|
460
460
|
* `publicKeyB64` becomes `MemberPeer.MemberKey` and it self-signs the binding —
|
|
461
461
|
* `MemberPeer.Authorized` verifies the signature against `MemberKey` itself, so a
|
|
462
|
-
* peer can only be registered by the very member it belongs to (no
|
|
462
|
+
* peer can only be registered by the very member it belongs to (no manager
|
|
463
463
|
* involved). The founder passes its `strandMemberKeyPair`; an invited member
|
|
464
|
-
* passes its own keypair. Typed as {@link
|
|
465
|
-
* base64url keypair shape — no
|
|
464
|
+
* passes its own keypair. Typed as {@link Ed25519KeyPair} only for the shared
|
|
465
|
+
* base64url keypair shape — no manager privilege is implied.
|
|
466
466
|
*/
|
|
467
|
-
memberKeyPair:
|
|
467
|
+
memberKeyPair: Ed25519KeyPair;
|
|
468
468
|
/** The peer/node id (libp2p peer id string) to associate with the member. */
|
|
469
469
|
peerId: string;
|
|
470
470
|
}
|
|
@@ -526,115 +526,115 @@ async function memberPeerExists(db: Database, memberKey: string, peerId: string)
|
|
|
526
526
|
return false;
|
|
527
527
|
}
|
|
528
528
|
|
|
529
|
-
// ──
|
|
529
|
+
// ── Manager rotation (add / remove RBAC admins) ───────────────────────────────
|
|
530
530
|
|
|
531
|
-
/** Parameters for {@link
|
|
532
|
-
export interface
|
|
531
|
+
/** Parameters for {@link addManager}. */
|
|
532
|
+
export interface AddManagerParams {
|
|
533
533
|
/**
|
|
534
|
-
* An EXISTING
|
|
535
|
-
* `Strand.
|
|
536
|
-
* rejects a non-
|
|
537
|
-
*
|
|
534
|
+
* An EXISTING manager's strand keypair. Its `publicKeyB64` must already be a
|
|
535
|
+
* `Strand.Manager` row (the existing-manager branch of `Manager.Authorized`
|
|
536
|
+
* rejects a non-manager once the founder manager exists); it signs the new
|
|
537
|
+
* manager key and is bound as `context.ManagerKey`.
|
|
538
538
|
*/
|
|
539
|
-
|
|
540
|
-
/** The member key to promote — the new `
|
|
541
|
-
|
|
539
|
+
byManagerKeyPair: Ed25519KeyPair;
|
|
540
|
+
/** The member key to promote — the new `Manager.MemberKey` row. */
|
|
541
|
+
newManagerKey: string;
|
|
542
542
|
}
|
|
543
543
|
|
|
544
544
|
/**
|
|
545
|
-
* Promote a member to `
|
|
545
|
+
* Promote a member to `Manager` on the signature of an existing manager.
|
|
546
546
|
*
|
|
547
|
-
* The existing-
|
|
547
|
+
* The existing-manager branch of `Manager.Authorized` verifies
|
|
548
548
|
* `digest(coalesce(new.MemberKey, old.MemberKey))` — `new.MemberKey` =
|
|
549
|
-
* `
|
|
550
|
-
* `context.
|
|
551
|
-
* `'|'` join) and binds itself as `context.
|
|
549
|
+
* `newManagerKey` on insert — against a `Manager` row matching
|
|
550
|
+
* `context.ManagerKey`. So the signer signs the new manager key directly (no
|
|
551
|
+
* `'|'` join) and binds itself as `context.ManagerKey`.
|
|
552
552
|
*
|
|
553
|
-
* Once the founder
|
|
553
|
+
* Once the founder manager exists, the schema's `(select count(1) from Manager)
|
|
554
554
|
* <= 1` bootstrap shortcut no longer applies to a second add (at commit the count
|
|
555
555
|
* includes the new row, so it is ≥ 2), so this genuinely exercises signature
|
|
556
|
-
* verification — a non-
|
|
556
|
+
* verification — a non-manager signer, or a signature over the wrong key, is
|
|
557
557
|
* rejected.
|
|
558
558
|
*
|
|
559
|
-
* @param db - The closed strand's database (founder
|
|
560
|
-
* @param params - The authorizing
|
|
561
|
-
* @throws If `
|
|
559
|
+
* @param db - The closed strand's database (founder manager already seated).
|
|
560
|
+
* @param params - The authorizing manager's keypair and the new manager key.
|
|
561
|
+
* @throws If `Manager.Authorized` rejects; the insert rolls back.
|
|
562
562
|
*/
|
|
563
|
-
export async function
|
|
564
|
-
const {
|
|
565
|
-
const signature = signStrandPayload(
|
|
563
|
+
export async function addManager(db: Database, params: AddManagerParams): Promise<void> {
|
|
564
|
+
const { byManagerKeyPair, newManagerKey } = params;
|
|
565
|
+
const signature = signStrandPayload(newManagerKey, byManagerKeyPair.privateKeyB64);
|
|
566
566
|
await db.exec(
|
|
567
|
-
`insert into Strand.
|
|
568
|
-
with context
|
|
567
|
+
`insert into Strand.Manager (MemberKey)
|
|
568
|
+
with context ManagerKey = ?, Signature = ?
|
|
569
569
|
values (?)`,
|
|
570
|
-
[
|
|
570
|
+
[byManagerKeyPair.publicKeyB64, signature, newManagerKey],
|
|
571
571
|
);
|
|
572
|
-
log('Added
|
|
572
|
+
log('Added manager %s by %s', newManagerKey, byManagerKeyPair.publicKeyB64);
|
|
573
573
|
}
|
|
574
574
|
|
|
575
|
-
/** Parameters for {@link
|
|
576
|
-
export interface
|
|
575
|
+
/** Parameters for {@link removeManager}. */
|
|
576
|
+
export interface RemoveManagerParams {
|
|
577
577
|
/**
|
|
578
|
-
* The keypair authorizing the removal, bound as `context.
|
|
579
|
-
* signing `digest(
|
|
580
|
-
* existing
|
|
578
|
+
* The keypair authorizing the removal, bound as `context.ManagerKey` and
|
|
579
|
+
* signing `digest(targetManagerKey)`. For an ADMIN removal this is a DIFFERENT
|
|
580
|
+
* existing manager (satisfying the existing-manager branch); for a
|
|
581
581
|
* SELF-resignation it is the target's OWN keypair (`publicKeyB64 ===
|
|
582
|
-
*
|
|
582
|
+
* targetManagerKey`, satisfying the former-manager self branch). The same
|
|
583
583
|
* context construction satisfies whichever branch applies — see
|
|
584
|
-
* {@link
|
|
584
|
+
* {@link removeManager}.
|
|
585
585
|
*/
|
|
586
|
-
|
|
587
|
-
/** The `
|
|
588
|
-
|
|
586
|
+
byManagerKeyPair: Ed25519KeyPair;
|
|
587
|
+
/** The `Manager.MemberKey` row to delete. */
|
|
588
|
+
targetManagerKey: string;
|
|
589
589
|
}
|
|
590
590
|
|
|
591
591
|
/**
|
|
592
|
-
* Remove
|
|
593
|
-
*
|
|
592
|
+
* Remove a `Manager` row — either an admin removing a different manager or a
|
|
593
|
+
* manager resigning itself.
|
|
594
594
|
*
|
|
595
595
|
* On a DELETE, `coalesce(new.MemberKey, old.MemberKey)` binds `old.MemberKey =
|
|
596
|
-
*
|
|
597
|
-
* key for BOTH accepting branches of `
|
|
596
|
+
* targetManagerKey` (there is no `new` row), so the signed payload is the target
|
|
597
|
+
* key for BOTH accepting branches of `Manager.Authorized`, and a single context
|
|
598
598
|
* construction serves both:
|
|
599
|
-
* - **Admin removal**: `
|
|
600
|
-
* existing-
|
|
601
|
-
* context.
|
|
602
|
-
* - **Self-resignation**: `
|
|
603
|
-
* equals `
|
|
604
|
-
* `old.MemberKey = context.
|
|
599
|
+
* - **Admin removal**: `byManagerKeyPair` is a different existing manager; the
|
|
600
|
+
* existing-manager branch verifies the signature against `A.MemberKey =
|
|
601
|
+
* context.ManagerKey`.
|
|
602
|
+
* - **Self-resignation**: `byManagerKeyPair` IS the target (its `publicKeyB64`
|
|
603
|
+
* equals `targetManagerKey`); the former-manager self branch verifies
|
|
604
|
+
* `old.MemberKey = context.ManagerKey` and the self-signature over `old.MemberKey`.
|
|
605
605
|
*
|
|
606
606
|
* The caller selects the case purely by which keypair it passes — no branching here.
|
|
607
607
|
*
|
|
608
608
|
* KNOWN PLATFORM GAP (not fixable here): the optimystic bootstrap-mode transactor
|
|
609
609
|
* evaluates deferred (subquery-bearing) CHECK constraints only on INSERT, not on
|
|
610
|
-
* DELETE. `
|
|
611
|
-
* `
|
|
610
|
+
* DELETE. `Manager.Authorized` is deferred, so the platform currently accepts ANY
|
|
611
|
+
* `Manager` delete regardless of signature — `removeManager`'s authorization is
|
|
612
612
|
* effectively unenforced at runtime until that gap is closed (filed as
|
|
613
613
|
* `optimystic-deferred-check-not-enforced-on-delete`). This writer still builds the
|
|
614
614
|
* correct, signed delete so it works unchanged once enforcement lands — exactly as
|
|
615
615
|
* the invite path issues correct inserts despite the open PK-uniqueness gap.
|
|
616
616
|
*
|
|
617
|
-
* KNOWN SCHEMA HAZARD (not guarded here): even with enforcement, `
|
|
618
|
-
* Authorized`'s `(select count(1) from
|
|
617
|
+
* KNOWN SCHEMA HAZARD (not guarded here): even with enforcement, `Manager.
|
|
618
|
+
* Authorized`'s `(select count(1) from Manager) <= 1` bootstrap branch is true at
|
|
619
619
|
* commit whenever a delete drops the count to ≤ 1, so removing the strand's LAST (or
|
|
620
|
-
* second-to-last)
|
|
620
|
+
* second-to-last) manager would be accepted regardless of signature — and removing
|
|
621
621
|
* the last one orphans the strand (no one can ever add another). The schema does not
|
|
622
|
-
* prevent this; a "min-one-
|
|
622
|
+
* prevent this; a "min-one-manager" invariant is deferred to a future schema change
|
|
623
623
|
* rather than grown into this writer.
|
|
624
624
|
*
|
|
625
625
|
* @param db - The closed strand's database.
|
|
626
|
-
* @param params - The authorizing keypair and the target
|
|
627
|
-
* @throws If `
|
|
628
|
-
* while > 2
|
|
626
|
+
* @param params - The authorizing keypair and the target manager key.
|
|
627
|
+
* @throws If `Manager.Authorized` rejects (e.g. a non-manager admin removal
|
|
628
|
+
* while > 2 managers remain); the delete rolls back.
|
|
629
629
|
*/
|
|
630
|
-
export async function
|
|
631
|
-
const {
|
|
632
|
-
const signature = signStrandPayload(
|
|
630
|
+
export async function removeManager(db: Database, params: RemoveManagerParams): Promise<void> {
|
|
631
|
+
const { byManagerKeyPair, targetManagerKey } = params;
|
|
632
|
+
const signature = signStrandPayload(targetManagerKey, byManagerKeyPair.privateKeyB64);
|
|
633
633
|
await db.exec(
|
|
634
|
-
`delete from Strand.
|
|
635
|
-
with context
|
|
634
|
+
`delete from Strand.Manager
|
|
635
|
+
with context ManagerKey = ?, Signature = ?
|
|
636
636
|
where MemberKey = ?`,
|
|
637
|
-
[
|
|
637
|
+
[byManagerKeyPair.publicKeyB64, signature, targetManagerKey],
|
|
638
638
|
);
|
|
639
|
-
log('Removed
|
|
639
|
+
log('Removed manager %s by %s', targetManagerKey, byManagerKeyPair.publicKeyB64);
|
|
640
640
|
}
|